Mixing generics and non-generics in C#
The problem
I’m coming across this situation more and more recently, so I figured others might be as well:
Suppose you have a concept called Location
, and you want to support a bunch of different types of Location
. You may implement an ILocation
interface and have e.g. Address : ILocation
, Coordinate : ILocation
etc.
Now suppose you want to allow the end-user of your application to pick a Location
, but you want to build the code so it doesn’t really care what type of Location
it is.
This could be a useful design to allow for adding more types of locations in the future, think Open/Closed Principle.
The simple solution
Now one way to go about this, would be to abstract the service/whatever retrieving locations (be it from an external service, a database or something third):
public interface ILocationService
{
ILocation GetLocationAsync(Guid id, CancellationToken cancellationToken = default);
}
Now you can create a bunch of services for your different location types:
internal class AddressLocationService : ILocationService
{
public ILocation GetLocationAsync(Guid id, CancellationToken cancellationToken = default)
{
// Logic here
return address;
}
}
And even use Keyed Dependency Injection if you want to, by adding e.g. a enum LocationType
.
The shortcomings of this solution
Wait what? What shortcomings, we’re golden right??
Well yes, BUT what if you wanted to take the quality of your code up a notch and do some Unit Testing?
All your tests of AddressLocationService
would go something like this:
[Fact]
public async Task GetLocationAsync_ValidId_ReturnsAddress()
{
// Arrange
var service = new AddressLocationService();
// Act
var location = await service.GetLocationAsync(validAddressId);
// Assert
var address = Assert.IsType<Address>(location);
// Actual assertions
}
You’d need to explicitly cast (or Assert
) your results back to the Address
type every single time.
Sure it’s doable, but it’s not exactly nice
Furthermore what in this design is actually protecting you from inadvertently returning e.g. a Coordinate
from the service called AddressLocationService
? Nothing, as there’s no strong typing here.
Finally what if you’d like some strong typing of e.g. your ids? (Consider using StrongTypedId by yours truly ;-) )
That’s not possible either, as your method signature is set in stone by the ILocationService
.
Generics to the rescue, or????
No worries I hear you say, we’ll just add the magic sauce of generics to the mix and change our ILocationService
into an ILocationService<T>
- that’ll surely fix our type issues for the results.
Heck whilst we’re at it, why don’t we go ahead and handle the strong typing of ids as well with ILocationService<TId, TLocation>
Our new interface would look like this:
public interface ILocationService<TId, TLocation>
where TId: StrongTypedGuid<TId>
where TLocation: ILocation
{
TLocation GetLocationAsync(TId id, CancellationToken cancellationToken = default);
}
And here’s a couple of implementations for good measure:
public class AddressLocationService : IlocationService<AddressId, Address>
{
public Address GetLocationAsync(AddressId id, CancellationToken cancellationToken = default)
{
// Logic here
return address; // Won't compile if this isn't an Address
}
}
public class CoordinateLocationService: ILocationService<CoordinateId, Coordinate>
{
public Coordinate GetLocationAsync(CoordinateId id, CancellationToken cancellationToken = default)
{
// Logic here
return coordinate; // Again won't compile if this isn't a Coordinate
}
}
This is gonna be AWESOME for unit testing, we’ll get the right return types right away, we’re protected from mixing id types. Generics saved the day!!
Fact]
public async Task GetLocationAsync_ValidId_ReturnsAddress()
{
// Arrange
var service = new AddressLocationService();
// Act
var address = await service.GetLocationAsync(validAddressId);
// Assert
// Actual assertions, as address is already of the right type
}
The shortcomings of this solution
Wait what? I’m still not satisfied??
Well here’s the thing, whilst the above design using generics DOES resolve the shortcomings of the initial solution, it also completely BREAKS the initial solution and actually doesn’t serve the initial purpose. This is because you can no longer simply go keyed dependency injection and grab the relevant ILocationService
via e.g. enum LocationType
, as the ILocationService<TId, TLocation>
is now heavily typed with generics.
Bollocks, if only there was a way to have your cake AND eat it too…
The mixed solution
Turns out there is, by combining 3 C# language features (some of which are quite lesser known), we can mix the two solutions and get the best of both worlds WITHOUT their drawbacks. The features we’ll be needing are:
So how do we do it? Well we’ll be having BOTH ILocationService
interfaces defined, and let the generic one implement the non-generic one:
public interface ILocationService
{
ILocation GetLocationAsync(Guid id, CancellationToken cancellationToken = default);
}
public interface ILocationService<TId, TLocation> : ILocationService
where TId: StrongTypedGuid<TId>
where TLocation: ILocation
{
new TLocation GetLocationAsync(TId id, CancellationToken cancellationToken = default); // Shadow method, hiding the version from ILocationService
ILocation ILocationService.GetLocationAsync(Guid id, CancellationToken cancellationToken) // Explicit Interface Implementation
{
return this.GetLocationAsync(StrongTypedGuid<TId>.Create(id), cancellationToken); // Default Interface Member
}
}
Ok a lot is happening here, let’s go over it:
- We HIDE the “simple”
GetLocationAsync
method on our generic interface, so anyone implementing the generic interface ONLY exposes the strongly typed version. - We Explicitly Implement the “simple”
GetLocationAsync
method using the Default Interface Member feature, to ensure any classes implementing the generic interface won’t have to implement both methods. Our default implementation simple forwards the method call to the strong-typed one.
Note: This only works the the types are compatible, meaning your want a Covariant return type and a Contravariant input type.
In our case we ensure Covariance of the return type via a generic constraint: where TLocation: ILocation
, and we actually forego the Contravariance requirement by means of the StrongTypedGuid
type, which is capable of creating a more specific type from a lesser one using it’s Create
method.
If you’re not using a library that supports this, you cannot have stronger typed input arguments!
Ok that sounds somewhat well and dandy, but how would our AddressLocationService
and unit testing look?
Well they’ll look exactly like the generic solution above:
public class AddressLocationService : IlocationService<AddressId, Address>
{
public Address GetLocationAsync(AddressId id, CancellationToken cancellationToken = default)
{
// Logic here
return address; // Won't compile if this isn't an Address
}
}
Fact]
public async Task GetLocationAsync_ValidId_ReturnsAddress()
{
// Arrange
var service = new AddressLocationService();
// Act
var address = await service.GetLocationAsync(validAddressId);
// Assert
// Actual assertions, as address is already of the right type
}
But what about our use case with Keyed DI? No worries, just inject the services as ILocationService
:
services.AddKeyedSingleton<ILocationService, AddressLocationService>(LocationTypes.Address);
services.AddKeyedSingleton<ILocationService, CoordinateLocationService>(LocationTypes.Coordinate);
And you’ll be getting the “simple” ILocationService
for those situations that require the utmost abstraction level.
In other words: You’re getting your cake AND you can eat it too, bon appetit :-)