Software Architecture

Domain-Driven Design (DDD) từ A đến Z

Admin · Cập nhật 19/07/2026

Domain-Driven Design từ A đến Z: Strategic và Tactical Design, các khái niệm cốt lõi, ví dụ thực tế, kết hợp Clean Architecture cùng best practices và anti-pattern.

Chuyên mục: Software Architecture
Mức độ: Trung cấp → Nâng cao

Mục lục

  1. Domain-Driven Design là gì?
  2. Khi nào nên áp dụng DDD?
  3. Các khái niệm cốt lõi
  4. Strategic Design
  5. Tactical Design
  6. Ví dụ thực tế
  7. DDD kết hợp Clean Architecture
  8. Ưu điểm và nhược điểm
  9. Best Practices
  10. Anti-Patterns
  11. Checklist
  12. FAQ

Domain-Driven Design là gì?

Domain-Driven Design (DDD) là phương pháp thiết kế phần mềm tập trung vào nghiệp vụ (Domain) thay vì công nghệ.

Thay vì bắt đầu từ Database hoặc Framework, DDD bắt đầu bằng việc hiểu rõ bài toán nghiệp vụ, ngôn ngữ của chuyên gia nghiệp vụ (Domain Expert) và mô hình hóa chúng thành mã nguồn.


Khi nào nên áp dụng?

DDD phù hợp khi:

  • Hệ thống có nghiệp vụ phức tạp.
  • Có nhiều quy tắc kinh doanh.
  • Dự án phát triển lâu dài.
  • Có nhiều nhóm phát triển cùng tham gia.

Không nên áp dụng cho:

  • CRUD đơn giản.
  • Website giới thiệu.
  • MVP nhỏ.

Các khái niệm cốt lõi

Domain

Lĩnh vực nghiệp vụ mà phần mềm giải quyết.

Ví dụ:

  • Ngân hàng
  • Thương mại điện tử
  • Quản lý bệnh viện
  • Quản lý tuyển dụng

Entity

Đối tượng có danh tính (Identity).

Ví dụ:

User
Order
Invoice

Hai User có cùng tên vẫn khác nhau nếu khác ID.


Value Object

Đối tượng không có Identity.

Ví dụ:

Money
Email
Address

Hai Value Object bằng nhau nếu toàn bộ giá trị bằng nhau.

Ví dụ một Value Object Money trong TypeScript. Nó bất biến (immutable) và tự bảo vệ các bất biến nghiệp vụ (không cho phép cộng hai loại tiền khác nhau):

export class Money {
  private constructor(
    public readonly amount: number,
    public readonly currency: string,
  ) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) throw new Error("Số tiền không được âm");
    if (!currency) throw new Error("Thiếu loại tiền tệ");
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Không thể cộng hai loại tiền khác nhau");
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

So sánh nhanh Entity và Value Object:

Tiêu chí Entity Value Object
Có Identity (ID)? Không
So sánh bằng cách nào Theo ID Theo toàn bộ giá trị
Tính bất biến Có thể thay đổi trạng thái Nên bất biến
Ví dụ User, Order, Invoice Money, Email, Address

Aggregate

Một nhóm Entity và Value Object được quản lý như một đơn vị.

Ví dụ:

Order
 ├── OrderItem
 ├── ShippingAddress
 └── Payment

Chỉ Aggregate Root được truy cập từ bên ngoài.

Trong ví dụ dưới đây, Order là Aggregate Root. Mọi thao tác lên OrderItem đều phải đi qua Order để root có thể đảm bảo các quy tắc nghiệp vụ (ví dụ: không thêm hàng khi đơn đã thanh toán):

export class Order {
  private items: OrderItem[] = [];
  private status: "DRAFT" | "PAID" = "DRAFT";

  constructor(public readonly id: string, public readonly userId: string) {}

  // Không cho phép sửa items trực tiếp từ bên ngoài,
  // mọi thay đổi phải qua phương thức của Aggregate Root
  addItem(productId: string, quantity: number, price: Money) {
    if (this.status === "PAID") {
      throw new Error("Không thể thêm sản phẩm vào đơn đã thanh toán");
    }
    this.items.push(new OrderItem(productId, quantity, price));
  }

  get total(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal()),
      Money.create(0, "VND"),
    );
  }

  markAsPaid() {
    if (this.items.length === 0) {
      throw new Error("Đơn hàng rỗng không thể thanh toán");
    }
    this.status = "PAID";
  }
}

class OrderItem {
  constructor(
    public readonly productId: string,
    public readonly quantity: number,
    public readonly price: Money,
  ) {}

