Microsoft Workloads on AWS

Modernizing a WCF service to CoreWCF: Lessons learned

In this blog post, we will share the challenges faced and lessons learned from modernizing a Windows Communication Framework (WCF) service to CoreWCF for a SaaS-based company. CoreWCF is a port of server side of WCF to .NET Core. It is an open source project that is supported by Microsoft. This project has significant contributions from AWS and its goal is to allow existing WCF services to move to .NET Core.

The customer had multiple WCF services that were implemented on legacy .NET Framework 4.x and hosted on Microsoft Internet Information Services (IIS). They deployed the WCF services in an Auto Scaling group of Amazon Elastic Compute Cloud (Amazon EC2) instances, which were Microsoft-licensed Windows Server instances. The WCF services were not containerized and had a dependency on IIS. To scale the services, they added more Windows Server instances to the Auto Scaling group, which increased their operational costs.

The customer wanted to reduce Microsoft licensing costs and improve application scalability to cater to their increasing client base. To reduce Microsoft licensing cost, we upgraded the WCF services to .NET 6 and removed the IIS dependency so that the services could run on Linux machines. We used the Porting Assistant for .NET to port the code from .NET Framework 4.x to .NET 6. Another option we considered was to containerize the WCF services and run them as Windows containers on Amazon EC2 Windows instances. Running multiple containers on a single Amazon EC2 instance improves the compute density; however, it doesn’t remove the Microsoft licensing cost. Hence, we adopted the first solution.

To improve application scalability, our recommendation was to containerize the WCF services (after porting to .NET 6 and CoreWCF) and run them on an Amazon Elastic Kubernetes Service (Amazon EKS) cluster. Scaling the WCF services by adding more Amazon EC2 Windows instances was not the preferred solution because of the high Microsoft licensing costs.

Since there were 80+ clients consuming their WCF services, the customer wanted to maintain backward compatibility for the WCF services that were considered for upgrade. They needed the ability to build on .NET 4.x and .NET 6.

Modernization steps

We took the following steps to modernize one WCF service out of six eligible services.

Step 1: Identify the right WCF service

The first task in this exercise was to identify a WCF service that could be upgraded within 6-8 weeks. We needed to choose a service with the least dependencies and complexity. We considered many factors, such as number of planned updates, the availability of subject matter experts (SMEs), and the criticality of the service (i.e., how heavily is it used in production).

We identified a few eligible services based on inputs from SMEs. We built dependency graphs and recorded data about dependencies, like count, complexity, and .NET Framework version.

We chose the service with the least complexity and fewest dependencies for upgrade.

Step 2: Identify nested dependencies

The selected WCF service had 20+ dependencies and five of them had nested dependencies. In order to identify these dependencies and upgrade them in the right order, we implemented the following strategy (as shown in Figure 1):

  1. Manually created a dependency tree. We started with the service layer/project, noted its dependencies, analyzed each dependency to identify the child dependencies, and repeated this for all child dependencies.
  2. Upgraded all the Nth-level dependencies/projects in the dependency tree from .NET 4.x to .NET 6 and CoreWCF.
  3. Created nuget packages with multi-framework support (.NET 4.x and .NET 6) for the upgraded projects.
  4. Tested the upgraded packages by referencing and accessing methods of the same package in two console applications, each targeting a different framework: .NET 4.x and .NET 6.
  5. Repeated steps 2-4 for (N-1)th-level dependencies in the dependency tree.

Figure 1: Identifying and upgrading nested dependencies
Figure 1: Identifying and upgrading nested dependencies

We upgraded the dependencies in parallel by using different Git branches and periodically merged them into the dev branch.

Step 3: Port the dependencies

