Chapter 11 – Get Your Hands Dirty on Clean Architecture

Chapter 11

Taking Shortcuts Consciously

In the preface of this book, I cursed the fact that we feel forced to take shortcuts all the time, building up a great heap of technical debt we never have the chance to pay back.

To prevent shortcuts, we must be able to identify them. So, the goal of this chapter is to raise awareness of some potential shortcuts and discuss their effects.

With this information, we can identify and fix accidental shortcuts. Or, if justified, we can even consciously opt-in to the effects of a shortcut.

Imagine the preceding sentence in a book about construction engineering or, even scarier, in a book about avionics. Most of us, however, are not building the software equivalent of a skyscraper or an airplane. And software is soft and can be changed more easily than hardware, so sometimes it's actually more economical to (consciously) take a shortcut first and fix it later (or never).

Why Shortcuts Are Like Broken Windows

In 1969, psychologist Philip Zimbardo conducted an experiment to test a theory that later became known as the Broken Windows Theory (https://www.theatlantic.com/ideastour/archive/windows.html).

He parked one car without license plates in a Bronx neighborhood and another in an allegedly "better" neighborhood in Palo Alto. Then he waited.

The car in the Bronx was picked clean of valuable parts within 24 hours and then passersby started to randomly destroy it.

The car in Palo Alto was not touched for a week, so Zimbardo smashed a window. From then on, the car had a similar fate to the car in the Bronx and was destroyed in the same short amount of time by people walking by.

The people taking part in looting and destroying the cars came from all social classes and included people who were otherwise law-abiding and well-behaved citizens.

This human behavior has become known as the Broken Windows Theory. In my own words:

As soon as something looks run-down, damaged, [insert negative adjective here], or generally untended, the human brain feels that it's OK to make it more run-down, damaged, or [insert negative adjective here].

This theory applies to many areas of life:

  • In a neighborhood where vandalism is common, the threshold to loot or damage an untended car is low.
  • When a car has a broken window, the threshold to damage it further is low, even in a "good" neighborhood.
  • In an untidy bedroom, the threshold to throw our clothes on the ground instead of putting them into the wardrobe is low.
  • In a group of people where bullying is common, the threshold to bully just a little more is low.

Applied to working with code, this means:

  • When working on a low-quality code base, the threshold to add more low-quality code is low.
  • When working on a codebase with a lot of coding violations, the threshold to add another coding violation is low.
  • When working on a codebase with a lot of shortcuts, the threshold to add another shortcut is low.

With all this in mind, is it really a surprise that the quality of many so-called "legacy" codebases has eroded so badly over time?

The Responsibility of Starting Clean

While working with code doesn't really feel like looting a car, we all are unconsciously subject to Broken Windows psychology. This makes it important to start a project clean, with as few shortcuts and as little technical debt as possible. Because, as soon as a shortcut creeps in, it acts as a broken window and attracts more shortcuts.

Since software projects are often very expensive and long-running endeavors, keeping broken windows at bay is a huge responsibility for us as software developers. We may even not be the ones finishing the project and others have to take over. For them, it's a legacy codebase they don't have a connection to, lowering the threshold for creating broken windows even further.

There are times, however, when we decide that a shortcut is a pragmatic thing to do, be it because the part of the code we are working on is not that important to the project as a whole, or that we are prototyping, or for economical reasons.

We should take great care to document such consciously added shortcuts, perhaps in the form of Architecture Decision Records (ADRs) as proposed by Michael Nygard in his blog (http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). We owe that to our future selves and to our successors. If every member of the team is aware of this documentation, it will even reduce the Broken Windows effect, because the team will know that the shortcuts have been taken consciously and for good reason.

The following sections each discuss a pattern that can be considered a shortcut in the hexagonal architecture style presented in this book. We will have a look at the effects of the shortcuts and the arguments that speak for and against taking them.

Sharing Models between Use Cases

In Chapter 4, Implementing a Use Case, I argued that different use cases should have a different input and output model, meaning that the types of input parameters and the types of return values should be different.

The following figure shows an example where two use cases share the same input model:

Figure 11.1: Sharing the input or output model between use cases leads to coupling between the use cases

The effect of sharing, in this case, is that SendMoneyUseCase and RevokeActivityUseCase are coupled to each other. If we change something within the shared SendMoneyCommand class, both use cases are affected. They share a reason to change in terms of the single responsibility principle. The same is true if both use cases share the same output model.

Sharing input and output models between use cases is valid if the use cases are functionally bound – that is, if they share a certain requirement. In this case, we actually want both use cases to be affected if we change a certain detail.

If both use cases should be able to evolve separately from each other, however, this is a shortcut. In this case, we should separate the use cases from the start, even if it means duplicating input and output classes if they look the same at the start.

So, when building multiple use cases around a similar concept, it's worthwhile to regularly ask the question of whether use cases should evolve separately from each other. As soon as the answer becomes a "yes," it's time to separate the input and output models.

Using Domain Entities as Input or Output Models

If we have an Account domain entity and an incoming port, SendMoneyUseCase, we might be tempted to use the entity as the input and/or output model of the incoming port, as shown in the following figure:

Figure 11.2: Using a domain entity as the input or output model of a use case couples the domain entity to the use case

The incoming port has a dependency on the domain entity. The consequence of this is that we have added another reason for the Account entity to change.

Wait, the Account entity doesn't have a dependency on the SendMoneyUseCase incoming port (it's the other way around), so how can the incoming port be a reason to change for the entity?