  subtotal(): Money {
    return Money.create(this.price.amount * this.quantity, this.price.currency);
  }
}

Nhờ đóng gói này, không ai có thể tạo ra một đơn hàng ở trạng thái không hợp lệ — quy tắc nghiệp vụ sống ngay trong Domain thay vì bị rải rác ở Controller.


Repository

Lớp chịu trách nhiệm truy cập Aggregate.

interface OrderRepository {
    findById(id:string)
    save(order:Order)
}

Domain Service

Chứa nghiệp vụ không phù hợp đặt trong Entity.

Ví dụ:

  • Tính phí vận chuyển.
  • Tính tỷ giá.
  • Tính điểm thưởng.

Factory

Được sử dụng khi việc tạo Entity quá phức tạp.


Strategic Design

Strategic Design giúp chia hệ thống thành các miền nghiệp vụ.

Các khái niệm:

  • Bounded Context
  • Context Map
  • Ubiquitous Language

Ví dụ:

E-Commerce

Customer Context

Inventory Context

Payment Context

Shipping Context

Tactical Design

Bao gồm:

  • Entity
  • Value Object
  • Aggregate
  • Repository
  • Factory
  • Domain Service

Đây là phần được lập trình viên sử dụng hằng ngày.


DDD kết hợp Clean Architecture

Presentation

Application

Domain
 ├── Entity
 ├── Aggregate
 ├── Value Object
 ├── Repository
 └── Domain Service

Infrastructure

Domain hoàn toàn không phụ thuộc Prisma, Express hay Next.js.


Ví dụ

Đặt hàng:

  1. Tạo Order Aggregate.
  2. Thêm OrderItem.
  3. Tính tổng tiền.
  4. Kiểm tra tồn kho.
  5. Thanh toán.
  6. Lưu Order.

Business Logic nằm trong Domain thay vì Controller.


Ví dụ thực tế với Next.js + Prisma

Điểm cốt lõi của DDD trong dự án Next.js là: Domain không được biết gì về Prisma. Domain định nghĩa một Repository dưới dạng interface, còn tầng Infrastructure hiện thực interface đó bằng Prisma và chịu trách nhiệm "dịch" giữa Aggregate và bảng dữ liệu.

Interface Repository nằm trong tầng Domain, chỉ nói về Order chứ không nhắc tới Prisma:

// domain/order/order.repository.ts
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

Hiện thực nằm trong Infrastructure, ánh xạ giữa Domain và schema Prisma:

// infrastructure/prisma-order.repository.ts
import { PrismaClient } from "@prisma/client";

export class PrismaOrderRepository implements OrderRepository {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string): Promise<Order | null> {
    const row = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    if (!row) return null;

    // Tái dựng Aggregate từ dữ liệu thô
    const order = new Order(row.id, row.userId);
    for (const item of row.items) {
      order.addItem(
        item.productId,
        item.quantity,
        Money.create(item.price, "VND"),
      );
    }
    return order;
  }

  async save(order: Order): Promise<void> {
    // Ánh xạ ngược Aggregate về bảng Prisma
    await this.prisma.order.update({
      where: { id: order.id },
      data: { total: order.total.amount },
    });
  }
}

Cuối cùng, một Use Case (tầng Application) điều phối luồng nghiệp vụ và được gọi từ Route Handler của Next.js:

// application/pay-order.ts
export async function payOrder(orderId: string, repo: OrderRepository) {
  const order = await repo.findById(orderId);
  if (!order) throw new Error("Không tìm thấy đơn hàng");

  order.markAsPaid(); // quy tắc nghiệp vụ chạy trong Domain
  await repo.save(order);
}

Nhờ tách lớp như vậy, nếu sau này bạn đổi từ Prisma sang Drizzle hay gọi một API khác, chỉ tầng Infrastructure thay đổi — Domain và Use Case giữ nguyên.


Ưu điểm

  • Tập trung vào nghiệp vụ.
  • Giảm phụ thuộc framework.
  • Dễ mở rộng.
  • Dễ kiểm thử.
  • Phù hợp hệ thống lớn.

Nhược điểm

  • Khó học.
  • Thiết kế ban đầu mất thời gian.
  • Không phù hợp CRUD nhỏ.

Best Practices

  • Sử dụng Ubiquitous Language thống nhất.
  • Một Aggregate chỉ có một Aggregate Root.
  • Entity không phụ thuộc ORM.
  • Value Object nên bất biến (Immutable).
  • Repository chỉ truy cập dữ liệu.

