We treat Hexagonal Architecture (Ports and Adapters) as the gold standard for decoupling business logic from infrastructure. Ideally, the core domain doesn't know whether data is stored in PostgreSQL, Mongo, or an in-memory map.
However, the most common anti-pattern I see in code reviews—across both Spring Boot and NestJS ecosystems—is the Leaky Persistence Model.
This happens when the class annotated with @Entity (JPA) or @Entity() (TypeORM) is passed directly into the core domain use cases. If your "Domain Model" has database annotations on it, you have broken the architecture. You are no longer writing Hexagonal Architecture; you are writing a monolith coupled to your ORM.
The Root Cause: Why We Do It
Two drivers cause this anti-pattern:
- Boilerplate Aversion: Creating a
Userdomain class and aUserEntitydatabase class feels like redundant work. They often share 90% of the same fields. - Misunderstanding ORM Lifecycles: Developers often view the ORM Entity as "The Object." However, ORM Entities are proxies. They carry hidden baggage: change tracking, lazy-loading mechanisms, and database-specific constraints.
When you pass an ORM Entity into a Domain Service, you invite side effects. A domain method modifying a field might inadvertently trigger a database update (Dirty Checking) even if save() is never called. Worse, your unit tests now require mocking complex DB relationships or setting up an H2/SQLite container just to test simple math.
The Fix: The Mapping Strategy
To fix this, we must enforce a strict boundary: The Domain Model and the Persistence Entity must be separate classes.
We bridge them using a Mapper and a Repository Adapter.
The following solution uses TypeScript and NestJS with TypeORM, but the exact same pattern applies to Java/Spring Boot (using Records and JpaRepository).
1. The Pure Domain Model
This class belongs in your domain layer. It has zero dependencies on TypeORM, NestJS, or any external framework. It encapsulates logic, not just data.
// src/domain/model/product.model.ts
export class Product {
constructor(
private readonly id: string,
private title: string,
private priceInCents: number,
private stockQuantity: number,
) {
if (priceInCents < 0) {
throw new Error("Price cannot be negative");
}
}
// Domain Logic: The core allows modification only through valid rules
public purchase(quantity: number): void {
if (this.stockQuantity < quantity) {
throw new Error("Insufficient stock");
}
this.stockQuantity -= quantity;
}
// Getters allow reading state, but setters are private/non-existent
public getSnapshot() {
return {
id: this.id,
title: this.title,
priceInCents: this.priceInCents,
stockQuantity: this.stockQuantity,
};
}
}
2. The Persistence Entity
This class belongs in your infrastructure or adapter layer. Its sole purpose is to map data to a database table. It is anemic (no logic).
// src/infrastructure/persistence/entities/product.entity.ts
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity({ name: 'products' })
export class ProductEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ name: 'price_cents', type: 'int' })
priceInCents: number;
@Column({ name: 'stock_qty', type: 'int' })
stockQuantity: number;
}
3. The Mapper
This is the critical glue code often skipped. It translates the database structure to the domain structure and back. This isolates the domain from DB schema changes (e.g., column renaming).
// src/infrastructure/persistence/mappers/product.mapper.ts
import { Product } from '../../../domain/model/product.model';
import { ProductEntity } from '../entities/product.entity';
export class ProductMapper {
static toDomain(entity: ProductEntity): Product {
return new Product(
entity.id,
entity.title,
entity.priceInCents,
entity.stockQuantity,
);
}
static toPersistence(domain: Product): ProductEntity {
const snapshot = domain.getSnapshot();
const entity = new ProductEntity();
entity.id = snapshot.id;
entity.title = snapshot.title;
entity.priceInCents = snapshot.priceInCents;
entity.stockQuantity = snapshot.stockQuantity;
return entity;
}
}
4. The Repository Adapter
The Domain defines a Port (Interface). The Infrastructure implements that Port. This is where the conversion happens. The Domain layer never sees the ProductEntity.
The Port (Domain Layer):
// src/domain/ports/product.repository.port.ts
import { Product } from '../model/product.model';
export interface ProductRepositoryPort {
findById(id: string): Promise<Product | null>;
save(product: Product): Promise<void>;
}
The Adapter (Infrastructure Layer):
// src/infrastructure/persistence/repositories/typeorm-product.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ProductRepositoryPort } from '../../../domain/ports/product.repository.port';
import { Product } from '../../../domain/model/product.model';
import { ProductEntity } from '../entities/product.entity';
import { ProductMapper } from '../mappers/product.mapper';
@Injectable()
export class TypeOrmProductRepository implements ProductRepositoryPort {
constructor(
@InjectRepository(ProductEntity)
private readonly ormRepo: Repository<ProductEntity>,
) {}
async findById(id: string): Promise<Product | null> {
const entity = await this.ormRepo.findOneBy({ id });
if (!entity) return null;
// CRITICAL: We map Entity -> Domain before returning
return ProductMapper.toDomain(entity);
}
async save(product: Product): Promise<void> {
// CRITICAL: We map Domain -> Entity before saving
const entity = ProductMapper.toPersistence(product);
await this.ormRepo.save(entity);
}
}
Why This Works
1. Inversion of Control
Notice the save method signature: save(product: Product). The domain service passes a pure Product object. It has no idea TypeORM exists. This means you can swap TypeORM for Prisma, or even a Redis cache, simply by changing the Adapter and Mapper. The Domain Logic remains untouched.
2. Elimination of "Dirty Context" Bugs
In JPA/Hibernate (Java) and TypeORM, if you modify an attached Entity, the ORM might flush those changes automatically at the end of a transaction. By returning a Pure Domain Object, we detach the object from the ORM's lifecycle. Changes to the Product domain object are only persisted if we explicitly call repository.save().
3. Testability
You no longer need to mock Repository, DeepPartial, or EntityManager to test your business logic.
// Pure unit test - runs in milliseconds
test('should reduce stock on purchase', () => {
const product = new Product('1', 'Test Item', 1000, 5); // Simple instantiation
product.purchase(2);
expect(product.getSnapshot().stockQuantity).toBe(3);
});
Conclusion
Decoupling your Domain Model from your Persistence Model introduces boilerplate: a second class and a mapper. This is the tax we pay for architecture.
However, the return on investment is a domain that is truly independent, immune to framework upgrades, and incredibly easy to test. If you see @Column or @OneToMany inside your business logic, you aren't doing Hexagonal Architecture—you're just organizing a monolith in folders. Separate the models.