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

Vào đây sau GĐ07 (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ó.

Kiểm chứng ngày 2026-10-05. Mã mẫu mới (mục 3.1, 6.1, 7.1 và bài tập 8) đã chạy với Node 24.21.0, Vitest 5.0.3, ESLint 10.12.0 (flat config) + typescript-eslint 8.71.0, NestJS 12.1.2, class-validator 0.15.1, và tsc --strict (TypeScript 6.0.3 và 7.0.2 đều sạch). Phần Prisma ghi rõ "code tham chiếu, chưa chạy". Nguồn phiên bản: https://registry.npmjs.org/ và https://nodejs.org/en/about/previous-releases.


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.

textReady
        ┌──────────────────────────────────────────┐        │  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ó.

typescriptReady
// ── 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Đ14    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:

typescriptReady
@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Đ07. DI không phải để "code đẹp" — nó là công cụ cơ khí để thực thi quy tắc phụ thuộc.

Sơ đồ: đảo chiều phụ thuộc
textReady
 Không đảo (xấu)                  Đảo chiều (tốt) PlaceOrderUseCase                PlaceOrderUseCase ----> OrderRepository        |                                                 (interface, nằm        v  import                                         trong application/) PrismaOrderRepository                                        ^        |                                                     | implements        v                                           PrismaOrderRepository     Prisma                                                   |                                                              v Mũi tên mã nguồn đi RA ngoài                              Prisma                                  Mọi mũi tên mã nguồn chỉ VÀO trong;                                  chỉ module Nest (dây nối) biết cả hai.

Đọc hình: ở cột phải, PlaceOrderUseCase không có dòng import nào tới Prisma. Thay Prisma bằng InMemoryOrderRepository chỉ là đổi dòng useClass.


3.1 Transaction và outbox qua cổng UnitOfWork#

Mục 3 để PlaceOrderUseCase gọi orders.save() rời rạc. Khi một thao tác phải ghi nhiều thứ cùng nhau (đơn và ý định gửi sự kiện), use case cần nói "cùng một transaction" mà không biết Prisma tồn tại. Cách: thêm một cổng do tầng application sở hữu.

typescriptReady
// application/ports/unit-of-work.tsexport interface Outbox { add(topic: string, payload: Record<string, unknown>): Promise<void> }export interface Tx { orders: OrderRepository; outbox: Outbox }// Mọi thứ trong `work` dùng CÙNG một transaction: cùng commit hoặc cùng rollback.export interface UnitOfWork {  run<T>(work: (tx: Tx) => Promise<T>): Promise<T>}// application/use-cases/place-order.tsexport class PlaceOrderUseCase {  constructor(private readonly uow: UnitOfWork, private readonly payments: PaymentGateway) {}  async execute(input: PlaceOrderInput) {    const order = Order.place(UserId.from(input.userId),      input.lines.map((l) => OrderLine.of(l.sku, l.qty, l.unitPriceMinor)))    // 1) Gọi hệ thống ngoài NGOÀI transaction: không giữ kết nối DB trong lúc chờ mạng.    const paid = await this.payments.charge(input.userId, order.totalMinor(), input.idempotencyKey)    if (!paid.ok) throw new ApplicationError('PAYMENT_FAILED', paid.reason)    // 2) Đơn + ý định gửi sự kiện ghi cùng một transaction.    await this.uow.run(async ({ orders, outbox }) => {      await orders.save(order)      await outbox.add('order.placed', { orderId: order.id.value, totalMinor: order.totalMinor() })    })    return { orderId: order.id.value, totalMinor: order.totalMinor() }  }}

Adapter Prisma nằm ở infrastructure/ (code tham chiếu, chưa chạy; PrismaOrderRepository và PrismaOutbox nhận client của transaction thay vì PrismaService):

typescriptReady
@Injectable()export class PrismaUnitOfWork implements UnitOfWork {  constructor(private readonly prisma: PrismaService) {}  run<T>(work: (tx: Tx) => Promise<T>): Promise<T> {    return this.prisma.$transaction(      (db) => work({ orders: new PrismaOrderRepository(db), outbox: new PrismaOutbox(db) }),      { timeout: 10_000 },   // Prisma mặc định 5 giây cho transaction tương tác    )  }}

Hai quyết định đáng nói:

  • Charge nằm ngoài transaction. Một lệnh gọi mạng chậm giữ kết nối DB (và khoá) suốt thời gian chờ, cạn pool khi tải cao. Cái giá là khe hở: charge xong rồi uow.run lỗi thì tiền đã trừ mà chưa có đơn. Chốt bằng idempotency key truyền sang provider để lần thử lại không trừ tiền lần hai (GĐ10 mục 3).
  • Outbox thay cho queue.add() trong use case. Một relay riêng đọc bảng outbox rồi đẩy vào hàng đợi (GĐ10 mục 4); use case không biết BullMQ tồn tại.

Đã chạy (Vitest 5.0.3, fake InMemoryUnitOfWork ghi vào bản nháp và chỉ gộp khi work thành công; 4 test của use case qua):

Tình huốngKết quả quan sát
thanh toán bị từ chốikhông có đơn, outbox rỗng
thành côngmột đơn và một dòng order.placed có đúng orderId, totalMinor: 1000
uow.run lỗi sau khi chargerollback: không đơn, không dòng outbox mồ côi; thử lại cùng key: provider được gọi 2 lần nhưng chỉ trừ 1 lần
đơn rỗngEMPTY_ORDER ném ra trước khi chạm cổng thanh toán (0 lần gọi provider)

Fake này kiểm hành vi use case, không chứng minh $transaction của Prisma rollback đúng: việc đó thuộc test tích hợp với DB thật (GĐ13).


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:

textReady
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:

textReady
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:

typescriptReady
class Order { status: string; lines: Line[] }          // túi dữ liệuclass 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:

typescriptReady
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Đ10 mục 4), không nhét cả hai vào một transaction khổng lồ.

6.1 Order đầy đủ: id, dòng hàng, khôi phục từ DB#

Mục 3 chỉ phác Order và còn thiếu OrderId, OrderLine, markDelivered (mà test ở mục 8 gọi). Bản đầy đủ, chạy được:

typescriptReady
// domain/ids.ts: OrderId.next() sinh UUID; OrderId.from(raw) kiểm định dạng, sai thì DomainError('INVALID_ORDER_ID')// domain/order-line.tsexport class OrderLine {  private constructor(readonly sku: string, readonly qty: number, readonly unitPriceMinor: number) {}  static of(sku: string, qty: number, unitPriceMinor: number): OrderLine {    if (!Number.isInteger(qty) || qty <= 0) throw new DomainError('INVALID_QTY')    if (!Number.isInteger(unitPriceMinor) || unitPriceMinor < 0) throw new DomainError('INVALID_PRICE')    return new OrderLine(sku, qty, unitPriceMinor)  }  get totalMinor(): number { return this.qty * this.unitPriceMinor }}// domain/order.tsexport type OrderStatus = 'PENDING' | 'DELIVERED' | 'CANCELLED'export interface OrderSnapshot {            // hình dạng phẳng để lưu/đọc, không có logic  id: string; userId: string; status: OrderStatus  lines: { sku: string; qty: number; unitPriceMinor: number }[]}export class Order {  private constructor(    readonly id: OrderId, readonly userId: UserId,    private readonly lines: readonly OrderLine[], private status: OrderStatus,  ) {}  static place(userId: UserId, lines: OrderLine[]): Order {       // tạo mới: kiểm bất biến    if (lines.length === 0) throw new DomainError('EMPTY_ORDER')    return new Order(OrderId.next(), userId, [...lines], 'PENDING')  }  static restore(s: OrderSnapshot): Order {                       // đọc từ DB: giữ id cũ    return new Order(OrderId.from(s.id), UserId.from(s.userId),      s.lines.map((l) => OrderLine.of(l.sku, l.qty, l.unitPriceMinor)), s.status)  }  markDelivered(): void {    if (this.status !== 'PENDING') throw new DomainError('CANNOT_DELIVER', this.status)    this.status = 'DELIVERED'  }  cancel(): void {    if (this.status === 'DELIVERED') throw new DomainError('CANNOT_CANCEL_DELIVERED')    this.status = 'CANCELLED'  }  get currentStatus(): OrderStatus { return this.status }  totalMinor(): number { return this.lines.reduce((sum, l) => sum + l.totalMinor, 0) }  toSnapshot(): OrderSnapshot {    return { id: this.id.value, userId: this.userId.value, status: this.status,      lines: this.lines.map((l) => ({ sku: l.sku, qty: l.qty, unitPriceMinor: l.unitPriceMinor })) }  }}

Vì sao có restore riêng: place sinh id mới và áp quy tắc "tạo đơn"; đọc lại từ DB thì phải giữ id cũ và không được coi một đơn đã giao là đơn mới. OrderMapper ở tầng ngoài chỉ biết OrderSnapshot, không đụng vào field private của Order (code tham chiếu, chưa chạy):

typescriptReady
// infrastructure/order-mapper.tsexport const OrderMapper = {  toDomain: (row: OrderRow): Order => Order.restore(row),                 // row có đúng hình OrderSnapshot  toPersistence: (o: Order): OrderSnapshot => o.toSnapshot(),             // dòng hàng ghi trong cùng transaction}

Đã chạy (Vitest 5.0.3, tsc --strict sạch, 5 test của Order qua): place rồi markDelivered rồi cancel ném DomainError và trạng thái vẫn DELIVERED; đơn rỗng ném EMPTY_ORDER; đơn đã huỷ không giao được (CANNOT_DELIVER); restore(order.toSnapshot()) giữ nguyên id, trạng thái và tổng tiền (1100 với hai dòng hàng); restore với id không phải UUID ném INVALID_ORDER_ID thay vì dựng một đối tượng sai. Test markDelivered rồi cancel ở mục 8 chạy đúng với lớp này.

Lưu ý khi chạy thử: constructor có tham số private/readonly (parameter property) không chạy được bằng cơ chế bỏ kiểu của Node. Đã thử với một class tối thiểu trên Node 24.21.0: node file.ts báo ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX (parameter property is not supported in strip-only mode). Chạy qua tsc hoặc Vitest, hoặc viết field tường minh.


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 DomainError có code, không bao giờ ném BadRequestException của Nest.

Hai trường phái:

typescriptReady
// A. Exception có phân loại — ít ồn, hợp với Nest, dùng exception filterexport 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Đ04 và GĐ07. 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.

typescriptReady
@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 })  }}