One of the key modernization tasks was to port all the dependencies, instead of rewriting on .NET 6, to reduce the development time and minimize code churn. Porting required us to explore internal and external packages. We took the following approach to port the dependencies (as shown in Figure 2):

  1. Check if each dependency is still being used. Find all files/classes/projects referencing the dependency. Remove the ‘using’ statement, remove the package/project reference, build, and run the solution. If there are no build or runtime errors, then it is not required. Delete package or project references for this dependency.
  2. Check if an upgraded version of the dependency exists in .NET 6. If yes, use one with the same major version; otherwise, use the latest version.
  3. Check if the dependency is an internal package for which we have code access. If yes, port the project to .NET 6 using Porting Assistant for .NET.
  4. Use the compiler directive (#if NETFRAMEWORK) if no compatible replacement package is available.

Figure 2: Porting dependencies to .NET 6
Figure 2: Porting dependencies to .NET 6

Step 4: Enable multi-framework builds

The customer wanted to have side-by-side deployments of the legacy WCF service and the upgraded/CoreWCF service. In order for this to work, the projects had to support multiple frameworks.

When Porting Assistant for . NET ports .NET Framework 4.x code to .NET 6, it changes the project file structure and SDK style, and updates the TargetFramework. It also updates references to the latest compatible versions.

To allow a multi-framework build, we added new build configurations: Debug6 and Release6. We set the TargetFramework as net6.0 for these configurations. We also updated the project files to have conditional references based on TargetFramework:

<ItemGroup Condition = "'$(TargetFramework)' == 'net48'">
    <PackageReference Include="Newtonsoft.Json" Version="9.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net6.0'">
    <PackageReference Include="CoreWCF.Primitives" />
</ItemGroup>

We updated the Directory.build.props file to set the TargetFramework based on the build configuration. We manually added this file to the solution folder.

<Project>
    <PropertyGroup>
        ...
        <Configurations>Debug;Release;Debug6;Release6</Configurations>
        ...    
    </PropertyGroup>
    ...
    <PropertyGroup Condition=" '$(Configuration)' == 'Release6' ">
        <ConfigurationGroup>Release</ConfigurationGroup>
        <TargetFramework>net6.0</TargetFramework>
    </PropertyGroup>
</Project>

We updated the customer’s CI/CD pipeline (Azure DevOps) to build projects for both .NET Framework 4.x and .NET 6. Every build creates project assemblies for a specific framework based on the build configuration (Debug/Release/Debug6/Release6).

If you want to create multiple versions of the assemblies for every build, you can use Multi-targeting. With multi-targeting, every build produces multiple versions of the project assembly in their respective framework-specific folders.

Step 5: Handle CoreWCF limitations and specific features

Configuration changes

Legacy WCF has a strong configuration system. It allows DevOps teams to configure WCF services during deployment without touching code. They do this by updating the <system.serviceModel> section in web.config, which is the default configuration file.

CoreWCF does not support the following elements under <system.serviceModel> in web.config:

  • <host>
  • <behaviors>
  • <extensions>

We had to configure these in the startup code.

CoreWCF does not support Mex endpoints to expose service metadata. Check the WSDL/Help Page section for details on how to expose Service metadata.

The solution that we upgraded had to support .NET frameworks versions 4.6.2 and 6.0. There was too much configuration in the existing web.config file, which is not automatically loaded in .NET 6. Even though the preferred file format for configs in .NET Core 3.1 or later frameworks is JSON, we continued to use a single XML file to avoid multiple configuration files—which would make it difficult to maintain, build, and deploy.

To use a specific configuration file in CoreWCF, you need to use the CoreWCF.ConfigurationManager package and explicitly load it. The below code specifies “web.config” as the service configuration file:

builder.Services.AddServiceModelConfigurationManagerFile("web.config");

Handle WCF Behaviors

As mentioned earlier, CoreWCF does not support behaviors configuration in the configuration file. We removed the <extensions> and <behaviors> section from web.config and added service and endpoint behaviors in the Startup.cs file:

IServiceBuilder serviceBuilder = ...;

var basicHttpBinding = new BasicHttpBinding(...) {...};
var soapEndpointBehaviors = new IEndpointBehavior[] { new HeaderForwardingBehavior() };
var serviceBehaviors = new IServiceBehavior[] { new CachingServiceBehavior() };

AddService<UserService>();
AddEndpoint<UserService, IUserService>("/soap", basicHttpBinding, soapEndpointBehaviors);

AddService<JobService>(serviceBehaviors);
AddEndpoint<JobService, IJobService>("/soap", basicHttpBinding, soapEndpointBehaviors);

...

public void AddService<TService>(IEnumerable<IServiceBehavior> serviceBehaviors = null) 
{
        serviceBuilder.AddService<TService>(serviceOptions => {
            serviceOptions.BaseAddresses.Add(new Uri($"http://localhost/{typeof(TService).Name}.svc", UriKind.Absolute));
        });
    
        serviceBuilder.ConfigureServiceHostBase<TService>(serviceHost => {
            foreach (var behavior in serviceBehaviors) {
                serviceHost.Description.Behaviors.Add(behavior);
            }
        });
}

public void AddEndpoint<TService, TContract>(string address, Binding binding, IEnumerable<IEndpointBehavior> endpointBehaviors) 
{
   serviceBuilder.AddServiceEndpoint<TService, TContract>(binding, addr, endpoint => {
        foreach (var behavior in endpointBehaviors) {
          endpoint.EndpointBehaviors.Add(behavior);
        }
    });
}

Although Porting Assistant for .NET ports most of the code from .NET Framework 4.x to .NET 6, we had to make the following additional changes:

  • Add/update nuget package references in the project files (*.csproj).
  • Update WCF custom behaviors to use namespaces, classes, and methods from CoreWCF.

In order to maintain backward compatibility (.NET Framework 4.x and .NET 6), we used compiler directives to have framework-specific code blocks/statements. The following code snippet shows the use of compiler directives to make WCF custom behaviors work for .NET Framework 4.x and .NET 6:

#if NETFRAMEWORK
   using System.ServiceModel;
   ...
#else
   using CoreWCF;
   ...
#endif

public class MyCustomBehavior : IServiceBehavior, IOperationBehavior
{
    ...
    public void ApplyDispatchBehavior(ServiceDescription serviceDescription, ...)
    {
       foreach (var endpoint in serviceDescription.Endpoints)
        {
#if NETFRAMEWORK
            foreach (var operation in endpoint.Contract.Operations)
                if (!operation.Behaviors.Contains(GetType()))
                    operation.Behaviors.Add(this);
#else
            foreach (var operation in endpoint.Contract.Operations)
                if (!operation.OperationBehaviors.Contains(GetType()))
                    operation.OperationBehaviors.Add(this);
#endif
        }
    }
   ...

    public void ApplyClientBehavior(OperationDescription operationDescription, ClientOperation clientOperation)
    {
#if NETFRAMEWORK
        clientOperation.ParameterInspectors.Add(new MyParameterInspector(...));
#else
        clientOperation.ClientParameterInspectors.Add(new MyParameterInspector(...));
#endif
    }
   ...
}

Alternatively, you can have two files for each custom behavior where each file has code specific to a framework. You can adopt this pattern if there are too many framework-specific code blocks or if you want to avoid spaghetti code. However, this pattern can lead to duplication of code across files for the different framework versions. You must also follow a file naming convention to identify the framework from the filename, such as MyCustomBehavior_Net4x.cs (for .NET Framework 4.x) and MyCustomBehavior_Net6.cs (for .NET 6).

MyCustomBehavior_Net4x.cs would have the following content:

#if NETFRAMEWORK
using System.ServiceModel;
...
public class MyCustomBehavior : IServiceBehavior, IOperationBehavior 
{ 
  ...
}  
#endif

In MyCustomBehavior_Net6.cs, the compiler directive would be #if !NETFRAMEWORK.

CoreWCF supports WebHTTP behavior, but the System.ServiceModel in .NET 6 does not. We used HttpClient.

Handle Asynchronous Programming Model (APM) changes

In the legacy WCF service, we implemented asynchronous operations with an Asynchronous Programming Model (APM) pattern using the IAsyncResult interface. CoreWCF does not support this pattern. For custom behaviors, the ApplyDispatchBehavior method throws PlatformNotSupportedException in CoreWCF.

To resolve this issue, our recommendation is to use the Task-based Asynchronous Pattern (TAP), which is more efficient, maintainable, and readable than the IAsyncResult pattern. We refactored the code to use Task and async/await operations instead of the IAsyncResult and BeginXxxx/EndXxxx methods.

// APM-style asynchronous programming:

public IAsyncResult BeginAddNumbers(int a, int b, AsyncCallback callback, object state)
{
    Func<int, int, int> addNumbersDelegate = AddNumbers;
    return addNumbersDelegate.BeginInvoke(a, b, callback, state);
}

public int EndAddNumbers(IAsyncResult result)
{
    Func<int, int, int> addNumbersDelegate = (Func<int, int, int>)result.AsyncState;
    return addNumbersDelegate.EndInvoke(result);
}

// TAP-style asynchronous programming:

public Task<int> AddNumbersAsync(int a, int b)
{
    return Task.Run(() => AddNumbers(a, b));
}

Check the OperationDescription class implementation to understand how the different types of operations (SyncMethod, TaskMethod, and BeginMethod) are invoked.

Step 6: Remove IIS dependency

Handle W3C logs

The customer hosted their existing WCF service on IIS. IIS generates W3C logs. As a part of the upgrade, we changed the web server from IIS to Kestrel and added the following startup code to get W3C logs:

services.AddW3CLogging(options =>
    {
       options.FileName = "W3CLog_";
       options.LoggingFields = W3CLoggingFields.All;
       options.LogDirectory = "<path-to-logs-directory>";

       //AdditionalRequestHeaders is supported in AspNetCore 7.0
       options.AdditionalRequestHeaders.Add("correlation-id");
   });
...
    
app.UseW3CLogging();

Handle HTTP handlers and modules

Kestrel does not support HTTP handlers and modules. We migrated them to middleware in .NET 6. You will need to analyze your HTTP handlers and HTTP modules. If they cannot be written as middleware in .NET 6, you may have to refactor your service.

See how to migrate HTTP handlers and modules on MSDN.

Step 7: Unit testing

We made the following changes to the unit tests.

Multi-framework support

The unit tests had to work for .NET Framework 4.7.2 and for .NET 6. We handled this by adding compiler directives (#if NETFRAMEWORK) to have framework-specific code blocks. Refer to code snippet in the Handle WCF behaviors section.

Service contract annotations

There were two contracts that we used on both the client side and the server side. For server-side usage, we used CoreWCF.ServiceContract and CoreWCF.OperationContract attributes. For the client side, we used System.ServiceModel.ServiceContract and System.ServiceModel.OperationContract. We handled this by applying annotations from both the namespaces:

[CoreWCF.ServiceContract]
[System.ServiceModel.ServiceContract]
public interface IUserService
{
    [CoreWCF.OperationContract]
    [System.ServiceModel.OperationContract]
    User GetUser(string userId);
}

Additional solutions and tips

Multiple endpoints

For WCF services that expose multiple endpoints with different behaviors and bindings, the order in which the endpoints are added to the service is important.

Consider the following example:

serviceBuilder.AddService<Service1>((options => { ... }))
    .AddServiceEndpoint<Service1, IService1>(new WSHttpBinding(SecurityMode.None), "/soap", config => { })
    .AddServiceEndpoint<Service1, IService1>(new WSHttpBinding(SecurityMode.None), "",
        config =>
        {
            config.EndpointBehaviors.Add(app.ApplicationServices.GetRequiredService<SimpleEndpointBehavior>());
            config.EndpointBehaviors.Add(app.ApplicationServices.GetRequiredService<HttpHeaderForwardingBehavior>());
        });

Here, Service1 exposes two endpoints: “” and “/soap”.

We need to add the endpoints in the following sequence:

  1. Add the non-generic or specific endpoint (“/soap”)
  2. Add the generic endpoint (“”)

If the generic endpoint (“”) is added first, then the non-generic endpoint (“/soap”) will not be reachable and requests for “/soap” will get routed to service endpoint: “”.

WSDL / Help page

In order to expose WSDL for a WCF service with multiple endpoints, implement this:

In Startup → ConfigureServices()

services.AddServiceModelServices()
        .AddServiceModelMetadata()
        .AddSingleton<IServiceBehavior, UseRequestHeadersForMetadataAddressBehavior>();

In Startup → Configure()

app.UseServiceModel(serviceBuilder =>
    {
        var serviceMetadataBehavior = app.ApplicationServices.GetRequiredService<ServiceMetadataBehavior>();
       serviceMetadataBehavior.HttpGetEnabled = true;
        
        serviceBuilder.AddService<Service1>((options => { 
            options.DebugBehavior.HttpHelpPageEnabled = true;
            options.BaseAddresses.Add(new Uri("http://localhost:8080/Service1.svc", UriKind.Absolute)); 
        }))
        .AddServiceEndpoint<Service1, IService1>(new WSHttpBinding(SecurityMode.None), "/soap", config => { });
    });

With this modification, you’ll be able to access the help page of Service1 at http://localhost:8080/Service1.svc and the WSDL using the base URL of the service at http://localhost:8080/Service1.svc?wsdl.

To get the base address from Kestrel, you may use this code snippet:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server.Features;

IWebHost host = CreateWebHostBuilder(args).Build();
var address = host.ServerFeatures.Get<IServerAddressesFeature>().Addresses.FirstOrDefault();
AppDomain.CurrentDomain.SetData("BaseUrl", address ?? "");
host.Run();

In Startup → Configure

string baseUrl = AppDomain.CurrentDomain.GetData("BaseUrl").ToString();

Conclusion

In this blog post, we have shared all the steps taken and lessons learned in modernizing WCF services to CoreWCF for one of our customers. With these steps and solutions, we helped our customer get closer to Windows Server licensing freedom. By upgrading to CoreWCF and .NET 6 and by removing the dependency on IIS, we were able to move the application closer to running on Linux.

In order to completely move away from Windows Server and run on Linux, the customer also had to:

  • Remove Windows Registry access and use configs – in file or DB or the AWS Systems Manager Parameter Store.
  • Update all Windows-style file paths used in the application.
  • Remove all Windows-specific APIs / operations and replace them with platform-neutral operations.

The information shared in this blog post will serve as a good migration handbook for teams who want to modernize WCF services.

Check our reference implementation for modernizing WCF services to CoreWCF. It covers most of the topics discussed in this blog post. This should help you get a head start with CoreWCF. After getting hands-on experience with CoreWCF, you may start your modernization journey by using  the Porting Assistant for .NET to port your legacy .NET Framework 4.x code to .NET 6. If you have a large monolith, we recommend using the AWS Microservice Extractor for .NET, which is a modernization tool designed to create dependency graphs and break monolith codebases into microservices.


AWS can help you assess how your company can get the most out of cloud. Join the millions of AWS customers that trust us to migrate and modernize their most important applications in the cloud. To learn more on modernizing Windows Server or SQL Server, visit Windows on AWSContact us to start your migration journey today.

Ramkumar Ramanujam

Ramkumar Ramanujam

Ramkumar Ramanujam is a Senior Cloud Consultant at AWS Professional Services. He enables customers to modernize and migrate their .NET workloads to AWS and has special interest in Containers and Serverless technology. Outside of work, he loves drawing/painting and cricket.

Ankush Jain

Ankush Jain

Ankush Jain is a Cloud Consultant at AWS Professional Services based out of Pune, India. He currently focuses on helping customers migrate their .NET applications to AWS. He is passionate about cloud, with a keen interest in serverless technologies.

Sanjay Chaudhari

Sanjay Chaudhari

Sanjay Chaudhari is a Cloud Consultant with AWS Professional Services. He works with customers to migrate and modernize their Microsoft workloads to the AWS Cloud.

Witold Kowalik

Witold Kowalik

Witek is Senior Cloud Application Architect at AWS Profesional Services based ou of Warsaw, Poland. He is a serverless enthusiast helping customers to modernize their systems with cloud native solutions. Big fan of IoT