DEV Community

Markus
Markus

Posted on • Originally published at the-main-thread.com on

From Spring to Quarkus: Building SOAP Services with Shared Contracts and DTOs

Hero Image

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode
  • 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the applications

Open two terminals.

Terminal 1: run the SOAP server

cd soap-service
./mvnw quarkus:dev
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Terminal 2: run the SOAP client

cd soap-client
./mvnw quarkus:dev 
Enter fullscreen mode Exit fullscreen mode

Verify everything works

Open the proxy endpoint:

curl "http://localhost:8081/proxy?name=Spring+Dev"
Enter fullscreen mode Exit fullscreen mode

Expected output:

Hello Spring Dev, from Quarkus SOAP!
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)