Understanding the hexagonal architecture
This quote lays the groundwork for understanding hexagonal architecture. We can go even further with Cockburn's thoughts and make our application work without any technology, not just the technology related to UI or databases.
One of the main ideas of the hexagonal architecture is to separate business code from technology code. Still, not just that, we must also make sure the technology side depends on the business one so that the latter can evolve without any concerns regarding which technology is used to fulfill business goals. And we must also be able to change technology code by causing no harm to its business counterpart. To achieve these goals, we must determine a place where the business code will exist, isolated and protected from any technology concerns. It'll give rise to the creation of our first hexagon: the Domain hexagon.
In the Domain hexagon, we assemble the elements responsible for describing the core problems we want our software to solve. Entities and value objects are the main elements that are utilized in the Domain hexagon. Entities represent things we can assign an identity to, and value objects are immutable components that we can use to compose our entities. The terms used in this book refer to both the entities and value objects that come from DDD principles.
We also need ways to use, process, and orchestrate the business rules coming from the Domain hexagon. That's what the Application hexagon does. It sits between the business and technology sides, serving as a middleman to interact with both parties. The Application hexagon utilizes ports and use cases to perform its functions. We will explore those things in more detail in the next section.
The Framework hexagon provides the outside world interface. That's the place where we have the opportunity to determine how to expose application features – this is where we define REST or gRPC endpoints, for example. And to consume things from external sources, we use the Framework hexagon to specify the mechanisms to fetch data from databases, message brokers, or any other system. In the hexagonal architecture, we materialize technology decisions through adapters. The following diagram provides a high-level view of the architecture:
Next, we'll go deeper into the components, roles, and structures of each hexagon.
Domain hexagon
The Domain hexagon represents an effort to understand and model a real-world problem. Suppose you're in a project that needs to create a network and topology inventory for a telecom company. This inventory's main purpose is to provide a comprehensive view of all the resources that comprise the network. Among those resources, we have routers, switches, racks, shelves, and other equipment types. Our goal here is to use the Domain hexagon to model the knowledge required to identify, categorize, and correlate those network and topology elements into code, as well as provide a lucid and organized view of the desired inventory. That knowledge should be, as much as possible, represented in a technology-agnostic form.
This quest is not a trivial one. Developers involved in such an undertaking may not know telecom businesses and set aside this inventory thing. As recommended by Domain-Driven Design: Tackling Complexity in the Heart of Software, it's necessary to consult domain experts or other developers who already know the domain problem. If none of them are available, you should try to fill the knowledge gap by looking at books or any other material that teaches about the problem domain.
Inside the Domain hexagon, we have entities corresponding to critical business data and rules. They are critical because they represent a model of the real problem. That model takes some time to evolve and consistently reflect the problem we're trying to model. That's the case with new software projects where neither developers nor domain experts have a clear vision of the system's purpose in its early stages. In such scenarios, which are particularly recurrent in startup environments, it's normal and predictable to have an initial awkward domain model that evolves only as business ideas evolve and are validated by users and domain experts. It's a curious situation where the domain model is unknown, even to the so-called domain experts.
On the other hand, in scenarios where the problem domain exists and is clear in the minds of domain experts, if we fail to grasp that problem domain and how it translates into entities and other Domain objects, such as value objects, we will build our software based on weak or wrong assumptions.
This can be considered one reason why any software starts simple and, as its code base grows, accumulates technical debt and becomes harder to maintain. These weak assumptions may lead to a fragile and unexpressive code that can initially solve business problems but is not ready to accommodate changes in a cohesive way. Bear in mind that the Domain hexagon is composed of whatever kind of object categories you feel are good for representing the problem domain. Here is a representation based just on Entities and Value Objects:
Now, let's talk about the components that comprise this hexagon.
Entities
Entities help us build more expressive code. What characterizes an entity is its sense of continuity and identity, as described by Domain-Driven Design: Tackling Complexity in the Heart of Software. That continuity is related to the life cycle and mutable characteristics of the object. For example, in our network and topology inventory scenario, we mentioned the existence of routers. For a router, we can define whether its state is enabled or disabled.
Also, we can assign some properties describing the relationship that a router has with different routers and other network equipment. All those properties may change over time, so we can see that the router is not a static thing and that its characteristics inside the problem domain can change. Because of that, we can state that the router has a life cycle. Apart from that, every router should be unique in an inventory, so it must have an identity. So, this sense of continuity and identity are the elements that determine an entity.
The following code shows a Router
entity class composed of the RouterType
and RouterId
value objects:
public class Router { private final RouterType; private final RouterId; public Router(RouterType, RouterId routerId){ this.routerType = routerType; this.routerId = routerId; } public static Predicate<Router> filterRouterByType(RouterType routerType){ return routerType.equals(RouterType.CORE) ? isCore() : isEdge(); } private static Predicate<Router> isCore(){ return p -> p.getRouterType() == RouterType.CORE; } private static Predicate<Router> isEdge(){ return p -> p.getRouterType() == RouterType.EDGE; } public static List<Router> filterRouter(List<Router> routers, Predicate<Router> predicate){ return routers.stream() .filter(predicate) .collect(Collectors.<Router>toList()); } public RouterType getRouterType() { return routerType; } }
Now, let's move on and look at value objects.
Value objects
Value objects help us complement our code's expressiveness when there is no need to identify something uniquely, as well as when we are more concerned about the object's attributes than its identity. We can use value objects to compose an entity object, so we must make value objects immutable to avoid unforeseen inconsistencies throughout the Domain. In the router example presented previously, we can represent the Type
router as a value object attribute from the Router
entity:
public enum Type { EDGE, CORE; }
Next, we'll learn about the Application hexagon.
Application hexagon
So far, we've been discussing how the Domain hexagon encapsulates business rules with entities and value objects. But there are situations where the software does not need to operate directly at the Domain level. Clean Architecture: A Craftsman's Guide to Software Structure and Design states that some operations exist solely to allow the automation provided by the software. These operations – although they support business rules – would not exist outside the context of the software. We're talking about application-specific operations.
The Application hexagon is where we abstractly deal with application-specific tasks. I mean abstract because we're not directly dealing with technology concerns yet. This hexagon expresses the software's user intent and features based on the Domain hexagon's business rules.
Based on the same topology and inventory network scenario described previously, suppose you need a way to query routers of the same type. It would require some data handling to produce such results. Your software would need to capture some user input to query for router types. You may want to use a particular business rule to validate user input and another business rule to verify the data that's fetched from external sources. If no constraints are violated, your software provides some data showing a list of routers of the same type. We can group all those different tasks in a use case. The following diagram depicts the Application hexagon's high-level structure based on Use Cases, Input Ports, and Output Ports:
The following sections will discuss the components of this hexagon.
Use cases
Use cases represent the system's behavior through application-specific operations, which exist within the software realm to support the domain's constraints. Use cases may interact directly with entities and other use cases, making them flexible components. In Java, we represent use cases as abstractions defined by interfaces expressing what the software can do. The following code shows a use case that provides an operation to get a filtered list of routers:
public interface RouterViewUseCase { List<Router> getRouters(Predicate<Router> filter); }
Note the Predicate
filter. We're going to use it to filter the router list when implementing that use case with an input port.
Input ports
If use cases are just interfaces describing what the software does, we still need to implement the use case interface. That's the role of the input port. By being a component that's directly attached to use cases, at the Application level, input ports allow us to implement software intent on domain terms. Here is an input port providing an implementation that fulfills the software intent stated in the use case:
public class RouterViewInputPort implements RouterViewUseCase { private RouterViewOutputPort routerListOutputPort; public RouterViewInputPort(RouterViewOutputPort routerViewOutputPort) { this.routerListOutputPort = routerViewOutputPort; } @Override public List<Router> getRouters(Predicate<Router> filter) { var routers = routerListOutputPort.fetchRouters(); return Router.retrieveRouter(routers, filter); } }
This example shows us how we could use a domain constraint to make sure we're filtering the routers we want to retrieve. From the input port's implementation, we can also get things from outside the application. We can do that using output ports.
Output ports
There are situations in which a use case needs to fetch data from external resources to achieve its goals. That's the role of output ports, which are represented as interfaces describing, in a technology-agnostic way, which kind of data a use case or input port would need to get from outside to perform its operations. I say agnostic because output ports don't care if the data comes from a particular relational database technology or a filesystem, for example. We assign this responsibility to output adapters, which we'll look at shortly:
public interface RouterViewOutputPort { List<Router> fetchRouters(); }
Now, let's discuss the last type of hexagon.
Framework hexagon
Things seem well organized with our critical business rules constrained to the Domain hexagon, followed by the Application hexagon dealing with some application-specific operations through the means of use cases, input ports, and output ports. Now comes the moment when we need to decide which technologies should be allowed to communicate with our software. That communication can occur in two forms, one known as driving and another known as driven. For the driver side, we use Input Adapters, and for the driven side, we use Output Adapters, as shown in the following diagram:
Let's look at this in more detail.
Driving operations and input adapters
Driving operations are the ones that request actions to the software. It can be a user with a command-line client or a frontend application on behalf of the user, for example. There may be some testing suites checking the correctness of things exposed by your software. Or it can be just other applications in a large ecosystem needing to interact with some exposed software features. This communication occurs through an Application Programming Interface (API) built on top of the input adapters.
This API defines how external entities will interact with your system and then translate their request to your domain's application. The term driving is used because those external entities are driving the behavior of the system. Input Adapters can define the application's supported communication protocols, as shown here:
Suppose you need to expose some software features to legacy applications that work just with SOAP over HTTP/1.1 and, at the same time, you need to make those same features available to new clients who could leverage the advantages of using GRPC over HTTP/2. With the hexagonal architecture, you could create an input adapter for both scenarios, with each adapter attached to the same input port that would, in turn, translate the request downstream to work in terms of the domain. Here is an input adapter using a use case reference to call one of the input port operations:
public class RouterViewCLIAdapter { RouterViewUseCase; public RouterViewCLIAdapter(){ setAdapters(); } public List<Router> obtainRelatedRouters(String type) { return routerViewUseCase.getRouters( Router.filterRouterByType(RouterType.valueOf( type))); } private void setAdapters(){ this.routerViewUseCase = new RouterViewInputPort (RouterViewFileAdapter.getInstance()); } }
This example illustrates the creation of an input adapter that gets data from the STDIN. Note the use of the input port through its use case interface. Here, we passed the command that encapsulates input data that's used on the Application hexagon to deal with the Domain hexagon's constraints. If we want to enable other communication forms in our system, such as REST, we just have to create a new REST adapter containing the dependencies to expose a REST communication's endpoint. We will do this in the following chapters as we add more features to our hexagonal application.
Driven operations and output adapters
On the other side of the coin, we have driven operations. These operations are triggered from your application and go into the outside world to get data to fulfill the software's needs. A driven operation generally occurs in response to some driving one. As you may imagine, the way we define the driven side is through output adapters. These adapters must conform to our output ports by implementing them.
Remember, an output port tells us which kind of data it needs to perform some application-specific tasks. It's up to the output adapter to describe how it will get the data. Here is a diagram of Output Adapters and Driven operations:
Suppose your application started working with Oracle relational databases and, after a while, you decided to change technologies and move on to a NoSQL approach, embracing MongoDB instead as your data source. In the beginning, you'd have just one output adapter to allow persistence with Oracle databases. To enable communication with MongoDB, you'd have to create an output adapter on the Framework hexagon, leaving the Application and, most importantly, Domain hexagons untouched. Because both the input and output adapters are pointing inside the hexagon, we're making them depend on both the Application and Domain hexagons, hence inverting the dependency.
The term driven is used because those operations are driven and controlled by the hexagonal application itself, triggering actions in other external systems. Note in the following example how the output adapter implements the output port interface to specify how the application is going to obtain external data:
public class RouterViewFileAdapter implements RouterViewOutputPort { @Override public List<Router> fetchRouters() { return readFileAsString(); } private static List<Router> readFileAsString() { List<Router> routers = new ArrayList<>(); try (Stream<String> stream = new BufferedReader( new InputStreamReader(RouterViewFileAdapter .class.getClassLoader(). getResourceAsStream("routers.txt"))) .lines()){ stream.forEach(line ->{ String[] routerEntry = line.split(";"); var id = routerEntry[0]; var type = routerEntry[1]; Router = new Router (RouterType.valueOf(type),RouterId.of(id)); routers.add(router); }); } catch (Exception e){ e.printStackTrace(); } return routers; } }
The output port states what data the application needs from outside. The output adapter in the previous example provides a specific way to get that data through a local file.
Having discussed the various hexagons in this architecture, we will now look at the advantages that this approach brings.
Advantages of the hexagonal approach
If you're looking for a pattern to help you standardize the way software is developed at your company or even in personal projects, the hexagonal architecture can be used as the basis to create such standardization by influencing how classes, packages, and the code structure as a whole are organized.
In my experience of working on large projects with multiple vendors and bringing lots of new developers to contribute to the same code base, the hexagonal architecture helps organizations establish the foundational principles on which the software is structured. Whenever a developer switched projects, he had a shallow learning curve to understand how the software was structured because he was already acquainted with hexagonal principles he'd learned about in previous projects. This factor, in particular, is directly related to the long-term benefits of software with a minor degree of technical debt.
Applications with a high degree of maintainability that are easy to change and test are always welcomed. Now, let's learn how the hexagonal architecture can help us obtain such advantages.
Change-tolerant
Technology changes are happening at a swift pace. New programming languages and a myriad of sophisticated tools are emerging every day. To beat the competition, very often, it's not enough to just stick with well-established and time-tested technologies. The use of cutting-edge technology becomes no longer a choice but a necessity, and if the software is not prepared to accommodate such changes, the company risks losing money and time in big refactoring because the software architecture is not change-tolerant.
So, the ports and adapters nature of the hexagonal architecture gives us a strong advantage by providing the architectural principles to create applications that are ready to incorporate technological changes with less friction.
Maintainability
If it's necessary to change some business rule, you know that the only thing that should be changed is the Domain hexagon. On the other hand, if we need to allow an existing feature to be triggered by a client that uses a particular technology or protocol that is not supported by the application yet, we just need to create a new adapter, which we can only do in the Framework hexagon.
This separation of concerns seems simple, but when enforced as an architectural principle, it grants a degree of predictability that's enough to decrease the mental overload of grasping the basic software structures before deep diving into its complexities. Time has always been a scarce resource, and if there's a chance to save it through an architecture approach that removes some mental barriers, I think we should at least try it.
Testability
One of the hexagonal architecture's ultimate goals is to allow developers to test the application when its external dependencies are not present, such as its UI and databases, as Alistair Cockburn stated. This does not mean, however, that this architecture ignores integration tests. Far from that – instead, it allows a more continuous integration approach by giving us the required flexibility to test the most critical part of the code, even in the absence of technology dependencies.
By assessing each of the elements comprising the hexagonal architecture and being aware of the advantages such an architecture can bring to our projects, we're now furnished with the fundamentals to develop hexagonal applications.