Skip to main content

Structure

NestJS applications follows the Domain Driven Design and Layered Architecture patterns.

Layered Architecture Diagram

To develop in a DDD compliant code, we structure each service in layers using directories:

<service-path>/src
domain/
Entity/ <-- Aggregates Models (Entity)
Event/ <-- Domain Events raised by the aggregates
Repository/ <-- Interfaces for accessing Entities
Exceptions/ <-- Exceptions that the entity OR repositories raise
application/
Command/ <-- CQRS Commands that will call domain aggregates
Query/ <-- CQRS Queries that will fetch domain aggregates
infrastructure/
Controller/ <-- All HTTP Controllers and queue service workers
EventStream/ <-- Event Handlers for Event Stream and Message Stream events
IntegrationEvents/ <-- Sagas to dispatch Event Stream and Message Stream events
RestResource/ <-- Implementations of rest resource interfaces
TypeOrm/ <-- Database layer mappings and repository implementations

Domain Layer

All "business logic" code should be added to a relevant domain aggregate object. Business logic refers to logic that affects the state of the system, such as creating a item, updating a group, or renewing a subscription.

// domain/Entity/Item.ts
import { AggregateRoot } from '@tsm/nest/domain-events';

export class Item extends AggregateRoot {
public addItemBonusId(bonusId: number) {
// Validate
if (bonusId < 0) {
throw new BonusIdMustBePositiveException();
}

// Update state
this.bonusIds.push(bonusId);

// Raise change event
this.record(new ItemBonusAdded(this.id, bonusId));
}
}
// domain/Repository/ItemRepository.ts
export interface ItemRepository {
/**
* throws ItemNotFoundException if item is not found
*/
findOne(id: string): Promise<Item>;
save(item: Item): Promise<Item>;
}
// domain/Exceptions/BonusIdMustBePositiveException.ts
export class BonusIdMustBePositiveException extends DomainException {
constructor() {
super('Bonus ID must be positive');
}
}
// domain/Exceptions/ItemNotFoundException.ts
export class ItemNotFoundException extends DomainException {
constructor() {
super('Item not found');
}
}

Application Layer

Business process is performed in the Application Layer. Business process refers to the owner of when and where the business logic is updated. We use CQRS to provide an interface into our application layer, offering Commands to instruct change (new aggregates, updating aggregates, etc) and Queries to retrieve state (e.g. fetching a list of all groups).

Note, the CQRS Commands and Queries should not contain the business logic. This belongs on the Domain Aggregate. See "Designing Your Entities" below for more information.

// application/Command/AddItemBonus.ts
export class AddItemBonus {
constructor(
public readonly itemId: string,
public readonly bonusId: number,
) {}
}
// application/CommandHandler/AddItemBonusHandler.ts

@CommandHandler(AddItemBonus)
export class AddItemBonusHandler implements ICommandHandler<AddItemBonus, Item> {
public constructor(
// Note: We inject the interface, not the implementation to conform
// to the Dependency Inversion Principle in DDD
@Inject(ItemRepository) private repository: ItemRepositoryInterface,
) {}

public async execute(command: AddItemBonus) {
const item = await this.repository.findOne(command.itemId);

item.addItemBonusId(command.bonusId);

return await this.repository.save(item);
}
}

Infrastructure Layer

All code "outside" our system is considered Infrastructure. This includes Database configuration, HTTP Controllers, Service Worker processes, and Repository implementations.

// infrastructure/Controller/ItemController.ts

@Controller('/item')
export class ItemController {
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
) {}

@Post()
public async createItem(@Body() body: CreateItemResource) {
try {
const item = await this.commandBus.execute(new CreateItem(body.name));

return {
id: item.id,
name: item.name,
};
} catch (e) {
if (e instanceof BonusIdMustBePositiveException) {
throw new UnprocessableEntity('Unprocessable Entity', {
cause: e,
}); // We know the bonus id is invalid, so we can throw an UnprocessableEntity error
} elseif (e instanceof ItemNotFoundException) {
throw new NotFound('Not Found', {
cause: e,
}); // We know the item is not found, so we can throw a 404
} else {
throw e; // throw the root exception and let nest handle the error
}
}
}
}

Shared Libraries

Often we may need to share interfaces that our service exposes (via HTTP or an event) with other services or frontends. Each application should create a libs/types/app-<app-name> library and export all interfaces and types.

Example: libs/types/app-item-api