Skip to content
JCJohnny Chung
← All posts

Domain Modeling cho Business Rule Phức Tạp: Khi Type System Làm Việc Thay Documentation

Áp dụng Type-Driven Design và DDD tactical patterns để model nghiệp vụ phức tạp — bài học từ production về làm sao types trở thành source of truth duy nhất.

· 7 min read

Mô hình miền nghiệp vụ phức tạp

Ảnh: User:Jean-Marie Favre, Language Archeologist and Software Explorer at the LIG, University of Grenoble, BY-SA qua openverse (nguồn)

Team mình vừa ship xong một module pricing cho hệ thống logistics cross-border. Đề bài nghe đơn giản: tính phí vận chuyển theo khu vực, trọng lượng, loại hàng, và một đống exception rule mà business team gọi là “đặc thù từng thị trường”. Thực tế là 47 rule khác nhau, mỗi rule có điều kiện overlap, priority khác nhau, và thay đổi theo quý.

Lần đầu tiếp cận, junior dev viết một class PricingCalculator với 300 dòng if-else. Code review thì thấy rõ smell: primitive obsession khắp nơi, business logic rải rác, test coverage 12% vì mock data quá phức tạp. Tôi biết pattern này — đã gặp ở dự án trước, và dự án trước đó nữa.

Vấn đề không phải ở logic. Vấn đề ở chỗ team model domain bằng primitive types thay vì domain types.

Primitive Obsession Là Nợ Xấu Ngầm

string cho regionCode, number cho weightKg, boolean cho isFragile — nghe hợp lý nhưng thực tế là tự tay gỡ bỏ khả năng compiler giúp mình. Khi regionCode là string, compiler cho phép gán "VN-HCM" vào chỗ cần "US-CA". Khi weightKg là number, không gì cản trở việc truyền -5 hoặc 999999.

Tôi từng làm một project fintech nơi accountId là string khắp codebase. Một ngày production bug: transferService nhận userId thay vì accountId vì cả hai đều là string. Compiler im lặng. Log chỉ hiện “invalid account”. Debug mất 4 tiếng.

Điểm mấu chốt: types không chỉ để compile pass. Types là documentation không thể lỗi thời.

Type-Driven Design: Từ Requirement Sang Type Signature

Thay vì bắt đầu bằng implementation, team mình bắt đầu bằng type signatures. Business rule: “Phí cơ bản tùy khu vực, cộng thêm phí trọng lượng theo bậc thang, trừ giảm giá nếu khách VIP, nhưng không áp dụng cho hàng nguy hiểm.”

Viết thành type trước:

// Branded types — zero runtime cost, compile-time safety
type RegionCode = string & { readonly __brand: unique symbol };
type WeightKg = number & { readonly __brand: unique symbol };
type MoneyVND = number & { readonly __brand: unique symbol };

// Domain primitives với validation baked in
const RegionCode = (s: string): RegionCode => {
  if (!REGION_REGEX.test(s)) throw new Error(`Invalid region: ${s}`);
  return s as RegionCode;
};

const WeightKg = (n: number): WeightKg => {
  if (n <= 0 || n > MAX_WEIGHT) throw new Error(`Invalid weight: ${n}`);
  return n as WeightKg;
};

// Business rule as type — impossible to construct invalid state
interface PricingInput {
  readonly region: RegionCode;
  readonly weight: WeightKg;
  readonly cargoType: CargoType;
  readonly customerTier: CustomerTier;
  readonly surcharges: ReadonlyArray<Surcharge>;
}

// Output cũng typed — caller biết chính xác gì sẽ nhận được
interface PricingResult {
  readonly baseFee: MoneyVND;
  readonly weightFee: MoneyVND;
  readonly discount: MoneyVND;
  readonly surcharges: MoneyVND;
  readonly total: MoneyVND;
  readonly breakdown: ReadonlyArray<PricingLineItem>;
}

// Pure function — dễ test, dễ reason, dễ parallelize
const calculatePricing = (input: PricingInput): PricingResult => {
  // implementation ở đây
};

Type-safe domain primitives

Ảnh: Indrajit Das, CC BY-SA 3.0 qua wikimedia (nguồn)

