by Zoran Horvat
In this article we will tackle one problem which is intrinsic to extending capabilities of existing classes. The problem is manifested when we desire to extend functionality used by a client. Let’s start with the example and it will become clear where the problem is.
In the picture below, we can see a client which collaborates with some other object. The client wants to print some data and the printer object is there to perform the operation.
The detail which complicates matters a bit is that client is generic type. Printer must be paired with the client to support the same data type as that produced by the client.
We can implement this requirement directly in code, without need to introduce any abstraction:
public class Printer<T>
{
public void Print(T data)
{
Console.WriteLine("[{0}]", data);
}
}
public class Client<T>
{
private Printer<T> printer;
public Client(Printer<T> printer)
{
this.printer = printer;
}
public void DoBusiness()
{
T data = this.Produce();
this.printer.Print(data);
}
private T Produce()
{
return default(T);
}
}
This implementation is straight-forward. Client expects concrete printer as its constructor dependency. Internally, Client manages a complex domain operation, which is represented by the DoBusiness method. Part of this operation is sending certain data to the printer.
On the other hand, Printer is just responsible to print the data. Nothing more than that.
But, after this stage, things begin to complicate. What happens if we do not have appropriate printer available, but have some other? As you will see, this case will have undesired impact on the client design.
It is easy to complicate even such a small application like this one with two classes only. Here is an alternate scenario. We have a printer available, but its generic type argument does not match the generic type argument of the client that wishes to use it. In other words, business process produces data of one type and printer expects data of some other type. Such mismatch causes a lot of pain on the client side.
In order to print its data, client now has to use some convenient converter. That would be an object which can convert data from one type to another. The Converter object must be passed on to the client along with the mismatched printer.
How do we deal with this problem and who is supposed to implement additional logic? According to this diagram, the client will pay the price.
Do you feel the injustice in this situation? Why would the client be responsible for inability of the environment to pass it the right printer? That is the good question, but it doesn’t hit the target. Central point in this diagram is that we cannot implement the client in the same way as before anymore. The client now expects two generic parameters. Previously, it was only one generic parameter.
The second generic parameter is totally alien to the client. That is the type expected by the printer. In order to satisfy this additional requirement, we might have to implement a completely separate client class. But keep on mind that client’s primary role is to implement the domain logic. With having two implementations of the client we are hurting the domain model design. It begins to complicate due to a totally unrelated requirement. Actually, this requirement about conversion before printing should be placed in infrastructure rather than the domain.
So we are not going to implement this complicated client. Instead, we will apply one design technique which saves the client from having to know about this whole complication with data conversion.
The following class diagram depicts the basic idea of hiding an algorithm or a concept behind an interface.
In this case, we are letting the client collaborate with abstract printer. Concrete printer, which is the Printer<T> generic class, remains hidden from the client and client doesn’t couple with it.
What the client doesn’t know from its perspective is that there is another concrete implementation of the IPrinter interface. RelayPrinter is a generic class with two generic parameters. One indicates data type sent by the client. Another indicates data type which is actually printed. To bridge the gap between these two types, RelayPrinter uses a converter. Once again, client doesn’t know that such concrete printer exists. Client only knows about abstract printer with one generic type parameter.
The first change that we will make in code to implement this design is to introduce abstract printer:
public interface IPrinter<in T>
{
void Print(T data);
}
public class Printer<T>: IPrinter<T>
{
public void Print(T data)
{
Console.WriteLine("[{0}]", data);
}
}
public class Client<T>
{
private IPrinter<T> printer;
public Client(IPrinter<T> printer)
{
this.printer = printer;
}
...
}
From now on, concrete printer will implement the abstract printer interface and the client will only talk to abstract printers.
Data conversion is the infrastructure task and it will also be represented by an abstract interface:
public interface IConverter<in TInput, out TOutput>
{
TOutput Convert(TInput data);
}
Now that abstractions are in place, we can provide the RelayPrinter implementation.
public class RelayPrinter<TData, TOutput>: IPrinter<TData>
{
private IConverter<TData, TOutput> converter;
private IPrinter<TOutput> printer;
public RelayPrinter(IConverter<TData, TOutput> converter, IPrinter<TOutput> printer)
{
this.converter = converter;
this.printer = printer;
}
public void Print(TData data)
{
TOutput convertedData = this.converter.Convert(data);
this.printer.Print(convertedData);
}
}
Implementation of the RelayPrinter is quite simple. But the most fascinating part of it is that this concrete class maps two generic types into one. The whole idea is similar to partial application, which is characteristic to functional languages. In partial application, a function with two arguments would be reduced to a function expecting one parameter.
In case of relay printer, class with two generic type arguments is reduced to a class with one generic type parameter. The idea is basically the same: client observes the reduced version of the type and knows how to use it; knowing more would needlessly complicate the design of the client.
Classes written like relay printer in this example are often playing the role of helpers, decorators, proxies and similar objects. They rarely do any actual work, but rather glue together two or more objects that can perform certain operations on their own.
RelayPrinter cannot print anything. In order to have anything printed out, we must have actual implementation of the IPrinter generic interface which deals with ink. We also need an actual implementation of the IConverter interface. The role of the RelayPrinter is then not to print data, but rather to orchestrate operations offered by the converter and another strongly typed printer.
In that respect, RelayPrinter could be exposed together with the IPrinter generic interface. RelayPrinter really is just a library class. It doesn't have to be part of the domain model because its features are not related to any particular domain.
If you wish to learn more, please watch my latest video courses
In this course, you will learn the basic principles of object-oriented programming, and then learn how to apply those principles to construct an operational and correct code using the C# programming language and .NET.
As the course progresses, you will learn such programming concepts as objects, method resolution, polymorphism, object composition, class inheritance, object substitution, etc., but also the basic principles of object-oriented design and even project management, such as abstraction, dependency injection, open-closed principle, tell don't ask principle, the principles of agile software development and many more.
More...
In this course, you will learn how design patterns can be applied to make code better: flexible, short, readable.
You will learn how to decide when and which pattern to apply by formally analyzing the need to flex around specific axis.
More...
This course begins with examination of a realistic application, which is poorly factored and doesn't incorporate design patterns. It is nearly impossible to maintain and develop this application further, due to its poor structure and design.
As demonstration after demonstration will unfold, we will refactor this entire application, fitting many design patterns into place almost without effort. By the end of the course, you will know how code refactoring and design patterns can operate together, and help each other create great design.
More...
In four and a half hours of this course, you will learn how to control design of classes, design of complex algorithms, and how to recognize and implement data structures.
After completing this course, you will know how to develop a large and complex domain model, which you will be able to maintain and extend further. And, not to forget, the model you develop in this way will be correct and free of bugs.
More...
Zoran Horvat is the Principal Consultant at Coding Helmet, speaker and author of 100+ articles, and independent trainer on .NET technology stack. He can often be found speaking at conferences and user groups, promoting object-oriented and functional development style and clean coding practices and techniques that improve longevity of complex business applications.