You adopted Hexagonal Architecture (Ports and Adapters) and Domain-Driven Design (DDD) to tame complexity. Yet, looking at your NestJS codebase, the "Domain" layer often looks suspiciously empty. You likely have User or Order classes that are nothing more than bags of properties decorated with TypeORM or MikroORM annotations, entirely void of behavior.
This is the Anemic Domain Model anti-pattern. Even in a modular architecture, if your Service layer holds 100% of the if/else logic and validation, and your Entities are just data carriers, you are writing procedural code disguised as Object-Oriented Programming. You aren't doing DDD; you're doing "Transaction Script" with extra steps.
The Root Cause: Why NestJS Pushes You Toward Anemia
The NestJS ecosystem, while excellent, inadvertently encourages anemic models through two primary vectors:
- ORM Coupling: Tutorials often teach you to use
@Entity()classes as your primary domain objects. Because ORMs require empty constructors and public properties (or proxy magic) to hydrate objects from the database, encapsulation is broken immediately. You cannot enforce invariants (e.g., "An order cannot be created without items") if the database driver can instantiate an empty Order. - The "Service" Mental Model: NestJS developers are trained to put "logic" in Services. Consequently, the Service becomes a God-object that mutates the passive Entity.
To fix this, we must sever the link between the Persistence Schema and the Domain Entity.
The Fix: Rich Domain Models & Invariant Protection
We will refactor an "Order Fulfillment" feature. Instead of a service checking if an order is paid before shipping, the Order entity will enforce this itself.
1. The Value Object (Encapsulation primitives)
First, stop using primitive number types for money. Logic leaks when primitives rule the domain.
// src/domain/value-objects/money.vo.ts
export class Money {
constructor(
private readonly amount: number,
private readonly currency: string
) {
if (amount < 0) {
throw new Error('Money amount cannot be negative');
}
}
static from(amount: number, currency: string): Money {
return new Money(amount, currency);
}
add(other: Money): Money {
if (other.currency !== this.currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
get value(): number {
return this.amount;
}
get currencyCode(): string {
return this.currency;
}
}
2. The Rich Domain Entity
This class depends on zero frameworks. No TypeORM, no NestJS decorators. It uses private properties and exposes behavior, not state.
// src/domain/models/order.model.ts
import { Money } from '../value-objects/money.vo';
import { randomUUID } from 'crypto';
export enum OrderStatus {
PENDING = 'PENDING',
PAID = 'PAID',
SHIPPED = 'SHIPPED',
}
export class Order {
// State is private or readonly. No external mutation allowed.
private _status: OrderStatus;
private constructor(
public readonly id: string,
public readonly customerId: string,
private _total: Money,
status: OrderStatus,
public readonly createdAt: Date
) {
this._status = status;
}
// FACTORY METHOD: Replaces the "new" keyword to ensure validity on creation
static create(customerId: string, price: number, currency: string): Order {
if (!customerId) throw new Error('Order requires a customer');
return new Order(
randomUUID(),
customerId,
Money.from(price, currency),
OrderStatus.PENDING,
new Date()
);
}
// RECONSTITUTION: For the repository to restore state from DB
static reconstitute(
id: string,
customerId: string,
amount: number,
currency: string,
status: OrderStatus,
createdAt: Date
): Order {
return new Order(id, customerId, Money.from(amount, currency), status, createdAt);
}
// BEHAVIOR: Logic lives here, not in the service
pay(paymentAmount: Money): void {
if (this._status !== OrderStatus.PENDING) {
throw new Error('Order is already processed');
}
// Domain Logic: precise comparison using Value Object
if (paymentAmount.value < this._total.value) {
throw new Error('Insufficient payment amount');
}
this._status = OrderStatus.PAID;
}
ship(trackingId: string): void {
if (this._status !== OrderStatus.PAID) {
throw new Error('Cannot ship an unpaid order');
}
if (!trackingId) {
throw new Error('Tracking ID is required for shipping');
}
this._status = OrderStatus.SHIPPED;
// Potentially raise a Domain Event here (e.g., OrderShippedEvent)
}
// Getters for reading state (Snapshot), but no Setters
get status(): OrderStatus {
return this._status;
}
get total(): number {
return this._total.value;
}
get currency(): string {
return this._total.currencyCode;
}
}
3. The Infrastructure Layer (Persistence)
Now we need the Database Entity (TypeORM/Prisma) and a Mapper. This allows the DB schema to evolve independently of the Domain logic.
// src/infrastructure/persistence/entities/order.entity.ts
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity('orders')
export class OrderEntity {
@PrimaryColumn('uuid')
id: string;
@Column()
customerId: string;
@Column('decimal', { precision: 10, scale: 2 })
amount: number;
@Column()
currency: string;
@Column()
status: string;
@Column()
createdAt: Date;
}
// src/infrastructure/persistence/mappers/order.mapper.ts
import { Order, OrderStatus } from '../../../domain/models/order.model';
import { OrderEntity } from '../entities/order.entity';
export class OrderMapper {
static toDomain(entity: OrderEntity): Order {
return Order.reconstitute(
entity.id,
entity.customerId,
Number(entity.amount), // decimal comes as string in some drivers
entity.currency,
entity.status as OrderStatus,
entity.createdAt
);
}
static toPersistence(domain: Order): OrderEntity {
const entity = new OrderEntity();
entity.id = domain.id;
entity.customerId = domain.customerId;
entity.amount = domain.total;
entity.currency = domain.currency;
entity.status = domain.status;
entity.createdAt = domain.createdAt;
return entity;
}
}
4. The Application Service (The Orchestrator)
The Service layer is now thin. It simply fetches the domain object, invokes a method, and saves it. It does not make decisions.
// src/application/services/order.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderEntity } from '../../infrastructure/persistence/entities/order.entity';
import { OrderMapper } from '../../infrastructure/persistence/mappers/order.mapper';
import { Money } from '../../domain/value-objects/money.vo';
@Injectable()
export class OrderService {
constructor(
@InjectRepository(OrderEntity)
private readonly orderRepo: Repository<OrderEntity>,
) {}
async processPayment(orderId: string, amount: number, currency: string): Promise<void> {
// 1. Fetch Data (Infrastructure)
const orderEntity = await this.orderRepo.findOneBy({ id: orderId });
if (!orderEntity) throw new NotFoundException('Order not found');
// 2. Hydrate Domain Model
const order = OrderMapper.toDomain(orderEntity);
// 3. Execute Business Logic (Domain)
// The model protects itself. If status is wrong, IT throws.
const payment = Money.from(amount, currency);
order.pay(payment);
// 4. Persist State (Infrastructure)
const updatedEntity = OrderMapper.toPersistence(order);
await this.orderRepo.save(updatedEntity);
}
}
The Explanation: Why This Works
- Invariants are Guaranteed: By making the
Orderconstructor private and removing setters, it is physically impossible to have anOrderin the system that has aSHIPPEDstatus but hasn't beenPAID(assuming you only mutate via theshipmethod). The compiler and runtime enforce your business rules. - Testability: You can unit test the
Orderclass extensively without mocking TypeORM repositories. Your business logic tests become fast, pure TypeScript tests. - Separation of Concerns: Your database schema (
OrderEntity) uses@Columndecorators. Your domain model (Order) uses business logic. If you decide to switch from SQL to MongoDB, you only change the Repository and Mapper; the Domain Model logic remains untouched.
Conclusion
To prevent Anemic Domain Models in NestJS:
- Ban Public Setters: If you can do
order.status = 'PAID', your encapsulation is broken. - Separate Persistence from Domain: Do not put
@Entityon your Domain class. - Use Factory Methods: Control creation to ensure objects start in a valid state.
- Move Logic Down: If a Service method is checking properties of an object to decide what to do, move that method into the object.
This requires more boilerplate (Mappers and separate classes), but for long-lived, complex enterprise applications, the maintainability gains are massive.