A Best Practice for Designing Interfaces in .NET C#
Is it enough to define IMyInterface<T>? do I need IMyInterface as well?
While working on your masterpiece software system, you define your own interfaces to act as contracts between your different system modules and to expose some contracts to the outside world. This is not rocket science to any software developer.
However, what I learned throughout my years as a Software Engineer is that we should put more care about how we design our interfaces. If you search the internet, you would find tons of resources discussing the best practices to follow when designing interfaces, some of these are actually good resources.
But, I have one more best practice to tell you about which I couldn’t find online except for rare coincidences.
Photo by Susan Q Yin on Unsplash
Is it enough to define IMyInterface? do I need IMyInterface as well?
To answer this question, let’s walk through the example below step by step, so bear with me and don’t rush things out.
Photo by Goh Rhy Yan on Unsplash
Disclaimer
We would not follow a lot of the best practices here for simplicity and to drive the main focus to the topic we are discussing. So, no unnecessary abstractions, no care for immutability,….and so on.
Main Entity Classes
These are the main entity classes we are going to use.
Generic Interface
This is the generic interface we are going to use and we should focus on.
We notice on this interface the following:
It is a generic interface which accepts a generic parameter of type
Data
.It has an
Initialize
method.It has a
Read(int dataId)
method which expects an integer parameter and returns aTData
.It has
Write(TData data)
method which expects aTData
parameter.
Generic Interface Implementer
This is the class implementing our generic interface. The class itself is generic.
We notice on this class the following:
It is a generic class.
The methods are throwing exceptions and this is intentionally done to drive the main focus to where it belongs.
Inside the Employee Module
Now, in your system, you have a module dedicated to managing Employee data, let’s call this module; Employee Module.
Inside the Employee Module, you are sure of the type of the data you are dealing with, it is obviously of type EmployeeData
.
That’s why you can write code like the one below without having any problems.
Inside the Asset Module
Now, in your system, you have a module dedicated to managing Asset data, let’s call this module; Asset Module.
Inside the Asset Module, you are sure of the type of the data you are dealing with, it is obviously of type AssetData
.
That’s why you can write code like the one below without having any problems.
What About a Common Module
Now let’s assume that you have a common module that has a Run(IReaderWriter readerWriter)
method. Inside this method you want to call the Initialize
method of the passed in readerWriter
parameter.
You would try to write something like this:
Image by Ahmed Tarek
It is clear now that you can’t do it as you don’t have a non-generic definition of the IReaderWriter
interface. In other words, we only have IReaderWriter<TData>
, not IReaderWriter
.
Photo by Brett Jordan on Unsplash
Wrong Expectations
Now, I can hear someone shouting from a far distance saying:
Huh, it is a piece of cake. Let’s use
IReaderWriter<object>
. Every class is a child of Object, right?….. genius.
My answer to him is that he should do his homework as this is not going to work. If either you or him don’t trust me on this, just give it a try and you would see the following:
Image by Ahmed Tarek
You need more explanation right, the short answer is because your interface is Invariant; you can’t call a method expecting IReaderWriter<SomeClass>
and pass in an instance of IReaderWriter<AnyOtherClass>
. The only acceptable call would be with passing IReaderWriter<SomeClass>
, nothing else.
If you want to understand more you can check my story Covariance and Contravariance in .NET C#. Have hard time understanding it? Let me simplify it for you.
Photo by Brett Jordan on Unsplash
This Is the Way
Now you understand why we need to define a non-generic IReaderWriter
interface.
Therefore, moving on to the implementation, we can end up with this code:
Now, we can notice the following:
We defined a new interface but this time it is a non-generic interface.
This interface would define only the
Initialize()
method as this is what we actually need in the common module or even the new ones if they come in the future.Now the other generic interface can safely extend the non-generic one with the
Read
andWrite
methods without changing anything on the signature.
Let’s Give It a Test Drive
So, now let’s go back to our common module and see if it is going to work or not.
Image by Ahmed Tarek
Finally, it is working. Let’s celebrate and get something to eat and drink, what a trip :)
I don’t want to be a bearer of bad news here, but, I have bad news…
Photo by Nik Shuliahin on Unsplash
Why So Sad
You have new requirements coming in and the common module needs some modifications. The common module now should be able to store and retrieve data in and from a Blob storage. The Blob storage can store any kind of data.
So, based on this input, you would try to do something like this:
Image by Ahmed Tarek
It is clear now that it is not going to work as IReaderWriter
interface doesn’t define the Read
and Write
methods. They are defined in the IReaderWriter<TData>
. However, on the common module and at the moment of calling StoreInBlob
and RetrieveFromBlob
methods, we don’t know the type of data. So, what to do!!!
Photo by NeONBRAND on Unsplash
The Wrong Way To Go
You might now lose hope and sadly decide to drop the whole generic interface thing. You would change the code to the following:
So, now the common module would be fine as follows:
Image by Ahmed Tarek
However, you lost the edge of dealing with strong-typed objects as follows:
Image by Ahmed Tarek
Now you have to cast your Employee object so that you can access its unique members, like EmployeeName
as in the image.
Image by Ahmed Tarek
Similarly, you have to cast your Asset object so that you can access its unique members, like AssetName
as in the image.
So, now what????
Photo by Michael Carruth on Unsplash
Moment of Truth
The keyword for the best solution here is the word new
. Let me break it down for you.
We can notice the following:
The
IReaderWriter
interface now defines are the required methods.However, for the
Read
andWrite
methods, they are now using the parentData
entity type.The
IReaderWriter<TData>
interface now extends theIReaderWriter
interface.This means that it also indirectly defines the three methods we know about.
However, inside the
IReaderWriter<TData>
interface, we need to use the generic typeTData
, not the parentData
.To do so, we need to add
TData Read(int dataId);
andvoid Write(TData data);
methods to theIReaderWriter<TData>
interface.For the read method, you can’t do this because the parent interface, the non-generic one, already defines the same exact method in terms of name and input parameters, but only different return type.
This would confuse the compiler at run time as it would not know which method to call, the one returning
Data
or the other one returningTData
.That’s why the compiler would not allow you to do so unless you add the
new
keyword at the start of the method definition as in the code above.This instructs the compiler to hide the
Read
method inherited from the parent and replace it with the one defined after thenew
keyword.Now, you might ask, why didn’t we do the same with the
Write
method?The answer is simply because we don’t need to do so. In the parent interface, we already have a method called
Write
which expects a parameter of typeData
which is the parent of all types that could be passed in to theWrite
method.This means that this method could be called passing any
TData
could come.Another thing, if you try to use the
new
keyword with theWrite
method, you would get a warning that you are actually not hiding anything from the parent interface. This is logical as the twoWrite
methods have different input parameter types, so, it is sound and clear to the compiler that they are two different methods.
We can notice the following:
The old three methods are the same.
Now we have two more methods implemented.
The first method is
Data IReaderWriter.Read(int dataId) { return Read(dataId); }
.This method is an explicit implementation of the
Data Read(int dataId);
method defined in the parentIReaderWriter
interface.This means that whenever an object of the
FileReaderWriter<TData>
class is casted, implicitly or explicitly, as the non-generic interfaceIReaderWriter
, thisRead
method implementation would be used.The second method is
void IReaderWriter.Write(Data data) { Write((TData)data); }
.This method is an explicit implementation of the
void Write(Data data);
method defined in the parentIReaderWriter
interface.This means that whenever an object of the
FileReaderWriter<TData>
class is casted, implicitly or explicitly, as the non-generic interfaceIReaderWriter
, thisWrite
method implementation would be used.
This now leads to the following:
Image by Ahmed Tarek
And
Image by Ahmed Tarek
Finally, everything is working as it should :)
Photo by Nick Fewings on Unsplash
It Is What It Is
This design technique -afraid of even calling it a pattern- is already used in .NET classes you are using daily. Did you notice that in .NET we have IEnumerable
and IEnumerable<T>
? Could you imagine if we don’t have IEnumerable
what would life be :)
This would mean that you can’t write code which loops on an enumerable, just an enumerable. You would always need to know first the type of items inside the enumerable.
You can argue that you still can write a method which accepts <T>
and then it would pass it to the IEnumerable<T>
, but my friend, this would keep bubbling up till you eventually would have to choose an entity type. This entity type is not always defined on all layers or levels of code as we proved above.
Therefore, my final advice to you, don’t try to beat around the bushes, it is what it is…
Photo by Sincerely Media on Unsplash
Resources
These are resources you might find useful.
When Not To Use DI, IoC, and IoC Containers
Know when DIs aren’t the right solution, and the better design to use instead.
How to Fully Cover .NET C# Console Application With Unit Tests
Know the What? and How? to fully cover your Console Application using TDD, DI, and IoC.
That’s it, hope you found reading this story as interesting as I found writing it.
This article is originally published Development Simply Put.