.NET Abstractions – It’s Not Just About Testing!

With the introduction of .NET Core we got a framework that works not just on Windows, but on Linux and macOS as well. One of the best parts of .NET Core is that the APIs stayed almost the same compared to the old .NET, meaning developers can use their .NET skills to build cross-platform applications. The bad part is that the static types and classes without abstractions are still there as well.

In this article:

pg
Pawel Gerr is architect consultant at Thinktecture. He focuses on backends with .NET Core and knows Entity Framework inside out.

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 EqualsGetHashCode 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.

Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
AI
sg
One of the more pragmatic ways to get going on the current AI hype, and to get some value out of it, is by leveraging semantic search. This is, in itself, a relatively simple concept: You have a bunch of documents and want to find the correct one based on a given query. The semantic part now allows you to find the correct document based on the meaning of its contents, in contrast to simply finding words or parts of words in it like we usually do with lexical search. In our last projects, we gathered some experience with search bots, and with this article, I'd love to share our insights with you.
17.05.2024
Angular
sl_300x300
If you previously wanted to integrate view transitions into your Angular application, this was only possible in a very cumbersome way that needed a lot of detailed knowledge about Angular internals. Now, Angular 17 introduced a feature to integrate the View Transition API with the router. In this two-part series, we will look at how to leverage the feature for route transitions and how we could use it for single-page animations.
15.04.2024
.NET
kp_300x300
.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023