At the architectural level, we saw that an important concern is to ensure that application (or domain) code is fully separated from the infrastructure code. At the class level, the most important recommendation I can give you is to ensure that classes are fully controllable (that is, you can easily control what the class under test does) and observable (you can see what is going on with the class under test and inspect its outcome).
For controllability, the most common implementation strategy I apply is the one we used. if a class depends on another class, make it so the dependency can easily be replaced by a mock, fake, or stub. Look back at the PaidShoppingCartsBatch
class (listing 7.2). It depends on four other classes. The PaidShoppingCartsBatch
class receives all its dependencies via constructor, so we can easily inject mocks. The version of PaidShoppingCartsBatch
in listing 7.6 does not receive its dependencies but instead instantiates them directly. How can we test this class without depending on databases, web services, and so on? It is almost the same implementation but much harder to test. It is that easy to write untestable code.
Listing 7.6 A badly implemented PaidShoppingCartsBatch
public class VeryBadPaidShoppingCartsBatch {
public void processAll() {
ShoppingCartHibernateDao db = new ShoppingCartHibernateDao(); ❶
List<ShoppingCart> paidShoppingCarts = db.cartsPaidToday();
for (ShoppingCart cart : paidShoppingCarts) {
DeliveryCenterRestApi deliveryCenter =
new DeliveryCenterRestApi(); ❷
LocalDate estimatedDayOfDelivery = deliveryCenter.deliver(cart);
cart.markAsReadyForDelivery(estimatedDayOfDelivery); ❸
db.persist(cart); ❸
SMTPCustomerNotifier notifier = new SMTPCustomerNotifier(); ❹
notifier.sendEstimatedDeliveryNotification(cart);
SAPSoapWebService sap = new SAPSoapWebService(); ❺
sap.cartReadyForDelivery(cart);
}
}
}
❶ Instantiates the database adapter. Bad for testability!
❷ Notifies the delivery system about the delivery. But first, we need to instantiate its adapter. Bad for testability!
❸ Marks as ready for delivery and persist
❹ Sends a notification using the adapter directly. Bad for testability!
❺ Notifies SAP using the adapter directly. Bad for testability!
Traditional code tends to be responsible for instantiating its dependencies. But this hinders our ability to control the internals of the class and use mocks to write unit tests. For our classes to be testable, we must allow their dependencies (especially the ones we plan to stub during testing) to be injected.
In the implementation, this can be as simple as receiving the dependencies via constructor or, in more complex cases, via setters. Making sure dependencies can be injected (the term dependency injection is commonly used to refer to this idea;) improves our code in many ways:
- It enables us to mock or stub the dependencies during testing, increasing our productivity during the testing phase.
- It makes all the dependencies more explicit. They all need to be injected (via constructor, for example).
- It offers better separation of concerns: classes do not need to worry about how to build their dependencies, as the dependencies are injected into them.
- The class becomes more extensible. This point is not related to testing, but as a client of the class, you can pass any dependency via the constructor.
NOTE A Java developer may recognize several frameworks and libraries connected to dependency injection, such as the well-known Spring framework and Google Guice. If your classes allow dependencies to be injected, Spring and Guice will automatically help you instantiate those classes and their tree of dependencies. While such frameworks are not needed at testing time (we usually pass the mocked dependencies manually to the classes under test), this approach is particularly useful to instantiate classes and their dependencies at production time. I suggest learning more about such frameworks!
By devising interfaces that represent the abstract interactions that domains and infrastructure classes will have with each other (the ports), we better separate the concerns, reduce the coupling between layers, and devise simpler flows of interactions between layers. In our example, the PaidShoppingCartsBatch
domain class does not depend on the adapters directly. Rather, it depends on an interface that defines what the adapters should do abstractly. The SAP
port interface knows nothing about how the real SAP works. It provides a cartReadyForDelivery
method to the domain classes. This completely decouples the domain code from details of how the external infrastructure works.
The dependency inversion principle (note the word inversion, not injection) helps us formalize these concepts:
- High-level modules (such as our business classes) should not depend on low-level modules. Both should depend on abstractions (such as interfaces).
- Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
Figure 7.3 illustrates the principle. The domain objects, which are considered high-level classes, do not depend on low-level details such as a database or web service communication. Instead, they depend on abstractions of those low-level details. In the figure, the abstractions are represented by the interfaces.
Figure 7.3 An illustration of the dependency inversion principle
Note the pattern: our code should always depend as much as possible on abstractions and as little as possible on details. The advantage of always depending on abstractions and not on low-level details is that abstractions are less fragile and less prone to change than low-level details. You probably do not want to change your code whenever a low-level detail changes.
Again, coming up with interfaces for everything is too much work. I prefer to make sure all of my classes offer a clear interface to their consumers—one that does not leak internal details. For those more familiar with object-oriented programming concepts, I am talking about proper encapsulation.
How does depending on an abstraction help with testing? When you unit-test a class, you probably mock and stub its dependencies. When you mock, you naturally depend on what the mocked class offers as a contract. The more complex the class you are mocking, the harder it is to write the test. When you have ports, adapters, and the dependency inversion principle in mind, the interface of a port is naturally simple. The methods that ports offer are usually cohesive and straight to the point.
In the example, the ShoppingCartRepository
class has a List<ShoppingCart>
cartsPaidToday()
method. It is clear what this method does: it returns a list of shopping carts that were paid today. Mocking this method is trivial. Its concrete adapter implementation is probably complicated, full of database-related code and SQL queries. The interface removes all this complexity from testing the PaidShoppingCartsBatch
class. Therefore, designing the ports in a simple way also makes your code easier to test. Complex ports and interfaces require more work.
When things become more complicated, making sure dependencies are always injected may not be as straightforward as I have made it seem. It is much easier not to do this. But you must convince yourself that the extra effort will pay off later during testing.
NOTE This is a quick introduction to the Hexagonal Architecture and to the Dependency Inversion Priniciple. I suggest you dive into the related literature, including the books by Martin (2014) and Freeman and Pryce (2009), for more details. I also recommend Schuchert’s guest post on dependency inversion in the wild in Fowler’s wiki (2013); he explains the difference between dependency inversion and dependency injection and gives lots of examples of how he applied the principle in real-world situations.
Leave a Reply