SOAP vs REST: The Core Differences
SOAP (Simple Object Access Protocol) and REST (Representational State Transfer) represent fundamentally different philosophies about API design. Understanding the conceptual differences is essential before planning a migration.
| Aspect | SOAP | REST |
|---|---|---|
| **Protocol** | Protocol (strict spec) | Architectural style (flexible) |
| **Transport** | HTTP, SMTP, JMS, TCP | HTTP only (in practice) |
| **Data format** | XML (mandatory) | JSON, XML, CSV, any |
| **Contract** | WSDL (machine-readable) | OpenAPI/Swagger (convention) |
| **Error handling** | SOAP Fault envelope | HTTP status codes + body |
| **Security** | WS-Security (headers) | OAuth 2.0, API keys, JWT |
| **Caching** | Not cacheable by default | HTTP caching (GET requests) |
| **State** | Often stateful (WS-*) | Stateless (per request) |
When Migration Makes Sense
- New clients are mobile/JavaScript apps that work better with JSON
- SOAP overhead (XML parsing, WS-* stack) is a performance bottleneck
- You want to expose your API as a public developer platform
- Existing SOAP clients are being retired or can be updated
When to Keep SOAP
- Enterprise B2B integrations with contractual WSDL requirements
- High-security government or financial systems using WS-Security
- Reliable messaging requirements (WS-ReliableMessaging)
- Partners cannot update their systems
Step 1: WSDL Analysis
The WSDL (Web Services Description Language) document is your migration blueprint. It formally defines every operation, data type, and fault your SOAP service exposes.
# Fetch the WSDL:
curl https://your-soap-service.com/service?wsdl > service.wsdl
# Install xmllint for analysis:
sudo apt install libxml2-utils
# List all operations (methods) in the WSDL:
xmllint --xpath '//wsdl:operation/@name' --noout service.wsdl 2>/dev/null
# Or with Python lxml:
python3 - << 'EOF'
from lxml import etree
tree = etree.parse('service.wsdl')
ns = {'wsdl': 'http://schemas.xmlsoap.org/wsdl/'}
ops = tree.xpath('//wsdl:operation/@name', namespaces=ns)
for op in sorted(set(ops)):
print(op)
EOF
Mapping SOAP Operations to REST Endpoints
SOAP operations are verbs; REST uses HTTP verbs + resource nouns:
| SOAP Operation | REST Endpoint | HTTP Method | Notes |
|---|---|---|---|
| `GetUser(userId)` | `/users/{id}` | GET | Read, cacheable |
| `CreateUser(userData)` | `/users` | POST | Returns 201 Created |
| `UpdateUser(userId, data)` | `/users/{id}` | PUT or PATCH | Full vs partial |
| `DeleteUser(userId)` | `/users/{id}` | DELETE | Returns 204 |
| `SearchUsers(criteria)` | `/users?q=...` | GET | Query params |
| `ProcessOrder(order)` | `/orders` | POST | Action as resource |
| `GetOrderStatus(orderId)` | `/orders/{id}/status` | GET | Sub-resource |
For SOAP operations that are genuinely RPC-like (not CRUD), treat the action as a resource: CalculateShipping(items) → POST /shipping-quotes.
Step 2: Error Code Translation
SOAP Fault Structure
SOAP uses a uniform envelope for all errors regardless of the error type:
<!-- SOAP Fault Response (always HTTP 500 in basic SOAP!) -->
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<soap:Fault>
<faultcode>soap:Client</faultcode> <!-- Client or Server -->
<faultstring>User not found</faultstring>
<detail>
<error code="USER_NOT_FOUND" />
</detail>
</soap:Fault>
</soap:Body>
</soap:Envelope>
Mapping to HTTP Status Codes
| SOAP Fault | SOAP `faultcode` | REST HTTP Status | Notes |
|---|---|---|---|
| Input validation failure | `soap:Client` | 400 Bad Request | Client's fault |
| Authentication failed | `soap:Client` | 401 Unauthorized | Add WWW-Authenticate |
| Insufficient permissions | `soap:Client` | 403 Forbidden | Auth OK, access denied |
| Resource not found | `soap:Client` | 404 Not Found | |
| Business rule violation | `soap:Client` | 422 Unprocessable Entity | Semantic error |
| Rate limit exceeded | `soap:Client` | 429 Too Many Requests | Add Retry-After |
| Server-side error | `soap:Server` | 500 Internal Server Error | |
| Service unavailable | `soap:Server` | 503 Service Unavailable | Add Retry-After |
REST Error Response Format (RFC 7807 Problem Details)
{
"type": "https://api.your-domain.com/errors/user-not-found",
"title": "User Not Found",
"status": 404,
"detail": "No user with ID 42 exists in the system.",
"instance": "/users/42"
}
Step 3: Migration Strategy
Strangler Fig Pattern
The strangler fig pattern (Martin Fowler) migrates incrementally without a big-bang cutover:
┌──────────────────────────────┐
│ API Gateway │
└──────────────────────────────┘
│ │
┌─────────┘ └────────────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Legacy SOAP Service │ │ New REST Service │
│ (being strangled) │ │ (growing) │
└──────────────────────┘ └──────────────────────┘
Implementation with nginx as the routing layer:
# Route to REST for new endpoints, SOAP for legacy:
location /api/v2/ {
proxy_pass http://rest_service;
}
location /services/ {
# SOAP → REST adapter (translation layer)
proxy_pass http://soap_adapter;
}
SOAP Adapter (Translation Shim)
Build a thin adapter that accepts SOAP requests from legacy clients and calls the new REST service internally:
from fastapi import FastAPI, Request
from lxml import etree
import httpx
app = FastAPI()
@app.post('/services/UserService')
async def soap_adapter(request: Request) -> str:
body = await request.body()
tree = etree.fromstring(body)
ns = {'soap': 'http://schemas.xmlsoap.org/soap/envelope/'}
op = tree.xpath('//soap:Body/*[1]', namespaces=ns)[0]
operation = op.tag.split('}')[1] # Strip namespace
if operation == 'GetUser':
user_id = op.find('userId').text # type: ignore
async with httpx.AsyncClient() as client:
resp = await client.get(f'http://rest-service/users/{user_id}')
user = resp.json()
return f'''<soap:Envelope xmlns:soap="...">
<soap:Body><GetUserResponse>
<id>{user["id"]}</id>
<name>{user["name"]}</name>
</GetUserResponse></soap:Body></soap:Envelope>'''
raise ValueError(f'Unknown operation: {operation}')
Step 4: Client Communication Plan
Versioned Endpoint Dual Availability
Maintain both API versions simultaneously during transition:
POST /services/UserService # SOAP (legacy, deprecated)
GET /api/v2/users/{id} # REST (new)
Add deprecation notices to SOAP responses:
<!-- In SOAP response header during deprecation period: -->
<soap:Header>
<Deprecation>true</Deprecation>
<Sunset>2026-06-01</Sunset>
<Link href="https://api.your-domain.com/docs/v2" rel="successor-version"/>
</soap:Header>
Migration Timeline Template
Month 1-2: REST API v2 in beta — invite early adopters
Month 3: REST API v2 stable — announce SOAP deprecation
Month 4-5: SOAP endpoints return Deprecation header
Month 6: SOAP endpoints begin returning 410 Gone for migrated operations
Month 7+: Decommission SOAP infrastructure
Communicate proactively: send deprecation notices via email to registered API consumers, post migration guides in your developer portal, and offer migration office hours for large enterprise clients who need hands-on help.