Interfacing in style
Tuesday, 9. Feb 2021
I tend to give the same talks to clients whenever I enter another project, mostly to get everybody up to speed when there are deficits. In writing everything down, I hope to make the knowledge transfer a little easier, so that I can refer to this and other similar articles in the future.
Interfaces are a very useful and important construct in C#, and I believe that any developer worth their salt is going to agree. But they can also be one of the biggest flaws behind an error-prone architecture or a hard to maintain code base. I will start by describing the two basic kinds of interfaces and then describe the typical situation in the code base of most clients. The focus will be on how to improve this towards a more modular architecture for a cleaner and easier to maintain code.
General kinds of interfaces
The first kind are interfaces that are very simple descriptions of a generalized behaviour, like INotifyPropertyChanged or IDisposable. Interfaces like that provide a simple API for a specific interaction or manipulation of objects. There exists no single common implementation for these, as they are used in an ‘I can do X’ kind of manner. Types often implement many such behaviours.
The second kind are interfaces that describe the public API of a type in great detail. They often inherit a set of behaviour type interfaces as well, and there exists at least one common implementation. Interfaces like that can be used to decouple any implementing type from the calling code, to allow for a rapid replacement of the underlying implementation.
public interface IDataChannel : INotifyPropertyChanged, IDisposable {
// properties
// methods
// …
}
public class DataChannel : IDataChannel {
// properties
// methods
// …
// INotifyPropertyChanged members
// IDisposable members
}
As always, there is a grey area in between, a wide middle ground that contains hybrids. These can be partial type descriptions or a commonly used set of behaviours combined into a single interface for pure ease of use reasons.
But I want to talk about the second group, since this is the one with the highest potential. It’s also the one that people seem to have the biggest issues with. In most cases, there is a defining interface and a single implementation to go with it. Both are declared public members within the same assembly, and can be accessed equally from anywhere in the code base. This is the norm with most of my clients, so I’ll start from here. Since an interface is a contract, I will refer to them as contracts for the rest of this article.
Hiding the implementation
The given setup works alright, but it makes it easy to accidentally use the implementing type when the contract should be used instead. Most teams that I’ve worked with, assure that ‘We all watch out in our code reviews, and we only use the interfaces’, but that’s usually far from the truth. If the implementation is there and available, then it’s going to be used at some point. Furthermore, if the implementing type is used in the code, then there is no point in having the contract at all. Our goal should therefore be to make it as hard as possible – if not impossible – to directly use a concrete implementation.
A good solution is to hide the implementation by making it internal and to instantiate it using a factory. Every consumer of the code will be forced to use the contract, so that the implementing type cannot leak into referring code.
public interface IDataChannel : INotifyPropertyChanged, IDisposable {
// …
}
internal class DataChannel : IDataChannel {
// …
}
public static class Factory {
public static IDataChannel CreateDataChannel() => new DataChannel();
}
If a DI container is used, then making the implementation internal isn’t feasible any more, since it must be accessible and therefore be declared public. But making a type accessible doesn’t mean that it can’t be hidden from the view of a consumer. The attribute EditorBrowsable on a public type is a good way of hiding a type or member from the IDE’s intellisense.
public interface IDataChannel : INotifyPropertyChanged, IDisposable {
// …
}
[EditorBrowsable(EditorBrowsableState.Never)]
public class DataChannel : IDataChannel {
// …
}
public static class Factory {
public static IDataChannel CreateDataChannel() => new DataChannel();
}
Splitting interface and implementation
As all roads lead to Rome, there are many ways to implement a functionality. Therefore, it isn’t probable that there will only ever be a single implementation for a given contract. Requirements and environments change and so does technology. Specialized high-performance implementations are a common way of leveraging advanced mechanisms that may only exist with a specific platform.
Being able to freely replace the implementation of a contract without recompiling code also means that consumers will want to pick their preferred implementation themselves. And if there isn’t going to be one single common implementation, then there is no point in shipping it with the contract either. It’s a good idea to split both into separate assemblies, so that the consumer isn’t required to load unnecessary types.
There are multiple ways for naming the individual assemblies, and it all boils down to personal preference. The important bit is to stay consistent and to not mix the naming style, especially with larger organizations. It also pays to choose a naming style that allows for further extension in the future.
Contract assembly name | Implementation assembly name |
---|---|
MyModule.dll | MyModule.Core.dll |
MyModule.Interfaces.dll | MyModule.dll |
MyModule.Api.dll | MyModule.dll |
MyModule.Contracts.dll | MyModule.dll |
public interface IDataChannel : INotifyPropertyChanged, IDisposable {
// …
}
// default implementation that works on all platforms and uses inefficient voodoo.
internal class DataChannel : IDataChannel {
// …
}
public static class Factory {
public static IDataChannel CreateDataChannel() => new DataChannel();
}
The naming of an implementing assembly should make the technology or strategy in use apparent. This helps in deciding which one of the available implementations will be appropriate for the given use-case.
// high performance implementation based on named pipes, only available on windows systems.
internal class NamedPipeDataChannel : IDataChannel {
// …
}
public static class Factory {
public static IDataChannel CreateDataChannel() => new NamedPipeDataChannel();
}
Individual implementing assemblies can also have their own expanded contracts based on the initial contracts. It can make sense to create yet another assembly for those, if further differentiations in implementation are to be expected.
// contract for network based communication
public interface INetworkDataChannel : IDataChannel {
// …
}
// uses tdp as underlying protocol
internal class TCPDataChannel : INetworkDataChannel {
// …
}
public static class Factory {
public static INetworkDataChannel CreateDataChannel() => new TCPDataChannel();
}
// uses udp as underlying protocol
internal class UDPDataChannel : INetworkDataChannel {
// …
}
public static class Factory {
public static INetworkDataChannel CreateDataChannel() => new UDPDataChannel();
}
Final thoughts
Placing contracts and concrete implementations into separate assemblies makes the handling of code a lot cleaner and safer. It prevents developers from creating unwanted dependencies to a specific technology and provides ample space for quick and easy type replacements. If done right, interfaces can unfold their full potential as an API and become a single point of truth for every consumer to take advantage of.
I often hear concerns about increasing the complexity of the code base when adding more assemblies. But it’s a lot easier to handle five small and specialized projects than a single large one, if the individual assemblies are properly isolated from one another. And it’s not just about the use of dependency injection, it’s about creating a robust and maintainable architecture that is much easier to use correctly than incorrectly.
DI containers may make the use of factories obsolete, but it pays to have them on board for good measure. And since factories are also just types, the same interface strategies can be applied here as well to allow for injected object creation mechanisms. This is a topic for another post, however.
The full demo code is available on GitHub.