Không any, không unknown, không optional fields lẻ tẻ. Mọi invalid state đều unrepresentable. Compiler thành documentation executable.

Algebraic Data Types Cho Business Rule Variations

47 rule pricing không phải 47 if branch. Chúng là variations của một domain concept. ADT (Algebraic Data Types) model điều này tự nhiên:

// Discount rule — sealed hierarchy, exhaustive matching
type DiscountRule =
  | { readonly type: "VIP_TIER"; readonly tier: "GOLD" | "PLATINUM"; readonly percent: 5 | 10 | 15 }
  | { readonly type: "VOLUME"; readonly minShipmentsPerMonth: number; readonly percent: number }
  | { readonly type: "PROMOTIONAL"; readonly code: PromoCode; readonly expiresAt: Date }
  | { readonly type: "NONE" };

// Surcharge rule — same pattern
interface SurchargeRule {
  readonly appliesTo: (input: PricingInput) => boolean;
  readonly calculate: (input: PricingInput) => MoneyVND;
  readonly description: string;
  readonly priority: number; // lower = higher priority
}

// Rule engine — pure, composable, testable
const evaluateDiscount = (rule: DiscountRule, input: PricingInput): MoneyVND => {
  switch (rule.type) {
    case "VIP_TIER":
      return input.customerTier === rule.tier ? calculatePercent(input.subtotal, rule.percent) : 0;
    case "VOLUME":
      return input.monthlyShipments >= rule.minShipmentsPerMonth ? calculatePercent(input.subtotal, rule.percent) : 0;
    case "PROMOTIONAL":
      return isValidPromo(rule.code, input) && rule.expiresAt > new Date()
        ? calculatePercent(input.subtotal, rule.percent)
        : 0;
    case "NONE":
      return 0;
  }
};

// Exhaustiveness check — thêm rule mới mà quên handle case thì compile error
const _exhaustive: never = rule;

Thêm rule mới? Chỉ cần thêm variant vào union type. Compiler báo lỗi tất cả chỗ chưa handle. Không còn bug “quên cập nhật switch case ở 5 file khác”.

Repository Pattern Với Type-Safe Query

Domain model xong, cần persist. Repository pattern trong DDD thường bị hiểu lầm là “wrapper cho ORM”. Thực tế repository là collection-like interface cho aggregate root — trừu tượng hóa storage, không phải query builder.

// Domain layer — chỉ biết interface
interface PricingRuleRepository {
  findByRegion(region: RegionCode): Promise<ReadonlyArray<PricingRule>>;
  findActiveAt(date: Date): Promise<ReadonlyArray<PricingRule>>;
  save(rule: PricingRule): Promise<void>;
}

// Infrastructure layer — implementation detail
class PostgresPricingRuleRepository implements PricingRuleRepository {
  constructor(private readonly pool: Pool) {}

  async findByRegion(region: RegionCode): Promise<ReadonlyArray<PricingRule>> {
    const rows = await this.pool.query(
      `SELECT * FROM pricing_rules WHERE region = $1 AND active_from <= NOW() AND (active_to IS NULL OR active_to > NOW())`,
      [region]
    );
    return rows.map(mapRowToDomain); // mapRowToDomain: Row -> PricingRule (validated)
  }

  // ...
}

// Application service — orchestrate, không chứa business logic
class PricingApplicationService {
  constructor(
    private readonly repo: PricingRuleRepository,
    private readonly calculator: PricingCalculator
  ) {}

  async quote(input: PricingInput): Promise<PricingResult> {
    const rules = await this.repo.findByRegion(input.region);
    return this.calculator.calculate(input, rules);
  }
}

Điểm quan trọng: mapRowToDomain validate data từ DB trước khi tạo domain object. DB có thể dirty (legacy data, migration bug, manual insert), nhưng domain layer luôn clean. Đây là boundary validation — khác với validation ở API layer.

Testing: Property-Based Thay Với Example-Based

Với pure functions và ADT, property-based testing (fast-check, jqwik) mạnh hơn example-based gấp lần. Thay vì viết 50 test case thủ công:

