From Spring to Quarkus: Building SOAP Services with Shared Contracts and DTOs
Learn how to structure a Quarkus multi-module project with a SOAP server, client, and a clean SEI contract module that works across teams and projects.
SOAP still powers core integrations in finance, healthcare, and government systems. Many Spring developers are familiar with WebServiceTemplate
, Jaxb2Marshaller
, and the configuration baggage that comes with them.
Quarkus makes SOAP development lighter. With Apache CXF as an extension, you can:
Expose SOAP endpoints with a couple of config lines.
Generate and inject SOAP clients using annotations.
Run everything live with hot reload (
quarkus:dev
).
In this tutorial, you’ll build a multi-module Quarkus project with both a SOAP server and a SOAP client. The server exposes a simple greeting service. The client calls it via REST and returns the SOAP response. Before we dive into details, a big THANK YOU to my amazing colleague Peter Palaga, who helped me fixed issues I ran into while building this out.
Prerequisites
Java 17 or newer
Maven 3.9+
Basic familiarity with SOAP basics
If you want to just sneak at the code and configuration, feel free to just clone my Github repository and check out the soap-multi project.
Create the parent project
We’ll create a folder that will contain two maven projects: soap-service
and soap-client
.
mkdir quarkus-soap-multi
cd quarkus-soap-multi
Create the server module
Generate the SOAP server module:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=soap-service \
-Dextensions="quarkus-cxf" \
-DnoCode
cd soap-service
Sharing Service Interfaces Across Modules
When you create both a SOAP server and a SOAP client in the same multi-module project, the client needs access to the service endpoint interface (SEI). There are a couple of ways to achieve this.
Option 1: Depend on a service interface module
The simplest approach is to let the client depend on a soap-contract
module. This way, the GreetingService
interface is on the classpath of the client.
However, for Quarkus Arc (CDI) to see these classes correctly, the soap-service
JAR must contain a Jandex index. You can generate it by adding the jandex-maven-plugin
to the soap-contract/pom.xml
:
<plugin>
<groupId>io.smallrye</groupId>
<artifactId>jandex-maven-plugin</artifactId>
<version>3.4.0</version>
<executions>
<execution>
<id>make-index</id>
<goals>
<goal>jandex</goal>
</goals>
</execution>
</executions>
</plugin>
This ensures Arc can discover annotations like @WebService
and register the classes properly.
Option 2: Generate client stubs from WSDL
In many real-world scenarios, the service implementation JAR is not available to clients (for example, if the service is provided by a third party). In that case, the usual pattern is to generate the Java classes from the WSDL directly in the client project.
Quarkus CXF supports this with Maven plugins. You configure the cxf-codegen-plugin
to generate sources during the build. Documentation and examples are available here:
Quarkus CXF: Generate Java from WSDL.
This way:
The service team publishes the WSDL.
The client team generates Java stubs from it.
No runtime dependency on the service implementation is required.
To keep things simple, we are going with Option 1 in this tutorial.
Create the contract module
We’ll keep the soap-contract
module as a plain Java project, and we’ll define both the SEI and DTO classes inside it. This mimics real-world SOAP usage where the contract includes not just operations but also structured payloads. Generate a plain Maven project:
mvn archetype:generate \
-DgroupId=org.acme.soap \
-DartifactId=soap-contract \
-Dversion=1.0.0-SNAPSHOT \
-DarchetypeArtifactId=maven-archetype-quickstart \
-DinteractiveMode=false
Remove the generated App.java
and test classes.
Dependencies in the SEI (soap-contract module)
Jakarta JWS for @WebService
and @WebMethod
annotations
<dependency>
<groupId>jakarta.jws</groupId>
<artifactId>jakarta.jws-api</artifactId>
<version>3.0.0</version>
<scope>provided</scope>
</dependency>
This gives you
@WebService
and@WebMethod
.Use
provided
scope because the runtime (Quarkus with CXF) already includes it.
Jakarta XML Bind (JAXB) for DTO marshalling/unmarshalling
<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>4.0.2</version>
<scope>provided</scope>
</dependency>
Needed for
@XmlRootElement
,@XmlAccessorType
, etc.Again,
provided
scope is fine — CXF brings a JAXB implementation at runtime.
Keep the SEI module to Jakarta JWS + JAXB APIs only, and mark them as provided
.
Define DTOs in the contract module
soap-contract/src/main/java/org/acme/soap/GreetingRequest.java
:
package org.acme.soap;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
@XmlRootElement(name = "GreetingRequest")
@XmlAccessorType(XmlAccessType.FIELD)
public class GreetingRequest {
@XmlElement(required = true)
private String name;
public GreetingRequest() {
}
public GreetingRequest(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
soap-contract/src/main/java/org/acme/soap/GreetingResponse.java
:
package org.acme.soap;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
@XmlRootElement(name = "GreetingResponse")
@XmlAccessorType(XmlAccessType.FIELD)
public class GreetingResponse {
@XmlElement(required = true)
private String message;
public GreetingResponse() {
}
public GreetingResponse(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
Create the Service Interface
soap-contract/src/main/java/org/acme/soap/GreetingService.java
:
package org.acme.soap;
import jakarta.jws.WebMethod;
import jakarta.jws.WebService;
@WebService(targetNamespace = "http://soap.acme.org/", serviceName = "GreetingService")
public interface GreetingService {
@WebMethod
GreetingResponse greet(GreetingRequest request);
}
Share the interface between modules
In soap-service/pom.xml
and later in soap-client/pom.xml
add:
<dependency>
<groupId>org.acme.soap</groupId>
<artifactId>soap-contract</artifactId>
<version>${project.version}</version>
</dependency>
Now both server and client can directly use org.acme.soap.GreetingService
.
Add the implementation
soap-service/src/main/java/org/acme/soap/GreetingServiceImpl.java
:
package org.acme.soap;
import jakarta.jws.WebService;
@WebService
public class GreetingServiceImpl implements GreetingService {
@Override
public GreetingResponse greet(GreetingRequest request) {
String name = request.getName();
String msg = "Hello " + name + ", from Quarkus SOAP!";
return new GreetingResponse(msg);
}
}
Configure the endpoint
soap-service/src/main/resources/application.properties
:
quarkus.cxf.path = /soap
# Publish "GreetingService" under the context path /${quarkus.cxf.path}/greet
quarkus.cxf.endpoint."/greet".implementor = org.acme.soap.GreetingServiceImpl
quarkus.cxf.endpoint."/greet".logging.enabled = pretty
#payload logging
quarkus.cxf.logging.enabled-for = services
quarkus.http.port = 8081
That exposes /soap/greet
as a SOAP endpoint and enables payload logging. We also move the application to port 8081. The client will be running on 8080.
Create the client module
Generate the SOAP client module:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=soap-client \
-Dextensions="quarkus-cxf,quarkus-rest-jackson" \
-DnoCode
cd soap-client
Configure the SOAP client
soap-client/src/main/resources/application.properties
:
cxf.it.greeter.baseUri=http://localhost:8081
quarkus.cxf.client.greeting.wsdl = ${cxf.it.greeter.baseUri}/soap/greet?wsdl
quarkus.cxf.client.greeting.client-endpoint-url = ${cxf.it.greeter.baseUri}/soap/greet
quarkus.cxf.client.greeting.service-interface = org.acme.soap.GreetingService
quarkus.cxf.client.greeting.endpoint-name = GreetingService
Create a client bean
soap-client/src/main/java/org/acme/client/GreetingClientBean.java
:
package org.acme.client;
import io.quarkiverse.cxf.annotation.CXFClient;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.acme.soap.GreetingRequest;
import org.acme.soap.GreetingResponse;
import org.acme.soap.GreetingService;
@ApplicationScoped
public class GreetingClientBean {
@Inject
@CXFClient("greeting-client")
GreetingService client;
public String callGreeting(String name) {
GreetingRequest req = new GreetingRequest(name);
GreetingResponse resp = client.greet(req);
return resp.getMessage();
}
}
Add a REST proxy endpoint
To make testing easy, expose a REST endpoint that calls the SOAP client:
soap-client/src/main/java/org/acme/client/GreetingProxyResource.java
:
package org.acme.client;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
@Path("/proxy")
public class GreetingProxyResource {
@Inject
GreetingClientBean greetingClient;
@GET
public String proxy(@QueryParam("name") String name) {
return greetingClient.callGreeting(name);
}
}
Run the applications
Open two terminals.
Terminal 1: run the SOAP server
cd soap-service
./mvnw quarkus:dev
Log output:
INFO [org.apa.cxf.end.ServerImpl] Setting the server's publish address to be /greet
INFO [io.qua.cxf.tra.CxfHandler] Web Service org.acme.soap.GreetingServiceImpl on /soap available.
Terminal 2: run the SOAP client
cd soap-client
./mvnw quarkus:dev
Verify everything works
Open the proxy endpoint:
curl "http://localhost:8081/proxy?name=Spring+Dev"
Expected output:
Hello Spring Dev, from Quarkus SOAP!
You’ve now confirmed the SOAP client successfully invoked the SOAP service.
What Spring developers should notice
Shared contract: You can reuse interfaces without JAXB marshallers or
WebServiceTemplate
.Live coding: Both apps reload instantly with
quarkus:dev
.Cloud readiness: Both modules can be containerized and run natively.
Next steps
Secure the SOAP service with
quarkus-elytron-security
.Add multiple services to the same app.
Compare startup times by building native images.
SOAP may be old, but it’s not going away. With Quarkus, you can keep legacy integrations alive without heavy frameworks or slow redeploys.
Quarkus makes SOAP feel modern again.
Thanks for sharing Markus. While this "Java first" approach presented as "Option 1" is sometimes useful, for rapid prototyping and POCs, the professional practice requires, more often than not, the "Option 2", i.e. a "WSDL-first" approach. As you're explaining, SOAP services are represented, enterprise wide, as WSDL documents, containing XSDs of several hundreds lines, with complicated namespaces, etc. This is why, in my opinion, an "Option 2" implementation, would be more realistic. Especially if it addresses the case of WSDL imports, including shared schemas, etc.
But thank you anyway for popularizing this forgotten technology that only SOA people like me still remember :-)