Level Up/MuleSoft

How to Migrate a Legacy SOAP Integration to REST in MuleSoft.

A complete strangler-fig playbook: assessment, facade, security translation, DataWeave, MUnit, and safe decommissioning. Audience: Integration Architects, MuleSoft Tech Leads. Runtime: Mule 4.6 (Anypoint Studio 7.17+).

</> How-to guideMuleSoftAugust 202615 min read
dispatch://mulesoft, legacy-migrationHT-06
Migrate Legacy SOAP Integration to REST in MuleSoft Legacy Modernisation
August 2026 · 15 min readHow-to guide

SOAP integrations do not retire gracefully. They accumulate. Over a decade of incremental changes, a single CustomerService.asmx absorbs validation rules, derived fields, authorisation logic, and dozens of undocumented exceptions that the original architect retired three jobs ago. The contract on disk says one thing. The behaviour in production says another. And the modern systems that now need to consume this data speak REST and JSON, not SOAP and XML.

This guide walks through the production-grade approach Ampleshift uses to migrate legacy SOAP integrations to REST inside MuleSoft. The pattern is the strangler fig: stand up a MuleSoft REST facade in front of the legacy service, route consumers to the facade, then progressively move logic into the facade until the SOAP backend can be retired with no downtime. The article assumes Mule 4.6 on CloudHub 2.0 or Runtime Fabric, but every pattern works on Mule 4.4 with minor adjustments noted inline.

This is not a translation exercise. A naive SOAP to REST migration that copies operations one-for-one ends up with REST endpoints that look like SOAP wearing a JSON jacket: verb-in-the-path URLs, deeply nested response bodies, and security models bolted onto each operation. Get the design right at the start and the migration pays for itself in the first reuse. Get it wrong and you carry the legacy estate forward in a new wrapper.

The hidden costs of staying on legacy SOAP

Before walking through the how, name the why. Teams that postpone SOAP migration do so because the service still works. That is the trap. SOAP services that still work are quietly draining engineering capacity, blocking modernisation, and accumulating risk that only becomes visible during an incident.

Operational drag that never shows up on a roadmap

Every team that touches a legacy SOAP service pays a tax. WSDL parsing in modern toolchains is patchy. WS-Security configurations break on JDK upgrades. The XML schema validator that worked under Java 8 throws on Java 17. SOAP fault deserialisation in newer client libraries handles edge cases differently from the .NET 2.0 client the original integration was built against. Each of these is a half-day fix. Twelve of them a year is a senior developer-month, billed to keep something running that delivers no new value.

Security debt that compounds

WS-Security predates modern identity. UsernameToken with PasswordDigest is still in production at many enterprises, and the OASIS Username Token Profile 1.1.1 states that unless the digested password is sent on a secured channel or the token is encrypted, the digest offers no real additional security over a plaintext password. Verifying that digest also forces the server to hold a recoverable password, because it recomputes a SHA-1 hash over the nonce, the created timestamp and the password itself, which rules out the salted one-way storage the rest of the estate uses. X.509 token profiles assume a PKI rotation cadence most organisations no longer maintain. SAML 1.1 assertions are passed in SOAP headers that no central identity provider monitors. Compliance audits flag these year after year. The remediation cost rises with every audit cycle that ends with a written exception.

The reuse ceiling

Modern integration value comes from reuse: System APIs feed Process APIs feed Experience APIs, and the same building blocks serve mobile, partner, AI agent, and analytics use cases. SOAP services are nearly impossible to reuse this way. They expose RPC-style operations, not resources. Their payloads are tightly bound to the original consumer's needs. Every new consumer requires a new operation, a new schema, a new release. The cost of the eleventh consumer is roughly the cost of the first.

Cloud-native cost penalty

SOAP services are heavy. On an identical order payload, XML runs approximately 1.5x the bytes of compact JSON, rising to roughly 2.3x once namespace prefixes and indentation are counted. WS-Security headers add hundreds of bytes per request. Persistent SOAP sessions hold connections open and resist horizontal scaling. On any per-request, per-megabyte, or per-instance cloud billing model, the legacy service is paying a premium for behaviour modern alternatives provide for free.

