Skip to content

HARRY-22 — GĐ7 — Clean Architecture: tầng, ranh giới, và khi nào đừng dùng

GĐ7 — Clean Architecture: tầng, ranh giới, và khi nào đừng dùng

Vào đây sau GĐ6 (NestJS). Lý do: Clean Architecture chỉ có ý nghĩa khi bạn đã cảm thấy đau vì code rối. Nếu học trước, nó chỉ là một mớ thư mục bạn copy mà không hiểu vì sao.

Giai đoạn này là giai đoạn dễ bị làm quá nhất trong lộ trình. Mục tiêu không phải là "áp dụng Clean Architecture", mà là hiểu nguyên lý đằng sau nó (quy tắc phụ thuộc) rồi áp dụng đúng liều — vì phần lớn dự án chỉ cần một phần nhỏ của nó.


1. Vấn đề thật sự mà nó giải quyết

Câu chuyện quen thuộc. Bạn viết OrderController gọi thẳng prisma.order.create(), tính giảm giá ngay trong controller, gọi Stripe ngay trong đó luôn, rồi gửi email. Sáu tháng sau:

  • Muốn test logic giảm giá → phải dựng DB + mock Stripe + mock SMTP.
  • Muốn thêm một đường vào khác (CLI, worker, gRPC) → phải copy logic.
  • Muốn đổi Stripe sang Paddle → phải sửa ở 14 chỗ.
  • Muốn biết "quy tắc giảm giá của hệ thống là gì" → phải đọc 8 file controller.

Chẩn đoán. Không phải "thiếu tầng". Vấn đề là logic nghiệp vụ bị trộn lẫn với chi tiết kỹ thuật, nên nó không thể được đọc, test, hay thay đổi độc lập.

Định nghĩa. Clean Architecture (Robert C. Martin, 2012) — cùng họ với Hexagonal Architecture / Ports & Adapters (Alistair Cockburn, 2005) và Onion Architecture (Jeffrey Palermo, 2008) — là một cách tổ chức code sao cho logic nghiệp vụ không biết gì về thế giới bên ngoài.

Ba tên gọi, một ý tưởng. Đừng để bị rối vì thuật ngữ.


2. Quy tắc phụ thuộc — thứ duy nhất phải nhớ

Dependency Rule: mã nguồn chỉ được phụ thuộc vào phía trong. Không bao giờ ngược lại.

        ┌──────────────────────────────────────────┐
        │  Frameworks & Drivers                    │   ← Express/Nest, Prisma,
        │  ┌────────────────────────────────────┐  │     Redis, Stripe, S3
        │  │  Interface Adapters                │  │   ← Controller, Presenter,
        │  │  ┌──────────────────────────────┐  │  │     Repository impl, Mapper
        │  │  │  Application (Use Cases)     │  │  │   ← "Đặt hàng", "Huỷ đơn"
        │  │  │  ┌────────────────────────┐  │  │  │
        │  │  │  │  Domain (Entities)     │  │  │  │   ← Quy tắc nghiệp vụ thuần
        │  │  │  └────────────────────────┘  │  │  │
        │  │  └──────────────────────────────┘  │  │
        │  └────────────────────────────────────┘  │
        └──────────────────────────────────────────┘
              phụ thuộc chỉ đi vào TRONG  ──────►

Bốn tầng, nói bằng tiếng Việt đời thường:

TầngChứa gìĐược import gìVí dụ
DomainThực thể + quy tắc nghiệp vụ luôn đúng bất kể ứng dụng nàoKhông gì cả (chỉ ngôn ngữ + lib thuần)Order biết "không huỷ được đơn đã giao"
ApplicationUse case — một hành động người dùng thực hiệnDomain + interface của cổng raPlaceOrderUseCase
AdaptersDịch giữa trong và ngoàiApplication + DomainOrderController, PrismaOrderRepository
InfrastructureFramework, DB, HTTP client, hàng đợiTất cảNest module, Prisma client, Stripe SDK

Phép thử một câu. Mở file trong tầng Domain của bạn. Nếu thấy import { PrismaClient }, import { Request } từ Express, hay @Injectable() của Nest — bạn đã vi phạm quy tắc. Domain không được biết những thứ đó tồn tại.

Tại sao quan trọng. Vì nó cho phép trả lời "quy tắc nghiệp vụ nằm ở đâu" bằng một thư mục, và test quy tắc đó không cần DB, không cần mạng, chạy trong mili-giây.


3. Dependency Inversion — cơ chế làm cho nó chạy được

Đây là chỗ hầu hết người học bị tắc. Use case cần đọc DB, nhưng không được phụ thuộc vào Prisma. Giải quyết thế nào?

