All articles

WCF in the AI Era: Testing Legacy Services and Migrating to CoreWCF or gRPC

A pragmatic WCF migration path: pin behavior with generated-client tests, lift unchanged contracts onto .NET 10 with CoreWCF (verified end to end), then move consumers to gRPC on your schedule.

0 · log in to like, save & follow Share on LinkedIn Share on X

WCF never went away — banks, insurers, and manufacturers run thousands of SOAP services that work every day. What changed is the platform under them: classic WCF is tied to .NET Framework, which means no .NET 10 performance, no modern containers, and a shrinking hiring pool. Meanwhile the AI-assistant era raises the stakes — services locked in legacy runtimes are the hardest ones to expose to modern tooling. This guide is the pragmatic path: keep testing what you have, lift the contracts onto modern .NET with CoreWCF, and know when the real destination is gRPC. The CoreWCF sample here is verified end to end on .NET 10.

WCF in the AI Era: Testing Legacy Services and Migrating to CoreWCF or gRPC

Step 0: stabilize what exists

Before migrating anything, make the current behavior testable — it's your safety net. WCF Test Client handles exploratory checks; for regression protection, generate a modern client and pin behavior in tests:

dotnet tool install -g dotnet-svcutil
dotnet-svcutil http://legacy-server/CalculatorService.svc?wsdl