7.1 Controller mỏng: từ HTTP tới use case#

Controller là adapter vào: dịch HTTP thành đầu vào use case rồi dịch kết quả ngược lại, không chứa quy tắc nghiệp vụ. Kiểu validate theo GĐ07 mục 6 (class-validator + ValidationPipe bật whitelist/forbidNonWhitelisted); lỗi dịch bằng DomainExceptionFilter ở mục 7. Import tương đối ESM phải có đuôi .js.

typescriptReady
// infrastructure/http/place-order.dto.ts: DTO thuộc tầng ngoài, decorator nằm ở đây, không nằm trong domainclass OrderLineDto {  @IsString() @IsNotEmpty() sku!: string  @IsInt() @Min(1) qty!: number  @IsInt() @Min(0) unitPriceMinor!: number}export class PlaceOrderDto {  @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => OrderLineDto)  lines!: OrderLineDto[]}// infrastructure/http/orders.controller.ts@Controller('v1/orders')@UseGuards(AuthGuard)                 // guard JWT thật ở GĐ07@UseFilters(DomainExceptionFilter)export class OrdersController {  constructor(private readonly placeOrder: PlaceOrderUseCase) {}  @Post() @HttpCode(201)  place(    @Req() req: { user: { id: string } },    @Headers('idempotency-key') idempotencyKey: string | undefined,    @Body() dto: PlaceOrderDto,  ) {    if (!idempotencyKey) throw new BadRequestException('Idempotency-Key header is required')    return this.placeOrder.execute({      userId: req.user.id,            // lấy từ token, không bao giờ từ body      lines: dto.lines,      idempotencyKey,    })  }}