Agent readiness

The newest cost did not exist when most of these services were written. Enterprises are wiring APIs into AI agents, and that wiring has a format. Tool calling is defined in JSON Schema. OpenAPI 3.1 aligns fully with JSON Schema 2020-12, which puts a described REST API one mechanical transform from a tool definition. MuleSoft MCP Bridge, generally available since March 2026, turns APIs already published in Exchange into MCP tools through automated policies on Omni Gateway without changing the underlying API. A WSDL has no first-party generator to a tool definition: the schema, the payload mapping, and the error semantics become code your team writes and maintains, one operation at a time, indefinitely.

Prerequisites

RequirementDetails
Anypoint Studio 7.17+Mule 4.6.x runtime. Java 17 required. Set JAVA_HOME to JDK 17 before starting Studio.
Web Service Consumer connector 1.9.0+Available in Exchange. Handles SOAP envelope construction, WS-Security headers, MTOM, and fault parsing natively.
APIKit module 1.10.0+Scaffolds REST flows from a RAML or OAS spec published in Exchange.
Access to the WSDL and production logsYou need the deployed WSDL, not a local copy. Production access logs covering at least 30 days are required for Step 1.
Secure Properties CLIUsed to encrypt WS-Security credentials for the service account in Step 4.
API Manager accessOrganisation Administrator or API Manager permissions to apply the OAuth 2.0 policy in Step 4.

Step 1: Inventory the SOAP estate

Migration starts with an honest inventory. Not what the WSDL says exists, but what production actually does. The artefact you are building in this step is a Service Inventory Sheet that lists every SOAP service in scope, every operation, every active consumer, the actual call volume, the security profile, and the business logic each operation embeds. This sheet drives every decision that follows.

Discover the services

Pull the list of SOAP endpoints from three sources and reconcile the differences. The CMDB lists what was registered. The API gateway access logs list what is actually called. The application teams list what they think they own. Each of these is incomplete on its own. Mismatches are where surprises live.

# Pull last 30 days of unique SOAP endpoints from gateway logs
# (NGINX access log example, adjust for your gateway)
awk '$7 ~ /\.asmx|\.svc|\/services\//' /var/log/nginx/access.log* \
  | awk '{print $7}' | sort -u > soap_endpoints_actual.txt

# Compare to CMDB export
diff soap_endpoints_actual.txt soap_endpoints_cmdb.txt

Map operations to consumers

For each service, list every operation in the WSDL. For each operation, identify the consumers calling it. The fastest way to do this without instrumenting the SOAP service is to enable SOAPAction header logging at the gateway and aggregate by client IP, mTLS subject, or User-Agent over a fourteen-day window. Operations with no traffic over that window are dead code: they do not need to be migrated at all.

Classify each operation by complexity

Not every operation deserves the same migration treatment. Classify each into one of four buckets. The classification drives the order of work.

BucketCharacteristicsMigration approach
Simple passthroughNo business logic, single system, thin wrapperDirect facade, migrate first
Business logic embeddedValidation, derived fields, state transitionsFacade first, logic extracted in phase 2
OrchestrationCalls multiple downstream systemsMigrate downstream dependencies first
Undocumented behaviourInconsistent responses, no spec alignmentCapture 100+ production samples before touching

Step 2: Design the REST API first

The single most damaging shortcut in any SOAP to REST migration is translating operations one-for-one. GetCustomerById becomes GET /customers/getCustomerById. SaveOrder becomes POST /orders/save. The result is REST in name only. Design the REST API as if the SOAP service did not exist. Then translate to it.

Model resources, not operations

Identify the nouns. For each noun, define the canonical representation and the standard collection and instance endpoints. The verbs are HTTP methods, not URL segments.

# Resource modelling: from SOAP operations to REST resources
GetCustomer            ->  GET    /customers/{customerId}
CreateCustomer         ->  POST   /customers
UpdateCustomerAddress  ->  PATCH  /customers/{customerId}/addresses/{addressId}
DeleteCustomer         ->  DELETE /customers/{customerId}
GetCustomerOrders      ->  GET    /customers/{customerId}/orders
SearchCustomers        ->  GET    /customers?email=...&status=...&page=1