Anti-Patterns

  • Đưa toàn bộ logic vào Service.
  • Entity chỉ chứa getter/setter.
  • Aggregate quá lớn.
  • Domain phụ thuộc Framework.

Sai lầm thường gặp

  • Anemic Domain Model (mô hình thiếu máu). Entity chỉ có getter/setter còn toàn bộ logic bị dồn vào Service. Đây là anti-pattern phổ biến nhất: nó vô hiệu hóa lợi ích của DDD vì nghiệp vụ không được đóng gói trong đối tượng nghiệp vụ. Hãy để hành vi (behavior) sống trong Entity/Aggregate.
  • Domain phụ thuộc trực tiếp vào Prisma. Đặt kiểu @prisma/client hay gọi prisma bên trong Entity khiến Domain gắn chặt với ORM, không thể test độc lập và khó thay thế công nghệ. Domain chỉ nên phụ thuộc vào interface do chính nó định nghĩa.
  • Aggregate quá lớn. Gom quá nhiều Entity vào một Aggregate (ví dụ Customer chứa cả lịch sử đơn hàng nhiều năm) khiến mỗi lần load/save trở nên nặng nề và dễ xung đột. Hãy giữ Aggregate nhỏ, chỉ chứa những gì phải nhất quán tức thời cùng nhau.
  • Bỏ qua Ubiquitous Language. Code dùng thuật ngữ kỹ thuật (recordA, flag2) trong khi chuyên gia nghiệp vụ nói bằng thuật ngữ khác, dẫn tới hiểu nhầm. Tên class, phương thức nên phản ánh đúng ngôn ngữ nghiệp vụ.
  • Truy cập Entity con vượt qua Aggregate Root. Sửa OrderItem trực tiếp mà không qua Order sẽ phá vỡ các bất biến mà root bảo vệ. Mọi thay đổi phải đi qua Aggregate Root.
  • Áp DDD cho bài toán CRUD đơn giản. Dựng đầy đủ Entity, Value Object, Aggregate, Repository cho một trang quản lý danh mục tĩnh chỉ làm tăng chi phí mà không mang lại giá trị. DDD dành cho miền nghiệp vụ phức tạp.

Checklist

  • Xác định đúng Domain.
  • Có Aggregate Root.
  • Có Repository Interface.
  • Value Object bất biến.
  • Business Logic nằm trong Domain.

FAQ

DDD có bắt buộc dùng Clean Architecture?

Không. Tuy nhiên hai phương pháp thường được kết hợp để đạt khả năng mở rộng và bảo trì tốt hơn.

DDD có phù hợp với Next.js?

Có. Domain có thể độc lập hoàn toàn với Next.js, còn tầng Infrastructure sẽ triển khai Repository bằng Prisma hoặc ORM khác.

Sự khác nhau giữa Entity và Value Object là gì?

Entity có Identity (một ID) và được so sánh theo ID đó, kể cả khi các thuộc tính khác trùng nhau. Value Object không có Identity và được so sánh theo toàn bộ giá trị. Ví dụ hai người dùng cùng tên vẫn là hai Entity khác nhau, nhưng hai địa chỉ có cùng số nhà, đường, thành phố thì được coi là bằng nhau. Value Object nên bất biến.

Bounded Context là gì và tại sao lại quan trọng?

Bounded Context là ranh giới trong đó một mô hình và ngôn ngữ nghiệp vụ có ý nghĩa nhất quán. Cùng một từ "Khách hàng" có thể mang nghĩa khác nhau ở bối cảnh Bán hàng và bối cảnh Hỗ trợ. Chia hệ thống thành các Bounded Context giúp mỗi đội làm việc độc lập, tránh việc một model khổng lồ phải thỏa mãn mọi nhu cầu.

Có thể dùng chung Entity của Domain làm model Prisma không?

Không nên. Model Prisma phản ánh cấu trúc bảng và phụ thuộc vào ORM, còn Entity của Domain phản ánh hành vi nghiệp vụ. Trộn lẫn hai thứ khiến Domain bị gắn chặt với hạ tầng. Hãy giữ chúng tách biệt và ánh xạ qua lại trong tầng Infrastructure như ví dụ Repository ở trên.


Kết luận

Domain-Driven Design không phải là một framework mà là tư duy thiết kế phần mềm. Khi áp dụng đúng, DDD giúp mô hình hóa nghiệp vụ rõ ràng, giảm phụ thuộc công nghệ và tạo nền tảng vững chắc cho các hệ thống lớn, đặc biệt khi kết hợp với Clean Architecture, Repository Pattern và CQRS.