Wednesday, January 26, 2022

Hexagonal architecture in practice: Fundamentals

The overarching goal of Hexagonal architecture is to isolate the business logic from the infrastructure concerns1. Infrastructure refers to the specific technology dependencies we choose to adopt, e.g. database, presentation, libraries. This separation of concerns enables flexibility2 if and when we need adjust our technology choices, e.g. to scale, improve security, support new features, etc.

Hexagonal architecture3 comes in a few flavors, but from my perspective, they all try to achieve the aforementioned goal. Some of these flavors may be familiar, e.g. Onion architecture4, Ports and Adapters, Clean architecture5, Functional Core and Imperative Shell6.

Structure

This is my simplified interpretation of the architecture. So what do the concentric circles mean are what are the layers responsible for?

Dependency Rule

The circles look like an onion7 in order to reinforce the fundamental rule that each layer can only depend on what is inside of it8. Put literally, Domain can not have depedencies on the Application or Infrastructure; Application depends on Domain but not Infrastructure; and Infrastructure depends on both the Domain and Application.

This rule helps us mitigate leaky abstractions, where if we want implementation details to leak through, it has to be explicitly exposed in the Application or Domain. For example, a choice to use cursor over offset pagination9 will manifest itself as a cursor or offset argument in a Application service.

Importantly, the 3rd party libraries the domain adopts will become dependencies of the parent layers - so typically it is in our best interest to keep the domain dependency free.

Domain

The Domain contains our entities10. For example, for an ecommerce app, this may include Product, Order, Customer, etc. It can also include any exceptions that could arise from interacting with these entities, e.g. InsufficientStock.

When adding entities and logic to the Domain, it can be helpful to think in the context of a larger system where other apps could consume our entities11. We can ask ourselves, is this entity or logic widely shareable? For example, in order to interact with our app, consumers need to build a client service on their end, it would be awesome to provide them with the types to use in their client, better yet, define a client interface for them.

Application

The Application implements the use cases12 of the app. For example, a Customer can create an Order for a Product; we can add/remove/update a Product; a Customer can search through the Product(s).

Services

In order to achieve the use cases, we will likely need the services with the capability to save and retrieve our entities and to communicate with other apps (e.g. payment processors). Instead of implementing a service for saving/retrieving a Product from PostgreSQL, we should define an interface for what capabilities we need - e.g. we may need to saveProduct and getProductById, which would be supported by most databases; but a capability like searchProducts would likely influence what we choose, e.g. ElasticSearch or even a multi-database strategy to support all these capabilities.

This process of defining the interface before implementation, enables us to delay the technology decisions until we have a solid understanding of the requirements.

Most languages support the concept of an interface131415.

Handlers

The use case can be defined as a single function that takes a request and any dependent services in order to return a response - I call this a handler. The handler is agnostic to how it is called, whether that be from a REST API or GraphQL, this is an infrastructure concern.

For example, to enable a Customer to create an Order for a Product, we would have a CreateOrderRequest that would include the CustomerId and ProductIds and on completion, return us a CreateOrderResponse with an OrderId in it. It may depend on services like a CustomerService for getting the customer address, ProductService for verifying if there is any stock left and an OrderService for saving the order and generating an OrderId.

import { CustomerId, ProductId, OrderId } from "domain";

type CreateOrderRequest = {
customerId: CustomerId;
productIds: ProductId[];
};

type CreateOrderResponse = {
orderId: OrderId;
};

const createOrder = (
request: CreateOrderRequest,
dependencies: {
customerService: CustomerService;
productService: ProductService;
}
): CreateOrderResponse => {
// ...
};


Infrastructure

This is where our app meets the real world. We need to make technology choices. We need to implement the service interfaces and wire the handlers to some kind of runtime.

Implementing services

Our Application layer defines an OrderService interface, a specific provider would be PostgreSQLOrderService, GraphQLOrderService or ShopifyOrderService - as we can see there is flexibility in what technology we choose.

The implementation and testing of a service could involve its building out its own Hexagonal architecture, given it is complex enough. For example, serializing an entity, e.g. Product, to tables in a SQL database could involve mapping to a ProductDao (data access object16) that may require dates in a specific format (e.g ISO-8601 string), booleans in bits, or additional fields that represent columns for indexing purposes - all database concerns. There could also be complexity where a single Product may need to saved to multiple databases, one as a transactional record (PostgreSQL) and the other for search (ElasticSearch).

Wiring up handlers

Handlers are agnostic to how they are called. Wiring them to a runtime will require mapping the inputs to the request and the response to the outputs.

For example, if we had a REST API, we will need to decide whether the inputs are exposed through the HTTP body, query and/or path parameters; how do we output errors? e.g. when to return 400s and 500s; how will the output be formatted in the body, do we use JSON?, etc.

Runtimes are not limited to APIs (GraphQL, REST, gRPC), they can also be console apps, queue processors, etc.