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.
| What is tested | How | Tests |
|---|---|---|
| Raw SOAP compatibility | Requests 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 nulls | 10 |
| WSDL compatibility | Operations, SOAP actions, faults and schema types compared with the legacy WSDL, and the classic ?wsdl address still answers | 2 |
| The real legacy client | The compiled net48 exe runs against CoreWCF with only the address changed, and its transcript must match | 1 |
| A System.ServiceModel client | Typed calls and typed faults against the new endpoint | 3 |
| REST and OpenAPI | Status codes, problem details, tracking, the OpenAPI document | 7 |
| One shared layer | Booked by SOAP and read by REST, then the reverse | 1 |
| Guards on the golden files | The files exist, cover every operation and hold the expected legacy values | 3 |
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.
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:
| Kept in SOAP | Why |
|---|---|
The RefrenceNo field name, typo included | Clients 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 fault | Inconsistent, but clients have code paths written for each. REST returns a plain 404 for both. |
| Charging per started half kilo | Existing invoices depend on it. A migration is the wrong moment to change a business rule. |
| Fault shapes, validation order and messages | Clients may show or parse them. |
Namespace, SOAP actions and basicHttpBinding | They are the 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; } } - }
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"))); } - } - }