Skip links

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: Hexagonal Architecture Promyze 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:
  1. 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/…).
  2. 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)
We’ve developed a lightweight system that relies on the EventEmitter provided by NodeJS. We’ve decided to centralize all our events in a shared module outside hexagons as payload objects, such as BestPracticeCreatedPayload emitted when a best practice is created. Here is how it’s implemented in the hexagon: Hexagonal Architecture In the UI layer, we can find the Listeners that react to events emitted by others hexagons in the API. In the Application layer, the events will be emitted and handled by other hexagons. In the Infra layer, we implement the services that will perform the event messaging. Instead of using EventEmitter from Node, we could use other alternatives, such as sending a message to a RabbitMQ channel.

Other discussions

One important point is that most hexagons use shared concepts like BestPractice or Users. We voluntarily chose to duplicate their definitions in the Domain and Infra layers. One cool thing with Mongoose is that, when writing the mapping model, we can specify only the fields we need in the hexagon. And if you ask, “what happens when the model evolves ?”, I’d say this does not happen very often; when it does, only a few hexagons are impacted, so we don’t consider this a main concern for us. This version currently fills our need, especially because we’re a small team, and we want each of us to be able to work on any component of the app. We ban knowledge silos, and our weekly workshops to discuss coding standards are helpful. So we’re all responsible for the code, and no one is in charge of a particular domain. So if we radically change how best practices are designed, we can easily discuss this. Our monolith works fine with a single database instance, and if we need in the future to deploy micro-services, we’ve prepared the ground for that. Plus, as we use TDD, we’ve built a security net to prevent possible regressions. Finally, our main specificity was to build the ExposedServices when it made sense for each hexagon. As we run in monolithic approach, we considered a relevant solution. If, in the future, our system evolves and we need to run micro-services, we’ll need to replace the methods calls with class HTTP calls.

Adapt generic concepts in your context

As said earlier, the way we wrote hexagons has evolved and might still evolve. We continuously train our skills on that topic and don’t claim we’re experts in that field (feel free to share your thoughts with us; we’d be happy to take them! :-)). To keep our coding standards aligned, we use our platform Packmind to share and discuss our best practices. If you also want to define your tailored coding standards, not only on hexagonal architecture, but also on Security, JavaScript, Performance, or whatever topic that matters to you, Packmind might be a good ally for that. In a future post, we’ll focus on how we’ve implemented concepts from the clean architecture in front-end, still using TDD, to remain independent as much as possible from the underlying framework (React in our context) and the API.