# Operations that look like RPC but model as state transitions
ActivateCustomer       ->  PATCH  /customers/{customerId}  body: {"status":"ACTIVE"}
ApproveOrder           ->  POST   /orders/{orderId}/approvals
CancelOrder            ->  POST   /orders/{orderId}/cancellations

Author the contract in RAML or OAS

Author the contract first, design-first, in Anypoint Design Center. Use RAML 1.0 for an estate already on RAML, or OAS 3.0.3 for new estates. Publish to Exchange before writing a line of implementation code. Every consumer team reviews the contract before the facade exists. This is how breaking changes are caught at the design table instead of in a release retro.

#%RAML 1.0
title: Customer API
version: v1
mediaType: application/json
protocols: [ HTTPS ]
baseUri: https://api.example.com/customer/v1
securitySchemes:
  oauth_2_0: !include security/oauth_2_0.raml
types:
  Customer:       !include types/customer.raml
  CustomerCreate: !include types/customer-create.raml
  Error:          !include types/error.raml
/customers:
  securedBy: [ oauth_2_0: { scopes: [ customer:read ] } ]
  get:
    description: Search customers by email or status
    queryParameters:
      email?: string
      status?: string
      page?:  { type: integer, default: 1 }
    responses:
      200: { body: { application/json: { type: Customer[] } } }
  post:
    securedBy: [ oauth_2_0: { scopes: [ customer:write ] } ]
    body: { application/json: { type: CustomerCreate } }
    responses:
      201: { body: { application/json: { type: Customer } } }
      400: { body: { application/json: { type: Error } } }
  /{customerId}:
    uriParameters:
      customerId: { type: string, pattern: '^[A-Z0-9-]{6,32}$' }
    get:
      responses:
        200: { body: { application/json: { type: Customer } } }
        404: { body: { application/json: { type: Error } } }

Step 3: Build the MuleSoft REST facade

The facade is the new Mule application that exposes the REST contract designed in Step 2 and calls the legacy SOAP service as its backend during the transition. Build it with APIKit scaffolding from the published RAML. Every route is generated. The implementation work is the transformation logic between REST and SOAP.

Scaffold the project from the RAML

In Anypoint Studio: File → New → Mule Project. Check “Import a published API” and select your Exchange asset. APIKit generates customer-api.xml, schemas, and example responses. Add the WSC dependency to pom.xml.

<!-- pom.xml additions -->
<dependency>
  <groupId>org.mule.modules</groupId>
  <artifactId>mule-apikit-module</artifactId>
  <version>1.10.0</version>
  <classifier>mule-plugin</classifier>
</dependency>
<dependency>
  <groupId>org.mule.connectors</groupId>
  <artifactId>mule-wsc-connector</artifactId>
  <version>1.9.0</version>
  <classifier>mule-plugin</classifier>
</dependency>

Configure the SOAP backend with Web Service Consumer

Use the Web Service Consumer (WSC) connector rather than raw HTTP requests. WSC handles SOAP envelope construction, WS-Security headers, MTOM attachments, and fault parsing natively. The connector reads the WSDL at design time and at runtime, exposing each operation as a typed Mule operation.

<wsc:config name="Legacy_Customer_SOAP_Config" doc:name="Web Service Consumer">
  <wsc:connection
      wsdlLocation="wsdl/CustomerService.wsdl"
      service="CustomerService"
      port="CustomerServiceSoap"
      address="${legacy.soap.endpoint}">
    <wsc:web-service-security>
      <wsc:outgoing-security-strategies>
        <wsc:username-token-security-strategy
            username="${legacy.wss.user}"
            password="${secure::legacy.wss.password}"
            passwordType="DIGEST"
            addNonce="true"
            addCreated="true"/>
      </wsc:outgoing-security-strategies>
    </wsc:web-service-security>
  </wsc:connection>
</wsc:config>

Implement a representative flow

Here is the implementation of GET /customers/{customerId}. The flow receives the REST request, transforms the URI parameter into the SOAP request body using DataWeave, invokes the WSC operation, and transforms the SOAP response to the canonical Customer JSON.