Đã chạy (Nest 12.1.2, use case nối với InMemoryUnitOfWork và cổng thanh toán giả, tsc --strict sạch, gọi bằng fetch):

RequestKết quả
hợp lệ, có Idempotency-Key201 { orderId, totalMinor: 1000 }
không có header đăng nhập401 (guard mẫu ném UnauthorizedException)
thiếu Idempotency-Key400
qty: -1400 lines.0.qty must not be less than 1
lines: []400 lines must contain at least 1 elements
body có thêm userId: "hacker"400 property userId should not exist
cổng thanh toán từ chối402 { "error": "PAYMENT_FAILED" } qua filter

Sau chuỗi trên chỉ có một đơn và một dòng outbox: các request bị chặn không chạm tới use case. Hai chỗ cần để ý:

  • Trong Nest 12 kiểu của @Headers() chỉ nhận tên header, không nhận pipe (tsc báo TS2554 khi truyền new ParseUUIDPipe()), nên kiểm header ngay trong handler hoặc viết decorator tham số riêng.
  • Hai tầng kiểm tra là cố ý: DTO chặn input sai hình ở biên (400), còn bất biến thật (EMPTY_ORDER 422, INVALID_QTY) vẫn nằm trong domain, vì use case còn có đường vào khác ngoài HTTP (worker, CLI).

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.

