Kora SOAP Client — compile-time clients from WSDL
Kora sub-skill — obey the kora-v2 meta rules on every task: R0 ground the workspace on Kora 2.0 refs before starting (framework source at tag
2.0.0.RC1+kora-examplesatmigration/2.0;kora-docsis 1.x only) · R1 read this sub-skill before writing code · R2 Kora 2.0 APIs only — no Spring/Micronaut/Quarkus, no Kora 1.x APIs, no invented annotations or config keys · R3 journal any incorrect Kora usage. Add comments/Javadoc only if asked.
Version: Kora 2.0 (io.koraframework, 2.0.0.RC1 on Maven Central) | Java: 25 | Kotlin: 2.4 + KSP | Gradle: 9+ | JAX-WS: jakarta only
Kora consumes SOAP services through compile-time generated clients. There are two generators in the chain and they are easy to confuse:
wsdl2java(Apache CXF) turns a.wsdlintojakarta.jws.@WebServiceinterfaces plus JAXB model classes. This is a build-tool step, not a Kora one.- The Kora processor (
annotation-processorsfor Java,symbol-processorsfor Kotlin) sees@WebServiceand generates, into the same package, an implementation$<Iface>_SoapClientImpland a@Moduleinterface$<Iface>_SoapClientModulethat registers it in the graph.
You then inject the WSDL interface — never the generated $…_SoapClientImpl — and call its
methods. Calls are synchronous: the generated method overrides the interface method verbatim and
returns the WSDL payload type. Kora 2.0 has no reactive, CompletionStage or suspend SOAP contract.
Migrating from Kora 1.x
The 2.0 side of every row is verified in the Kora 2.0 framework source; the 1.x side describes what
the Kora 1.x documentation specified for ru.tinkoff.kora:soap-client.
| Kora 1.x | Kora 2.x |
|---|---|
BOM ru.tinkoff.kora:kora-parent |
io.koraframework:kora-bom |
ru.tinkoff.kora:soap-client |
io.koraframework:soap-client |
ru.tinkoff.kora.soap.client.common.* |
io.koraframework.soap.client.common.* |
javax.jws.WebService or jakarta.jws.WebService |
jakarta.jws.WebService only — the 2.0 processors register no javax variant. useJakarta = true is mandatory |
<method>Async returning CompletionStage<T> on $…_SoapClientImpl |
removed — only the interface's own synchronous methods are generated |
InvalidHttpResponseSoapException |
SoapInvalidHttpResponseException |
SoapRequestMarshallingException / SoapResponseUnmarshallingException extend RuntimeException |
both now extend SoapException — one catch (SoapException) covers them |
SoapEnvelopeProcessors.wssAuth(...) |
SoapEnvelopeProcessorsUtils.wssAuth(...) in …common.util |
Envelope processor wired by replacing the generated client factory with your own @Module method |
the generated module injects it: declare a @Tag(Iface.class) Function<SoapEnvelope, SoapEnvelope> component and nothing else |
SoapClientLogger.SoapClientLoggerBodyMapper @DefaultComponent for masking logged bodies |
removed — subclass DefaultSoapClientLoggerFactory instead (telemetry reference) |
telemetry.metrics.enabled defaults to true |
defaults to false — nothing is reported until you switch it on |
Metrics kora.soap.client.* (counter + DistributionSummary) |
one Timer rpc.client.duration with OpenTelemetry semconv tags |
implementation("…:soap-client") { exclude group: "jakarta.xml" … } |
drop every exclude — 2.0 soap-client declares the jakarta/JAXB stack itself as api at pinned versions |
annotationProcessor "…:soap-client-annotation-processor" listed separately |
already inside io.koraframework:annotation-processors (KSP: symbol-processors) |
logging.level { … } |
logging.levels { … } |
SoapRequestExecutor and SoapResult are not new and not renamed — 1.x had both, with the same
Success(Object body) / Failure(SoapFault fault, String faultMessage) shape. Only the package moved.
Key facts (do not get these wrong)
- Inject the WSDL interface (
SimpleService), not$SimpleService_SoapClientImpl. The generated module registers the impl as the interface type, with@DefaultComponent. SoapResultnever reaches your code. It is the return type of the internalSoapRequestExecutor.call(SoapEnvelope). The generated method unwraps it:Success→ cast to the declared return type;Failure→ throw a typed@WebFaultexception orSoapFaultException.- The config section is the
@WebServicename, which is the WSDLportTypename — not thewsdl:servicename.<wsdl:portType name="SimpleService">→soapClient.SimpleService. Resolution order:@WebService.name→serviceName→portName→ interface simple name. urlis required;timeoutdefaults to60s. There are no other top-level keys.telemetry.logging.enabledandtelemetry.metrics.enableddefault tofalse;telemetry.tracing.enableddefaults totrue.@KoraAppneedsSoapClientModuleplus an HTTP transport module and a config module. The generated$…_SoapClientModuleis@Module-annotated and picked up automatically — do not extend it from@KoraApp.- No Jakarta-EE
javax.*.javax.jws.*,javax.xml.ws.*andjavax.xml.bind.*play no part in Kora 2.0 SOAP generation. Two JDKjavaxpackages are still correct and must not be "migrated":javax.xml.namespace.QName(the type ofSoapFault.getFaultcode()) andjavax.xml.parsers.*. - No Kora
Context. There is noContexttype in Kora 2.0 at all; the SOAP client propagates telemetry throughScopedValue, which needs nothing from you. - After changing
packageName(or any package rename), runclean+--no-build-cache— see Stale generated sources.
Quick Start
1. gradle.properties
2.0.0.RC1 is the Kora 2.0 release on Maven Central. 2.0.0-SNAPSHOT is the master development
line and needs the snapshot repository — do not put it in a new project.
koraVersion=2.0.0.RC1
2. Build file
Full, ready-to-copy build files live in assets/ —
build.gradle.template (Java) and
build.gradle.kts.template (Kotlin/KSP). The Kora-specific parts:
plugins {
id "java"
id "application"
id "com.github.bjornvester.wsdl2java" version "2.0.2"
}
// the koraBom configuration and the extendsFrom wiring are in the template
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion")
annotationProcessor "io.koraframework:annotation-processors"
implementation "io.koraframework:soap-client"
implementation "io.koraframework:http-client-ok" // any transport; supplies HttpClient
implementation "io.koraframework:config-hocon"
implementation "io.koraframework:logging-logback"
}
wsdl2java {
cxfVersion = "4.0.2"
wsdlDir = layout.projectDirectory.dir("src/main/resources/wsdl")
useJakarta = true
markGenerated = true
verbose = false
packageName = "com.example.generated.soap"
generatedSourceDir.set(layout.buildDirectory.dir("generated/sources/wsdl2java/java"))
includesWithOptions = [
"**/simple-service.wsdl": ["-wsdlLocation", "https://example.com/simple/service?wsdl"],
]
}
useJakarta = trueis mandatory. Withfalsethe plugin emitsjavax.jwsannotations and the Kora processor generates nothing at all — no error, just a missing component later.- Never add excludes to
soap-client. It declaresjakarta.xml.ws-api,jakarta.xml.bind-api,org.glassfish.jaxb:jaxb-runtimeandcommons-codecasapidependencies on purpose. - Kotlin needs one extra line so KSP can see the Java sources wsdl2java produced:
sourceSets.main { java.srcDir(layout.buildDirectory.dir("generated/sources/wsdl2java/java")) }. - Coordinates, transports and the
cxfVersionquestion are covered in architecture-reference.md → Gradle wiring.
3. WSDL and generation
Put the contract under src/main/resources/wsdl/, then:
./gradlew wsdl2java # CXF: WSDL -> @WebService interface + JAXB classes
./gradlew classes # Kora processor: -> $Iface_SoapClientImpl + $Iface_SoapClientModule
4. Application graph
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.http.client.ok.OkHttpClientModule;
import io.koraframework.logging.logback.LogbackModule;
import io.koraframework.soap.client.common.SoapClientModule;
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
OkHttpClientModule,
SoapClientModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
@KoraApp
interface Application : HoconConfigModule, LogbackModule, OkHttpClientModule, SoapClientModule
fun main() {
KoraApplication.run { ApplicationGraph.graph() }
}
SoapClientModule contributes exactly one component — the DefaultSoapClientTelemetryFactory. The
client and its config come from the generated $…_SoapClientModule.
5. Configuration
soapClient.SimpleService {
url = ${SOAP_CLIENT_URL}
timeout = 30s
telemetry.logging.enabled = true
}
httpClient {
connectTimeout = 5s
readTimeout = 2m
}
logging.levels {
"root": "WARN"
"io.koraframework": "INFO"
"com.example.generated.soap.SimpleService.request": "TRACE"
"com.example.generated.soap.SimpleService.response": "TRACE"
}
soapClient.<Service>.timeout becomes the per-request timeout on the outgoing HTTP request; the
transport-level httpClient.connectTimeout / readTimeout are separate and belong to the HTTP
client module.
6. Inject and call
import io.koraframework.common.annotation.Component;
import com.example.generated.soap.SimpleService;
import com.example.generated.soap.TestRequest;
@Component
public final class CustomerService {
private final SimpleService service;
public CustomerService(SimpleService service) {
this.service = service;
}
public String lookup(String id) {
var request = new TestRequest();
request.setVal1(id);
var response = service.test(request);
return response.getVal1();
}
}
@Component
class CustomerService(private val service: SimpleService) {
fun lookup(id: String): String? {
val request = TestRequest().apply { val1 = id }
return service.test(request).val1
}
}
The @WebService interface is Java generated by CXF, so from Kotlin its types are platform types —
assign the result to a nullable type (String?) rather than fighting the compiler with !!.
Generated artefacts
For com.example.generated.soap.SimpleService the processor writes into the same package:
| Generated type | What it is |
|---|---|
$SimpleService_SoapClientImpl |
implements SimpleService. One SoapRequestExecutor field per @WebMethod. Public constructors (HttpClient, SoapClientTelemetryFactory, SoapServiceConfig) and (…, Function<SoapEnvelope, SoapEnvelope>), both throws JAXBException |
$SimpleService_SoapClientModule |
@Module interface, auto-discovered. simpleService_SoapConfig(...) → @DefaultComponent @Tag(SimpleService.class) SoapServiceConfig, read from config.get("soapClient.SimpleService"). simpleService_SoapClientImpl(...) → @DefaultComponent SimpleService |
Full signatures, the request/response mapping, RPC Holder out-parameters and MTOM/XOP handling:
architecture-reference.md.
Configuration reference
io.koraframework.soap.client.common.SoapServiceConfig, section soapClient.<ServiceName>:
| Key | Required | Default | Notes |
|---|---|---|---|
url |
yes | — | Endpoint the POST goes to. Missing → graph build fails on a null config value |
timeout |
no | 60s |
Applied as the HTTP request timeout |
telemetry.logging.enabled |
no | false |
|
telemetry.metrics.enabled |
no | false |
|
telemetry.metrics.slo |
no | TelemetryConfig.MetricsConfig.DEFAULT_SLO |
Timer SLO buckets |
telemetry.metrics.tags |
no | {} |
Extra tags on rpc.client.duration |
telemetry.tracing.enabled |
no | true |
|
telemetry.tracing.attributes |
no | {} |
Extra span attributes |
There is no soapClient.<Service>.headers, retries, or keepAlive key — anything beyond the
table above belongs to the HTTP transport module or does not exist.
Errors
Everything is unchecked and, except for typed WSDL faults, rooted at SoapException:
RuntimeException
└── io.koraframework.soap.client.common.exception.SoapException
├── SoapFaultException // SOAP Fault with no matching typed fault; getFault()
├── SoapInvalidHttpResponseException // HTTP status other than 200 or 500
├── SoapRequestMarshallingException // JAXB marshalling of the request failed
└── SoapResponseUnmarshallingException // JAXB unmarshalling of the response failed
A <wsdl:fault> produces a typed @WebFault exception that the operation throws directly; it does
not extend SoapException, so catch it first. Transport failures arrive as a SoapException
wrapping an HttpClientConnectionException / HttpClientTimeoutException — there is no bare
ConnectException to catch.
→ error-handling-reference.md
Telemetry
| Signal | Shape |
|---|---|
| Metric | Timer rpc.client.duration, tags rpc.system=soap, rpc.service, rpc.method, server.address, server.port, http.response.status_code, error.type, fault.code, system.config, system.name.simple, system.name.canonical |
| Span | SOAP <service> <method>, SpanKind.CLIENT, attributes rpc.*, server.*, http.response.status_code, plus fault.code / fault.actor on a fault |
| Logs | Loggers <interface FQN>.request and <interface FQN>.response. Envelope bodies are logged only at TRACE |
→ telemetry-reference.md
Customising the request envelope (WS-Security)
The generated module injects an optional, tagged envelope processor. Declare it and it is applied to every request envelope before marshalling — no need to replace the generated client.
import io.koraframework.common.annotation.Module;
import io.koraframework.common.annotation.Tag;
import io.koraframework.soap.client.common.envelope.SoapEnvelope;
import io.koraframework.soap.client.common.util.SoapEnvelopeProcessorsUtils;
import java.util.function.Function;
@Module
public interface SoapEnvelopeModule {
@Tag(SimpleService.class)
default Function<SoapEnvelope, SoapEnvelope> simpleServiceEnvelopeProcessor(SoapCredentialsConfig config) {
return SoapEnvelopeProcessorsUtils.wssAuth(config.username(), config.password());
}
}
wssAuth adds a WS-Security UsernameToken with a plaintext PasswordText, so it is only safe
over TLS. For anything else, write your own Function<SoapEnvelope, SoapEnvelope> that appends to
envelope.getHeader().getAny(). @Module interfaces are discovered automatically — do not also
extend this one from @KoraApp.
Stale generated sources
wsdl2java does not delete its previous output, and the Gradle build-cache key does not account for
a changed packageName. After a rename, the old and the new package sit in the same source set and
the compiler reports errors in files you never wrote, all under build/generated/:
error: package ru.tinkoff.kora.example.generated.soap does not exist
./gradlew clean --continue
./gradlew classes testClasses --continue --no-build-cache
Never edit anything under build/generated/ and never add a dependency to "find" the missing 1.x
package — both hide the real problem.
Troubleshooting
| Problem | Cause / fix |
|---|---|
No component found for dependency … SimpleService |
The Kora processor never ran on the generated interface. Check annotationProcessor "io.koraframework:annotation-processors" (Kotlin: ksp "io.koraframework:symbol-processors"), and that wsdl2java ran before compileJava |
Interface generated, but no $…_SoapClientImpl |
useJakarta = false (or omitted) → javax.jws annotations, which 2.0 ignores. Set useJakarta = true and rebuild after clean |
| Kotlin: interface exists but KSP does not see it | Missing sourceSets.main { java.srcDir(…wsdl2java/java) } |
No component found for dependency … SoapClientTelemetryFactory |
SoapClientModule is not in the @KoraApp interface list |
No component found for dependency … HttpClient |
No transport module: add OkHttpClientModule / ApacheHttpClientModule / JdkHttpClientModule |
ConfigValueException: … null at path: 'ROOT.soapClient.SimpleService.url' |
url missing, or the section name is not the portType name (a wsdl:service name such as SimpleServiceService will not match) |
Multiple components match dependency: … SimpleService |
Two non-default providers of the interface. The generated one is @DefaultComponent and yields to yours, so a collision means you declared it twice — or mark one fallback @DefaultComponent |
Phantom package ru.tinkoff.kora… does not exist under build/generated |
Stale generated sources |
| No metrics / no request logs | Both default to false; set telemetry.metrics.enabled / telemetry.logging.enabled per client |
| Logging enabled but no XML in the logs | Bodies are TRACE-only; raise <interface FQN>.request / .response to TRACE |
'test' overrides nothing in Kotlin |
The Kotlin type does not match the Java contract — keep the CXF-generated Java interface as the contract and do not re-declare it in Kotlin |
Reference Files
| File | Description |
|---|---|
| architecture-reference.md | Codegen chain, generated signatures, request/response mapping, RPC Holder, MTOM/XOP, Gradle wiring, testing |
| error-handling-reference.md | Exception hierarchy, typed @WebFault faults, SoapFault API, transport failures, resilience |
| telemetry-reference.md | rpc.client.duration tags, span shape, logger names and levels, body masking, custom factories |
| Asset | Description |
|---|---|
| build.gradle.template | Java: BOM, annotation-processors, wsdl2java |
| build.gradle.kts.template | Kotlin: BOM, KSP symbol-processors, wsdl2java, java.srcDir |
Common Pitfalls
useJakarta = false— silently produces ajavax.jwsinterface that Kora 2.0 does not process.- Injecting
$SimpleService_SoapClientImpl— inject the WSDL interface; the impl is an implementation detail and its name starts with$. - Expecting
SoapResultfrom a generated method — it is internal toSoapRequestExecutor. - Reactive /
suspend/CompletionStageSOAP methods — none exist in 2.0; do not enable wsdl2java's async method generation either, the generated mapping casts the SOAP body straight to the declared return type. - 1.x jakarta/glassfish
excludeblocks carried over — they strip the JAXB runtime that 2.0soap-clientdeliberately ships. - Using the
wsdl:servicename as the config section — it is theportTypename. logging.level— the key islogging.levels.- Assuming metrics are on —
metrics.enabledwastruein 1.x and isfalsein 2.0. - Editing
build/generatedto fix phantomru.tinkoff.koraerrors —clean+--no-build-cache.