<flow name="get:\customers\(customerId):customer-api-config">
  <ee:transform doc:name="Build SOAP request">
    <ee:message>
      <ee:set-payload><![CDATA[%dw 2.0
output application/java
---
{
  GetCustomer: {
    CustomerId: attributes.uriParams.customerId
  }
}]]></ee:set-payload>
    </ee:message>
  </ee:transform>
  <wsc:consume
      config-ref="Legacy_Customer_SOAP_Config"
      operation="GetCustomer"
      doc:name="Call legacy SOAP"/>
  <ee:transform doc:name="Map SOAP response to Customer JSON">
    <ee:message>
      <ee:set-payload><![CDATA[%dw 2.0
output application/json
---
{
  customerId: payload.body.GetCustomerResponse.Customer.CustomerId,
  email:      payload.body.GetCustomerResponse.Customer.EmailAddress,
  firstName:  payload.body.GetCustomerResponse.Customer.FirstName,
  lastName:   payload.body.GetCustomerResponse.Customer.LastName,
  status:     lower(payload.body.GetCustomerResponse.Customer.AccountStatus),
  createdAt:  payload.body.GetCustomerResponse.Customer.CreatedDate
                as DateTime {format: "yyyy-MM-dd'T'HH:mm:ssXXX"},
  addresses: (payload.body.GetCustomerResponse.Customer.Addresses.*Address default [])
    map (addr) -> {
      addressId:  addr.AddressId,
      line1:      addr.Line1,
      city:       addr.City,
      postalCode: addr.PostalCode,
      country:    addr.CountryCode
    }
}]]></ee:set-payload>
    </ee:message>
  </ee:transform>
</flow>

Step 4: Translate WS-Security to OAuth 2.0 and mTLS

The facade exposes a modern REST API. The backend still speaks WS-Security. These two security models do not translate one-for-one and you do not want them to. The facade authenticates the consumer using OAuth 2.0 Client Credentials, enforces scopes via API Manager, and then opens an authenticated connection to the legacy backend using a service identity that the SOAP service knows. Two trust boundaries, two identities, one consumer.

Enforce OAuth 2.0 at the facade with API Manager

Front the facade with an API Manager policy. Use the External OAuth 2.0 Access Token Enforcement policy if your IdP is Okta, Auth0, Azure AD, or ForgeRock. Use the built-in Mule OAuth Provider if you do not yet have an enterprise IdP. Configure scopes that match the resource: customer:read, customer:write, order:read, order:write.

# API Manager: Customer API v1
# Policies tab -> Apply New Policy
Policy:        External OAuth 2.0 Access Token Enforcement
Token URL:     https://idp.example.com/oauth2/v1/token
Validation:    Token Introspection (RFC 7662)
Scopes:        customer:read customer:write
# Also apply: Rate Limiting (1000 req/min)
#             IP Allowlist (corporate egress + partner subnets)
#             Client Enforcement (Client ID required)

Outbound credentials to the SOAP backend

Inside the facade, the connection to the SOAP service uses a single service identity. Do not propagate the end-user identity into WS-Security UsernameToken. Most legacy SOAP services have no concept of OAuth scopes and will reject tokens they do not recognise. Pass a static service account credential injected via Secure Properties, and log the original OAuth principal as a correlation field for audit.

<!-- secure-properties.yaml (encrypted with Mule CLI) -->
legacy:
  wss:
    user: "![QUx0Z3lZbi8...encrypted]"
    password: "![M3ZkS3Vudi8...encrypted]"

<!-- secure-properties-config.xml -->
<secure-properties:config
    name="Secure_Properties_Config"
    file="secure-properties-${mule.env}.yaml"
    key="${runtime.secure.key}">
  <secure-properties:encrypt algorithm="AES" mode="CBC"/>
</secure-properties:config>

Identity propagation for audit

The legacy SOAP service still needs to audit who initiated the action. Pass the OAuth client_id and an X-Original-User correlation header into a custom SOAP header that the backend logs. This is the audit trail across the trust boundary.