typescriptReady
// 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()   // yếu: anyId luôn ra null; bản chặt hơn ở khung bên dưới})

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Đ13 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.

Kết quả mong đợi, và vì sao test thứ hai ở trên chưa đủ chặt

Code tham chiếu, chưa chạy; kết quả là suy ra từ code. Test thứ hai gọi repo.findById(anyId) với một id tuỳ ý nên luôn ra null, dù use case có lưu đơn hay không: nó không bắt được lỗi nó định bắt. Viết chặt hơn: kiểm chính thứ đã lưu.

typescriptReady
class InMemoryOrderRepository implements OrderRepository {  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(repo.store.size).toBe(0)           // kho trống: đúng thứ cần chứng minh})

Mong đợi: store.size bằng 0 với thứ tự trong mục 3 (charge rồi mới save). Đảo hai dòng (save trước, charge sau) thì test fail với expected 1 to be 0: đó là cách chứng minh test có răng. Test domain (Order.place, cancel) chạy thuần bộ nhớ nên cỡ vài mili-giây mỗi test.

Một cạnh sắc của chính ví dụ mục 3: nếu charge thành công rồi save lỗi, tiền đã bị trừ mà không có đơn. Đó là bài toán hai hệ thống; lời giải là PENDING → DONE kèm idempotency key phía provider (GĐ10 mục 3).


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ó).

    Đáp án

    Chọn billing/quota ("trừ lượt dùng, không vượt hạn mức"): có quy tắc thật (0 <= used <= limit) mà nhỏ. Tiêu chí chọn: có invariant cần bảo vệ, có ít nhất hai đường vào (HTTP, worker), test cần nhanh. Module CRUD thuần thì không đáng.

  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).

    Lời giải và cách kiểm tra

    Code tham chiếu, chưa chạy. Chiều import: infrastructure --> application --> domain. Xem Khung và mã dùng chung ở dưới cho cây thư mục và mã Quota, ConsumeQuota, QuotaRepository.

    Adapter Prisma: find đọc hàng rồi Quota.restore(...); save ghi lại. Cạnh sắc cần chốt: hai request song song cùng đọc used = 9, cùng ghi 10 và vượt hạn mức (lost update). Chọn một: cột version (optimistic lock, UPDATE ... WHERE version = $n, 0 dòng thì ném lỗi để retry) hoặc câu nguyên tử UPDATE quota SET used = used + $1 WHERE user_id = $2 AND used + $1 <= "limit" (limit là từ khoá dành riêng của PostgreSQL nên phải đặt trong dấu nháy kép hoặc đổi tên cột). Test tích hợp với DB thật phải phủ chỗ này.

    Lỗi hay gặp: để @Injectable() trong application/; repo trả row Prisma thay vì entity.

  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:

    typescriptReady
    // eslint.config.mjs — flat config (ESLint 10 không còn đọc .eslintrc)import { defineConfig } from 'eslint/config'import tseslint from 'typescript-eslint'export default defineConfig([  { files: ['src/**/*.ts'], languageOptions: { parser: tseslint.parser } },  {    files: ['src/modules/*/domain/**'],    rules: { 'no-restricted-imports': ['error', {      patterns: ['@nestjs/*', '@prisma/*', '**/generated/prisma/**', 'express',        '**/infrastructure/**', '**/application/**'],    }]},  },  {    files: ['src/modules/*/application/**'],    rules: { 'no-restricted-imports': ['error', {      patterns: ['@nestjs/*', '@prisma/*', '**/generated/prisma/**', '**/infrastructure/**'],    }]},  },])
    Đáp án

    Mong đợi (chưa chạy trên dự án này; riêng mẫu **/generated/prisma/** đã thử bằng ESLint 10 trong thư mục tạm và báo no-restricted-imports): thêm vào domain/quota.ts dòng import { PrismaClient } from '../../../generated/prisma/client.js' (Prisma 7 không còn import từ @prisma/client, nên mẫu @prisma/* một mình không bắt được; điều chỉnh số ../ theo vị trí file) thì npx eslint src báo no-restricted-imports và exit code khác 0. Tự thử một file vi phạm cố ý cho từng mẫu, đặc biệt đường dẫn tương đối như ../application/...: đừng tin mẫu khớp đúng khi chưa thấy nó đỏ. Đưa vào CI: npx eslint src --max-warnings 0.

    Lưu ý: typescript-eslint 8 chưa chạy với TypeScript 7; ghim TypeScript tương thích cho bước lint.

  4. Viết test domain chạy không cần DB; đo và ghi lại thời gian chạy.

    Đáp án
    typescriptReady
    it('Quota không cho tiêu quá hạn mức', () => {  const q = Quota.restore('u1', 10, 9)  expect(() => q.consume(2)).toThrowError(expect.objectContaining({ code: 'QUOTA_EXCEEDED' }))  expect(q.usedUnits).toBe(9)})

    Chạy npx vitest run src/modules/quota/domain, ghi số liệu thật của máy bạn ở dòng Duration (mong đợi: cỡ vài mili-giây mỗi test vì thuần bộ nhớ). Chưa chạy.

  5. Viết InMemoryRepository và test use case bằng nó.

    Đáp án
    typescriptReady
    class InMemoryQuotas implements QuotaRepository {  readonly store = new Map<string, Quota>()  async find(id: string) { return this.store.get(id) ?? null }  async save(q: Quota) { this.store.set(q.userId, q) }}it('vượt hạn mức thì ném QUOTA_EXCEEDED và không đổi số đã dùng', async () => {  const repo = new InMemoryQuotas()  repo.store.set('u1', Quota.restore('u1', 10, 9))  await expect(new ConsumeQuota(repo).execute('u1', 2)).rejects.toMatchObject({ code: 'QUOTA_EXCEEDED' })  expect(repo.store.get('u1')!.usedUnits).toBe(9)})

    Kiểm trạng thái thật (usedUnits), không toHaveBeenCalledWith. Code tham chiếu, chưa chạy.

  6. Giữ nguyên các module khác ở kiểu Nest thường.

    Đáp án

    Tự kiểm: git diff --stat chỉ chạm src/modules/quota/**, cấu hình lint và README; các module khác không đổi import hay cấu trúc. Lint rule chỉ áp dụng cho src/modules/quota/** nếu bạn muốn so sánh công bằng (đổi files từ * thành quota).

  7. 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.

    Đáp án

    Bảng README gợi ý (điền số thật của máy bạn):

    Module quota (Clean)Module khác (Nest thường)
    Số file nguồnghi số thậtghi số thật
    Thời gian chạy testghi số thậtghi số thật
    Dễ đọc / dễ đổinhận xét của bạnnhận xét của bạn
    Chọn cho module tiếp theokết luận và lý do

    Lỗi hay gặp: ghi "Clean tốt hơn" mà không có số đo nào; kết luận không gắn với mức phức tạp nghiệp vụ (mục 4).

  8. Cố ý vi phạm quy tắc phụ thuộc, từng cách một, và xác nhận lint báo đỏ. Một rule chưa từng thấy đỏ thì chưa có bằng chứng nó hoạt động.

    Đáp án

    Dùng đúng cấu hình ở bài 3. Tạo năm file vi phạm (xoá sau khi thử) rồi chạy npx eslint src:

    File vi phạmDòng importKết quả
    domain/bad-nest.tsimport { Injectable } from '@nestjs/common'báo no-restricted-imports
    domain/bad-relative.tsimport type { OrderRepository } from '../application/ports/order-repository.js'báo (mẫu **/application/** khớp cả đường dẫn tương đối và import type)
    domain/bad-deep.tsimport { OrdersModule } from '../infrastructure/orders.module.js'báo
    application/bad-infra.tsimport { OrdersModule } from '../infrastructure/orders.module.js'báo
    application/bad-prisma.tsimport { PrismaClient } from '../../../generated/prisma/client.js'báo (mẫu **/generated/prisma/**)

    Đã chạy (ESLint 10.12.0, typescript-eslint 8.71.0, trên cây orders/ ở mục 3.1 đến 7.1): trước khi thêm file vi phạm eslint src thoát mã 0; sau khi thêm, 5 lỗi no-restricted-imports, mã thoát 1; xoá các file vi phạm thì lại thoát 0. Chưa thử require()/import() động: rule này không phải bằng chứng mọi đường import đều bị chặn, nên giữ thêm review khi có import lạ.

    Đưa ba file vi phạm đầu vào một nhánh thử trong CI để thấy pipeline đỏ, rồi bỏ. Lỗi hay gặp: viết mẫu bằng đường dẫn cứng (../../infrastructure) nên import từ thư mục khác lọt qua.

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.

Khung và mã dùng chung

Code tham chiếu, chưa chạy. Cây thư mục và chiều import:

textReady
 src/modules/quota/   domain/          quota.ts  domain-error.ts     <- không import ai   application/     ports/quota-repository.ts     <- import domain                    use-cases/consume-quota.ts   infrastructure/  prisma-quota-repository.ts    <- import application + domain                    quota.module.ts   quota.controller.ts mũi tên import: infrastructure --> application --> domain
typescriptReady
// domain/domain-error.tsexport class DomainError extends Error {  constructor(readonly code: string) { super(code) }}// domain/quota.ts  (invariant: 0 <= used <= limit)import { DomainError } from './domain-error.js'export class Quota {  private constructor(readonly userId: string, readonly limit: number, private used: number) {}  static restore(userId: string, limit: number, used: number) { return new Quota(userId, limit, used) }  consume(units: number): void {    if (!Number.isInteger(units) || units <= 0) throw new DomainError('INVALID_UNITS')    if (this.used + units > this.limit) throw new DomainError('QUOTA_EXCEEDED')    this.used += units  }  get usedUnits() { return this.used }}// application/ports/quota-repository.tsimport type { Quota } from '../../domain/quota.js'export interface QuotaRepository {  find(userId: string): Promise<Quota | null>  save(q: Quota): Promise<void>}// application/use-cases/consume-quota.tsexport class ConsumeQuota {  constructor(private readonly quotas: QuotaRepository) {}  async execute(userId: string, units: number) {    const q = await this.quotas.find(userId)    if (!q) throw new DomainError('QUOTA_NOT_FOUND')    q.consume(units)    await this.quotas.save(q)    return { used: q.usedUnits }  }}

Done khi#

  • Phát biểu được Dependency Rule trong một câu

    Đáp án

    Mã nguồn chỉ được phụ thuộc vào phía trong (domain), không bao giờ ngược lại: domain không biết use case, framework hay DB. Phép thử: file trong domain/ không có import từ Nest, Prisma, Express. Xem mục 2.

  • Biết Clean / Hexagonal / Onion là ba tên của cùng một ý tưởng

    Đáp án

    Clean (Martin), Hexagonal / Ports & Adapters (Cockburn), Onion (Palermo) đều đặt nghiệp vụ ở lõi, hạ tầng ở rìa, phụ thuộc hướng vào trong. Khác nhau về cách vẽ và từ vựng, không khác nguyên lý. Xem mục 1.

  • Giải thích được Dependency Inversion: tầng trong sở hữu interface, tầng ngoài hiện thực nó

    Đáp án

    Use case cần đọc DB nhưng không được import Prisma, nên tầng trong định nghĩa interface (OrderRepository) và tầng ngoài hiện thực (PrismaOrderRepository). Nhờ vậy mũi tên mã nguồn chỉ vào trong dù luồng chạy đi ra ngoài. Sai thường gặp: đặt interface cạnh implementation ở tầng ngoài. Xem mục 3.

  • Nối được liên hệ: DI của NestJS chính là cơ chế thực thi kiến trúc này

    Đáp án

    Container Nest là chỗ duy nhất nối ORDER_REPOSITORY với PrismaOrderRepository (provide/useClass/useFactory). DI không phải để "code đẹp"; nó là cơ chế cơ khí thực thi quy tắc phụ thuộc, và để thay fake khi test. Xem mục 3, GĐ07.

  • Định vị được dự án của mình trên thang 0–5 và bảo vệ được vị trí đó

    Đáp án

    Trả lời kiểu: "DA3 ở bậc 2-3: Controller → Service → Repository, module theo tính năng, interface cho cổng ra ở module billing; chưa cần aggregate/CQRS vì nghiệp vụ chưa phức tạp". Bảo vệ bằng ba câu hỏi: nghiệp vụ có phức tạp thật không, có nhiều đường vào không, test có cần nhanh không. Xem mục 5.

  • Tổ chức thư mục theo tính năng, không theo loại file

    Đáp án

    modules/orders/{domain,application,infrastructure} thay vì controllers/, services/, repositories/: một thay đổi về "orders" chạm một thư mục. Tự kiểm: thêm một field cho Order chỉ phải mở một thư mục. Xem mục 5.

  • Phân biệt anemic vs rich domain model; viết được Value Object tự validate

    Đáp án

    Anemic: class chỉ có field, quy tắc ở service nên bị bỏ qua được. Rich: trạng thái private, đổi qua order.cancel() để tự bảo vệ invariant. VO: bất biến, so theo giá trị, validate ở create (Email.create, Money.of). Xem mục 6.

  • Giải thích aggregate và quy tắc một transaction = một aggregate

    Đáp án

    Cụm object đổi cùng nhau qua một root (Order với OrderLine). Một transaction chỉ sửa một aggregate; cần sửa hai thì dùng domain event + outbox (eventual consistency). Sai thường gặp: một transaction khổng lồ khoá nhiều aggregate. Xem mục 6, GĐ10.

  • Domain ném DomainError có code; chỉ một chỗ ở biên dịch sang HTTP status

    Đáp án

    Domain ném DomainError(code), không ném BadRequestException; một @Catch(DomainError) filter có bảng code → status duy nhất. Tự kiểm: grep -r "HttpException\|BadRequestException" src/modules/*/domain rỗng. Xem mục 7.

  • Test domain chạy không cần DB; dùng fake thay vì mock từng phương thức

    Đáp án

    Order.place/cancel test thuần bộ nhớ, không DB. Use case test bằng InMemoryRepository (hiện thực thật, đơn giản) và kiểm trạng thái chứ không toHaveBeenCalledWith. Không thay test tích hợp với DB thật. Xem mục 8.

  • Nhận ra được ít nhất 4 phản mẫu ở mục 9 trong code của chính mình

    Đáp án

    Ví dụ: IUserService một implementation; repo trả DTO; entity có @Column; mapper 300 dòng; DTO = entity = model DB. Mỗi mục phải gắn với một file thật trong code của bạn. Xem mục 9.

  • Có lint rule chặn vi phạm quy tắc phụ thuộc trong CI

    Đáp án

    Có eslint.config.mjs với no-restricted-imports theo thư mục domain/ và application/, chạy trong CI. Tự kiểm: cố ý thêm một import vi phạm, CI phải đỏ. Xem mục 10 (và lời giải).

  • 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

    Đáp án

    Ít nhất ba: nhiều file hơn (~9 so với 2 cho một CRUD), mapping thủ công ba hình dạng dữ liệu, mất tiện ích ORM, đường học dốc, dễ tạo abstraction rỗng. Bonus: nêu khi nào không đáng (CRUD, MVP, một người). Xem mục 4.

  • Viết được một use case ghi nhiều thứ cùng nhau qua cổng UnitOfWork, với lệnh gọi hệ thống ngoài nằm ngoài transaction

    Đáp án

    Use case gọi uow.run(({ orders, outbox }) => ...); adapter Prisma bọc $transaction. Charge chạy trước, ngoài transaction, để không giữ kết nối DB lúc chờ mạng; khe hở "đã trừ tiền nhưng chưa có đơn" chốt bằng idempotency key truyền sang provider. Tự kiểm: test với fake InMemoryUnitOfWork cho uow.run lỗi thì không còn đơn và không còn dòng outbox mồ côi. Sai thường gặp: gọi API thanh toán bên trong $transaction. Xem mục 3.1, GĐ10 mục 3, 4.

  • Dựng được Order có restore/toSnapshot để mapper không cần đụng field private

    Đáp án

    place tạo mới và kiểm bất biến; restore(snapshot) giữ id cũ và dựng lại từ dữ liệu phẳng; toSnapshot() trả hình phẳng cho mapper. Tự kiểm: Order.restore(order.toSnapshot()) ra cùng id, trạng thái và tổng tiền; restore với id hỏng ném DomainError. Xem mục 6.1.

  • Viết được controller mỏng: DTO validate ở biên, userId lấy từ token, lỗi domain dịch bằng filter

    Đáp án

    DTO dùng class-validator với whitelist và forbidNonWhitelisted; controller chỉ gọi placeOrder.execute({ userId: req.user.id, ... }). Tự kiểm bằng curl: body có userId thừa ra 400; thanh toán bị từ chối ra 402 qua filter; không có logic nghiệp vụ trong controller. Sai thường gặp: nhận userId từ body. Xem mục 7.1.

  • Đã thấy lint rule phụ thuộc báo đỏ với từng kiểu vi phạm, kể cả import tương đối

    Đáp án

    Thêm file vi phạm cố ý cho @nestjs/*, ../application/..., ../infrastructure/..., đường dẫn Prisma sinh ra; npx eslint src phải thoát mã khác 0. Bằng chứng nằm ở bài tập 8 của mục 10 (5 lỗi, mã thoát 1). Sai thường gặp: tin cấu hình đúng mà chưa từng thấy nó đỏ.


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í.

    Hướng trả lời hiện tại

    Với một dự án đầu tiên, cho đường đọc đi tắt (query thẳng DB, trả DTO) và giữ đường ghi qua use case là đủ; chỉ tách mô hình đọc/ghi khi truy vấn đọc cần hình dạng khác hẳn hoặc cần scale riêng. Chưa kết luận chắc chắn.

  • 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.

    Hướng trả lời hiện tại

    Coi là ngoài phạm vi; nếu cần lịch sử thay đổi, bảng audit/outbox (GĐ14, GĐ10) thường rẻ hơn nhiều. Chưa kết luận chắc chắ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ướng trả lời hiện tại

    Dùng tín hiệu "mapper dài hơn entity" làm chuông báo; khi nó kêu, thử gộp hình dạng (ví dụ cho DTO và entity dùng chung kiểu) trước khi thêm tầng. Chưa kết luận chắc chắn.