I like the Strangler Fig pattern. It’s one of those transitional architecture patterns to get to a better system. But strangling SOAP is a bit difficult. If you’ve ever tried to “just put a proxy in front” of a legacy SOAP service, you’ll quickly bump into a painful detail: SOAP typically exposes a single HTTP endpoint. All operations share one path, and the actual method is picked using headers like SOAPAction or action, plus the body. That makes the usual reverse‑proxy trick an “all or nothing” mechanism. Not at all a Strangler Fig approach. But there is a way!

Strangling SOAP

This blog shows a pragmatic Strangler Fig approach for SOAP. Host one modern .NET application that selectively handles the operations are migrated in modern .NET, and transparently forwards all other operations to the legacy service (for example .NET Framework). This approach prevents changes on changing clients, WSDL URL, or endpoint path while still moving towards modern .NET. This blog also includes the use of SoapCore, to implement SOAP in modern .NET, and OpenTelemetry for monitoring. The mechanism allows for the move from .NET Framework to modern .NET without breaking clients. Then once in .NET you might also decide to migrate away from SOAP altogether. That subject is not covered in this article.

Find the example code on GitHub.

Why a generic proxy (like YARP) is not the answer here

YARP is excellent for HTTP routing, but SOAP concentrates all operations behind a single URL. That creates a few problems for a “drop-in proxy” approach:

  • Single path routing: There’s no per-operation path to route on. Everything is a POST on the same endpoint.
  • Operation is in headers/body: The router would need to parse SOAPAction or the SOAP envelope to decide per-call routing. YARP can match headers when you enumerate them up front, but you either:
    • list every legacy operation (becoming a change tax for every migration step), or
    • use a catch-all and can’t distinguish what should be handled locally vs forwarded.

Net effect: a proxy alone won’t let you gradually “take over” operations inside a single SOAP endpoint while keeping WSDL and client expectations intact. You need a component that can be both a SOAP server (for migrated ops) and a transparent SOAP forwarder (for everything else).

The example legacy application

The example legacy application is a simple .NET Framework SOAP service that exposes three operations (Greet, Add and Subtract) using WCF. This would be the application that you want to modernize.

Patch for the examples legacy service in the project: /LegacyService

For those that cannot run .NET Framework (i.e., Mac and Linux), I have included a wiremock configuration to mock the legacy service (see /AppHost/LegacyMock).

The modernized service

This solution hosts one ASP.NET Core process that:

  • Implements a subset of the same contract IService.
  • Intercepts incoming requests, inspects SOAPAction, and if the action is not implemented in the new code, it forwards the raw request to the legacy service.

How it works (walkthrough)

1. Implement a SOAP server in modern .NET

In NewService/Program.cs:

builder.Services.AddSoapCore()
    .AddSoap(builder.Configuration, builder.Environment);
...
...
app.UseSoapEndpoint<IService>(soapActionPath, new SoapEncoderOptions());
  • AddSoap(...) (from SoapCore) enables hosting a SOAP endpoint in this app.
  • UseSoapEndpoint<IService> exposes the modern endpoint for the operations you’ve migrated.
  • Notice that /NewService/Soap/IService.cs and /LegacyService/IService.cs are syntactically identical. This is great for migration: you can reuse the same implementation.

To summarize, this is the SOAP server implementation in modern .NET. Making use of SoapCore. Big thanks to the SoapCore team for this great library!

2. Falling back to the Legacy service

In NewService/Program.cs:

builder.Services.AddHttpClient("ServiceClient", client =>
{
    client.BaseAddress = new Uri(
        Environment.GetEnvironmentVariable("STRANGLED_SOAP_SERVICE") 
        ?? "http://localhost:5000/");
});
...
...
app.MapSoapFallback<IService>(soapActionPath, "ServiceClient");
app.UseSoapEndpoint<IService>(soapActionPath, new SoapEncoderOptions());

The fact that MapSoapFallback is before UseSoapEndpoint means that the fallback middleware is executed before the SOAP endpoint. This allows the fallback middleware to inspect the incoming SOAPAction and decide whether to forward the request to legacy or not.