<ee:transform doc:name="Inject audit SOAP header">
  <ee:variables>
    <ee:set-variable variableName="soapHeaders"><![CDATA[%dw 2.0
output application/xml
ns audit http://example.com/audit/v1
---
{
  audit#Caller: {
    audit#ClientId:      attributes.headers.'x-client-id' default 'unknown',
    audit#Principal:     attributes.headers.'x-original-user' default 'service',
    audit#CorrelationId: vars.correlationId
  }
}]]></ee:set-variable>
  </ee:variables>
</ee:transform>
<wsc:consume config-ref="Legacy_Customer_SOAP_Config" operation="GetCustomer">
  <wsc:headers>#[vars.soapHeaders]</wsc:headers>
</wsc:consume>

Step 5: Master the XML and JSON transformations in DataWeave

Three transformations carry every SOAP to REST integration: request mapping (JSON to SOAP XML), response mapping (SOAP XML to JSON), and fault mapping (SOAP Fault to RFC 7807 Problem Details). Each has well-known traps. Get them right once and reuse the patterns across every operation.

REST JSON request to SOAP XML

The Web Service Consumer connector accepts a Java map and serialises it. You only need to produce the operation's expected structure as a Mule Map; WSC handles the SOAP envelope and namespaces. This is the recommended path. The alternative, building the full envelope by hand, is fragile and unnecessary.

%dw 2.0
output application/java
---
{
  CreateCustomer: {
    Customer: {
      EmailAddress: payload.email,
      FirstName:    payload.firstName,
      LastName:     payload.lastName,
      // SOAP service requires ISO-8601 dates with explicit timezone
      DateOfBirth:  payload.dateOfBirth as Date {format: "yyyy-MM-dd"},
      // Legacy enums are uppercase. New JSON contract uses Title Case.
      AccountType:  upper(payload.accountType default "STANDARD"),
      Addresses: {
        Address: payload.addresses default [] map ((addr) -> {
          Line1:       addr.line1,
          Line2:       addr.line2 default "",
          City:        addr.city,
          PostalCode:  addr.postalCode,
          CountryCode: upper(addr.country)
        })
      }
    }
  }
}

SOAP XML response to canonical JSON

The SOAP response payload from WSC mirrors the XML structure. The repeated-element trap is the one that breaks pipelines silently: in XML, a single child element is indistinguishable from a collection of one. DataWeave models this as either an object or an array depending on the data, and downstream maps break when the cardinality changes. Always normalise with the star selector and default to an empty array.