Say we need some information about an account in the use case that is not currently available in the Account entity. This information is ultimately not to be stored in the Account entity, however, but in a different domain or bounded context. We are tempted to add a new field to the Account entity nevertheless because it's already available in the use case interface.

For simple create or update use cases, a domain entity in the use case interface may be fine. Since the entity contains exactly the information, we need to persist its state in the database.

As soon as a use case is not simply about updating a couple of fields in the database, but instead implements more complex domain logic (potentially delegating part of the domain logic to a rich domain entity), we should use a dedicated input and output model for the use case interface, because we don't want changes in the use case to propagate to the domain entity.

What makes this shortcut dangerous is the fact that many use cases start their lives as a simple create or update use case only to become beasts of complex domain logic over time. This is especially true in an agile environment where we start with a minimum viable product and add complexity on the way forward. So, if we used a domain entity as the input model at the start, we must find the point in time to replace it with a dedicated input model that is independent of the domain entity.

Skipping Incoming Ports

While the outgoing ports are necessary to invert the dependency between the application layer and the outgoing adapters (to make the dependencies point inward), we don't need the incoming ports for dependency inversion. We could decide to let the incoming adapters access our application services directly, without incoming ports in between, as shown in the following figure:

Figure 11.3: Without incoming ports, we lose clearly marked entry points to the domain logic

By removing the incoming ports, we have reduced a layer of abstraction between incoming adapters and the application layer. Removing layers of abstraction usually feels rather good.

The incoming ports, however, define the entry points into our application core. Once we remove them, we must know more about the internals of our application to find out which service method we can call to implement a certain use case. By maintaining dedicated incoming ports, we can identify the entry points to the application at a glance. This makes it especially easy for new developers to get their bearings in the codebase.

Another reason to keep the incoming ports is that they allow us to easily enforce architecture. With the enforcement options from Chapter 10, Enforcing Architecture Boundaries, we can make certain that incoming adapters only call incoming ports and not application services. This makes every entry point into the application layer a very conscious decision. We can no longer accidentally call a service method that was not meant to be called from an incoming adapter.

If an application is small enough or only has a single incoming adapter so that we can grasp the whole control flow without the help of incoming ports, we might want to do without incoming ports. However, how often can we say that we know that an application will stay small or will only ever have a single incoming adapter over its whole lifetime?

Skipping Application Services

Aside from the incoming ports, for certain use cases, we might want to skip the application layer as a whole, as shown in the following figure:

Figure 11.4: Without application services, we don't have a specified location for domain logic

Here, the AccountPersistenceAdapter class within an outgoing adapter directly implements an incoming port and replaces the application service that usually implements an incoming port.

It is very tempting to do this for simple CRUD use cases, since in this case an application service usually only forwards a create, update, or delete request to the persistence adapter, without adding any domain logic. Instead of forwarding, we can let the persistence adapter implement the use case directly.

This, however, requires a shared model between the incoming adapter and the outgoing adapter, which is the Account domain entity in this case, so it usually means that we are using the domain model as the input model as described previously.

Furthermore, we no longer have a representation of the use case within our application core. If a CRUD use case grows to something more complex over time, it's tempting to add domain logic directly to the outgoing adapter, since the use case has already been implemented there. This decentralizes the domain logic, making it harder to find and to maintain.

In the end, to prevent boilerplate pass-through services, we might choose to skip the application services for simple CRUD use cases after all. Then, however, the team should develop clear guidelines to introduce an application service as soon as the use case is expected to do more than just create, update, or delete an entity.

How Does This Help Me Build Maintainable Software?

There are times when shortcuts make sense from an economic point of view. This chapter provided some insights into the consequences some shortcuts might have to help you decide whether to take them or not.

The discussion shows that it's tempting to introduce shortcuts for simple CRUD use cases, since for them, implementing the whole architecture feels like overkill (and the shortcuts don't feel like shortcuts). Since all applications start small, however, it's very important for the team to agree upon when a use case grows out of its CRUD state. Only then can the team replace the shortcuts with an architecture that is more maintainable in the long run.

Some use cases will never grow out of their CRUD state. For them, it might be more pragmatic to keep the shortcuts in place forever, as they don't really entail a maintenance overhead.

In any case, we should document the architecture and the decisions why we chose a certain shortcut so that we (or our successors) can re-evaluate the decisions in the future.