Hey, today I'm going to be writing about how you can apply domain-driven design (DDD) principles to a Nest.js project.
I've created a quick repository showing the architecture explained in this blog. Find it here.
Disclaimer
- I am by no means an expert in DDD, we merely decided to adopt it for Repetitio when we re-wrote the server.
- I will not cover what DDD is in this blog, please refer to this dev.to blog post or the DDD bible.
- Nest.js - A progressive Node.js framework for building efficient, reliable and scalable server-side applications. It mirrors Angular's architecture style and is built with TypeScript from the ground up.
Why Nest?
Nest is opinionated on how to structure your code, this works well in a DDD case as you need to be able to put strict boundaries around your code as to keep your code maintainable and readable. If you are looking for a powerful and scalable framework for your Node.js application, I highly recommend Nest.js.
So why would we do this?
Having used Nest.js in production for about 4 months we ended up, due to the project being unrestricted without clear boundaries, with a mess of spaghetti code for our server and API. We're talking about services
that are 600+ LOC long.
This is by no means a reflection of Nest.js, but rather a reflection of what happens to codebases without strict rules and boundaries implemented.
We decided to jump on the bandwagon and start to look into DDD as a way forward after we had decided to rewrite the server.
So ultimately what we wanted to get out of this exercise is a codebase that by nature of design stays tameable.
How do you do this?
We found that DDD outlines a clear structure to your code that not only makes sense but can minimise the amount of code/methods/classes that resides in aspects of your domain.
To use our old server as an example; we had a service called user.service.ts
that contained ALL logic for the user. Now, as with most applications, the user tends to take a center stage and encapsulates a lot of domain logic. Therefore, our service became huge, covering the entire user logic. It became hard to read and understand what each method was trying to achieve and what actions it was performing.
So let us use this example and apply some DDD logic to it, firstly we decided to be very strict with our domain layer, therefore in the user domain we only care about our user domain model, all other relationships are abstracted out into a new domain layer or a subdomain.
We kept the usage simple and made a class per action, rather than lumping them all into the same omnipotent, omniscient service. By the fact of simplifying, our domain layer becomes very functional with each class performing typically one action, with the methods within further reflecting this.
Our architecture
This is our architecture: (using a very generic user as an example for the different files and services)
src/
/API
/User
UserController.ts
CreateUserDTO.ts
APIModule.ts
/Auth
AuthModule.ts
/Database
DatabaseModule.ts
/Domain
/User
CreateUser.ts
IUserRepository.ts
User.ts
UserModule.ts
/Persistence
/User
UserRepository.ts
UserRepositoryModule.ts
UserEntity.ts
/Utils
/Mappers
/User
CreateUserDTOToUser.ts
/Services
/Email
EmailSenderService.ts
AppModule.ts
Environment.ts
main.ts
The Module
in this case is a Nest Module. We decided that each Domain would have its module, as would each persistence entity. However, we decided to lump all API endpoints into one module, as the only thing importing the APIModule
was the AppModule
on application bootstrap.
API
This layer contains all our endpoints and controllers. We tried to make a Controller class mirror a domain entity. So, if we had a user domain, we'd have a user controller. This pattern extends to the persistence layer too.
This is also an obvious space to declare any DTOs - sending or receiving.
Auth
This is where all our Guards, Strategies and general Auth configuration lies.
Database
You guessed it, this layer is responsible for connecting to any kind of data store, or multiple data stores for that matter.
Domain
The most important part of our codebase. The domain is a reflection of the problem we are trying to solve. This is broken up into our domain models (each warranting their folder). Our domain model, in our case, is a TypeScript types file.
We want to keep our domain layer pure from third party artefacts, so within our domain we should only reference our code. We don't want to see MongoDB schemas, third-party packages, reference to any database-specific logic or anything related to our API layer.
We define an interface that mocks our repository layer. This is defined in our domain layer as it is directly related to our domain. We are outlining how we want to be able to mutate our domain models, we are not bothered with the implementation (that is the persistence layer's job).
A few rules we adhere to with our domain layer:
- The domain actions should only accept the domain model as a parameter or an Id in string form. DTOs should be mapped before calling our domain layer.
- Leave all third-party libraries, packages etc outside the domain layer. It should be third-party dependency-free.
- It should only reference code that exists in the domain layer
- In theory, you should be able to cut and paste your domain layer it into any project (language-dependent) and it should work.
Dependency-free domain
One of the biggest mental challenges is organising your code in such a way that your domain layer is only dependent on other classes and files within your domain layer.
For example, to communicate with the persistence layer we can introduce the UserRepositoryModule
as a dependency to our UserModule
but would go against a key component of DDD - a dependency free domain. It's also why we have a User.ts
(domain) and UserEntity.ts
(persistence). One is our domain model, in its purest form. The other, our domain model but with the added attributes/functionality for whatever data store.
One way (and thanks to SeWaS for showing me how) we can use interface injection, rather than typical module injection to communicate with the persistence layer.
DomainAction.ts
is just a generic name which represents the many actions our domain will perform.
// Domain/User/DomainAction.ts
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { UserRepository } from '../../Persistence/User/Repository';
import { IUserRepository } from './IUserRepository';
const UserRepo = () => Inject('UserRepo');
@Injectable()
export class DomainAction {
constructor(
@UserRepo() private readonly userRepository: IUserRepository,
) {}
}
// Persistence/User/UserPersistenceProvider.ts
import { Provider } from "@nestjs/common";
import { UserRepository } from "./Repository";
export const UserRepoProvider: Provider = {
provide: 'UserRepo',
useClass: UserRepository
}
// Persistence/User/UserRepositoryModule.ts
import { UserRepoProvider } from './UserPersistenceProvider';
@Module({
providers: [UserRepoProvider],
exports: [UserRepoProvider],
})
export class UserRepositoryModule {}
Domain structure
Each domain action that gets performed on our domain model should constitute its own file and class. The name of this class should be explicit and leave no-one guessing as to its purpose.
For example:
/Domain
/User
Create.ts
Update.ts
Delete.ts
GetEmail.ts
RemoveToken.ts
IUserRepository.ts
UserEntity.ts
UserModule.ts
We can even drop the User from the name of the class as it is implied due to it residing within the user domain.
Persistence
Our persistence layer is where all our database queries are performed. This will contain the entity's Module and its Repository. The repository in this sense is a class that contains all database operations. Again, this should mirror our domain entities 1:1 and should typically only contain around 4/5 methods - predominately CRUD operations. Unlike our domain layer, we couple multiple actions within the same class.
Utils
These typically share functionality required across our domain.
This is where we store all our mappers that map data-transfer-objects to domain models and vice versa.
It is a good place to store singleton services, like the example, an email sender service.
Typical flow through our server
As I feel the need to show some code, I'll show some snippets of how this all looks if we were to create a very basic user in a purely fictional server. Obviously, I'm excluding import statements here.
// the user DTO we receive from the client
export class CreateUserDTO {
@IsString()
@IsNotEmpty()
public name: string;
@IsString()
@IsNotEmpty()
public password: string;
}
Class validator is great for validation, couple that with an AuthGuard
(in the Auth
layer) and you can handle all exceptions for your code in one place and handle the response object.
// UserController.ts
@Controller('user')
export class UserController {
constructor(
private readonly user: Create,
) {}
@Post()
public async Register(@Body() createUser: CreateUserDTO): Promise<HttpStatus> {
// all our mappings get done in static classes
const domainModel: User = UserMap.mapCreateDTOToUserModel(createUser);
await this.user.Create(domainModel);
return HttpStatus.OK;
}
As our domain is only concerned with our domain we need to make sure that all API layer related objects get mapped to the domain appropriate model, hence our mapping happening in the API layer.
// /Domain/User/Create.ts
const UserRepo = () => Inject('UserRepo');
const EmailService = () => Inject('EmailService');
@Injectable()
export class Create {
constructor(
@UserRepo() private readonly userRepository: IUserRepository,
@EmailService() private readonly email: IEmailSenderService,
) {}
public async Register(user: User): Promise<void> {
const registeredUser: User = await this.repository.Create(user);
await this.email.SendEmail(registeredUser.email, EmailOptions.AccountCreationEmailOptions, {
userId: registeredUser._id,
});
}
}
The EmailSenderService
is an example of shared logic across our domain that would exist in the Utils
layer mentioned above.
// /Persistence/User/UserRepository.ts
@Injectable()
export class UserRepository implements IUserRepository {
constructor(@InjectModel('User') private readonly user: Model<UserEntity>) {}
public async Create(newUser: User): Promise<UserEntity> {
return new Promise<UserEntity>((resolve, reject) => {
const createdUser: UserEntity = new this.user(newUser);
this.user.create(createdUser, (err: GenericError, addedEntity: UserEntity) => {
if (err) {
reject(err);
}
resolve(addedEntity);
});
});
}
}
We can also see the need to have two types of User
, one for our domain which is the purest form of our domain model and one that exists in our persistence layer that would be very similar to our domain model but would contain third-party (in this case, database-related) attributes or logic specific to our persistence layer.
This further re-enforces the need for our domain layer to be pure of all third-party artefacts.
Summary
This is a brief overview of the architecture we've employed at Repetitio. By holding ourselves to such rigid rules we've found our codebase has been pleasantly manageable, nothing like it was before! With clear logical layers to our server, it is easy to navigate and understand, allowing us to easily dive in and iterate with an ever-evolving set of requirements.
I've created a quick repository showing the architecture explained in this blog. Find it here.