%dw 2.0
output application/json
---
{
  customerId: payload.body.GetCustomerResponse.Customer.CustomerId,
  email:      payload.body.GetCustomerResponse.Customer.EmailAddress,
  firstName:  payload.body.GetCustomerResponse.Customer.FirstName,
  lastName:   payload.body.GetCustomerResponse.Customer.LastName,
  status:     lower(payload.body.GetCustomerResponse.Customer.AccountStatus),
  createdAt:  payload.body.GetCustomerResponse.Customer.CreatedDate
                as DateTime {format: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"},
  // Critical: '*Address' returns Array<Address> always, never a single object.
  // Without this, a single-address customer returns an object,
  // and a multi-address customer returns an array. Downstream code crashes.
  addresses: (payload.body.GetCustomerResponse.Customer.Addresses.*Address default [])
    map ((addr, idx) -> {
      addressId:  addr.AddressId default "addr-" ++ (idx + 1) as String,
      line1:      addr.Line1,
      line2:      addr.Line2 default null,
      city:       addr.City,
      postalCode: addr.PostalCode,
      country:    addr.CountryCode,
      isPrimary:  (addr.@primary default "false") == "true"
    })
}

SOAP Fault to RFC 7807 Problem Details

SOAP Faults have a different shape from a typical REST error. Map them once, centrally, and reuse the mapping across every operation. Use the RFC 7807 Problem Details specification as the canonical REST error format.

%dw 2.0
output application/json
---
{
  type:          "https://api.example.com/errors/" ++ (error.errorType.identifier default "unknown"),
  title:         error.description default "Backend error",
  status:        if (error.errorType.namespace == "WSC") 502 else 500,
  detail:        payload.body.Fault.faultstring default error.detailedDescription,
  instance:      "/customers/" ++ (attributes.uriParams.customerId default ""),
  errorCode:     payload.body.Fault.detail.ErrorCode default "E_UNKNOWN",
  correlationId: vars.correlationId
}

Step 6: Lock the behaviour with MUnit regression tests

This is the step that determines whether your migration ships safely or quietly breaks something in production three months from now. The MUnit suite is the contract between what the SOAP service does today and what the REST facade must do tomorrow. Build it before you migrate any business logic into the facade.

Capture canonical request/response pairs

From the production samples gathered in Step 1, pick the canonical pair per operation. Save the SOAP request in src/test/resources/mocks/{operation}/request.xml and the SOAP response in response.xml. Save the expected REST output in src/test/resources/expected/{operation}.json. These three files are the contract.

Write the MUnit test

<munit:test name="get-customer-by-id-returns-200-with-mapped-fields"
            description="GET /customers/{id} maps every documented field correctly">
  <munit:behavior>
    <munit-tools:mock-when processor="wsc:consume">
      <munit-tools:with-attributes>
        <munit-tools:with-attribute attributeName="operation" whereValue="GetCustomer"/>
      </munit-tools:with-attributes>
      <munit-tools:then-return>
        <munit-tools:payload
          value="#[readUrl('classpath://mocks/getCustomer/response.xml','application/xml')]"/>
      </munit-tools:then-return>
    </munit-tools:mock-when>
  </munit:behavior>
  <munit:execution>
    <munit:set-event>
      <munit:attributes value="#[{uriParams: {customerId: 'C-123456'}}]"/>
    </munit:set-event>
    <flow-ref name="get:\customers\(customerId):customer-api-config"/>
  </munit:execution>
  <munit:validation>
    <munit-tools:assert-that
        expression="#[payload]"
        is="#[MunitTools::equalTo(
          readUrl('classpath://expected/getCustomer.json','application/json'))]"/>
  </munit:validation>
</munit:test>

Achieve meaningful coverage

Aim for 100 percent flow coverage. MUnit reports application, resource and flow coverage only, all counted in event processors. The equivalent discipline is behavioural: every Choice branch, every Try scope and every Error Handler must execute in at least one test. Generate the coverage report on every CI build and fail the pipeline when coverage drops.

<!-- pom.xml: MUnit Maven plugin with coverage thresholds -->
<plugin>
  <groupId>com.mulesoft.munit.tools</groupId>
  <artifactId>munit-maven-plugin</artifactId>
  <version>3.1.0</version>
  <executions>
    <execution>
      <id>test</id>
      <phase>test</phase>
      <goals><goal>test</goal><goal>coverage-report</goal></goals>
    </execution>
  </executions>
  <configuration>
    <coverage>
      <runCoverage>true</runCoverage>
      <requiredApplicationCoverage>85</requiredApplicationCoverage>
      <requiredResourceCoverage>85</requiredResourceCoverage>
      <requiredFlowCoverage>100</requiredFlowCoverage>
      <failBuild>true</failBuild>
    </coverage>
  </configuration>
</plugin>

Step 7: Error handling and resilience

The facade sits in the call path of every consumer that has been migrated. When the SOAP backend has a bad day, the facade has a bad day, and so does every consumer behind it. Three resilience patterns separate facades that survive backend incidents from facades that amplify them.

Retry with exponential backoff for transient failures

Wrap every SOAP call in an Until Successful scope that retries on connectivity errors but never on business errors. Distinguishing the two requires precise error type matching.

<until-successful maxRetries="3" millisBetweenRetries="1000">
  <try>
    <wsc:consume config-ref="Legacy_Customer_SOAP_Config" operation="GetCustomer"/>
    <error-handler>
      <on-error-continue type="WSC:SOAP_FAULT">
        <!-- Business fault: do not retry. Re-raise so the outer handler maps to 4xx. -->
        <raise-error type="APP:LEGACY_BUSINESS_FAULT"
                     description="#[error.description]"/>
      </on-error-continue>
    </error-handler>
  </try>
