.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 diesem Artikel:

pg
Pawel Gerr ist Architekt und Consultant bei Thinktecture. Er hat sich auf .NET Core Backends spezialisiert und kennt Entity Framework von vorne bis hinten.

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.

Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
Database Access with Sessions
.NET
KP-round

Data Access in .NET Native AOT with Sessions

.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
Old computer with native code
.NET
KP-round

Native AOT with ASP.NET Core – Overview

Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
.NET
KP-round

Optimize ASP.NET Core memory with DATAS

.NET 8 introduces a new Garbage Collector feature called DATAS for Server GC mode - let's make some benchmarks and check how it fits into the big picture.
09.10.2023
.NET CORE
pg

Incremental Roslyn Source Generators: High-Level API – ForAttributeWithMetadataName – Part 8

With the version 4.3.1 of Microsoft.CodeAnalysis.* Roslyn provides a new high-level API - the method "ForAttributeWithMetadataName". Although it is just 1 method, still, it addresses one of the biggest performance issue with Source Generators.
16.05.2023
AI
favicon

Integrating AI Power into Your .NET Applications with the Semantic Kernel Toolkit – an Early View

With the rise of powerful AI models and services, questions come up on how to integrate those into our applications and make reasonable use of them. While other languages like Python already have popular and feature-rich libraries like LangChain, we are missing these in .NET and C#. But there is a new kid on the block that might change this situation. Welcome Semantic Kernel by Microsoft!
03.05.2023
.NET
sg

.NET 7 Performance: Regular Expressions – Part 2

There is this popular quote by Jamie Zawinski: Some people, when confronted with a problem, think "I know, I'll use regular expressions." Now they have two problems."

In this second article of our short performance series, we want to look at the latter one of those problems.
25.04.2023