Khách hàng đặt một đơn hàng, bộ phận hỗ trợ hủy nó, kho vận giao nó, còn tài chính hoàn tiền. Các danh từ nghe có vẻ dùng được ở mọi nơi: khách hàng, đơn hàng, sản phẩm, thanh toán. Vì thế, ta rất dễ tạo một class dùng chung cho mỗi danh từ rồi để toàn bộ công ty tái sử dụng. Sáu tháng sau, Order.status có mười bốn giá trị, không ai chắc chuyển trạng thái nào là hợp lệ, và việc đổi một field cần cuộc họp của năm team.

Vấn đề không nằm ở cách đặt tên kém hay thiếu abstraction. Cùng một từ có thể mang những nghĩa khác nhau trong những cuộc hội thoại nghiệp vụ khác nhau. Domain-Driven Design (DDD) gọi phạm vi mà một mô hình và ngôn ngữ cụ thể giữ được tính nhất quán là bounded context. Trước hết, đó là ranh giới về ngữ nghĩa; sau đó mới là ranh giới phần mềm. Khi dùng đúng, mỗi team có một mô hình chính xác cho công việc của mình, còn khác biệt giữa các mô hình được thể hiện rõ tại điểm tích hợp.

Một nghiệp vụ, nhiều sự thật

Hãy xét một nhà bán lẻ trực tuyến. Trong Sales, sản phẩm là thứ khách hàng có thể cấu hình và mua; thuộc tính quan trọng là giá, khả năng bán và các tùy chọn. Trong Warehouse, thứ tưởng như cùng một sản phẩm lại là hàng tồn được nhận diện bằng SKU, kích thước, yêu cầu lưu trữ và vị trí kệ. Finance quan tâm đến khoản mục sổ cái, nhóm thuế và doanh thu được ghi nhận. Không mô hình nào là Product chuẩn cho toàn doanh nghiệp. Mỗi mô hình là một sự thật hữu ích cho một nhóm quyết định cụ thể.

Bounded context cho biết một sự thật có hiệu lực ở đâu. Bên trong Sales, Product có thể mang nghĩa “mặt hàng chào bán” một cách an toàn. Tại ranh giới Warehouse, từ này không còn thẩm quyền. Kho có thể gọi khái niệm của mình là InventoryItem, dù cả hai cùng tham chiếu đến một vật phẩm ngoài đời. Khác biệt đó là thông tin cần giữ lại, không phải sự trùng lặp cần xóa.

Ranh giới thường được hiện thực bằng module, service có thể deploy độc lập, phạm vi sở hữu dữ liệu, hoặc kết hợp các hình thức này. Chúng không bắt buộc phải trở thành microservice. Một modular monolith vẫn có thể chứa các bounded context mạnh, với package và schema riêng. Ngược lại, hai service cùng import domain entity dùng chung và sửa cùng một bảng dữ liệu thì chưa thực sự có ranh giới.

Ubiquitous language là sự chính xác có thể thực thi

Trong mỗi context, chuyên gia nghiệp vụ và kỹ sư cùng phát triển một ubiquitous language: bộ từ vựng được dùng nhất quán trong trao đổi, yêu cầu, test, API và code. Nếu vận hành nói một đơn hàng chỉ được “phân bổ” sau khi mọi dòng hàng đã giữ được tồn kho, code nên thể hiện allocateOrder, thay vì một lệnh chung chung như updateStatus("READY"). Mô hình trở nên dễ phản biện vì phát biểu nghiệp vụ ánh xạ trực tiếp đến hành vi nhìn thấy được.

Ngôn ngữ này mang tính cục bộ, không phải từ điển bắt buộc cho toàn doanh nghiệp. Sales có thể “hủy đơn” trước khi chấp nhận, trong khi Fulfillment chỉ có thể “dừng lô giao” sau khi đã phân bổ hàng. Ép cả hai dùng cùng một động từ sẽ che khuất hai bộ quy tắc khác nhau. Hãy ghi nhận khác biệt bằng ví dụ và glossary, nhưng quan trọng hơn là để code làm chúng trở nên cụ thể.

