HOME
PROJECTS
Featured projectsProject archive
BLOG
All posts

Categories

AI EngineeringSoftware ArchitectureTechnical SEOWeb & Marketing
TOOLS
QR Code Maker
VIEN

Got an idea worth building?

I build websites, web apps and AI solutions. Free consultation, reply within 24 hours.

Get in touchBook a call

Nguyễn Sinh Nhật

Software Engineer

Software Engineer — Fullstack Developer and AI Engineer focused on scalable web apps and practical AI solutions.

Explore

  • Home
  • Projects
  • Blog
  • Tools

Tools

  • QR Code Maker
  • RSS feed

Contact

  • nhatnguyendev251@gmail.com
  • 0328 398 467
  • Ngu Hanh Son Ward, Da Nang City, Viet Nam

© 2026 Nguyễn Sinh Nhật. Built with Next.js & Sanity.

Available for new projects
Loading blog post
Back to all posts
A developer writing NestJS code with a clean interface and minimalist desk.

Software Architecture

Domain models, aggregates and repositories in NestJS explained

Designing domain models, scoping aggregate boundaries and implementing repository interfaces in NestJS through a product module. Avoid stuffing logic into services.

By Nguyễn Sinh Nhật•Published Sep 28, 2026•6 min read

Table of contents

  • Understanding DDD architecture in NestJS
  • Domain Model: representing the core entities
  • Value Object: attributes without identity
  • Aggregate Boundary: scoping change
  • Aggregate relationship diagram
  • Repository Interface: separating data access
  • Real implementation with TypeORM
  • Common mistakes when stuffing logic into services
  • 1. Business logic inside services
  • 2. Raw queries inside services
  • 3. Skipping the repository pattern
  • A real product-module design
  • Folder structure
  • Order flow
  • Conclusion
  • Reference resources
This article is also available in Vietnamese

In a real project, I built a product module with 500+ products and 200+ transactions per day. Applying DDD separated business logic from technical concerns and cut bugs from requirement changes by 40%.

Understanding DDD architecture in NestJS

Domain Model: representing the core entities

A domain model is the abstraction representing business concepts. In the product module, each product can have many variants, but only one main entity represents the domain concept. Value-object logic lives in its own class.

product.entity.tstypescript
@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  price: number;

  @OneToMany(() => ProductVariant, variant => variant.product)
  variants: ProductVariant[];
}

Value Object: attributes without identity

This value needs no identity, only computation and display. When updating a price, just replace the object instead of mutating entity fields.

price.vo.tstypescript
export class Price {
  constructor(
    public amount: number,
    public currency: string = 'VND'
  ) {}

  get formatted(): string {
    return `${this.amount.toLocaleString()} ${this.currency}`;
  }
}

Aggregate Boundary: scoping change

An aggregate boundary defines which entities may change together: Product is the aggregate root, with ProductVariant and Stock depending on it. Updating a price only touches Product and its variants, never other aggregates.

  • Product is the primary aggregate root.
  • ProductVariant depends on Product.
  • Stock depends on ProductVariant.

Aggregate relationship diagram

text
Product
├── ProductVariant
│   └── Stock
└── Category

ProductVariant may never change directly; everything goes through Product. This keeps data consistent.

Repository Interface: separating data access

A repository is the interface between domain and persistence. Every CRUD operation goes through it, making database engine swaps painless.

product.repository.tstypescript
export interface ProductRepository {
  findById(id: number): Promise<Product | null>;
  save(product: Product): Promise<Product>;
  delete(id: number): Promise<void>;
}

Real implementation with TypeORM

product-typeorm.repository.tstypescript
@Injectable()
export class ProductTypeORMRepository implements ProductRepository {
  constructor(
    @InjectRepository(Product)
    private readonly repo: Repository<Product>
  ) {}

  async findById(id: number): Promise<Product | null> {
    return this.repo.findOne({ where: { id }, relations: ['variants', 'category'] });
  }