Bằng cách đảo chiều phụ thuộc: tầng trong định nghĩa interface, tầng ngoài hiện thực nó.

ts
// ── domain/order.ts ─────────────────────────────────────────
// Không import gì từ framework. Đây là nghiệp vụ thuần.
export class Order {
  private constructor(
    readonly id: OrderId,
    readonly userId: UserId,
    readonly lines: OrderLine[],
    private status: OrderStatus,
  ) {}

  static place(userId: UserId, lines: OrderLine[]): Order {
    if (lines.length === 0) throw new DomainError('EMPTY_ORDER')
    return new Order(OrderId.next(), userId, lines, 'PENDING')
  }

  cancel(): void {
    if (this.status === 'DELIVERED') throw new DomainError('CANNOT_CANCEL_DELIVERED')
    this.status = 'CANCELLED'
  }

  totalMinor(): number {                    // tiền = số nguyên → GĐ13
    return this.lines.reduce((s, l) => s + l.unitPriceMinor * l.qty, 0)
  }
}

// ── application/ports/order-repository.ts ───────────────────
// CỔNG (port): interface do tầng trong sở hữu.
export interface OrderRepository {
  save(order: Order): Promise<void>
  findById(id: OrderId): Promise<Order | null>
}
export interface PaymentGateway {
  charge(userId: UserId, amountMinor: number, idempotencyKey: string): Promise<PaymentResult>
}

// ── application/use-cases/place-order.ts ────────────────────
export class PlaceOrderUseCase {
  constructor(
    private readonly orders: OrderRepository,      // interface, không phải Prisma
    private readonly payments: PaymentGateway,     // interface, không phải Stripe
  ) {}

  async execute(input: PlaceOrderInput): Promise<PlaceOrderOutput> {
    const order = Order.place(input.userId, input.lines)
    const result = await this.payments.charge(
      input.userId, order.totalMinor(), input.idempotencyKey,
    )
    if (!result.ok) throw new ApplicationError('PAYMENT_FAILED', result.reason)
    await this.orders.save(order)
    return { orderId: order.id.value, totalMinor: order.totalMinor() }
  }
}

// ── infrastructure/prisma-order-repository.ts ───────────────
// BỘ CHUYỂN ĐỔI (adapter): tầng ngoài hiện thực cổng của tầng trong.
@Injectable()
export class PrismaOrderRepository implements OrderRepository {
  constructor(private readonly prisma: PrismaService) {}

  async save(order: Order): Promise<void> {
    await this.prisma.order.upsert({
      where:  { id: order.id.value },
      create: OrderMapper.toPersistence(order),
      update: OrderMapper.toPersistence(order),
    })
  }
  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.prisma.order.findUnique({
      where: { id: id.value }, include: { lines: true },
    })
    return row ? OrderMapper.toDomain(row) : null
  }
}

Nối dây trong NestJS — chỗ duy nhất tầng ngoài gặp tầng trong:

ts
@Module({
  providers: [
    { provide: ORDER_REPOSITORY, useClass: PrismaOrderRepository },
    { provide: PAYMENT_GATEWAY,  useClass: StripePaymentGateway  },
    {
      provide: PlaceOrderUseCase,
      inject: [ORDER_REPOSITORY, PAYMENT_GATEWAY],
      useFactory: (o: OrderRepository, p: PaymentGateway) => new PlaceOrderUseCase(o, p),
    },
  ],
})
export class OrdersModule {}

Đây chính là câu trả lời cho "DI để làm gì" mà bạn đã gặp ở GĐ6. DI không phải để "code đẹp" — nó là công cụ cơ khí để thực thi quy tắc phụ thuộc.


4. Cái giá phải trả — nói thẳng

Mọi tài liệu về Clean Architecture đều bán cho bạn lợi ích. Đây là hoá đơn:

Chi phíCụ thể
Nhiều file hơnMột CRUD entity: entity + repo interface + repo impl + mapper + 4 use case + DTO + controller ≈ 9 file thay vì 2
Mapping thủ côngDomain ↔ persistence ↔ DTO. Ba hình dạng của cùng dữ liệu, phải viết và bảo trì chuyển đổi
Mất tiện ích của ORMKhông dùng được lazy loading, include lồng nhau, query builder linh hoạt nếu domain phải thuần
Đường học dốc cho người mớiNgười mới vào team mất vài tuần mới biết đặt code ở đâu
Dễ bị áp dụng máy mócTạo interface IUserService chỉ có đúng một implementation mãi mãi — abstraction rỗng