Note

Bounded context không chỉ là một thư mục gom các entity liên quan. Đặc tính quyết định của nó là thuật ngữ và invariant có một nghĩa nhất quán bên trong, còn việc chuyển đổi tại ranh giới phải rõ ràng.

Tìm ranh giới từ những điểm ma sát nghiệp vụ

Ranh giới tốt được khám phá, không được sinh tự động từ danh sách danh từ. Hãy bắt đầu bằng năng lực và quyết định nghiệp vụ: bán hàng, định giá, phân bổ tồn kho, vận chuyển, thu tiền và hỗ trợ khách hàng. Sau đó quan sát nơi ngôn ngữ, quy tắc, tốc độ thay đổi và quyền sở hữu bắt đầu phân kỳ.

Một số tín hiệu hữu ích:

  • Định nghĩa khác nhau. “Khách hàng đang hoạt động” trong Sales là người đủ điều kiện mua, nhưng trong Marketing là người vừa có tương tác gần đây.
  • Invariant khác nhau. Checkout cần mức giá đã báo ổn định; Catalog có thể chấp nhận việc cập nhật chỉ mục theo eventual consistency.
  • Nhịp thay đổi khác nhau. Khuyến mãi đổi hàng tuần, trong khi tính thuế thay đổi dưới kiểm soát pháp lý.
  • Thẩm quyền khác nhau. Warehouse sở hữu số lượng khả dụng; Sales có thể cache nhưng không được tự tạo ra con số đó.
  • Vòng đời khác nhau. Sales order, pick list, shipment và invoice có identifier liên quan nhưng đi qua các trạng thái riêng.

Event storming là một kỹ thuật khám phá thực dụng. Đặt các domain event như OrderPlaced, StockAllocated, ShipmentDispatchedPaymentCaptured trên dòng thời gian. Các cụm sự kiện thường gợi ý context tiềm năng; thuật ngữ mơ hồ và event bị tranh chấp thường hé lộ ranh giới. Kiểm tra từng ứng viên bằng ba câu hỏi: Quyết định nào thuộc về đây? Fact nào do nó sở hữu? Điều gì có thể thay đổi mà không cần phối hợp với nhóm khác?

Đừng tối ưu cho vùng độc lập lớn nhất có thể. Một context chứa mọi dependency của checkout chỉ là distributed monolith được vẽ thành một chiếc hộp. Cũng đừng tạo context nhỏ cho từng entity. Mục tiêu là độ gắn kết ngữ nghĩa cao: những quy tắc phải nhất quán sẽ thay đổi cùng nhau, còn quy tắc không liên quan giao tiếp qua contract.

Vẽ context map đúng với thực tế

Context map biểu diễn các bounded context và quan hệ giữa chúng. Khác component diagram, nó thể hiện hướng xã hội và ngữ nghĩa: ai ở upstream, ai phải thích nghi, và việc chuyển đổi diễn ra ở đâu. Thông tin đó dự báo chi phí thay đổi tốt hơn những mũi tên chỉ ghi “HTTP”.

flowchart LR catalog[Catalog Context] sales[Sales Context] warehouse[Warehouse Context] payments[Payments Context] shipping[Shipping Context] catalog -->|PublishedOffer v2\nPublished Language| sales sales -->|OrderPlaced v1\nCustomer/Supplier| warehouse sales -->|PaymentRequested v1\nACL at Payments edge| payments warehouse -->|ShipmentRequested v1\nCustomer/Supplier| shipping shipping -->|ShipmentDispatched v1| sales

Trong bản đồ này, Catalog là upstream của Sales đối với mặt hàng được phép bán. Sales là upstream của Warehouse đối với đơn đã chấp nhận, nhưng lại là downstream của Shipping đối với sự kiện giao hàng. “Upstream” phụ thuộc vào từng quan hệ; một context không nằm trên context khác trong mọi tình huống.

Các mẫu quan hệ và đánh đổi

DDD đặt tên cho một số mẫu quan hệ lặp lại. Nhãn chỉ có giá trị khi nó dẫn đến quyết định kỹ thuật hoặc quyết định phối hợp team.

