Modernising SOAP: wrapping and migrating to REST or gRPC

You rarely get to delete a SOAP contract. A facade lets new consumers use a modern protocol while the old one keeps working.

Patterns that work

PatternHow it worksRisk
FacadeA REST or gRPC surface that calls the SOAP service internallyThe facade becomes permanent plumbing
StranglerMove operations one at a time behind the new surface, routing bothTwo sources of truth during the transition
Dual publishOne implementation exposes both protocols from one coreLowest risk, most code
Contract-derivedGenerate the new interface from the WSDLGenerated shapes are usually not good API design
RetireConfirm no traffic, notify partners, switch offThe only option that actually removes complexity
# Deriving a resource-oriented surface from an RPC contract
# SOAP:  <PlaceOrder><sku>A-1</sku><qty>2</qty></PlaceOrder>   (an action)
# REST:  POST /orders  {"sku":"A-1","qty":2}                   (a resource)
paths:
  /orders:
    post:
      operationId: placeOrder
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Order' }
      responses:
        '201':
          description: Order accepted
          headers:
            Location: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '422':
          description: Business rejection, replaces the SOAP Fault detail
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
# The facade in practice: one translation layer, no business logic duplicated
import requests
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
SOAP_URL = "https://orders.internal/OrderService"

class Order(BaseModel):
    sku: str
    qty: int = 1

def soap_call(sku: str, qty: int) -> dict:
    body = (
        "<PlaceOrder xmlns='urn:example:orders'>"
        f"<sku>{sku}</sku><qty>{qty}</qty>"
        "</PlaceOrder>"
    )
    r = requests.post(SOAP_URL, data=body.encode("utf-8"), timeout=10,
                      headers={"Content-Type": ('application/soap+xml; charset=utf-8; '
                                                'action="urn:example:orders/PlaceOrder"')})
    return {"status": r.status_code, "body": r.text}

@app.post("/orders", status_code=201)
def place_order(order: Order):
    out = soap_call(order.sku, order.qty)
    if out["status"] >= 500:
        raise HTTPException(502, "upstream order service unavailable")
    return {"sku": order.sku, "qty": order.qty}

Migration checklist

  1. Inventory every operation and every known consumer, including scheduled jobs and partner integrations nobody documented.
  2. Decide per operation: keep, wrap or replace. A rarely used lookup can stay on SOAP forever at no cost.
  3. Turn SOAP faults into your new error model first, so failure behaviour is designed rather than improvised.
  4. Run both surfaces against the same backend and compare outputs on a sample of real traffic.
  5. Migrate one consumer, watch it for a full business cycle, then continue.
  6. Keep the SOAP endpoint until traffic is measurably zero for a defined period, then decommission with a rollback plan.
⚠️
Do not translate SOAP faults into HTTP 200 with an error field, or into HTTP 500 for business rejections. The status code is the only part of the response that every intermediary understands, and a wrong one breaks retries, alerting and dashboard accuracy at the same time.

FAQ

Is gRPC a better target than REST here?
For internal service-to-service calls, gRPC maps well onto an RPC contract and gives you streaming and generated clients. For partner-facing or browser-facing APIs, REST with JSON is usually the pragmatic choice.
How long should a facade live?
Treat it as a migration component with an owner and a review date. Facades that are never revisited become the permanent, undocumented contract everyone is afraid to change.

Testing and debugging SOAP services Calling a SOAP service, and why REST usually wins

Last refreshed 2026-09-18.