The real world is a difficult place. So difficult that you sometimes have to work on an old Monolithic application built with legacy technology. But is there a way that allows you to at least share code between the old and the new?
YouYou work as a developer for We’ve All Been There Inc. (WABT Inc.), and you have recently made a new API. This API will serve as a crucial component in WABT’s systems portfolio. Several systems need to integrate with it. Amongst them is “The Monolith” – an application running on .NET Framework (4.8). Your new API is a long step in the direction of splitting The Monolith, but there is still a long way to go. So you've still got to deal with legacy .NET for the foreseeable future.
To enable simple integrations with your new API, you decide to write a C# client library. The library should be distributed as a NuGet package and it has the following requirements:
- The client should utilize structured logging for debug and informational purposes.
- The team already has a library for logging in
NET48
, as this is not a native part of the framework: Wabt.Logging
- The team already has a library for logging in
- There should be an easy way of integrating the client with Dependency Injection (DI) systems in both
NET5.0
(new systems) andNET48
.- The Monolith uses Ninject for DI.
Using HttpClient to call an API is straight forward. If it wasn’t for the other requirements, the library would target netstandard2.0 and both it and this blog post would be over:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0</TargetFrameworks>
<AssemblyVersion>$(Version)</AssemblyVersion>
<VersionPrefix>1.33.7</VersionPrefix>
<!-- Other stuff-->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.*" />
</ItemGroup>
</Project>
With the extra requirements, it seems that targeting both net48
and net5.0
is unavoidable. But you don’t like the idea of having #if
directives all over the place. Is there any way you can have clean code, that is easy to maintain? Both the shared parts and the framework specifics? Having #if
directives everywhere sure isn’t clean, nor easy to maintain. So you need to think smart when solving this.
You decide to get the project file out of the way first. The csproj-file at least has simple constructs to allow you to define multiple TargetFrameworks
. You can also define specific dependencies for each target using a Condition
.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net5.0;net48</TargetFrameworks>
<AssemblyVersion>$(Version)</AssemblyVersion>
<VersionPrefix>1.33.7</VersionPrefix>
<!-- Other stuff-->
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net5.0'">
<!-- net5.0 dependencies -->
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.*" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net48'">
<!-- net48 dependencies -->
<Reference Include="System.Net.Http" />
<PackageReference Include="Wabt.Logging" Version="4.*" />
<PackageReference Include="Ninject" Version="3.*" />
<PackageReference Include="Ninject.Web.Common" Version="3.*" />
</ItemGroup>
<ItemGroup>
<!-- Common dependencies -->
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.*" />
</ItemGroup>
</Project>
With that out of the way, you decide to tackle the ApiClient. Inspired by an example from the .NET Docs, your first attempt looks like this:
#if NET5_0_OR_GREATER
using Microsoft.Extensions.Logging;
#else
using Wabt.Logger;
#endif
namespace Wabt.PingApiClient
{
public class ApiClient
{
private readonly HttpClient _client;
#if NET5_0_OR_GREATER
private readonly ILogger<ApiClient> _logger;
public ApiClient(HttpClient client, ILogger<ApiClient> logger)
{
_client = client;
_logger = logger;
}
#else
private readonly ILogger _logger;
public ApiClient(HttpClient client, ILogger logger)
{
_client = client;
_logger = logger;
}
#endif
public async Task<string> Ping()
{
var response = await _client.GetAsync("/api/ping");
if (response.StatusCode == HttpStatusCode.Forbidden)
{
# if NET5_0_OR_GREATER
_logger.LogError("You are not allowed to play Ping Pong");
#else
_logger.Error("You are not allowed to play Ping Pong");
#endif
throw new HttpRequestException("Forbidden");
}
return await response.Content.ReadAsStringAsync();
}
}
}
"Clean code, that is easy to maintain" isn’t what comes to mind when you look at this. There are still features to be added to the ApiClient and there’s #if directives everywhere. There has to be a better way. If you could maybe isolate the parts that are different depending on the target. In this case, that is the logging. So you do what any other great developer would do – you create a wrapper:
using System;
namespace Wabt.PingApiClient.Utils
{
#if NET5_0_OR_GREATER
using Microsoft.Extensions.Logging;
internal class ClientLogger<T> : IClientLogger<T>
{
private readonly ILogger<T> _logger;
public ClientLogger(ILogger<T> logger)
{
_logger = logger;
}
public void LogInformation(string messageTemplate, params object[] props) => _logger.LogError(messageTemplate, props);
public void LogError(string messageTemplate, params object[] props) => _logger.LogError(messageTemplate, props);
}
#else
using Wabt.Logger;
internal class ClientLogger<T> : IClientLogger<T>
{
private readonly ILogger _logger;
public ClientLogger(ILogger logger)
{
_logger = logger;
}
public void LogInformation(string messageTemplate, params object[] props) => _logger.Error(messageTemplate, props);
public void LogError(string messageTemplate, params object[] props) => _logger.Error(messageTemplate, props);
}
#endif
}
Okay, so this approach has an obvious downside. You now have to duplicate every logging method you need instead of leveraging the respective interfaces. But since you only need a few of them, it may be a trade off worth making. Because now, the ApiClient looks like this:
namespace Wabt.PingApiClient
{
public class ApiClient
{
private readonly HttpClient _client;
private readonly IClientLogger<ApiClient> _logger;
public ApiClient(HttpClient client, IClientLogger<ApiClient> logger)
{
_client = client;
_logger = logger;
}
public async Task<string> Ping()
{
_logger.LogInformation("Calling {baseAdress}{route}", _client.BaseAddress , "/api/ping");
var response = await _client.GetAsync("/api/ping");
if (response.StatusCode == HttpStatusCode.Forbidden)
{
_logger.LogError("You are not allowed to play Ping Pong");
throw new HttpRequestException("Forbidden");
}
return await response.Content.ReadAsStringAsync();
}
}
}
No #if
directives! In fact, it is completely oblivious to the multitargeting happening within ClientLogger. All you need to do now is make sure that ClientLogger is wired up in DI whenever someone tries to use the client.
When it comes to DI, the task of supporting Ninject for net48
and the standard IServiceCollection
for net5.0
isn’t all that complicated. While the two approaches share the main functionality, they are fundamentally different. So the simplest way is to have two different static classes with extension methods for IKernel
and IServiceCollection
respectively:
#if NET5_0_OR_GREATER
// ServiceCollectionExtensions.cs
using Microsoft.Extensions.DependencyInjection;
namespace Wabt.PingApiClient
{
public static class ServiceCollectionExtensions
{
public static void AddPingApiClient(this IServiceCollection services)
{
// wire it up!
}
}
}
#endif
#if NET48
// NinjectExtensions
using Ninject;
namespace Wabt.PingApiClient
{
public static class ServiceCollectionExtensions
{
public static void AddPingApiClient(this IKernel kernel)
{
// wire it up!
}
}
}
#endif
Notice the #if
directives. The entire file is dependent on the framework. So the net48
version does not contain ServiceCollectionExtensions.cs
and the net5.0
version does not have NinjectExtensions.cs
. So now The Monolith can use kernel.AddPingApiClient()
and the other applications can use services.AddPingApiClient()
- both from the same package.
This approach works well in this particular case. But if there was another project in WABT’s portfolio that has an Autofac based DI-system while also targeting net48
, it would be a little messier. This project does not need to reference Ninject. If you find yourself in this position, you are better off splitting the DI-extensions into separate libraries instead. Maybe keep the IServiceColleciton
-part in the core library (still for net5.0
only) and make separate ones for Ninject, Autofac and others you need.
When you look back at your multitargeting client library, you realize that your method builds on known principles. Isolate the ugly parts and build neat facades hiding it from the rest. That makes your code easier to maintain. It also makes writing tests simpler. And the best part about that is that you can target multiple frameworks in a test project as well. Running dotnet test
will run your tests once for each target. Now you’re thinking with multiple targets!