Published in:
Uncategorised
How a start-up implemented hexagonal architecture – The case of Packmind
Our platform Packmind is developed in a Client-Server model with a Front side developed in TS and React, while our back-end relies on Node.js. To give some context, the code base started 3 years ago, and we’ve always been a small team with max. 4 developers working on the code simultaneously. We currently have 156 KLOC in our back-end and 154 KLOC on the front-end.
Our motivations for implementing concepts from hexagonal architecture were to support the maintainability of our code base, and ease our life when working on it to add new features. When we discovered concepts from Domain-Driven Design, Clean & Hexagonal Architecture, we liked the idea of building separate bounded contexts and having in our code domains that encapsulate the business logic, being free of any third-party framework and technical implementation.
As we also use TDD (Test-Driven Development) in our coding process, we thought that applying ideas from hexagonal architecture and TDD would help to produce consistent hexagons covered by tests. This post shares how we currently implement a hexagon in our Node.js back-end.
This is how we do it in March 2023. Our practices have evolved for 2 years, and they’re likely to be improved in the future; if it’s the case, we’ll share a digest in a new post 😉
The key concepts
In our context, a hexagon targets a specific domain of our app, for instance, the automatic suggestions or the notifications. We enforce to make it self-sufficient and ensure it can be easily enabled or disabled at runtime. Each hexagon contains 4 distinct modules:- Domain: Contains the entities and aggregates specific to a domain and the repository interfaces (abstractions) to manipulate these entities.
- Application: Contains the services offered by the hexagon. This layer directly manipulates the Domain.
- Infrastructure: Implements the domains’ repository with concrete back-ends such as a database, files, in-memory, and more.
- UI: Handles how the rest of the world interacts with the application (Rest API, CLI, …).
Illustration
Let’s take a concrete example with a hexagon handling the Knowledge Hexagon, the core concept being the BestPractices, used to raise coding standards from our IDE & Code review extensions. We’ll skip irrelevant details for readability. Note that we’ve still included the folder structures if it can help some readers to understand how to organize their code.Domain & Infra
First, let’s zoom in on the Knowledge Hexagon on the relationship between the Domain and the Infra layers. In our context, we use a MongoDB implementation: The domain is simple and only contains entities and the repositories interfaces. As you can notice, the domain has no link with the Infra layer. We’ve chosen to use a “main” interface KnowledgeRepositories, that will expose all the repositories of our domain. In the Infra layer, we use Converter components to transform objects from Infra to Domain, and inversely. This is optional, and it all depends if you rely on an ORM or not. Instantiating the Infra Layer looks like this:
const bpMongoRepo = new BestPracticeRepositoryMongo();
const knowledgeRepositories: IKnowledgeRepositories = new KnowledgeRepositories(bpMongoRepo);
const infra: KnowledgeInfra = new KnowledgeInfra(knowledgeRepositories);
// const knowledgeRepositories: IKnowledgeRepositories = new KnowledgeRepositories(new BestPracticeRepositoryinMemory())) would allow to use an in-memory implementation instead
Application
Still in the Knowledge Hexagon, now let’s zoom in on the Application layer and how it interacts with the Domain layer: The application will stand as an API, containing all the services the hexagon offers to manipulate the domain, both entities and repositories. As domain repositories in the domain layer are abstractions (e.g., interfaces), they need concrete implementations at runtime. So we can reuse the one instantiated for the Infra layers.
const bpMongoRepo = new BestPracticeRepositoryMongo();
const knowledgeRepositories: IKnowledgeRepositories = new KnowledgeRepositories(bpMongoRepo);
const knowledgeServices: KnowledgeServices= new KnowledgeServices(knowledgeRepositories);
UI (Presentation)
Finally, let’s zoom in on the UI layer, which helps the rest of the world to interact with our hexagon: In our context, we have two types of interactions:- From our end-users that interact with the app from their IDE/Code review plugins or their web browsers. The express framework is used in this context.
- From other parts of our API (e.g., others hexagons) to avoid re-implementing business logic in a hexagon. We call them InternalExposedMethods. As we don’t use micro-services in our context, we considered that using such HTTP calls does not make sense. Instead, using these exposed methods works fine and fills our needs.
The big picture
Finally, if we all put things together, here is the big picture from the macro-level perspective. Again, we’ve included one concept (BestPractice) from the Knowledge Hexagon, but we could have added more in this context (such as CodeExample listed above in the Infra layer). Here is how the hexagon can be started programmatically:
const bpMongoRepo = new BestPracticeRepositoryMongo();
const knowledgeRepositories: IKnowledgeRepositories = new KnowledgeRepositories(bpMongoRepo);
const knowledgeServices: KnowledgeServices= new KnowledgeServices(knowledgeRepositories);
UI (Presentation)
Finally, let’s zoom in on the UI layer, which helps the rest of the world to interact with our hexagon: In our context, we have two types of interactions:- From our end-users that interact with the app from their IDE/Code review plugins or their web browsers. The express framework is used in this context.
- From other parts of our API (e.g., others hexagons) to avoid re-implementing business logic in a hexagon. We call them InternalExposedMethods. As we don’t use micro-services in our context, we considered that using such HTTP calls does not make sense. Instead, using these exposed methods works fine and fills our needs.
The big picture
Finally, if we all put things together, here is the big picture from the macro-level perspective. Again, we’ve included one concept (BestPractice) from the Knowledge Hexagon, but we could have added more in this context (such as CodeExample listed above in the Infra layer). Here is how the hexagon can be started programmatically:
class KnowledgeHexagonApp {
constructor() {
//Infra
const bpMongoRepo = new BestPracticeRepositoryMongo();
const knowledgeRepositories: IKnowledgeRepositories = new KnowledgeRepositories(bpMongoRepo);
const infra: KnowledgeInfra = new KnowledgeInfra(knowledgeRepositories);
//Application
const knowledgeServices: KnowledgeServices= new KnowledgeServices(knowledgeRepositories);
//UI
const routers = [new BestPracticeRouter(knowledgeServices), ...];
const exposedMethods = new InternalKnowledgeExposedMethods(knowledgeServices);
}
}
You can adapt this example to change the infrastructure layer to something different than MongoDB dynamically. If you want to use SQL or in-memory, this could be injected at runtime as a dependency of the KnowledgeHexagonApp constructor.
Managing tests
Each hexagon embeds a test suite resulting from the TDD practice. For the record, we use Mocha and Chai to write and run our tests. In our context, our tests layer has two main parts:- The first is to test the Application Layer and its functions, using Fake repositories defined in the Infra folder and using in-memory implementations (basic TypeScript List/Map/…).
- The second targets our concrete MongoDB implementation of the repositories. We’re not testing the Mongoose framework or the MongoDB infrastructure, but we ensure we have the right behavior when interacting with the repositories. It would be a pity to have a bug in production just because we misuse our ORM, right? We use the mongo-unit library that runs an embedded in-memory MongoDB server for that purpose. We use hard-coded fixtures in specific files, which mongo-unit will ingest before the beforeEach call.
Events management
It does not appear above, but in our API we’ve implemented a custom event management system. We have two requirements:- we want to track core events in our system (a best practice has been created) and record them in our database;
- each hexagon should be able to react to specific events (a user has been deleted)