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
| Pattern | How it works | Risk |
|---|---|---|
| Facade | A REST or gRPC surface that calls the SOAP service internally | The facade becomes permanent plumbing |
| Strangler | Move operations one at a time behind the new surface, routing both | Two sources of truth during the transition |
| Dual publish | One implementation exposes both protocols from one core | Lowest risk, most code |
| Contract-derived | Generate the new interface from the WSDL | Generated shapes are usually not good API design |
| Retire | Confirm no traffic, notify partners, switch off | The 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
- Inventory every operation and every known consumer, including scheduled jobs and partner integrations nobody documented.
- Decide per operation: keep, wrap or replace. A rarely used lookup can stay on SOAP forever at no cost.
- Turn SOAP faults into your new error model first, so failure behaviour is designed rather than improvised.
- Run both surfaces against the same backend and compare outputs on a sample of real traffic.
- Migrate one consumer, watch it for a full business cycle, then continue.
- 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.
Related
Testing and debugging SOAP services Calling a SOAP service, and why REST usually wins
Last refreshed 2026-09-18.