Khi nào chi phí này đáng:

  • Nghiệp vụ phức tạp thật (bảo hiểm, ngân hàng, logistics, tính giá nhiều tầng), không phải CRUD có validate.
  • Vòng đời dự án dài, nhiều người cùng làm.
  • Có nhiều đường vào cùng một logic: HTTP + worker + CLI + cron.
  • Ràng buộc nghiệp vụ cần test dày và test phải chạy nhanh.

Khi nào KHÔNG đáng:

  • CRUD + validate + phân quyền. Đây là 80% API.
  • Prototype, MVP, dự án dưới 3 tháng.
  • Team một người và bạn đang học.

Lời khuyên thẳng thắn cho DA3 của bạn: đừng Clean Architecture toàn bộ. Áp dụng cho một module có nghiệp vụ thật (billing hoặc quota), giữ phần còn lại ở kiểu Nest thông thường. Bạn học được nguyên lý, giữ được tốc độ, và so sánh được hai kiểu trong cùng một codebase — đó là câu chuyện rất mạnh khi phỏng vấn.


5. Thang độ kiến trúc — chọn đúng bậc

Đây là bảng hữu ích hơn bất kỳ sơ đồ vòng tròn nào:

BậcCấu trúcHợp với
0Tất cả trong controllerScript, demo, không bao giờ cho production
1Controller → Service → RepositoryMặc định tốt. 80% API dừng ở đây
2Bậc 1 + module theo tính năng (không theo loại file)Codebase bắt đầu lớn
3Bậc 2 + interface cho cổng ra (repo, gateway)Cần đổi hạ tầng, cần test không DB
4Clean/Hexagonal đầy đủ: domain thuần + use case + mapperNghiệp vụ phức tạp thật
5Bậc 4 + DDD chiến thuật (aggregate, domain event) + CQRSHệ lớn, nhiều team, event-driven

Sai lầm phổ biến nhất: nhảy thẳng lên bậc 4-5 vì đọc blog. Sai lầm phổ biến thứ hai: mắc kẹt ở bậc 0-1 khi nghiệp vụ đã rõ ràng phức tạp.

Cách tổ chức thư mục ở bậc 2 — theo tính năng, không theo loại file:

src/
  modules/
    orders/                 ← mọi thứ về orders nằm cùng chỗ
      domain/
      application/
      infrastructure/
      orders.controller.ts
      orders.module.ts
    billing/
    users/
  shared/
    result.ts
    errors.ts

Chứ không phải:

src/
  controllers/    ← muốn sửa "orders" phải mở 6 thư mục
  services/
  repositories/
  dtos/

Cách thứ hai trông ngăn nắp nhưng làm mọi thay đổi trở thành thay đổi rải rác.


6. Domain model: thực thể "béo", không phải túi dữ liệu

Anemic domain model (phản mẫu). Class chỉ có field + getter/setter, mọi logic nằm ở service:

ts
class Order { status: string; lines: Line[] }          // túi dữ liệu

class OrderService {
  cancel(order: Order) {
    if (order.status === 'DELIVERED') throw new Error()   // quy tắc nằm ngoài
    order.status = 'CANCELLED'
  }
}

Vấn đề: quy tắc "không huỷ đơn đã giao" có thể bị bỏ qua — bất kỳ ai cũng gán order.status = 'CANCELLED' được. Sáu tháng sau bạn có ba nơi đổi status với ba bộ quy tắc khác nhau.

Rich domain model. Trạng thái là private, thay đổi chỉ qua phương thức có ý nghĩa nghiệp vụ (order.cancel(), order.markDelivered()). Đối tượng tự bảo vệ tính hợp lệ của mình — gọi là invariant (bất biến nghiệp vụ).

Value Object. Kiểu dữ liệu nhỏ, bất biến, so sánh theo giá trị, tự validate:

ts
export class Email {
  private constructor(readonly value: string) {}
  static create(raw: string): Email {
    const v = raw.trim().toLowerCase()
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) throw new DomainError('INVALID_EMAIL')
    return new Email(v)
  }
}
export class Money {
  private constructor(readonly minor: number, readonly currency: string) {}
  static of(minor: number, currency: string) {
    if (!Number.isInteger(minor)) throw new DomainError('MONEY_MUST_BE_INTEGER')
    return new Money(minor, currency)
  }
  add(o: Money) {
    if (o.currency !== this.currency) throw new DomainError('CURRENCY_MISMATCH')
    return new Money(this.minor + o.minor, this.currency)
  }
}

Lợi ích cụ thể: Money.add khiến cộng USD với VND trở thành lỗi không thể viết ra được, thay vì một bug phát hiện ở production. Đây là điều number không bao giờ làm được.

