Kevin Chen WCF to modern .NET migration
Theme

WCF to modern .NET, without breaking a single client

A .NET Framework 4.8 WCF service moved to ASP.NET Core on .NET 10. The real compiled legacy client was pointed at the new host by changing one address in its config file, and its output is identical, line for line. Generated from the repo's README results, golden files and source.

27Tests
27Passed
0Failed
0Skipped
10Recorded SOAP exchanges

What is tested

What is testedHowTests
Raw SOAP compatibilityRequests captured from the real net48 service are replayed against CoreWCF, and the response XML is compared with what the old service sent, including faults and nulls10
WSDL compatibilityOperations, SOAP actions, faults and schema types compared with the legacy WSDL, and the classic ?wsdl address still answers2
The real legacy clientThe compiled net48 exe runs against CoreWCF with only the address changed, and its transcript must match1
A System.ServiceModel clientTyped calls and typed faults against the new endpoint3
REST and OpenAPIStatus codes, problem details, tracking, the OpenAPI document7
One shared layerBooked by SOAP and read by REST, then the reverse1
Guards on the golden filesThe files exist, cover every operation and hold the expected legacy values3

The legacy client, unchanged

The transcript from the compiled net48 client. A test runs the same exe against the new host and requires this output.

Book: HF-100004 charge=28.65 AUD
Get HF-100004: account=AC2001 Z03->Z08 Express 3.2kg items=2 charge=28.65 status=Booked booked=(just now) kind=Utc ref=PO-1234
Get HF-100001: account=AC1001 Z01->Z01 Standard 2.0kg items=1 charge=12.70 status=Delivered booked=2025-03-03T08:15:00.0000000Z kind=Utc ref=PO-7781
Get HF-100002: account=AC1002 Z02->Z07 Express 11.5kg items=3 charge=47.85 status=Despatched booked=2025-03-04T09:30:00.0000000Z kind=Utc ref=(none)
Get HF-999999: null
Track HF-100001: Booked at 2025-03-03T08:15:00.0000000Z in Z01
Track HF-100001: Despatched at 2025-03-03T09:00:00.0000000Z in Z01
Track HF-100001: PickedUp at 2025-03-03T09:45:00.0000000Z in Z01
Track HF-100001: Delivered at 2025-03-03T10:30:00.0000000Z in Z01
Track HF-999999: fault number=HF-999999 reason=Consignment not found.
Invalid booking: fault field=WeightKg problem=Weight must be above 0 and at most 500 kg. reason=Weight must be above 0 and at most 500 kg.

Recorded SOAP traffic

Captured from the real net48 service. Each request is replayed against CoreWCF and its response compared with what the old service sent, including faults and nulls.

HTTP status from the legacy service:

Request

Legacy response, the contract to match

What was kept, and why

Kept in SOAPWhy
The RefrenceNo field name, typo includedClients generated code from it. Renaming it would silently send them nulls. REST uses reference.
GetConsignment returns null for an unknown number, while TrackConsignment raises a typed faultInconsistent, but clients have code paths written for each. REST returns a plain 404 for both.
Charging per started half kiloExisting invoices depend on it. A migration is the wrong moment to change a business rule.
Fault shapes, validation order and messagesClients may show or parse them.
Namespace, SOAP actions and basicHttpBindingThey are the contract.

The code, before and after

The data contract

The wire contract is copied across untouched, typo included. Differences are the namespaces and the runtime's attributes.

- legacy/Harbour.Legacy.Service/Contracts.cs
+ modern/src/Harbour.Consignments.Web/Soap/Contracts.cs
78 lines the same, 24 removed, 23 added, ignoring indentation

+ // The frozen SOAP contract. Names, namespaces and even the "RefrenceNo" typo must not change, because
+ // clients we cannot see depend on them. The golden tests fail if anything here drifts from the legacy service.
- using System;
  using System.Runtime.Serialization;
+ using CoreWCF;
+ 
+ namespace Harbour.Consignments.Web.Soap;
- using System.ServiceModel;
  