</until-successful>

Circuit breaker for sustained backend failures

When the SOAP backend is unresponsive, retrying every request makes the situation worse and increases tail latency for consumers. There is no circuit breaker in the Mule runtime natively. Three real options exist: Omni Gateway ships a Circuit Breaker policy that sits at the gateway. The Anypoint MQ Subscriber source provides circuit breaking configured with an error threshold and trip timeout. Otherwise implement it with Object Store and a counter. The Ampleshift resilience framework includes a production-ready circuit breaker scope; see HT-001.

Map errors to the public contract

Consumers receive RFC 7807 Problem Details on every error path. Define one central global error handler in customer-api-globalerror.xml and let every flow inherit it. Each error type maps to a specific HTTP status and a stable problem-type URI.

<error-handler name="customer-api-globalerror">
  <on-error-propagate type="APIKIT:NOT_FOUND">
    <ee:transform>
      <ee:message><ee:set-payload><![CDATA[%dw 2.0
output application/json
---
{ type: "https://api.example.com/errors/not-found",
  title: "Resource not found", status: 404,
  detail: error.description, instance: attributes.requestPath,
  correlationId: vars.correlationId }]]></ee:set-payload></ee:message>
      <ee:variables>
        <ee:set-variable variableName="httpStatus">404</ee:set-variable>
      </ee:variables>
    </ee:transform>
  </on-error-propagate>
  <on-error-propagate type="APP:LEGACY_BUSINESS_FAULT">
    <!-- map to 422 Unprocessable Entity -->
  </on-error-propagate>
  <on-error-propagate type="WSC:CANNOT_DISPATCH, WSC:TIMEOUT, HTTP:CONNECTIVITY">
    <!-- map to 502 Bad Gateway -->
  </on-error-propagate>
  <on-error-propagate type="ANY">
    <!-- map to 500 Internal Server Error -->
  </on-error-propagate>
</error-handler>

Step 8: Run both stacks in parallel (strangler fig)

The migration does not happen in a single deployment. The strangler fig pattern runs the legacy SOAP service and the new REST facade side by side, routing each consumer to one or the other on its own schedule. Logic moves from the SOAP backend into the facade one operation at a time. When the last consumer is migrated and the last operation is reimplemented, the SOAP service is retired.

Route at the gateway, not in the application

The routing decision belongs at the edge. Front both stacks with an API gateway and use header-based or path-based routing rules. This keeps the consumers unaware of which backend serves them and lets the migration team flip traffic with a config change, not a deployment.

# Anypoint Flex Gateway: routing rules for the migration window
# Default: send everything to the new REST facade
- match: { path: '/customers**' }
  destination: customer-rest-facade.cloudhub.io
# Override: legacy consumers that still send SOAP envelopes
- match:
    path: '/customers**'
    headers: { Content-Type: 'text/xml*' }
  destination: legacy-customer-soap.example.com
# Override: opted-out consumers (move off this list one at a time)
- match:
    path: '/customers**'
    headers: { X-Client-Id: 'mainframe-batch-processor' }
  destination: legacy-customer-soap.example.com

Shadow traffic to validate equivalence

For the first two weeks of any consumer's migration, run shadow traffic. The gateway sends the request to the new facade as the primary destination and copies the request to the legacy SOAP service. Compare both responses asynchronously. Any divergence is a defect in the facade's transformation logic. Catch them here, not in production.

<flow name="shadow-comparison">
  <vm:listener config-ref="VM_Config" queueName="shadow.compare"/>
  <parallel-foreach>
    <route name="new-rest">
      <http:request config-ref="REST_Facade_Config"
                    method="#[vars.method]" path="#[vars.path]"/>
    </route>
    <route name="legacy-soap">
      <wsc:consume config-ref="Legacy_Customer_SOAP_Config"
                   operation="#[vars.soapOperation]"/>
    </route>
  </parallel-foreach>
  <ee:transform doc:name="Compute diff and record divergence">
    <ee:message><ee:set-payload><![CDATA[%dw 2.0
output application/json
---
{
  correlationId: vars.correlationId,
  match: payload[0].normalised == payload[1].normalised,
  diff:  if (payload[0].normalised != payload[1].normalised)
         diff(payload[0].normalised, payload[1].normalised) else null
}]]></ee:set-payload></ee:message>
  </ee:transform>
  <logger level="INFO" message="#[output application/json --- payload]"/>