  async save(product: Product): Promise<Product> {
    return this.repo.save(product);
  }
}

Common mistakes when stuffing logic into services

1. Business logic inside services

wrong-price.tstypescript
// Wrong: pricing logic lives in the service
@Injectable()
export class ProductService {
  async updatePrice(id: number, newPrice: number) {
    const product = await this.repo.findById(id);
    if (!product) throw new Error('Product not found');
    product.price = newPrice;
    await this.repo.save(product);
  }
}

Fix: extract the calculation into its own class; the service only orchestrates flow.

price-calculator.tstypescript
export class PriceCalculator {
  static applyDiscount(price: number, discount: number): number {
    return price * (1 - discount);
  }
}

// Service only handles flow
async updatePrice(id: number, newPrice: number) {
  const product = await this.repo.findById(id);
  if (!product) throw new Error('Product not found');
  product.price = PriceCalculator.applyDiscount(newPrice, 0.1);
  await this.repo.save(product);
}

2. Raw queries inside services

wrong-query.tstypescript
// Wrong
async findAvailableProducts() {
  return this.repo.query(`SELECT * FROM products WHERE stock > 0`);
}

Fix: push conditions down into the repository.

fixed-query.tstypescript
async findAvailableProducts(): Promise<Product[]> {
  return this.repo.find({ where: { stock: MoreThan(0) } });
}

3. Skipping the repository pattern

wrong-stock.tstypescript
// Wrong
async updateStock(id: number, quantity: number) {
  const product = await Product.findOne(id);
  product.stock += quantity;
  await product.save();
}

Fix: always go through the injected repository.

fixed-stock.tstypescript
async updateStock(id: number, quantity: number) {
  const product = await this.repo.findById(id);
  if (!product) throw new Error('Product not found');
  product.stock += quantity;
  await this.repo.save(product);
}

A real product-module design

Folder structure

text
src/
  products/
    dto/
      create-product.dto.ts
      update-product.dto.ts
    entities/
      product.entity.ts
      product-variant.entity.ts
    interfaces/
      product.repository.ts
    services/
      product.service.ts
    controllers/
      product.controller.ts
    modules/
      product.module.ts

Order flow

  1. Client sends an order-creation request.
  2. OrderService calls ProductRepository for product info.
  3. ProductRepository reads from the database.
  4. OrderService applies business logic (stock check, pricing).
  5. OrderService calls OrderRepository to persist.

Conclusion

Applying DDD in NestJS separates business logic from technical concerns: easier maintenance, easier extension, fewer requirement-change bugs. Keep domain logic in entities and value objects, access data through repositories, keep business rules out of services, and define aggregate boundaries clearly.

Reference resources

  • NestJS Documentation
  • TypeORM Documentation
  • DDD in Practice (Eric Evans)

Frequently asked questions

It makes services heavy and hard to maintain. When requirements change, you must edit many places. Example: changing pricing logic means editing every service that uses it.

Identify entities that always change together. In the product module, product and variant always change together, so they belong to one aggregate.

Not mandatory, but interfaces make implementations swappable. Example: switching the repository from TypeORM to MongoDB without touching services.

No. Put validation in the domain model. Example: when creating a product, check for a non-empty name inside the Product class, not the service.

You can mock the repository in unit tests instead of connecting to a real database.

Related posts

Keep exploring articles in this category.

A developer working on a Next.js application with a split screen of server and client components plus performance metrics.
Software ArchitectureSep 25, 20265 min read

How to Choose Server vs Client Components in Next.js: Practical Examples

A practical guide to choosing Server vs Client Components in Next.js, common hydration mismatch mistakes, and how to measure bundle size before and after.

Read article
Next.js illustration explaining proper use client usage with Server Components and Client Components.
Software ArchitectureAug 02, 20267 min read

One misplaced use client can make Next.js heavier

Understand Server and Client Component boundaries in Next.js to reduce browser JavaScript without losing interactivity.

Read article