- namespace Harbour.Legacy.Service
- {
  internal static class Ns
  {
      public const string Value = "http://harbourfreight.example/consignments/2014/06";
  }
  
  [ServiceContract(Namespace = Ns.Value)]
  public interface IConsignmentService
  {
      [OperationContract]
      [FaultContract(typeof(ValidationFault))]
      BookingResult BookConsignment(BookingRequest request);
  
      /// <summary>Returns null for an unknown number. Clients depend on that, so it never faults.</summary>
      [OperationContract]
+     ConsignmentDetail? GetConsignment(string consignmentNumber);
-         ConsignmentDetail GetConsignment(string consignmentNumber);
  
      [OperationContract]
      [FaultContract(typeof(ConsignmentNotFoundFault))]
      TrackingEvent[] TrackConsignment(string consignmentNumber);
  }
  
  [DataContract(Namespace = Ns.Value)]
  public enum ServiceLevel
  {
      [EnumMember] Standard,
      [EnumMember] Express,
+     [EnumMember] Overnight,
-         [EnumMember] Overnight
  }
  
  [DataContract(Namespace = Ns.Value)]
  public enum ConsignmentStatus
  {
      [EnumMember] Booked,
      [EnumMember] Despatched,
      [EnumMember] PickedUp,
+     [EnumMember] Delivered,
-         [EnumMember] Delivered
  }
  
  [DataContract(Namespace = Ns.Value)]
  public class BookingRequest
  {
+     [DataMember] public string? CustomerAccount { get; set; }
+     [DataMember] public string? PickupZone { get; set; }
+     [DataMember] public string? DeliveryZone { get; set; }
-         [DataMember] public string CustomerAccount { get; set; }
-         [DataMember] public string PickupZone { get; set; }
-         [DataMember] public string DeliveryZone { get; set; }
      [DataMember] public ServiceLevel ServiceLevel { get; set; }
      [DataMember] public decimal WeightKg { get; set; }
      [DataMember] public int Items { get; set; }
+     [DataMember(Name = "RefrenceNo")] public string? Reference { get; set; }
-         // The typo went live in 2014. Renaming it would break every client, so it stays on the wire.
-         [DataMember(Name = "RefrenceNo")] public string Reference { get; set; }
  }
  
  [DataContract(Namespace = Ns.Value)]
  public class BookingResult
  {
+     [DataMember] public string? ConsignmentNumber { get; set; }
-         [DataMember] public string ConsignmentNumber { get; set; }
      [DataMember] public decimal Charge { get; set; }
+     [DataMember] public string? Currency { get; set; }
-         [DataMember] public string Currency { get; set; }
  }
  
  [DataContract(Namespace = Ns.Value)]
  public class ConsignmentDetail
  {
+     [DataMember] public string? ConsignmentNumber { get; set; }
+     [DataMember] public string? CustomerAccount { get; set; }
+     [DataMember] public string? PickupZone { get; set; }
+     [DataMember] public string? DeliveryZone { get; set; }
-         [DataMember] public string ConsignmentNumber { get; set; }
-         [DataMember] public string CustomerAccount { get; set; }
-         [DataMember] public string PickupZone { get; set; }
-         [DataMember] public string DeliveryZone { get; set; }
      [DataMember] public ServiceLevel ServiceLevel { get; set; }
      [DataMember] public decimal WeightKg { get; set; }
      [DataMember] public int Items { get; set; }
      [DataMember] public decimal Charge { get; set; }
      [DataMember] public ConsignmentStatus Status { get; set; }
      [DataMember] public DateTime BookedUtc { get; set; }
+     [DataMember(Name = "RefrenceNo")] public string? Reference { get; set; }
-         [DataMember(Name = "RefrenceNo")] public string Reference { get; set; }
  }
  
  [DataContract(Namespace = Ns.Value)]
  public class TrackingEvent
  {
      [DataMember] public ConsignmentStatus Status { get; set; }
      [DataMember] public DateTime OccurredUtc { get; set; }
+     [DataMember] public string? Location { get; set; }
-         [DataMember] public string Location { get; set; }
  }
  
  [DataContract(Namespace = Ns.Value)]
  public class ValidationFault
  {
+     [DataMember] public string? Field { get; set; }
+     [DataMember] public string? Problem { get; set; }
-         [DataMember] public string Field { get; set; }
-         [DataMember] public string Problem { get; set; }
  }
  
  [DataContract(Namespace = Ns.Value)]
  public class ConsignmentNotFoundFault
  {
+     [DataMember] public string? ConsignmentNumber { get; set; }
-         [DataMember] public string ConsignmentNumber { get; set; }
  }
- }

The service class

Rules and storage moved out of the WCF class into a plain application layer. What remains is a thin adapter.