Aggregate. Cụm object thay đổi cùng nhau, có một gốc (aggregate root) là lối vào duy nhất. Order là root, OrderLine chỉ được sửa qua Order. Quy tắc thực dụng: một transaction = một aggregate. Cần sửa hai aggregate cùng lúc → dùng domain event + outbox (→ GĐ9 mục 4), không nhét cả hai vào một transaction khổng lồ.


7. Lỗi ở biên: Result hay exception

Domain ném lỗi kiểu gì và controller dịch ra HTTP thế nào? Đây là chỗ hai tầng gặp nhau và dễ rò rỉ nhất.

Nguyên tắc: tầng trong không biết HTTP tồn tại. Domain ném DomainErrorcode, không bao giờ ném BadRequestException của Nest.

Hai trường phái:

ts
// A. Exception có phân loại — ít ồn, hợp với Nest, dùng exception filter
export class DomainError extends Error {
  constructor(readonly code: string, message?: string) { super(message ?? code) }
}

// B. Result type — lỗi hiện trên chữ ký hàm, compiler ép xử lý
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }

Khuyến nghị cho lộ trình này: dùng A với exception filter duy nhất ở biên dịch code → HTTP status. Đơn giản, hợp Nest, và cùng cơ chế bạn đã có ở GĐ3GĐ6. Dùng B khi lỗi là phần bình thường của luồng (validate form nhiều trường, parse input), nơi exception làm code khó đọc.

ts
@Catch(DomainError, ApplicationError)
export class DomainExceptionFilter implements ExceptionFilter {
  private static readonly MAP: Record<string, number> = {
    EMPTY_ORDER:              422,
    CANNOT_CANCEL_DELIVERED:  409,
    PAYMENT_FAILED:           402,
    ORDER_NOT_FOUND:          404,
  }
  catch(e: DomainError, host: ArgumentsHost) {
    const status = DomainExceptionFilter.MAP[e.code] ?? 400
    host.switchToHttp().getResponse().status(status).json({ error: e.code })
  }
}

8. Test — phần thưởng thật sự

Đây là lợi ích cụ thể nhất, đo được nhất của kiến trúc này.

ts
// Test domain: không DB, không mạng, không framework. Mili-giây.
it('không cho huỷ đơn đã giao', () => {
  const order = Order.place(userId, [line])
  order.markDelivered()
  expect(() => order.cancel()).toThrow(DomainError)
})

// Test use case: fake in-memory cho cổng ra, không phải mock từng phương thức.
class InMemoryOrderRepository implements OrderRepository {
  private readonly store = new Map<string, Order>()
  async save(o: Order)      { this.store.set(o.id.value, o) }
  async findById(id: OrderId) { return this.store.get(id.value) ?? null }
}

it('không lưu đơn khi thanh toán thất bại', async () => {
  const repo = new InMemoryOrderRepository()
  const useCase = new PlaceOrderUseCase(repo, new AlwaysFailingPayment())
  await expect(useCase.execute(input)).rejects.toThrow(ApplicationError)
  expect(await repo.findById(anyId)).toBeNull()
})

Fake, không phải mock. InMemoryOrderRepository là một hiện thực thật và đơn giản của interface. Nó không vỡ khi bạn đổi thứ tự lời gọi, không cần expect(mock).toHaveBeenCalledWith(...), và test đọc lên như đặc tả nghiệp vụ.

Nhưng. Test này không chứng minh SQL của bạn đúng. Vẫn phải có test tích hợp với DB thật — GĐ12 giải thích vì sao "không mock DB" là nguyên tắc. Kiến trúc này cho bạn thêm một tầng test nhanh, không thay thế tầng test tích hợp.


9. Phản mẫu — dấu hiệu bạn đang làm quá

Dấu hiệuVì sao xấuNên làm
IUserService chỉ có một impl, mãi mãiAbstraction rỗng, thêm một lần nhảy khi đọc codeBỏ interface. Thêm khi có impl thứ hai thật sự
Repository trả về DTO thay vì entityRò rỉ hình dạng persistence vào tầng trongRepo trả entity domain; mapper nằm ở tầng ngoài
Use case gọi use case gọi use caseChuỗi gọi khó lần, transaction mờUse case gọi domain service, không gọi use case khác
Entity domain có decorator @Column/@EntityDomain phụ thuộc ORM → vi phạm quy tắcTách entity domain khỏi model persistence
Mapper 300 dòng cho một entityDomain và schema lệch nhau quá xaXem lại: có thể bạn cần ít tầng hơn, không phải nhiều hơn
DTO = entity = model DB (cùng một class)Đổi cột DB làm vỡ API công khaiTách ít nhất DTO khỏi model DB
Mỗi CRUD field có một use case riêngNghi lễ không mang lại gìCRUD dùng thẳng service. Use case dành cho hành động có nghiệp vụ