</flow>

Step 9: Observability: knowing when you can actually cut over

Cutover decisions are made on data, not opinion. Three signal categories determine whether a consumer is ready to leave the SOAP backend behind: traffic distribution, error rate parity, and latency parity. Wire them up before the first consumer is migrated.

Structured logging on every request

Emit a JSON log line on every facade request using the Ampleshift JSON Logger (HT-002). Required fields: correlationId, consumerId, operation, restMethod, restPath, legacyOperation, durationMs, restStatus, legacyStatus, divergent. These nine fields drive every cutover dashboard.

The cutover dashboard

Build a single Kibana dashboard with four panels. When all four read green for fourteen consecutive days for a given consumer, that consumer is ready to leave shadow mode.

PanelSignalCutover threshold
Traffic distribution% of consumer requests hitting REST facade100% for 14 days
Error rate parityFacade 5xx rate vs legacy SOAP fault rateFacade rate ≤ legacy rate
Latency parityp95 facade vs p95 legacyFacade p95 ≤ legacy p95 + 10%
Divergence rateShadow responses that differ from legacy< 0.1% divergence for 14 days

Step 10: Retire the SOAP endpoint

Retirement is a five-phase sequence, not a single deployment. Each phase has an exit criterion. Do not skip phases even when traffic appears to be zero. Hidden consumers, scheduled batch jobs that run once a quarter, and partner integrations with retry logic all surface during this phase in production unless you wait.

PhaseActionExit criterion
1. Confirm zero traffic30 days of zero requests to the SOAP endpointZero in access logs across all sources
2. Deprecation noticeAnnounce shutdown date to all registered consumersWritten acknowledgement from all teams
3. Return 410 GoneReplace SOAP endpoint with a 410 HTTP response30 days of 410 with no escalations
4. Remove the applicationUndeploy from CloudHub / Runtime FabricDeployment removed, no alerts triggered
5. Archive the codebaseTag the final release, move to long-term archive branchTagged commit confirmed in source control

Troubleshooting

SymptomLikely causeFix
WSC:INVALID_WSDL at startupWSDL imports a schema that is not reachable at runtimeBundle all imported XSDs in wsdl/ and set wsdlLocation to the local path
Single-element collection mapped as objectMissing star selector on repeated XML elementUse .*ElementName default [] on every collection field
WS-Security header rejected by backendClock skew between facade and SOAP host exceeds 5 minutesAlign NTP; the Created timestamp in the token must be within the WSSE window
OAuth policy returns 401 even with valid tokenScopes in the policy do not match scopes in the tokenCheck the policy's scope list and the token's scp claim for exact match
Shadow comparison always shows divergenceTimestamps or sequence IDs in the response differ per callNormalise volatile fields before comparison; exclude createdAt, requestId

Summary

This guide built a complete SOAP to REST migration in MuleSoft using the strangler-fig pattern. The facade exposes a freshly designed REST contract authored in RAML, calls the legacy SOAP backend through the Web Service Consumer connector, translates security between OAuth 2.0 and WS-Security, maps payloads with DataWeave, locks behaviour with MUnit, and retires the SOAP service in five disciplined phases.

The single most important operational constraint to remember: never let a SOAP to REST migration become a SOAP-to-REST translation. Design the REST contract first as if the legacy service did not exist. Then translate to it. The cost of the discipline is a few extra design conversations. The reward is an API estate that compounds in value across every project that follows.

Key takeaways

Have a SOAP estate that needs modernising?

Ampleshift migrates legacy integrations to modern REST APIs without downtime. Senior expertise from day one, no billing games.

Talk to an expert
Continue reading

Up next.

More field guides from engineers who have shipped the real thing.

Want us in your next story?

Connect with us today and let's turn your integration challenges into opportunities for growth.

Book a call