Guidelines for Nx Domain-Driven Hexagon projects

Domain-Driven Hexagon and Nx stand as two pillars within the Node.js ecosystem, widely recognized and adopted.

My team has made a strategic decision to leverage these frameworks as the foundation for our new Node.js development stack.

This journey had three parts:

  1. Defining a practical approach of the Domain-Driven Hexagon architecture: the 4-Layers architecture.
  2. Writing guidelines for Domain-Driven Hexagon projects powered by Nx.
  3. Writing use cases.

Let’s dive in each of them and see how combine Nx and Domain-Driven Hexagon principles to build robust applications.

Context

First, let’s start with some context: why would you ever need Nx and clear architecture guidelines?

Six months ago, a new client approached our agency with an ambitious project: the development of around 20 cloud components spread across 4 distinct cloud domains in, of course, a limited timeframe.

A typical cloud domain is composed of:

To meet these demands, we assembled a team of 15 developers and allocated a minimum of four months for development. As the project started, our primary concern was to establish a cohesive code architecture that would remain consistent across all repositories. It was imperative that any developer contributing to multiple repositories would seamlessly navigate the codebase. Moreover, our architecture had to prioritize code reuse and ensure the long-term maintainability of our codebase.

Thanks to prior experiences, Domain-Driven Hexagon & Nx have quickly been identified as great tools for our quest.

This is where the need for matching them together and writing clear guidelines about it appeared.

From domain-driven-hexagon to 4-layers architecture

The Domain-Driven Hexagon guidelines are great but a bit verbose. To streamline the process while retaining the essence of hexagonal principles, we’ve distilled the architecture into a single concise sentence:

Each component’s codebase should be structured into four distinct layers: core, infra, app, and bootstrap.

Basically, this dictates that every component should contain, at minimum, four folders (or packages - see the last section). Let’s see them in detail.

Core layer

The Core layer contains all the code related to the business part of a component.

It defines entities and provides a high level API to access the business rules. Most of the time, its public API is only classes called services and entities.

Its main elements are classic hexagonal concepts:

Melted together, they define the purpose of a component.

Infra layer

The Infra layer provides the requirements defined by the core layer. Its main elements are:

App layer

The App layer adapts the api provided by Core layer to its final usage. In our case, it mainly consists of API and lambdas.

For all, its main elements are:

For APIs:

For lambdas:

For simplicity, we banned Application Services but they could be considered one day.

Bootstrap layer

The Bootstrap layer contains configurations and the main function of a final component. Its main elements are:

This layer is close to the App layer. It does not explicitly exist in the hexagonal paper and it can be simply seen as a part of it. Nevertheless adding this concept helps to move to Nx and its libs / apps distinction. It is the purpose of the next section.

Transposing 4-layers architecture to a Nx monorepo

Once we clearly divided our components in 4-layers, the next step was to distribute them in Nx monorepos. Remember, the goal is to maximize code reuse and simplify maintenance.

We quickly realized that achieving a universally perfect arrangement for our components would be impractical. In reality, the transposition of layers within Nx monorepos may vary from one project to another.

Instead, alongside detailed use cases (visible in the next section), we defined rules for each of our Nx monorepos:

This is actually the mental model detailed by Nx.

In our 4-layers approach, most of the time, the apps folders should only contain the bootstrap layer of the final components. Their core, app and infra layers should be in the libs folder.

Nx makes creating packages easy. It is a great way to share logic across multiple components and packages.

Independent means they do not share code with other packages, such as http-client, database-client, third-party-client, tests-utils etc.

We place them in a libs/packages folder.

It is principle elicited in the clean coder:

Source code dependencies always point inwards. As you move inwards the level of abstraction increases.

In our 4-layers approach, Core is the center circle, it has no dependency:

Bootstrap -depends-on-> App -depends-on-> Core <-depends-on- Infra.

For instance, in terms of importation, it means: files in a App folder cannot import code from the Infra folder.

When two components (api or lambda for instance) have logics close from one to the other, their core and infra layers can be grouped in a common domain-${name} package. In this case, they share the same Core api (services and entities) and different App layers.

Two components can not dependent on each other as it could lead to circular dependencies. For instance:

Files in a libs/my-app-a folder cannot import code from the libs/my-app-b folder.

Files in a libs/domain-a folder cannot import code from the libs/domain-b folder.

All logics shared between the layers of two components should be placed in a dedicated shared package (shared/api, shared/core or shared/infra).

Let’s explore the application of those rules in concrete examples.

Use cases

Independent applications

Let’s consider a simple API my-sales which exposes two resources users and order.

A hierarchy could be:

```
.
├── apps/
│   └── api-my-sales*/
│       ├── main.ts
│       ├── project.json
│       └── ...
└── libs/
    ├── api-my-sales*/
    │   ├── project.json
    │   ├── _my-sales.module.ts_
    │   ├── api/
    │   │   ├── dtos/
    │   │   │   ├── users/
    │   │   │   │   ├── get-users.response.dto.ts
    │   │   │   │   └── get-users.request.dto.ts
    │   │   │   └── orders/
    │   │   │       ├── get-orders.response.dto.ts
    │   │   │       └── get-orders.request.dto.ts
    │   │   └── controllers/
    │   │       ├── users.controller.ts
    │   │       └── orders.controller.ts
    │   ├── core/
    │   │   ├── entities/
    │   │   │   ├── user.entity.ts
    │   │   │   └── order.entity.ts
    │   │   ├── services/
    │   │   │   ├── user/
    │   │   │   │   └── user.service.ts
    │   │   │   └── order/
    │   │   │       └── order.service.ts
    │   │   └── ports/
    │   │       └── ...
    │   └── infra/
    │       └── ...
    └── packages/
        └── my-lib*/
            └── project.json
```

In this case, folders are preferred instead of packages to materialize the 4-layers architecture:

Each layer is split by feature (core/services/users, core/services/orders etc).

Independent libraries resides in packages.

Sharing business rules between applications

Let’s consider a more complex example:

In this case, applications layers can be distributed across multiple packages to be shared, such as:

```
.
├── apps/
│   ├── *api-customer/
│   │   ├── main.ts
│   │   ├── project.json
│   │   └── ...
│   ├── *lambda-customer/
│   │   ├── main.ts
│   │   ├── project.json
│   │   └── ...
│   └── *lambda-order/
│       ├── main.ts
│       ├── project.json
│       └── ...
└── libs/
    ├── *api-customer/
    │   ├── project.json
    │   ├── my-api.module.ts
    │   └── api/
    │       ├── dtos/
    │       │   └── ...
    │       └── controllers/
    │           └── ...
    ├── *lambda-customer/
    │   ├── project.json
    │   └── lambda/
    │       └── ...
    ├── *lambda-order/
    │   ├── project.json
    │   └── lambda/
    │       └── ...
    ├── domain/
    │   ├── *customer/
    │   │   ├── project.json
    │   │   ├── core/
    │   │   │   └── ...
    │   │   ├── infra
    │   │   └── ...
    │   └── *order/
    │       ├── project.json
    │       ├── core/
    │       │   └── ...
    │       ├── infra
    │       └── ...
    │── shared/
    │   ├── *api/
    │   │   ├── project.json
    │   │   └── ...
    │   ├── *lambda/
    │   │   ├── project.json
    │   │   └── ...
    │   ├── *core/
    │   │   ├── project.json
    │   │   └── ...
    │   └── *infra/
    │       └── project.json
    │           └── ...
    └── packages/
        └── *my-lib/
            ├── project.json
            └── ...

```

The bootstrap layer of each package is in apps (/apps/api-customer, /apps/lambda-customer, /apps/lambda-order)

The app layer of each package has its own package (/libs/api-customer, /libs/lambda-customer, /libs/lambda-order). In fact, app depends on the destination. Nest should not be present in a lambda package for instance.

The shared core and infra layers are grouped to form a “domain”:

The shared packages provide a way to share logics between layers. There is one package per layer.

Independent libraries reside in packages.

Wrap up

With the hexagonal principles firmly established, the process of transposing them into an Nx project becomes more manageable when adhering to strict guidelines.

The guidelines outlined in this post enabled us to construct and deploy 4 cohesive monorepos, marking one of our greatest successes. Admittedly, adhering strictly to timelines has been a challenge, but isn’t that often the nature of projects?