A missing abstraction like an interface or an abstract base class means that the developers are unable to change the behavior of their own components by injecting new implementations into them – and with static types it is even worse, you can’t inject them at all. Luckily, most of the time we don’t have to and don’t want to change all the behaviors of all components we use unless we want to unit test a component. To be able to unit test one, and only one, component we have to provide it with dependencies that are completely under our control. An abstraction serves this purpose.
More and more of our customers demand unit tests and some of them are using .NET Core to be able to run the applications on Windows and Linux. Unfortunately, there are no abstractions available supporting .NET Core or they do not have the design decisions I would like to work with.
Inspired by SystemWrapper and System.IO.Abstractions I decided to create Thinktecture.Abstractions with certain opinionated design decisions in mind.
Design decisions
Interfaces vs abstract classes
Both an interface and an abstract class have pros and cons when it comes to create an abstraction. By implementing an interface, we are sure that there is no code running besides ours. Furthermore, a class can implement more than one interface. With base classes we don’t have that much flexibility but we are able to define members with different visibility and can implement implicit/explicit cast operators.
For Thinktecture.Abstractions I chose interfaces because of the flexibility and transparency. For example, if I start using base classes I could be inclined to use internal members preventing others to have access to some code. This approach would ruin the whole purpose of this project. Here is another example, imagine we are implementing a new stream because we are using interface the new stream can be both a Stream and a IStream. That means we don’t even need to convert this stream back and forth when working with it. This would be impossible with a base class.
Example:
public class MyStream : Stream, IStream
{
...
}
Same signature
The abstractions have the same signature as the .NET types. The response type, not being a part of the signature by definition, is always an abstraction.
Example:
public interface IStringBuilder
{
...
IStringBuilder Append(bool value);
}
Additionally, the methods with concrete types as arguments have overloads using abstractions, otherwise the developer is forced to make an unnecessary conversion just to pass the variable to the method.
Example:
public interface IMemoryStream : IStream
{
...
void WriteTo(IStream stream);
void WriteTo(Stream stream);
}
Don’t use reserved namespaces
The namespaces System.* and Microsoft.* should not be used to prevent collisions with types from the .NET team.
Conversion to abstraction
Conversion must not change the behavior or raise any exceptions. Using an extension method, we are able to convert a type without raising a NullReferenceException
even if the .NET type is null. For easy usage the extension methods for all types are in namespace Thinktecture.
Example:
Stream stream = null;
IStream streamAbstraction = stream.ToInterface(); // streamAbstraction is null
Conversion back to .NET type
The abstractions offer a method to get the .NET type back to use it with other .NET classes and 3rd party components. The conversion must not raise any errors.
Example:
IStream streamAbstraction = ...
Stream stream = streamAbstraction.ToImplementation();
some3rdPartyComponent.Do(stream);
Support for .NET Standard Library (.NET Core)
The abstractions should not just support the traditional full-blown frameworks like .NET 4.5 and 4.6 but .NET Standard Library (.NET Core) as well.
Structure mirroring
The assemblies with abstractions are as small as the underlying .NET assemblies, i.e. Thinktecture.IO.Abstactions contains interfaces for types from System.IO only. Otherwise the abstractions will impose much more dependencies than the application actually needs.
The version of the supported .NET Standard Library of the abstractions is equal to the version of the underlying .NET assembly, e.g. Thinktecture.IO.Abstractions and System.IO support both .NET Standard 1.0.
Inheritance mirroring
The inheritance hierarchy of the interfaces is the same as the ones of the concrete types. For example, a DirectoryInfo derives from FileSystemInfo and so does the interface IDirectoryInfo extend IFileSystemInfo.
Adapters (Wrappers)
The adapters are classes that make .NET types compatible with the abstractions. Usually, there is no need to use them directly besides for setup of dependency injection in composition roots. The adapters are shipped with abstractions, i.e. in Thinktecture.IO.Abstractions are both the IStream and StreamAdapter. Moving the adapters into their own assembly can be considered as cleaner but not pragmatic because the extension method ToInterface() is using the adapter and it is virtually impossible to write components without the need to convert a .NET type to an abstraction.
Example:
// using the adapter directly
Stream stream = ...;
IStream streamAbstraction = new StreamAdapter(stream);
// preferred way
IStream streamAbstraction = stream.ToInterface();
No change in behavior
The adapters must not change the behavior of the invoked method or property nor raise any exception unless this exception is coming from the underlying .NET type.
Static members and constructor overloads
For easier use of adapters, they should provide the same static members and constructor overloads as the underlying type.
Example:
public class StreamAdapter : IStream
{
public static readonly IStream Null;
...
}
public class FileStreamAdapter : IFileStream
{
public FileStreamAdapter(string path, FileMode mode) { ... }
public FileStreamAdapter(FileStream fileStream) { ... }
...
}
Override methods of Object
The methods Equals, GetHashCode and ToString should be overwritten and the calls be delegated to the underlying .NET type. These methods often are used for comparison in collections like Dictionary<TKey, TValue> otherwise the adapter will change (or rather break) the behavior.
Missing parts (?)
Factories, Builders
The Thinktecture.Abstractions assemblies are designed to be as lean as possible without introduction of new components. Factories and builders can (and should) be built on top of these abstractions.
Mocks
There is no need for me to provide any mocks because there are very powerful libraries like Moq that can be used when testing.
Enhancements
In the near future there will be further abstractions like for HttpClient and components that are built on top of the abstractions and are offering improved API or behavior.
Summary
Working with abstractions gives us the possibility to decide what implementations should be used in our applications. Furthermore, it is easier (or possible in the first place – think of static classes) to provide and use new implementations, compose them and derive from them. When it comes to testing then we can do it without abstractions but we would test more than just one component leading to more complex tests and it would be rather integration tests than unit tests. The integration tests are slower and more difficult to setup because they could need access to the file system, the network or the database. Another (unnecessary) challenge would be to isolate the integration tests from each other because they run in parallel, in general.