Mẫu Ý nghĩa Hữu ích khi Rủi ro chính
Partnership Hai team phối hợp thay đổi và cùng chịu kết quả Tích hợp có tính chiến lược và hai roadmap có thể đồng bộ Phối hợp trở thành coupling thường trực
Customer/Supplier Upstream ưu tiên nhu cầu rõ ràng của downstream customer Downstream có tiếng nói và contract có thể thương lượng Ưu tiên của upstream vẫn có thể lấn át
Conformist Downstream dùng nguyên mô hình upstream Chuẩn upstream ổn định và dịch thuật không thêm giá trị Khái niệm ngoại lai len sâu vào domain
Anti-Corruption Layer Downstream dịch mô hình bên ngoài sang ngôn ngữ của mình Hai mô hình khác nhau hoặc upstream biến động/legacy Code chuyển đổi và vận hành đều tốn chi phí
Published Language Các team chia sẻ integration schema có tài liệu Nhiều consumer cần contract ổn định, có version Schema có thể vô tình thành domain model toàn cục
Separate Ways Các context chủ động không tích hợp Lặp dữ liệu rẻ hơn coupling Người dùng có thể thấy dữ liệu trễ hoặc không nhất quán

Ví dụ, conform theo khái niệm của nhà cung cấp thanh toán ngay tại HTTP client có thể không gây hại. Nhưng để ProviderChargeStatus điều khiển fulfillment thì có. Chọn quan hệ dựa trên giá trị chiến lược, khoảng cách giữa hai mô hình và vị thế của team, không dựa trên luật máy móc rằng ranh giới nào cũng cần ACL.

Tích hợp bằng contract, không bằng object dùng chung

Giao tiếp xuyên context nên mang fact và request, không mang domain object sống. Contract có thể là biểu diễn HTTP, event schema hoặc file batch. Dù ở dạng nào, nó cần owner, ngữ nghĩa rõ ràng, quy tắc tương thích và kế hoạch ngừng sử dụng.

Hãy ưu tiên message mô tả điều đã xảy ra hoặc điều đang được yêu cầu: OrderPlaced, ReserveStock, StockRejected. Tránh xuất toàn bộ aggregate nội bộ. Nếu không, consumer sẽ phụ thuộc vào những field chưa từng được cam kết. Contract nên chứa identity ổn định, đơn vị, tiền tệ, timestamp và đủ thông tin idempotency để xử lý retry. Version phải có chủ đích: thêm field tùy chọn thường an toàn; đổi nghĩa một field thì không.

Ví dụ TypeScript sau giữ mô hình Sales và Payments tách biệt. Sales sở hữu aggregate Order cùng invariant: chỉ đơn đã đặt và đang chờ thanh toán mới được xác nhận. DTO của provider nằm ngoài mô hình đó. Một anti-corruption layer chuyển trạng thái riêng của provider, đơn vị tiền nhỏ nhất và webhook trùng lặp thành một port nhỏ hướng về Sales.

ts
// sales/domain/order.ts
export type OrderId = string & { readonly __brand: 'OrderId' };

export type Money = Readonly<{
  amount: number;
  currency: 'USD' | 'EUR';
}>;

export type OrderLine = Readonly<{
  sku: string;
  quantity: number;
  unitPrice: Money;
}>;

type OrderState =
  | { kind: 'draft' }
  | { kind: 'awaiting-payment'; paymentReference: string }
  | { kind: 'confirmed'; paidAt: Date }
  | { kind: 'cancelled'; reason: string };

export class Order {
  private state: OrderState = { kind: 'draft' };

  constructor(
    readonly id: OrderId,
    readonly lines: readonly OrderLine[],
  ) {
    if (lines.length === 0) throw new Error('An order requires at least one line');
  }

  total(): Money {
    const currency = this.lines[0]?.unitPrice.currency;
    if (!currency) throw new Error('An order requires at least one line');
    if (this.lines.some((line) => line.unitPrice.currency !== currency)) {
      throw new Error('Mixed currencies are not allowed');
    }
    return {
      amount: this.lines.reduce(
        (sum, line) => sum + line.quantity * line.unitPrice.amount,
        0,
      ),
      currency,
    };
  }

