Clean and Hexagonal Architectures for Dummies
Clean and hexagonal architectures can be complex topics. Let’s break them down using an iterative learning model.
Why another article about this? I struggled in the beginning to understand the clean architecture, so I decided to write my own explanation in a way that resembles how we learn: by building progressively refined iterative models.
📝 My inspiration for this article was Clean architecture for the rest of us. I used a mix of terminology from clean architecture, hexagonal architecture, onion architecture, and ports-and-adapters. There’s a lot in common between them as they’re all domain-centered architectures.
Stage 0: the app
There's your app and there’s the outside world. This will be our starting point. We’ll use the word app but that can mean a lot of things; from a monolith to a whole set of microservices; you can also apply it singularly to a backend service. Clean architecture doesn’t care about your deployment model. It’s not about the physical architecture but instead about the logical structure and the way things depend on each other. Clean architecture is a marketable name for the proper decoupling of software parts.
Stage 1: domain and infrastructure
Let’s zoom into the app and split it into domain and infrastructure. You could view them as high-level software layers.
The domain is your app’s core and the reason why it exists. It’s the added value to the business/users/world, i.e. it contains your app’s identity the same way a eukaryotic cell houses the DNA in its nucleus. It’s supposed to be independent of specific technologies like databases or web APIs; all it contains is business logic and the modeling of your domain.
The core logic, or business logic, of an application consists of the algorithms that are essential to its purpose. They implement the use cases that are the heart of the application. When you change them, you change the essence of the application. Ports-And-Adapters
The infrastructure is how your app speaks with the outer world — humans or other systems. This means that the infrastructure is all that’s not business logic. On one hand, it’s how the app gets delivered to the world (i.e. humans) like GUIs, web APIs, CLIs; on the other hand, it’s how the app communicates with systems like databases, queues, external APIs.
The deeper you go in the circle, the less likely to change, as an atom. The domain is more stable than the technologies around it. Ultimately, you could swap all the infrastructure without impacting your domain. The main idea of a domain-driven architecture is to protect your domain. The domain couldn’t care less about the surrounding infrastructure.
Stage 2: adapters, use cases, entities
OK, we’re ready for the second maturity level. This will be a refinement of the previous stage.
We’ll consider a sub-layer inside the infrastructure: the adapters (also known as services). This code is maintained by you. It’s how your app interacts with the world. Adapters have some relationship with the adapter pattern since they wrap the communication with an I/O device. Common examples include API gateways, REST endpoints, database repositories, GUI controllers, loggers, monitors, email/SMS services.
⚠️ Never put business logic in adapters. Adapters are supposed to be thin. They should only contain data conversion (i.e. parsing, formatting, stringification), error handling, and primitive validation (e.g. fail if a string is parsed as an integer).
The remaining part of the infrastructure is what remains when you take out the adapters: drivers, MVC frameworks, web frameworks, filesystem APIs, and any other mechanisms that bridge into external systems. They’re not your code so we can ignore them for now. What matters is that they’re details. Yes, a database is a detail.
📝 To be honest, it’s not practical to make all our adapters framework agnostic, especially the GUI ones. Still, trying to isolate the frameworks is a good rule of thumb.
Use cases and entities
Recall that the domain is the app’s business rules. This is typically called the “service layer”. If we zoom into it, we’ll see the use cases and the entities.
Use cases are pieces of functionality so they start with a verb (e.g. Create User, Delete Account, Withdraw Money). A use case can be a query or a command depending if you’re interested in its output (e.g. Get Loan) or its side-effect (e.g. Make Loan).
Use cases should bear no dependency on the way they’re delivered to the outside world (e.g. GUI, CLI). They solely depend on entities and abstractions of adapters.
📝 For the sake of making your app intents clear, I recommend creating a
domain folder with the list of use cases, one per file so they are modular and focus solely on one responsibility.
The entities reflect the concepts that your app manages (e.g. Account, Deal, Movie). There’s a common belief they don’t contain logic but that’s wrong. Entities have logic that relates directly to them.
⚠️ Don’t confuse entities with DTOs, as these only serve for transporting data. DTOs contain no logic and don’t mean much to the business; they’re implementation details (e.g. request/response models, serializers, etc.).
Use cases deal with entities, but entities should not depend on anything else. Entities are not database tables or GUI concepts.
⚠️ Database entities are not domain entities. That would be a bad idea because it’d couple the ORM with your app’s domain, which is bad in any architecture. It’s also very wrong when entities have a one-to-one relationship with relational database tables.
⚠️ A dirty domain: I think counter-examples are a good way to teach. So here are a few concepts and words that should never appear in your domain: REST, JSON, networking, file system, ORM, SQL, NoSQL, MVC, frameworks, templating engine, configuration (e.g. env vars).
Stage 3: primary and secondary adapters
Recall that adapters are the middle ground between the outside world and the domain (i.e. core app). We can categorize adapters into:
- Primary adapters (i.e. input ports) are delivery mechanisms of your app. Some external mechanism triggers their usage. Typical examples include API servers, GUI controllers, and jobs.
- Secondary adapters (i.e. output ports) are exit points of your app. The domain (use cases) triggers their usage. Typical examples include API clients, database adapters, loggers, and monitors.
Why are primary adapters also called delivery mechanisms? Because that’s the only way your app’s value can be used. Therefore, every interaction with your app must come through them. This includes APIs, GUIs, CLIs but also workers, data migrations, and other kinds of jobs. This ensures that the business rules are always respected.
Stage 4: ports and the dependency rule
Recall that the “inside can’t depend on the outside”. According to the dependency inversion principle:
[…] stable software architectures are those that avoid depending on volatile concretions, and that favor the use of stable abstract interfaces. Clean Architecture, Chapter 11
In other words, the domain/core can’t directly depend on any adapter. However, use cases rely on databases and other adapters. In fact, any secondary adapter has this problem (it only applies to secondary adapters because, for primary ones, the dependency direction is inwards). How to deal with it? The solution is the use cases relying on interfaces — ports; then, a concrete adapter implementation can be provided through dependency injection.
A port defines a communication language with an adapter; in practice, it’s an interface, the input and output types, and the possible errors. Ports abstract adapters and therefore belong to the domain (adapters belong outside of it). Here’s an example of a database port:
Stage 5: testing
- a use case in isolation (focusing on variations);
- an adapter with some deployed testing service (e.g. testing a data repository with a testing database, testing a REST client);
- a use case through an adapter;
- a user task (full-stack), using a primary adapter, passing through the domain, and using the necessary secondary adapters.
A typical mentioned example of this architecture is that you can use fake databases to run tests faster since the domain doesn’t know the actual adapters it’s using.
Keep in mind that applying a small set of testing patterns helps to create new functionality. I’m not defending any type of testing over the other since that depends on your testing strategy; just make sure you have one.
Stage 6: request and response models
So far, we’ve been calling the use cases and ports with entities and primitive values. However, those options can quickly become problematic:
If you’re a web developer, you’ll know the request/response model pattern. Request/response models are just data carriers; they encapsulate the data needed in the communication. Additionally, they play a role in code self-documentation. Value objects can also be used as part of that language.
Regarding the use case output, it can make sense to create a response if the output holds multiple pieces of data (e.g. in pagination, a response with the rows, the page, total rows, and other metadata). Adapters potentially need their own response models, to decouple them from the domain. For example, you should never return a database-specific row type; always convert it to an entity or a response model.
📝 Keep request/response models in context. I usually put them as inner classes of their owner which can be use cases or ports. That way, the context helps to document them.
Bear in mind, there’s a price to pay for this decoupling: we need more conversions within the layers. That’s why this isn’t something I do from day one, but rather when the complexity arrives.
⚠️ Never expose your entities to the outside world. Entities should live inside the domain. They can still go to the adapters but if you need to externalize them (e.g. JSON), use proper serializers (known as presenters). Otherwise, you are coupling the domain with the outside world and you may be externalizing sensitive information.
Stage 7 and beyond
How do we put all this together? We separate the assembling of the app from the app itself. This means loading the configuration (e.g. env vars), creating the dependencies, wiring them, and booting the app. All this should happen outside of the circles; the app shouldn’t know anything about it.
My favorite aspect of clean architecture is the standardization that it brings into the codebase. For example, when creating new components, there’s hardly anything else other than adapters, use cases, and entities — all the rest should be private. This creates a concise API with a small surface area to use in tests and implementation.
⚠️ Don’t test implementation details like command handlers or (de)serializers. Make them private.
Another example of following patterns is that it’s harder to make mistakes like adapters directly calling other adapters. You’ll quickly remember the concentric circles and the dependency rule.
📝 Don’t confuse the flow of control with the dependency rule: the flow of control describes how the program flows: user → device (e.g. browser)→ input port (e.g. web handler) → use case → output port (e.g. repository)→ device (e.g. database) → and all the way back to the user. The dependency rule states that dependencies can only point inwards (not outwards or sidewards).
More stages could have been examined, but this was an introductory article; what mattered to me was to show you the set of mental models you can use as a learning path for the clean/hexagonal architectures. The stages are also a possible path to evolve a simple app into a more complex one. Regardless of the stage that you’re at, take these as takeaways:
- your app is comprised of a domain — its heart and brain — and the infrastructure — its senses — . The domain contains entities, uses cases, ports, and value objects; the infrastructure contains the adapters that handle the I/O.
- libraries and frameworks should live outside the domain.
- the dependency rule: source code dependencies can only point inwards (e.g. a use case cannot reference a concrete adapter).
- adapters should ideally be replaceable and “plug and play”; it makes everything more testable and reduces the buy-in into specific technologies.
You can find all the presented concepts in my clean architecture sample project made in Kotlin. Here’s a nice summary of the clean architecture: