Architecture - Data segregation of Concerns
This article build upon the principles outlined in Architecture - Applied Separation of Concerns. For a deeper understanding, we recommend reviewing that piece first here.
How “close” should a domain entity be to the table/collection it finally lives in?
Software practices seem to fall along a spectrum shaped by project size, business complexity, and team taste for ceremony. Below we discuss the main “strands” or "schools" with why each one exists, and how they tend to look in a Typescript + Clean‑Architecture stack.
TL;DR: The closer your entity mirrors the table, the faster you ship CRUD — but the more your domain leaks persistence details. Moving rightward on the spectrum introduces mapping code but buys you isolation, richer invariants, easier testing, and safer future refactors. Choose the least ceremony that still protects your business rules, and keep the mapping inside the infrastructure ring so the domain remains blissfully ignorant of SQL, Mongo, or tomorrow’s shiny new store.
The Paradigms
| # | Nickname (Pattern) | Guiding Idea | Typical Mapping Code | Sweet Spot | Trade-offs |
|---|----------------------------------------|--------------------------------------------------|--------------------------------------------------------------------|------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
| 1 | Active Record / 1-to-1 | “My entity is the row” | Controller → `PizzaModel.find()` (entity is the ORM class) | CRUD admin tools, internal line-of-business apps, hack-days | Violates DIP, hard to unit-test “real” business rules, schema leaks everywhere |
| 2 | Thin Mapping (what’s in the canvas) | “Entity ≈ Row, but mapping lives in one place” | `PizzaModel ⇄ Pizza` with a simple map (same props) | Most apps that need unit tests and occasional refactors, but not deep business logic | Still easy to confuse persistence concerns with real domain logic; rich invariants often end up elsewhere |
| 3 | Rich Domain + Data Mapper | “Entities protect rules; rows are just state” | `PizzaMapper.toDomain(row)` / `.toPersistence(entity)` | Systems with complex rules (pricing, shipping, fintech) | Extra boilerplate; perf tuning means touching mapper layer |
| 4 | CQRS Projections | “One shape to mutate, many shapes to read” | Write: `Pizza.aggregateRoot` Read: `PizzaReadModel` via query handlers | High-traffic, high-complexity domains needing scaling or UX-specific queries | Eventual consistency; two models to keep in sync |
| 5 | Hexagonal + Anti-Corruption Layer | “My domain never bends to someone else’s schema” | Adapter → `ExternalPizzaDto ⇄ Pizza` | Integrations with legacy DBs or third-party APIs | More code to write and maintain; mental overhead managing boundaries |
Making a decision
How to choose /
```md
| Question to ask | Lean toward… |
| ------------------------------------------------------------------------ | ------------------------------------------------------ |
| **Is the domain basically CRUD?** | **#1 or #2.** Don’t over‑engineer. |
| **Do we need deterministic unit tests without a database?** | **#2+** (abstract repository) |
| **Does a “pizza” mean wildly different things in sales vs. production?** | **#3 or #4** (separate rich domain from storage shape) |
| **Are we integrating with multiple hostile/legacy sources?** | **#5** (anti‑corruption adapters) |
| **Will we refactor the DB often?** | Anything **≥ #2** so the domain is insulated. |
Examples
Let's showcase the above patterns with simple use cases (we will gather them in some service/interactor for simplicity) in a Typscript + Clean Architecture stack.
- Let's discuss the following : A pizza application
- Let's appreciate the denomination:
- DBO > Data Business Object.
- DSO > Data Storage Object.
- The applicative use case will involve managing a Pizza entity, where the domain entity (DBO) relates to the database table/collection (DSO)
(#1) Active Record / 1-to-1
In this pattern, the Pizza entity is essentially the database model (e.g., a TypeORM entity or Mongoose schema), and persistence logic lives directly in the entity or its repository.
- The Pizza DBO (entity) is the Pizza DSO (database row) (--> tight coupling between DBO and DSO, where DBO = DSO).
- No mapping layer; the entity is the ORM model. This introduces frameworks (eg. ORM) into the domain layer.
- Persistence logic (e.g., save, find) is handled by the ORM or directly in the entity/repository.
// src/domain/entities/pizza.entity.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity('pizzas')
export class Pizza {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column('simple-array')
toppings: string[];
@Column('decimal')
price: number;
// In Active Record, the DBO may include persistence methods/logic
// (TypeORM handles this via the repository (ie. calls the method on the DBO itself), but the entity is still the DSO)
}
// src/domain/pizza.interactor.ts
@Injectable()
export class PizzaInteractor {
constructor(private readonly pizzaRepository: PizzaRepository) {}
async createPizza(name: string, toppings: string[], price: number): Promise<Pizza> {
const pizza = { name, toppings, price };
return this.pizzaRepository.createPizza(pizza);
}
async getPizzaById(id: string): Promise<Pizza | null> {
return this.pizzaRepository.findPizzaById(id);
}
}
// src/data/repository/pizza.repository.ts
import { Pizza } from './pizza.entity';
@Injectable()
export class PizzaRepository {
constructor(
@InjectRepository(Pizza)
private readonly pizzaRepo: Repository<Pizza>,
) {}
async createPizza(pizza: Partial<Pizza>): Promise<Pizza> {
const newPizza = this.pizzaRepo.create(pizza);
return this.pizzaRepo.save(newPizza);
}
async findPizzaById(id: string): Promise<Pizza | null> {
return this.pizzaRepo.findOne({ where: { id } });
}
}
Trade-offs:
- Guiding principle: (eg. SQL) “the entity is the row” — the Pizza entity is the TypeORM model.
- Sweet Spot: CRUD-heavy apps with low complexity.
- Pros: Simple and fast for CRUD, minimal code, ideal for simple CRUD apps (e.g., admin tools or prototypes).
- Cons: Persistence details (e.g., TypeORM decorators, database types) leak into the domain. Unit testing is harder without a database, and schema changes directly impact the domain. Violates Dependency Inversion Principle (DIP) since the domain depends on the ORM.
(#2) Thin Mapping pattern
The use case remains managing a Pizza entities with for example methods to create and retrieve pizza orders. The Data Business Object (DBO) (domain Pizza entity) is distinct from the Data Storage Object (DSO) (database Pizza Model), with a simple mapping layer to translate between them. This provides some isolation between the domain and persistence layers, making unit testing easier and reducing schema leakage, ie. The Pizza entity (DBO) in the domain layer is structurally (potentially, if what we are managing is what we store) similar to the Pizza Model (DSO) in the infrastructure persistence layer (same properties), but they are separate objects.
- A simple mapping layer (e.g., PizzaMapper) converts between PizzaDBO and PizzaDSO.
- The domain is unaware of persistence details (what is exactly stored) or framework (e.g., TypeORM decorators, database schema).
// src/domain/entities/pizza.dbo.ts
export class PizzaDBO {
constructor(
public readonly id: string,
public readonly name: string,
public readonly toppings: string[],
public readonly price: number,
) {}
// .. domain logic
public calculateDiscountedPrice(discount: number): number {
return this.price * (1 - discount);
}
}
// src/domain/interactors/pizza.interactor.ts
import { PizzaRepository } from './data';
import { PizzaDBO } from './domain/';
@Injectable()
export class PizzaInteractor {
constructor(private readonly pizzaRepository: IPizzaRepository) {}
async createPizza(name: string, toppings: string[], price: number): Promise<PizzaDBO> {
const pizza = new Pizza(uuidv4(), name, toppings, price);
return this.pizzaRepository.createPizza(pizza);
}
async getPizzaById(id: string): Promise<PizzaDBO | null> {
return this.pizzaRepository.findPizzaById(id);
}
}
// src/data/mappers/pizza.mapper.ts
import { PizzaDBO } from './domain';
import { PizzaDSO } from './infrastructure';
@Injectable()
export class PizzaMapper {
toDomain(model: PizzaDSO): PizzaDBO {
return new PizzaDBO(model.id, model.name, model.toppings, model.price);
}
toPersistence(entity: PizzaDBO): PizzaDSO {
const dso = new PizzaDSO();
dso.id = entity.id;
dso.name = entity.name;
dso.toppings = entity.toppings;
dso.price = entity.price;
// .. add storage fields as required
return dso;
}
}
// src/infrastructure/pizza/pizza.repository.ts
@Injectable()
export class PizzaRepository implements IPizzaRepository {
constructor(
@InjectRepository(PizzaDSO)
private readonly pizzaRepository: Repository<PizzaDSO>,
private readonly pizzaMapper: PizzaMapper,
) {}
public async createPizza(pizza: PizzaDBO): Promise<PizzaDBO> {
const dso = this.pizzaMapper.toPersistence(pizza);
const saved_object = await this.pizzaRepository.save(dso);
return this.pizzaMapper.toDomain(saved_object);
}
public async findPizzaById(id: string): Promise<PizzaDBO | null> {
const dso = await this.pizzaRepository.findOne({ where: { id } });
return dso ? this.pizzaMapper.toDomain(dso) : null;
}
}
// src/infrastructure/pizza/pizza.dso.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity('pizzas')
export class PizzaDSO {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column('simple-array')
toppings: string[];
@Column('decimal')
price: number;
}
- Guiding Principle: “Entity ≈ (relates to) Row, but mapping handles conversions.”
- Sweet Spot: It's generally suitable for application needing strong testing and occasional refactors.
- Pros: The domain is isolated from persistence technicalities (no infrastructure in Pizza entity), making unit testing very easy (just to mock the repository). Schema changes are confined to the mapper and PizzaDSO to supports occasional refactors.
- Cons: The extra mapping code adds boilerplate. Since PizzaDBO and PizzaDSO may have the same properties, it’s easy to accidentally mix persistence and domain concerns. Stay shartp, for rich domain logic (e.g., complex invariants) not to end up in services or elsewhere.
(#3) Rich Domain + Data Mapper pattern. ie. Thin Extended
In this pattern, the Data Business Object (DBO) (domain Pizza entity) is a rich domain model encapsulating business rules and invariants, while the Data Storage Object (DSO) (database Pizza Model) is a simple persistence structure. A PizzaMapper handles complex mapping between the two, ensuring the domain remains isolated from persistence concerns. This pattern is ideal for systems with complex business logic, such as pricing or validation rules.
We will showcase how the rich domain model enforces business rules, how a domain use case orchestrates these rules, and how the repository handles persistence while keeping the domain isolated.
/*
The Pizza class in the domain layer is the Data Business Object (DBO) and a rich domain model.
It encapsulates business rules (e.g., validation of name, toppings, price) and domain logic (e.g., calculateFinalPrice for premium toppings).
Uses private fields and getters for encapsulation, with a factory method (create) to enforce invariants.
Independent of persistence; no TypeORM or database details.
*/
// src/domain/entities/pizza.entity.ts
export class PizzaDBO {
private _promotionsApplied: string[] = [];
private constructor(
private readonly _id: string,
private readonly _name: string,
private readonly _toppings: string[],
private readonly _basePrice: number,
) {
this.validate();
}
static create(id: string, name: string, toppings: string[], basePrice: number): Pizza {
return new PizzaDBO(id, name, toppings, basePrice);
}
// Public getters
get id(): string {
return this._id;
}
get name(): string {
return this._name;
}
get toppings(): string[] {
return [...this._toppings];
}
get basePrice(): number {
return this._basePrice;
}
get promotionsApplied(): string[] {
return [...this._promotionsApplied];
}
// Business rules and invariants
private validate(): void {
if (!this._name || this._name.length < 3) {
throw new Error('Pizza name must be at least 3 characters long');
}
if (this._toppings.length === 0) {
throw new Error('Pizza must have at least one topping');
}
if (this._basePrice < 5) {
throw new Error('Pizza base price must be at least $5');
}
const validToppings = ['tomato', 'mozzarella', 'pepperoni', 'mushrooms', 'olives'];
if (!this._toppings.every((topping) => validToppings.includes(topping))) {
throw new Error('Invalid topping detected');
}
}
// Domain logic: Apply a promotion
public applyPromotion(promotionType: 'COMBO' | 'LARGE_ORDER'): number {
let discount = 0;
if (promotionType === 'COMBO' && this._toppings.includes('tomato') && this._toppings.includes('mozzarella')) {
discount = 3; // $3 off for tomato + mozzarella combo
this._promotionsApplied.push('COMBO');
} else if (promotionType === 'LARGE_ORDER' && this._toppings.length >= 4) {
discount = 5; // $5 off for 4+ toppings
this._promotionsApplied.push('LARGE_ORDER');
}
return Math.max(this._basePrice - discount, 0);
}
// Domain logic: Calculate final price with premium toppings
public calculateFinalPrice(): number {
const premiumToppings = ['pepperoni', 'olives'];
const premiumCount = this._toppings.filter((t) => premiumToppings.includes(t)).length;
return this._basePrice + premiumCount * 2; // $2 per premium topping
}
}
/*
The interactor operate on the domain PizzaDBO entity.
Remains unaware of PizzaDSO or TypeORM models
*/
// src/application/pizza/pizza.service.ts
import { PizzaDBO } from '../pizza.dbo';
import { PizzaRepository } from '../ports/pizza.repository.interface';
@Injectable()
export class PizzaInteractor {
constructor(private readonly pizzaRepository: IPizzaRepository) {}
public async createPizza(name: string, toppings: string[], basePrice: number): Promise<PizzaDBO> {
const pizza = PizzaDBO.create(uuidv4(), name, toppings, basePrice);
return this.pizzaRepository.createPizza(pizza);
}
public async getPizzaById(id: string): Promise<PizzaDBO | null> {
return this.pizzaRepository.findPizzaById(id);
}
}
@Injectable()
export class CreatePizzaWithPromotionUseCase {
constructor(private readonly pizzaRepository: IPizzaRepository) {}
async execute(
name: string,
toppings: string[],
basePrice: number,
promotionType: 'COMBO' | 'LARGE_ORDER' | null,
): Promise<Pizza> {
// Create pizza with domain validation
const pizza = Pizza.create(crypto.randomUUID(), name, toppings, basePrice);
// Apply promotion if specified
if (promotionType) {
pizza.applyPromotion(promotionType);
}
// Persist the pizza
return this.pizzaRepository.createPizza(pizza);
}
}
/*
The PizzaMapper handles complex mapping between Pizza (DBO) and PizzaModel (DSO).
Eg. Maps domain basePrice to persistence base_price and uses the Pizza.create factory to enforce invariants when converting to the domain. Isolates the domain from persistence details, allowing structural differences (e.g., naming conventions).
*/
// src/infrastructure/pizza/pizza.mapper.ts
import { Pizza } from '../../domain/pizza/pizza.entity';
import { PizzaModel } from './pizza.model';
export class PizzaMapper {
public toDomain(model: PizzaDSO): PizzaDBO {
const pizza = Pizza.create(model.id, model.name, model.toppings, model.base_price);
// Reflect promotions in domain (re-apply to ensure domain consistency)
model.promotions_applied?.forEach((promo) => {
pizza.applyPromotion(promo as 'COMBO' | 'LARGE_ORDER');
});
return pizza;
}
public toPersistence(entity: PizzaDBO): PizzaDSO {
const model = new PizzaModel();
model.id = entity.id;
model.name = entity.name;
model.toppings = entity.toppings;
model.base_price = entity.basePrice;
model.promotions_applied = entity.promotionsApplied;
return model;
}
}
// src/infrastructure/pizza/pizza.repository.ts
export interface PizzaRepository {
createPizza(pizza: Pizza): Promise<Pizza>;
findPizzaById(id: string): Promise<Pizza | null>;
findAllPizzas(): Promise<Pizza[]>;
}
@Injectable()
export class PizzaRepository {
constructor(
@InjectRepository(PizzaModel)
private readonly pizzaSource: GatewaySQL<PizzaDSO>,
private readonly pizzaMapper: PizzaMapper,
) {}
async createPizza(pizza: PizzaDBO): Promise<PizzaDBO> {
const model = this.pizzaMapper.toPersistence(pizza);
const savedPizza = await this.pizzaSource.save(model);
return this.pizzaMapper.toDomain(savedPizza);
}
async findPizzaById(id: string): Promise<Pizza | null> {
const model = await this.pizzaRepo.findOne({ where: { id } });
return model ? this.pizzaMapper.toDomain(model) : null;
}
}
/*
The PizzaModel class in the infrastructure layer is the Data Storage Object (DSO).
A simple TypeORM entity with no business logic, only persistence structure.
Uses base_price (snake_case) to reflect database conventions, showing potential structural differences from the domain.
*/
// src/infrastructure/pizza/pizza.model.ts
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity('pizzas')
export class PizzaModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column('simple-array')
toppings: string[];
@Column('decimal')
base_price: number;
@Column('simple-array', { nullable: true })
promotions_applied: string[]; // Store applied promotions
}
- Guiding Principle: “DBO (Entities) protect/enforces rules; DSO (row) is a dumb persistence structure.”
- Sweet Spot: Systems with complex rules (e.g., pricing, validation), like fintech or logistics.
- Pros: Rich domain logic is encapsulated in Pizza, making business rules clear and testable. The domain is fully isolated from persistence, easing refactors and database changes. Unit testing is straightforward (mock the repository).
- Cons: More boilerplate (mapper, richer entity). Performance tuning may require adjusting the mapper layer. Developers must ensure business logic stays in the domain, not services
(#4) CQRS with Event Source Modelling
..upcoming, stay tuned.
(#5) Clean Architecture + Anti-Corruption Layer
..upcoming, stay tuned.
Thanks for reading. We hope the above shined some light on how domain entities relate to their database representations depending on the choices you make. Each pattern has its own trade-offs, and the right choice often depends on the specific requirements of your application, such as complexity, scalability, and maintainability.