  place(paymentReference: string): void {
    if (this.state.kind !== 'draft') throw new Error('Order is already placed');
    this.state = { kind: 'awaiting-payment', paymentReference };
  }

  confirmPayment(paidAt: Date): void {
    if (this.state.kind !== 'awaiting-payment') {
      throw new Error('Only an awaiting-payment order can be confirmed');
    }
    this.state = { kind: 'confirmed', paidAt };
  }
}
ts
// payments/integration/payment-provider.ts
import type { Money, OrderId } from '../../sales/domain/order.js';

type ProviderWebhook = Readonly<{
  event_id: string;
  object: {
    merchant_reference: string;
    state: 'authorized' | 'captured' | 'failed' | 'reversed';
    captured_amount_minor: number;
    currency_code: string;
    captured_at: string | null;
  };
}>;

export type PaymentResult =
  | { kind: 'pending' }
  | { kind: 'paid'; orderId: OrderId; amount: Money; paidAt: Date }
  | { kind: 'rejected'; orderId: OrderId; reason: string };

export interface ProcessedEvents {
  has(eventId: string): Promise<boolean>;
  add(eventId: string): Promise<void>;
}

export class PaymentProviderAcl {
  constructor(private readonly processedEvents: ProcessedEvents) {}

  async translate(webhook: ProviderWebhook): Promise<PaymentResult | null> {
    if (await this.processedEvents.has(webhook.event_id)) return null;

    const orderId = webhook.object.merchant_reference as OrderId;
    const result = this.toDomainResult(webhook, orderId);
    await this.processedEvents.add(webhook.event_id);
    return result;
  }

  private toDomainResult(
    webhook: ProviderWebhook,
    orderId: OrderId,
  ): PaymentResult {
    const payment = webhook.object;
    if (payment.state === 'captured' && payment.captured_at) {
      return {
        kind: 'paid',
        orderId,
        amount: {
          amount: payment.captured_amount_minor / 100,
          currency: this.toCurrency(payment.currency_code),
        },
        paidAt: new Date(payment.captured_at),
      };
    }
    if (payment.state === 'failed' || payment.state === 'reversed') {
      return { kind: 'rejected', orderId, reason: payment.state };
    }
    return { kind: 'pending' };
  }

  private toCurrency(code: string): Money['currency'] {
    if (code === 'USD' || code === 'EUR') return code;
    throw new Error(`Unsupported payment currency: ${code}`);
  }
}

Lớp này không chỉ là DTO mapper. Nó ngăn ngôn ngữ của provider quyết định hành vi Sales, từ chối tiền tệ không hỗ trợ, chuẩn hóa giá trị tiền và xử lý ngữ nghĩa giao nhận event. Trong production, việc ghi nhận event đã xử lý và đổi trạng thái đơn nên nằm trong transactional inbox hoặc một workflow nguyên tử tương đương; interface trông như in-memory ở trên chỉ làm lộ policy đó.

Warning

Đừng cast identifier bên ngoài hoặc parse timestamp như thể đó là giá trị nội bộ đáng tin ngay tại network boundary. Trước tiên phải kiểm tra schema, chữ ký webhook, số tiền, tiền tệ và ngày giờ; ví dụ rút gọn này tập trung vào chuyển đổi mô hình.

Đồng bộ team và kiểm thử ranh giới

Ranh giới không có team sở hữu sẽ dần bị xói mòn. Hãy giao cho một team thẩm quyền đối với model, data và published contract của mỗi context. Team đó vẫn cộng tác với consumer, nhưng consumer không nên đi vòng bằng cách ghi trực tiếp vào database dùng chung. Cấu trúc team và cấu trúc model không cần khớp mãi mãi, nhưng ma trận sở hữu nhiều-nhiều khiến thay đổi thông thường trở nên đắt đỏ.