Câu hỏi tự kiểm mỗi khi định thêm một lớp trừu tượng:

"Cái này chống lại một thay đổi tôi thực sự dự kiến, hay một thay đổi tôi tưởng tượng?"

Nếu là tưởng tượng — đừng thêm. Quy tắc YAGNI thắng Clean Architecture.


10. Bài tập — refactor một module của DA3

Yêu cầu.

  1. Chọn một module có nghiệp vụ thật trong DA3 (gợi ý: billing/quota, hoặc order nếu có).
  2. Tách thành domain/ (entity + value object, không import gì từ Nest/Prisma), application/ (use case + port interface), infrastructure/ (repo Prisma + gateway ngoài).
  3. Thêm lint rule chặn vi phạm quy tắc phụ thuộc — đây là phần quan trọng nhất, vì kỷ luật thủ công luôn thua:
jsonc
// .eslintrc — no-restricted-imports theo thư mục
{
  "overrides": [{
    "files": ["src/modules/*/domain/**"],
    "rules": { "no-restricted-imports": ["error", {
      "patterns": ["@nestjs/*", "@prisma/*", "express", "**/infrastructure/**", "**/application/**"]
    }]}
  }, {
    "files": ["src/modules/*/application/**"],
    "rules": { "no-restricted-imports": ["error", {
      "patterns": ["@nestjs/common", "@prisma/*", "**/infrastructure/**"]
    }]}
  }]
}
  1. Viết test domain chạy không cần DB; đo và ghi lại thời gian chạy.
  2. Viết InMemoryRepository và test use case bằng nó.
  3. Giữ nguyên các module khác ở kiểu Nest thường.
  4. Trong README, viết một đoạn so sánh hai kiểu trong chính codebase này: số file, thời gian test, mức dễ đọc — và kết luận bạn sẽ chọn kiểu nào cho module tiếp theo, kèm lý do.

Mục 7 là sản phẩm giao quan trọng nhất. Nó chứng minh bạn có phán đoán kiến trúc, không phải chỉ biết làm theo mẫu.


Done khi

  • [ ] Phát biểu được Dependency Rule trong một câu
  • [ ] Biết Clean / Hexagonal / Onion là ba tên của cùng một ý tưởng
  • [ ] Giải thích được Dependency Inversion: tầng trong sở hữu interface, tầng ngoài hiện thực nó
  • [ ] Nối được liên hệ: DI của NestJS chính là cơ chế thực thi kiến trúc này
  • [ ] Định vị được dự án của mình trên thang 0–5 và bảo vệ được vị trí đó
  • [ ] Tổ chức thư mục theo tính năng, không theo loại file
  • [ ] Phân biệt anemic vs rich domain model; viết được Value Object tự validate
  • [ ] Giải thích aggregate và quy tắc một transaction = một aggregate
  • [ ] Domain ném DomainErrorcode; chỉ một chỗ ở biên dịch sang HTTP status
  • [ ] Test domain chạy không cần DB; dùng fake thay vì mock từng phương thức
  • [ ] Nhận ra được ít nhất 4 phản mẫu ở mục 9 trong code của chính mình
  • [ ] Có lint rule chặn vi phạm quy tắc phụ thuộc trong CI
  • [ ] Nêu được ít nhất 3 chi phí thật của kiến trúc này — không chỉ lợi ích

Câu hỏi mở / chưa giải quyết

  • CQRS có nên vào đây không? Tách đường đọc và đường ghi giải quyết vấn đề thật (đọc cần hình dạng khác ghi), nhưng nó là bậc 5. Đề xuất thực dụng: cho phép đường đọc đi tắt từ controller xuống query thẳng DB (bỏ qua domain), giữ đường ghi đi qua use case. Bất đối xứng có chủ đích này giữ được phần lớn lợi ích với phần nhỏ chi phí.
  • Event sourcing cố tình không có trong lộ trình này. Nó thay đổi cách bạn lưu trữ dữ liệu ở mức căn bản và hiếm khi là lựa chọn đúng cho dự án đầu tiên.
  • Bao nhiêu mapping là quá nhiều? Chưa có ngưỡng khách quan. Tín hiệu thực dụng: nếu mapper dài hơn entity nó ánh xạ, tầng của bạn đang lệch quá xa.

Học bằng cách build. Chứng minh, đừng tin.