- legacy/Harbour.Legacy.Service/ConsignmentService.cs
+ modern/src/Harbour.Consignments.Web/Soap/ConsignmentSoapService.cs
21 lines the same, 109 removed, 38 added, ignoring indentation

+ using CoreWCF;
+ using Harbour.Consignments.Application;
+ using App = Harbour.Consignments.Application;
+ 
+ namespace Harbour.Consignments.Web.Soap;
- using System;
- using System.Collections.Generic;
- using System.Linq;
- using System.ServiceModel;
  
+ /// <summary>Translates between the frozen SOAP contract and the application layer. No business rules here.</summary>
+ [ServiceBehavior(InstanceContextMode = InstanceContextMode.Single, ConcurrencyMode = ConcurrencyMode.Multiple)]
+ public sealed class ConsignmentSoapService(ConsignmentBookingService service) : IConsignmentService
- namespace Harbour.Legacy.Service
  {
+     public BookingResult BookConsignment(BookingRequest? request)
-     // Typical of the era on purpose: validation, pricing and storage all live in the service class,
-     // so none of it can be reused or tested without hosting WCF.
-     [ServiceBehavior(InstanceContextMode = InstanceContextMode.Single, ConcurrencyMode = ConcurrencyMode.Multiple)]
-     public class ConsignmentService : IConsignmentService
      {
+         if (request is null) throw Invalid("request", "A booking request is required.");
+         try
-         private static readonly object Sync = new object();
-         private static readonly Dictionary<string, ConsignmentDetail> Consignments = new Dictionary<string, ConsignmentDetail>();
-         private static readonly Dictionary<string, List<TrackingEvent>> Events = new Dictionary<string, List<TrackingEvent>>();
-         private static int _nextNumber = 100001;
- 
-         static ConsignmentService()
          {
+             var booking = service.Book(new BookingCommand(
+                 request.CustomerAccount, request.PickupZone, request.DeliveryZone,
+                 (App.ServiceLevel)request.ServiceLevel, request.WeightKg, request.Items, request.Reference));
+             return new BookingResult { ConsignmentNumber = booking.ConsignmentNumber, Charge = booking.Charge, Currency = booking.Currency };
-             Seed("AC1001", "Z01", "Z01", ServiceLevel.Standard, 2.0m, 1, 12.70m, ConsignmentStatus.Delivered, new DateTime(2025, 3, 3, 8, 15, 0, DateTimeKind.Utc), "PO-7781");
-             Seed("AC1002", "Z02", "Z07", ServiceLevel.Express, 11.5m, 3, 47.85m, ConsignmentStatus.Despatched, new DateTime(2025, 3, 4, 9, 30, 0, DateTimeKind.Utc), null);
-             Seed("AC1003", "Z05", "Z09", ServiceLevel.Overnight, 0.5m, 1, 28.60m, ConsignmentStatus.Booked, new DateTime(2025, 3, 5, 16, 45, 0, DateTimeKind.Utc), "PO-9920");
          }
+         catch (BookingValidationException ex)
- 
-         private static void Seed(string account, string pickup, string delivery, ServiceLevel level, decimal weight, int items, decimal charge, ConsignmentStatus status, DateTime booked, string reference)
          {
+             throw Invalid(ex.Field, ex.Message);
-             var number = "HF-" + _nextNumber++;
-             Consignments[number] = new ConsignmentDetail
-             {
-                 ConsignmentNumber = number,
-                 CustomerAccount = account,
-                 PickupZone = pickup,
-                 DeliveryZone = delivery,
-                 ServiceLevel = level,
-                 WeightKg = weight,
-                 Items = items,
-                 Charge = charge,
-                 Status = status,
-                 BookedUtc = booked,
-                 Reference = reference
-             };
- 
-             var history = new List<TrackingEvent>();
-             for (var step = ConsignmentStatus.Booked; step <= status; step++)
-             {
-                 history.Add(new TrackingEvent
-                 {
-                     Status = step,
-                     OccurredUtc = booked.AddMinutes(45 * (int)step),
-                     Location = step == ConsignmentStatus.Delivered ? delivery : pickup
-                 });
          }
-             Events[number] = history;
      }
  
+     public ConsignmentDetail? GetConsignment(string consignmentNumber)
-         public BookingResult BookConsignment(BookingRequest request)
      {
+         var c = service.Find(consignmentNumber);
+         return c is null ? null : new ConsignmentDetail
-             if (request == null) throw Invalid("request", "A booking request is required.");
-             if (string.IsNullOrWhiteSpace(request.CustomerAccount)) throw Invalid("CustomerAccount", "Customer account is required.");
-             if (string.IsNullOrWhiteSpace(request.PickupZone)) throw Invalid("PickupZone", "Pickup zone is required.");
-             if (string.IsNullOrWhiteSpace(request.DeliveryZone)) throw Invalid("DeliveryZone", "Delivery zone is required.");
-             if (request.WeightKg <= 0 || request.WeightKg > 500) throw Invalid("WeightKg", "Weight must be above 0 and at most 500 kg.");
-             if (request.Items < 1) throw Invalid("Items", "At least one item is required.");
- 
-             // Weight is charged per started half kilo. Customers' invoices are built on this rule.
-             var halfKilos = Math.Ceiling(request.WeightKg * 2);
-             var charge = 9.50m + halfKilos * 0.80m;
-             if (request.PickupZone != request.DeliveryZone) charge += 4.00m;
-             if (request.ServiceLevel == ServiceLevel.Express) charge *= 1.5m;
-             if (request.ServiceLevel == ServiceLevel.Overnight) charge *= 2m;
-             charge = Math.Round(charge, 2, MidpointRounding.AwayFromZero);
- 
-             lock (Sync)
          {
+             ConsignmentNumber = c.ConsignmentNumber,
+             CustomerAccount = c.CustomerAccount,
+             PickupZone = c.PickupZone,
+             DeliveryZone = c.DeliveryZone,
+             ServiceLevel = (ServiceLevel)c.ServiceLevel,
+             WeightKg = c.WeightKg,
+             Items = c.Items,
+             Charge = c.Charge,
+             Status = (ConsignmentStatus)c.Status,
+             BookedUtc = c.BookedUtc,
+             Reference = c.Reference,
-                 var number = "HF-" + _nextNumber++;
-                 var now = DateTime.UtcNow;
-                 Consignments[number] = new ConsignmentDetail
-                 {
-                     ConsignmentNumber = number,
-                     CustomerAccount = request.CustomerAccount,
-                     PickupZone = request.PickupZone,
-                     DeliveryZone = request.DeliveryZone,
-                     ServiceLevel = request.ServiceLevel,
-                     WeightKg = request.WeightKg,
-                     Items = request.Items,
-                     Charge = charge,
-                     Status = ConsignmentStatus.Booked,
-                     BookedUtc = now,
-                     Reference = request.Reference
          };
-                 Events[number] = new List<TrackingEvent>
-                 {
-                     new TrackingEvent { Status = ConsignmentStatus.Booked, OccurredUtc = now, Location = request.PickupZone }
-                 };
-                 return new BookingResult { ConsignmentNumber = number, Charge = charge, Currency = "AUD" };
      }
-         }
  
-         public ConsignmentDetail GetConsignment(string consignmentNumber)
-         {
-             lock (Sync)
-             {
-                 ConsignmentDetail detail;
-                 return consignmentNumber != null && Consignments.TryGetValue(consignmentNumber, out detail) ? detail : null;
-             }
-         }
- 
      public TrackingEvent[] TrackConsignment(string consignmentNumber)
      {
+         var history = service.Track(consignmentNumber) ?? throw new FaultException<ConsignmentNotFoundFault>(
-             lock (Sync)
-             {
-                 List<TrackingEvent> history;
-                 if (consignmentNumber == null || !Events.TryGetValue(consignmentNumber, out history))
-                 {
-                     throw new FaultException<ConsignmentNotFoundFault>(
              new ConsignmentNotFoundFault { ConsignmentNumber = consignmentNumber },
              new FaultReason(new FaultReasonText("Consignment not found.", "en")));
+ 
+         return history
+             .Select(e => new TrackingEvent { Status = (ConsignmentStatus)e.Status, OccurredUtc = e.OccurredUtc, Location = e.Location })
+             .ToArray();
      }
-                 return history.ToArray();
-             }
-         }
  
+     private static FaultException<ValidationFault> Invalid(string field, string problem) =>
+         new(new ValidationFault { Field = field, Problem = problem }, new FaultReason(new FaultReasonText(problem, "en")));
-         private static FaultException<ValidationFault> Invalid(string field, string problem)
-         {
-             return new FaultException<ValidationFault>(
-                 new ValidationFault { Field = field, Problem = problem },
-                 new FaultReason(new FaultReasonText(problem, "en")));
  }
-     }
- }