Nên kiểm thử ở ba tầng. Thứ nhất, domain test diễn đạt invariant bằng ubiquitous language: đơn rỗng không thể tồn tại, nhiều tiền tệ trong một đơn bị từ chối, payment không thể xác nhận đơn draft. Thứ hai, unit test của ACL bao phủ mọi trạng thái upstream, giá trị sai, chuyển đổi đơn vị và event trùng. Thứ ba, contract test kiểm tra producer thật so với schema và kỳ vọng tương thích. Một số ít end-to-end test nên chứng minh luồng quan trọng, nhưng không thay thế test tập trung tại ranh giới.

Theo dõi contract version, correlation identifier, lỗi chuyển đổi, retry và dead letter. Lỗi ngữ nghĩa cần telemetry rõ ràng vì retry không thể sửa chúng. Đồng thời, xem lại context map khi trách nhiệm, quy định hoặc team đổi. Nếu hai context luôn đổi cùng nhau mà phép dịch không tạo giá trị, hãy gộp; nếu một model tích lũy luật cho các actor không liên quan, hãy tách.

Sai lầm thường gặp và lúc DDD là quá mức

Sai lầm phổ biến nhất là xem bounded context như công thức chia service. Các team vẽ một service cho mỗi danh từ, thêm messaging bất đồng bộ rồi gọi kết quả là DDD. Hệ thống nhận thêm network latency nhưng không có độc lập ngữ nghĩa. Hãy bắt đầu bằng ngôn ngữ và invariant; ranh giới process được quyết định sau.

Các lỗi lặp lại khác gồm package domain-model dùng chung, database cho phép nhiều context cùng ghi, integration event sao chép thẳng từ bảng nội bộ, và “enterprise canonical model” cố làm hài lòng tất cả. Những cơ chế này xóa quyền tự chủ cục bộ. Một thất bại khác là modeling mang tính nghi lễ: workshop tạo ra sơ đồ đẹp, nhưng code vẫn dùng bản ghi CRUD chung chung và status string.

DDD cũng có chi phí thật: cùng modeling, duy trì contract, dịch khái niệm và xem lại ranh giới. Khoản đầu tư này thường không đáng cho ứng dụng CRUD đơn giản, chiến dịch ngắn hạn, công cụ nội bộ của một team, hoặc domain có quy tắc ổn định. Transaction script với module rõ ràng có thể tốt hơn.

Hãy dùng strategic DDD ở nơi tính mơ hồ và luật nghiệp vụ chi phối chi phí: định giá, fulfillment, quản trị rủi ro, lập lịch, tuân thủ, hoặc năng lực tạo khác biệt khác. Dùng pattern đơn giản hơn cho vùng hỗ trợ. Ta vẫn có thể giữ context boundary quanh một subsystem phổ thông mà không cần xây rich aggregate bên trong. Mục tiêu không phải tối đa hóa DDD, mà là modeling vừa đủ để quyết định quan trọng an toàn và dễ thay đổi.

Tổng kết

  • Bounded context xác định nơi một domain model và ubiquitous language giữ nghĩa nhất quán; nó không tự động là một microservice.
  • Khám phá ranh giới từ quyết định, invariant, xung đột ngôn ngữ, quyền sở hữu và nhịp thay đổi, thay vì từ danh sách entity.
  • Context map làm rõ phụ thuộc upstream/downstream và quan hệ giữa các team.
  • Tích hợp qua contract có owner và version. Dùng anti-corruption layer khi mô hình bên ngoài có thể làm méo mô hình cục bộ.
  • Đồng bộ thẩm quyền team với quyền sở hữu model và data; kiểm thử riêng domain rule, phép chuyển đổi và tính tương thích contract.
  • Gộp hoặc tách context khi bằng chứng thay đổi, và bỏ qua rich DDD nếu độ phức tạp nghiệp vụ không đủ hoàn lại chi phí.

Bounded context không loại bỏ bất đồng trong doanh nghiệp. Nó đặt mỗi bất đồng tại một ranh giới nhìn thấy được, nơi các team có thể gọi tên, thương lượng, chuyển đổi và kiểm thử. Đó là điều biến một tập hợp những chiếc hộp thành kiến trúc có khả năng tiến hóa.