dotnet-svcutil produces a System.ServiceModel.*-based proxy that runs on modern .NET — meaning your test suite can be .NET 10 xUnit even while the service stays on Framework. Capture the SOAP envelopes for key operations (the test client's XML tab, or Fiddler) — byte-level goldens catch serialization drift during migration better than value asserts.

Path 1: CoreWCF — same contracts, modern runtime

CoreWCF is the community/.NET Foundation port of WCF's server stack to modern .NET, with Microsoft support. The migration is remarkably mechanical because the programming model is preserved:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddServiceModelServices();
builder.Services.AddServiceModelMetadata();          // publishes WSDL

var app = builder.Build();
app.UseServiceModel(serviceBuilder =>
{
    serviceBuilder.AddService<CalculatorService>();
    serviceBuilder.AddServiceEndpoint<CalculatorService, ICalculator>(
        new BasicHttpBinding(), "/CalculatorService.svc");
    app.Services.GetRequiredService<ServiceMetadataBehavior>().HttpGetEnabled = true;
});
app.Run();

The contract and implementation move over unchanged[ServiceContract], [OperationContract], [DataContract], [DataMember] all keep working; only the hosting bootstrap changes from ServiceHost/web.config to ASP.NET Core's builder. Verified from the companion repo — the service publishes WSDL and a classic System.ServiceModel client calls it untouched:

GET http://localhost:5401/CalculatorService.svc?wsdl   → <wsdl:definitions name="CalculatorService" ...
Add(2, 3) = 5
Quote(Quantity=4, UnitPrice=149.50) → net=598 gst=107.64 gross=705.64

That's the migration promise in three lines: existing callers don't know anything changed, but the service now runs on .NET 10 — containers, Linux, modern CI/CD, current security patches.

The other porting task is translating web.config into code. The mapping is mechanical once you see one example:

<!-- old web.config -->
<system.serviceModel>
  <services>
    <service name="CalculatorService">
      <endpoint address="" binding="basicHttpBinding" contract="ICalculator" />
    </service>
  </services>
  <behaviors>
    <serviceBehaviors><behavior><serviceMetadata httpGetEnabled="true" /></behavior></serviceBehaviors>
  </behaviors>
</system.serviceModel>

becomes exactly the UseServiceModel block above — endpoint XML maps to AddServiceEndpoint, behavior XML to the behavior services. Instancing and concurrency attributes ([ServiceBehavior(InstanceContextMode = ...)]) move over unchanged on the class. Config-driven binding tweaks (timeouts, message sizes, security modes) become constructor/property settings on the binding object — searchable, compile-checked, and reviewable in a PR, which after fifteen years of XML feels like a gift.

What transfers and what doesn't:

Works in CoreWCF Doesn't / partial
BasicHttp, WSHttp, NetTcp bindings NetNamedPipe (partial), MSMQ (no)
DataContract + XmlSerializer serialization WF integration, some WS-* extensions
Transport + message security (most modes) IIS-classic behaviors (host in Kestrel instead)
Metadata/WSDL publishing ServiceHost extensibility edge cases

If your service uses the left column — which covers the overwhelming majority — CoreWCF is weeks of work, not quarters.

Path 2: gRPC — the destination for service-to-service

When callers are yours to change, gRPC is what WCF wanted to be: strict contracts (.proto instead of [ServiceContract]), binary serialization (Protobuf instead of SOAP XML), streaming both ways, and first-class codegen in every language. The conceptual mapping is one-to-one:

WCF gRPC
[ServiceContract] interface service in .proto
[OperationContract] rpc method
[DataContract]/[DataMember] message fields
Duplex callbacks bidirectional streaming
ChannelFactory proxy generated client

Here's our calculator contract translated, so the mapping is concrete:

syntax = "proto3";
service Calculator {
  rpc Add (AddRequest) returns (AddReply);
  rpc Quote (Order) returns (OrderTotal);
}
message AddRequest { double a = 1; double b = 2; }
message AddReply { double result = 1; }
message Order { int32 quantity = 1; double unit_price = 2; }
message OrderTotal { double net = 1; double gst = 2; double gross = 3; }
public class CalculatorGrpc : Calculator.CalculatorBase
{
    public override Task<AddReply> Add(AddRequest r, ServerCallContext ctx) =>
        Task.FromResult(new AddReply { Result = r.A + r.B });

    public override Task<OrderTotal> Quote(Order o, ServerCallContext ctx)
    {
        var net = o.Quantity * o.UnitPrice;
        var gst = Math.Round(net * 0.18, 2);
        return Task.FromResult(new OrderTotal { Net = net, Gst = gst, Gross = net + gst });
    }
}

Two details migrating teams notice immediately: proto has no decimal (money either rides as double with care, a scaled int64, or a custom DecimalValue message — decide once, apply everywhere), and every field is optional with a zero default — the [DataMember(IsRequired = true)] discipline becomes validation code. Neither is a blocker; both belong in your migration notes before the first proto ships, not after.

And because CoreWCF hosts inside ASP.NET Core, the transition period is literally both endpoints in one app:

builder.Services.AddServiceModelServices();
builder.Services.AddGrpc();
// ...
app.UseServiceModel(sb => { /* SOAP endpoint as before */ });
app.MapGrpcService<CalculatorGrpc>();

One deployable, one implementation class behind both façades if you factor the logic out, and per-consumer cutover becomes a config change on their side.

The trade: gRPC has no WSDL/SOAP interop, so every caller must move with you — which is why the realistic enterprise sequence is CoreWCF first (unblock the runtime, callers untouched), then gRPC per consumer as each is ready, running both endpoints side by side in the same ASP.NET Core host during the transition. For browser or third-party callers, a minimal REST API façade often beats exposing gRPC-Web.

The AI-era angle

Two practical reasons this migration got more urgent, not less:

  • AI assistants amplify modern stacks. Copilots are dramatically better at ASP.NET Core, gRPC, and Protobuf than at web.config bindings — teams on modern hosting get more leverage from the tools everyone now uses. Legacy lock-in is now also a productivity tax.
  • Services are becoming agent-callable. Exposing a capability to AI agents means clean HTTP/gRPC/OpenAPI surfaces. A CoreWCF-hosted service sits in an ASP.NET Core app where adding a minimal-API or gRPC façade for agents is an afternoon; a Framework ServiceHost cannot follow you there.

Proving parity: the golden-envelope suite

The riskiest bugs in a WCF migration aren't crashes — they're silent serialization drift: a date that shifts time zones, a decimal that rounds differently, an enum that serializes by name where it used to go by number. Guard against the class, not the instances:

  • Golden envelopes: for each key operation, store the exact request/response XML captured from the legacy service (the test client's XML tab is the capture tool). After porting, replay each request against CoreWCF with raw HTTP and diff the response XML. Whitespace-normalize, then demand byte equality — every diff is either a bug or a consciously accepted change you write down.
  • Contract tests from both directions: the generated-proxy suite (client's view of the service) plus the golden envelopes (wire view). The pair catches both "the proxy hides a change" and "the change only shows on the wire".
  • Shadow traffic if stakes are high: run legacy and CoreWCF side by side, tee a copy of production requests to the new host, and diff responses offline for a week. Boring, mechanical, and it converts "we think it's identical" into a number.

Budget-wise, this suite is the largest single line item in a well-run WCF migration — and worth every hour, because it's also the suite that later validates the gRPC endpoints against the same expectations.

A sequencing that works

  1. Pin behavior with generated-client tests against the legacy service.
  2. Port hosting to CoreWCF on .NET 10; run the same test suite against it. Ship — callers unaffected.
  3. Containerize and fold into your CI/CD pipeline like any ASP.NET Core app.
  4. Introduce gRPC (or REST) endpoints alongside SOAP for consumers you control; migrate them one at a time.
  5. Retire the SOAP endpoint when its last caller is gone — measured in your time, not a big bang.

The verified CoreWCF service and classic client pair are in the companion repository — dotnet run the service, run the client, and you've reproduced a WCF migration in miniature.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.