// Property: total fee luôn >= base fee
test.prop([fc.record({
  region: regionCodeArb,
  weight: weightKgArb,
  cargoType: cargoTypeArb,
  customerTier: customerTierArb,
  surcharges: fc.array(surchargeArb)
})], (input) => {
  const result = calculatePricing(input);
  expect(result.total).toBeGreaterThanOrEqual(result.baseFee);
});

// Property: discount không bao giờ âm
// Property: surcharge priority order được respect
// Property: same input luôn cho same output (deterministic)

Generator regionCodeArb, weightKgArb sinh data valid theo domain constraints. Test chạy hàng nghìn case mỗi giây. Tìm được edge case mà con người không nghĩ ra: weight = 0.001, region vừa thêm vào DB chưa có rule, promoCode expired đúng millisecond gọi API.

Team mình giảm test code từ 800 dòng xuống 120 dòng, coverage từ 12% lên 94%, và quan trọng hơn: confidence level khác hẳn.

Refactor An Toàn: Compiler Làm Safety Net

Tháng sau business yêu cầu: “Thêm rule mới: khách hàng enterprise có contract năm được miễn phí surcharge fuel.”

Cách cũ: tìm tất cả chỗ tính surcharge, thêm if (customer.isEnterprise && customer.hasAnnualContract). Dễ miss, dễ break existing logic.

Cách type-driven:

// 1. Thêm variant vào DiscountRule (hoặc SurchargeRule tùy model)
type SurchargeRule =
  | { type: "FUEL"; ... }
  | { type: "HANDLING"; ... }
  | { type: "FUEL_WAIVER_ENTERPRISE"; readonly contractId: ContractId }; // NEW

// 2. Compiler báo lỗi ở evaluateSurcharge — phải handle case mới
const evaluateSurcharge = (rule: SurchargeRule, input: PricingInput): MoneyVND => {
  switch (rule.type) {
    case "FUEL": return calculateFuelSurcharge(input);
    case "HANDLING": return calculateHandlingFee(input);
    case "FUEL_WAIVER_ENTERPRISE":
      return hasValidEnterpriseContract(input.customerId, rule.contractId) ? 0 : calculateFuelSurcharge(input);
  }
};

// 3. Thêm test property: enterprise với contract hợp lệ -> fuel surcharge = 0
// 4. Deploy. Done.

Không grep codebase. Không lo miss case. Compiler dẫn dắt.

Trade-off Thực Tế

Không phải silver bullet. Chi phí upfront: branded types, ADT, repository interfaces, property-based test setup. Dự án nhỏ, team junior, deadline gấp — có thể over-engineering.

Nhưng với domain phức tạp, rule thay đổi thường xuyên, bug pricing cost cao (billing dispute, compliance, customer trust) — investment này trả lại trong 2-3 sprint đầu.

Metrics từ project logistics:

  • Pricing bug production: 8/tháng → 0.5/tháng
  • Time to add new rule: 2 ngày → 2 giờ
  • Onboarding dev mới hiểu domain: 2 tuần → 3 ngày (đọc type signature là hiểu business rule)
  • Refactor confidence: từ “pray and deploy” → “compile pass = ship”

Kết Luận Actionable

  1. Bắt đầu bằng type signature trước khi viết implementation. Domain types = executable spec.
  2. Dùng branded types cho mọi domain primitive. Zero runtime cost, compile-time safety.
  3. Model business rule variations bằng ADT (discriminated union). Exhaustiveness check = safety net.
  4. Repository interface ở domain layer, implementation ở infrastructure. Boundary validation tại mapping layer.
  5. Property-based testing cho pure domain logic. Tìm edge case mà example-based miss.
  6. Compiler là teammate tốt nhất. Để compiler làm việc nặng: validate, guide refactor, prevent regression.

Team review code với type-safe domain model

Ảnh: Tony Webster from Minneapolis, Minnesota, United States, CC BY 2.0 qua wikimedia (nguồn)

Tóm lại: type system không chỉ để catch typo. Dùng đúng cách, nó biến business requirement thành code không thể sai. Documentation không bao giờ outdated vì nó là code. Và quan trọng nhất — tôi ngủ ngon hơn vì biết production không sẽ nổ vì một if quên else.


Bạn đang model domain nào mà đau đầu vì rule phức tạp? Thử viết type signature trước — có thể sẽ ngạc nhiên về độ rõ ràng nó mang lại.