That logic is implemented in NewService/SoapProxy.cs. It

  • checks which actions are implemented in the new service,
  • inspects the incoming SOAPAction header, to either forward the request to legacy or handle it locally.
  • To forward, it uses the “ServiceClient” HttpClient.

The check on implementation is done by reflection on the IService contract. Any method marked with [OperationContract] is considered migrated. This use of reflection prevents you from having to maintain a list of migrated operations.

⚠️ Note that the CallFallbackSoapService() in NewService/SoapProxy.cs is a naive implementation that might need further hardening for production use! ⚠️

2. Observability with OpenTelemetry

In NewService/OpenTelemetry.cs there is a straightforward OpenTelemetry configuration. This enables you to see any incoming and outgoing HTTP requests.

Additionally, in NewService/Program.cs there is a AddSoapActionTag() call that adds a tag to all incoming requests. This allows you to see which SOAP action a certain HTTP call is about.

See it in action

Pre-requisites

  • .NET 9 SDK
  • Docker runtime or .NET Framework 4.8 runtime

Start the app

  • From the solution root, run the Aspire host:
    • Rider/Visual Studio: set startup to AppHost and Run
    • CLI: dotnet run --project AppHost/AppHost.csproj
  • Wait until both resources are healthy:
    • WireMock: http://localhost:5001
    • New service: https://localhost:7057.
  • Optional: open the Aspire dashboard and click the legacy-service-mock links:
    • Requests → see proxied SOAP calls arrive at legacy
    • Mappings → inspect or tweak WireMock mappings

When on Windows you can also decide to run the example lecagy application in .NET Framework 4.8 from /LegacyService/LegacyService.csproj. Then in AppHost/Apphost.cs comment the two marked lines. Running the example in this mode does not require Docker.

Make a few SOAP calls

There’s a ready-to-run HTTP scratch file: AppHost/EndpointTesting.http. Adjust the @host variable to match your new-service URL if it’s not https://localhost:7057.

1) Migrated operation: Greet (handled by the new .NET service)

POST /Service.svc
Content-Type: text/xml; charset=UTF-8
SOAPAction: http://netwatwezoeken.nl/IService/Greet

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
  <s:Body>
    <Greet xmlns="http://netwatwezoeken.nl">
      <input>
        <FirstName>Jos</FirstName>
        <LastName>Hendriks</LastName>
      </input>
    </Greet>
  </s:Body>
</s:Envelope>

Run it and expect a friendly response from the modern service (Hello Jos, this is modern .NET). In the Aspire Traces you should see a single span for the SOAP call: Soap call in Aspire

Notice that the soap action is visible in the trace.

2) Legacy operation: Add (forwarded to WireMock)

POST /Service.svc
Content-Type: text/xml; charset=UTF-8
SOAPAction: http://netwatwezoeken.nl/IService/Add

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
  <s:Body>
    <Add xmlns="http://netwatwezoeken.nl">
      <a>1</a>
      <b>3</b>
    </Add>
  </s:Body>
</s:Envelope>

Run it and expect a valid SOAP response coming from the legacy mock. In the Aspire Traces you should see a trace with two spans for the SOAP call: Soap call in Aspire

Notice that the .NET service has delegated the call to the legacy service.

Summary

You can migrate SOAP safely to modern .NET. One operation at a time while clients keep calling the same endpoint. The modern service serves calls to new operations; legacy still serves all others. No coordination needed with consumers. Thanks to SoapCore implementation of SOAP in modern .NET, this is a breeze. And with OpenTelemetry, you can see the SOAP calls and measure the impact of the migration.

This is the essence of the Strangler Fig pattern: the new tree grows around the old one, taking over function by function until the legacy can be retired.

Notes

  • NewService/SoapProxy.cs is not production hardened. Make adjustments accordingly before using this in production.
  • Some clients send quoted actions or use SOAP 1.2 action parameter. The sample focuses on SOAP 1.1 SOAPAction; extend the detector if you need both.