Bỏ qua và tới nội dung chính
Thiết kế contract và DSL

Khi nào nên tách value object, events và queries trong contract

Tách value object, events và queries đúng lúc giúp contract đủ chặt để thi công, nhưng không biến thành tài liệu rườm rà. Bài viết này chỉ ra cách khóa phạm vi, chọn ngôn ngữ mô tả và phân lớp contract để code đầu ra ít bất ngờ hơn.

Huỳnh Kim Đạt Huỳnh Kim Đạt
2 lượt xem 8 phút đọc
Khi nào nên tách value object, events và queries trong contract

TL;DR

Chỉ nên tách value object, events và queries khi chúng có mục đích, vòng đời hoặc quy tắc thay đổi khác nhau. Tách đúng giúp contract-first rõ phạm vi, dễ sinh code, dễ review và hỗ trợ traceability tốt hơn.

Key Takeaways

  • Value object nên tách khi có ý nghĩa nghiệp vụ riêng, có luật hợp lệ riêng hoặc được tái sử dụng ở nhiều contract.
  • Event nên tách khi cần workflow, audit, tích hợp bất đồng bộ hoặc mô tả điều đã xảy ra theo thời gian.
  • Query nên tách khi nhu cầu đọc khác nhu cầu ghi, cần filter/sort/pagination hoặc có view model riêng.
  • Contract hiệu quả cần phân lớp rõ: domain, app, rule, workflow, policy, API.
  • Phần gần với thi công như schema, rules, transition và quyền phải được viết có cấu trúc, không mơ hồ.

Trong cách làm contract-first, câu hỏi quan trọng không phải là viết contract thật dày, mà là tách đúng thứ cần tách. Nếu dồn mọi thứ vào một file, contract rất nhanh biến thành giấy tờ vô dụng: khó đọc, khó kiểm tra, khó dùng cho thi công. Ngược lại, nếu bám sát code quá mức, contract lại chỉ là bản sao của implementation, không còn giá trị điều phối giữa domain, nghiệp vụ, API và workflow.

Vấn đề thường gặp nhất là: khi nào nên tách value object, events và queries? Câu trả lời ngắn gọn là: tách khi mỗi nhóm khái niệm có vòng đời, mục đích sử dụngquy tắc thay đổi khác nhau. Khi đó, để chung sẽ gây mơ hồ; tách ra sẽ giúp contract dùng được cho thiết kế, kiểm thử, traceability và giao việc cho đội thi công.

1. Nhìn contract như một hệ lớp, không phải một tài liệu duy nhất

Một contract tốt thường không chỉ có một lớp mô tả. Trong thực tế, có thể chia thành các lớp cốt lõi sau:

  • Domain contract: mô tả khái niệm nghiệp vụ, entity, value object, invariant, thuật ngữ.
  • Application contract: mô tả use case, input, output, điều kiện thành công hoặc thất bại.
  • Rule contract: mô tả luật tính toán, ràng buộc kiểm tra, điều kiện hợp lệ.
  • Workflow contract: mô tả chuỗi bước, trạng thái, sự kiện chuyển trạng thái, timeout, retry, bù trừ.
  • Policy contract: mô tả quyền, RBAC contract, điều kiện ai được làm gì trong ngữ cảnh nào.
  • API contract: mô tả schema request/response, error shape, versioning, idempotency, pagination, filter.

Khi đội dự án không phân lớp, mọi khái niệm thường bị dồn vào một chỗ. Hậu quả là query lẫn với command, event lẫn với state, value object lẫn với payload API. Đó là lúc contract khó dùng cho software factory vì không còn ranh giới rõ ràng để code generator, reviewer hay QA bám theo.

2. Khi nào nên tách value object

Value object nên được tách riêng khi nó có ý nghĩa nghiệp vụ độc lập và được dùng lặp lại ở nhiều nơi. Dấu hiệu rõ nhất là cùng một nhóm field xuất hiện ở nhiều command, event hoặc query nhưng không nên copy-paste schema.

Nên tách value object khi:

  • Có quy tắc hợp lệ riêng: ví dụ Money, DateRange, Email, UserRole.
  • Được tái sử dụng ở nhiều hợp đồng: một cấu trúc xuất hiện trong create, update, event và response.
  • Cần đặt tên nghiệp vụ rõ ràng: thay vì để start_dateend_date rải rác, đặt thành EffectivePeriod.
  • Cần traceability tốt hơn: khi thay đổi quy tắc của một khái niệm, ta biết chính xác những nơi bị ảnh hưởng.

Không cần tách value object khi:

  • Nhóm field chỉ xuất hiện một lần và không có luật riêng.
  • Việc tách ra không làm tên gọi rõ hơn mà chỉ tăng số lượng schema.
  • Đó chỉ là dữ liệu kỹ thuật thuần túy của một endpoint, không có giá trị nghiệp vụ tái sử dụng.

Một nguyên tắc hữu ích là: nếu bạn phải giải thích ý nghĩa của một nhóm field bằng một danh từ ổn định trong domain, hãy cân nhắc tách nó thành value object.

Ví dụ

Contract xấu:
CreateWorkflowInput {
  approver_id,
  approver_role,
  approver_scope,
  start_at,
  end_at,
  currency,
  amount
}

Contract tốt:
CreateWorkflowInput {
  approver: ApprovalActor,
  valid_period: EffectivePeriod,
  budget: Money
}

Trong ví dụ trên, ApprovalActor, EffectivePeriodMoney đều là những khái niệm có luật riêng, đủ mạnh để đứng độc lập.

3. Khi nào nên tách events

Event không phải là bản ghi state. Event mô tả điều đã xảy ra, theo ngôn ngữ quá khứ và có giá trị trong workflow, audit, tích hợp liên hệ giữa hệ thống. Vì vậy, event nên được tách riêng khi hệ thống cần biết không chỉ dữ liệu hiện tại là gì, mà còn cần biết điều gì đã diễn ra.

Nên tách events khi:

  • Có workflow nhiều bước: duyệt, từ chối, hoàn tác, hết hạn, kích hoạt.
  • Cần tích hợp bất đồng bộ: một hành động phát tín hiệu cho hệ thống khác.
  • Cần audit và traceability: ai làm gì, khi nào, từ trạng thái nào sang trạng thái nào.
  • State và trigger không còn là một: ví dụ trạng thái hiện tại là approved, nhưng event có thể là approval_requested, approval_granted, approval_revoked.

Không cần tách events khi:

  • Hệ thống chỉ là CRUD đơn giản, không có dòng đời đáng kể.
  • Không có ai tiêu thụ event, không có audit, không có workflow.
  • Event chỉ lặp lại đúng thông tin state hiện tại mà không thêm ngữ nghĩa gì.

Trong workflow contract, event thường là phần bắt buộc phải rõ. Nếu bỏ qua event, đội thi công dễ hiểu sai điều kiện chuyển bước, thứ tự xử lý và hành vi side effect.

Ví dụ event contract

WorkflowApproved {
  workflow_id,
  approved_by,
  approved_at,
  approval_level,
  comment
}

WorkflowApprovalExpired {
  workflow_id,
  expired_at,
  pending_role
}

Hai event trên không chỉ nói trạng thái là gì, mà nói rõ điều gì đã xảy rangữ cảnh của nó.

4. Khi nào nên tách queries

Query nên tách khỏi command và event khi mục tiêu đọc khác mục tiêu ghi. Đây là điểm mà nhiều contract bị rối nhất: cùng một schema vừa dùng để nhập liệu, vừa dùng để trả kết quả, vừa dùng để phát event. Kết quả là contract khó tối ưu và ngôn ngữ bị trộn lẫn.

Nên tách queries khi:

  • View model khác domain model: dữ liệu hiển thị cần join, tính toán thêm, hoặc làm giàu từ nhiều nguồn.
  • Cần filter, sort, paginate riêng: đây là đặc trưng của API contract cho đọc.
  • Quyền đọc khác quyền ghi: RBAC contract cho read thường không giống write.
  • Cần tối ưu hiệu năng và shape response: query nên phục vụ nhu cầu tiêu thụ, không nhất thiết phản chiếu cấu trúc lưu trữ.

Không nên gộp query vào command khi:

  • Response của API bắt đầu chứa quá nhiều field nội bộ không phục vụ người dùng.
  • Đội backend phải bẻ domain object ra chỉ để đáp ứng UI.
  • Cùng một schema gây hiểu lầm rằng field nào cũng ghi được.

Một contract-first tốt thường dùng nguyên tắc: command để thay đổi, event để ghi nhận điều đã xảy ra, query để phục vụ đọc. Ba nhóm này có thể liên hệ với nhau, nhưng không nên ép dùng chung một schema trừ khi thật sự đơn giản.

5. Cách khóa phạm vi và ngôn ngữ để contract dùng được cho thi công

Contract chỉ có ích khi nó khóa được phạm vi. Nếu dùng ngôn ngữ chung chung như “xử lý hợp lý”, “kiểm tra đầy đủ”, “trả dữ liệu cần thiết”, thì không đội thi công nào biết phải làm đến đâu.

Nên khóa phạm vi theo các câu hỏi sau:

  • Khái niệm này thuộc lớp nào? Domain, app, rule, workflow, policy hay API?
  • Ai là người tiêu thụ? Backend, frontend, QA, code generator hay team tích hợp?
  • Tên gọi có phải ngôn ngữ domain ổn định không? Nếu chưa, phải chốt glossary trước.
  • Thứ gì là bắt buộc phải đúng cấu trúc? Input/output, errors, state transition, permission matrix.
  • Thứ gì chỉ cần viết ngắn? Bối cảnh, ví dụ, lý do thiết kế.

Trong một DSL contract, việc khóa ngôn ngữ còn quan trọng hơn. Tên của object, action, event và query phải nhất quán, tránh đồng nghĩa tùy hứng. Ví dụ đã dùng approve thì không đổi sang accept ở chỗ khác nếu ý nghĩa nghiệp vụ không hoàn toàn giống nhau.

6. Phần nào nên viết ngắn, phần nào bắt buộc phải rõ và có cấu trúc

Có thể viết ngắn:

  • Mở bài và bối cảnh tổng quan.
  • Giải thích tại sao chọn một mô hình nếu quyết định đã rõ.
  • Ví dụ minh họa không ảnh hưởng đến logic chính.

Bắt buộc phải rõ và có cấu trúc:

  • Schema input/output của command, event, query.
  • Invariant và rule kiểm tra.
  • State transition trong workflow contract.
  • Permission matrix trong RBAC contract hoặc policy contract.
  • Error contract: mã lỗi, điều kiện phát sinh, field liên quan.
  • Traceability: contract nào sinh ra code, test, migration hay tài liệu nào.

Nói cách khác, càng gần phần có thể kiểm chứng hoặc có thể sinh code, contract càng phải cấu trúc hóa.

7. Lỗi thường gặp khi contract ôm hết mọi thứ hoặc bám sát code quá mức

  • Gộp state, event và query vào một object duy nhất: khiến ý nghĩa mơ hồ.
  • Đặt tên theo bảng hoặc class hiện tại: contract bị khóa vào implementation, khó tiến hóa.
  • Tách quá nhiều object vụn: đọc contract như đi dò sơ đồ class, mất ý nghĩa nghiệp vụ.
  • Copy payload API thành domain contract: schema bên ngoài lấn át ngôn ngữ domain.
  • Không mô tả rule và transition: code sinh ra có thể đúng kiểu dữ liệu nhưng sai hành vi.
  • Không phân biệt bắt buộc và tùy chọn: đội thi công phải tự đoán.

Đây là chỗ contract coding dễ thất bại nếu chỉ chăm chăm “viết đủ field”. Một contract dùng được cho software factory phải trả lời được: viết cái gì, kiểm cái gì, và đâu là ranh giới giữa các lớp trách nhiệm.

8. Ví dụ: contract tốt và contract tệ khác nhau ở đâu

Tiêu chí Contract tệ Contract tốt
Phạm vi Nhồi domain, API, workflow, quyền vào một file Tách lớp rõ ràng theo mục đích sử dụng
Ngôn ngữ Tên field theo code hoặc DB Tên theo domain và DSL ổn định
Value object Copy-paste cụm field lặp lại Trích thành khái niệm có luật riêng
Events Thiếu hoặc mô tả như snapshot state Mô tả điều đã xảy ra, có ngữ cảnh và thời điểm
Queries Dùng chung schema với command Tối ưu theo nhu cầu đọc và quyền truy cập
Traceability Khó biết thay đổi ảnh hưởng chỗ nào Mỗi khái niệm có ranh giới và điểm tái sử dụng rõ

Một ví dụ rút gọn

Contract tệ:
Workflow {
  id,
  status,
  approve_user_id,
  approved_at,
  can_approve,
  can_reject,
  filter_status,
  page,
  per_page,
  event_type
}

Contract tốt:
Value Objects:
- ApprovalActor
- EffectivePeriod
- WorkflowStatus

Commands:
- RequestWorkflowApproval
- ApproveWorkflow
- RejectWorkflow

Events:
- WorkflowApprovalRequested
- WorkflowApproved
- WorkflowRejected
- WorkflowApprovalExpired

Queries:
- GetWorkflowDetail
- ListPendingWorkflows
- ListWorkflowAuditTrail

Policies:
- Approver can approve workflows in assigned scope
- Requester can view own workflows
- Auditor can view audit trail but cannot change state

Ở bản tốt, người đọc nhìn vào là biết phần nào dùng để ghi, phần nào dùng để đọc, phần nào dùng để theo dõi workflow, và phần nào là policy.

9. Một checklist thực dụng để quyết định có tách hay không

  1. Nếu một nhóm field có tên nghiệp vụ ổn định và được dùng lặp lại, hãy tách thành value object.
  2. Nếu hệ thống cần biết điều gì đã xảy ra theo thời gian, hãy tách events.
  3. Nếu nhu cầu đọc khác nhu cầu ghi, hãy tách queries.
  4. Nếu quyền đọc và quyền ghi khác nhau, hãy tách rõ policy/RBAC contract.
  5. Nếu object chỉ tồn tại vì code hiện tại đang như vậy, chưa chắc nó đáng có trong contract.
  6. Nếu contract không giúp giảm bất ngờ khi code được sinh hoặc được viết ra, contract đó cần làm lại.

Kết luận

Không có một số lượng object đúng cho mọi dự án. Điều quan trọng là tách theo trách nhiệm và vòng đời, không tách theo cảm hứng. Value object dùng để giữ khái niệm nghiệp vụ ổn định và tái sử dụng; events dùng để ghi nhận điều đã xảy ra trong workflow và tích hợp; queries dùng để phục vụ đọc theo đúng nhu cầu tiêu thụ. Khi ba nhóm này được phân định rõ, contract sẽ bớt mơ hồ, dễ trace, dễ review và phù hợp hơn cho cách làm contract-first.

Một contract tốt không phải contract dài nhất. Đó là contract khiến code đầu ra ít bất ngờ hơn.

Frequently Asked Questions

Có phải dự án nào cũng cần tách value object, events và queries không?

Không. Chỉ nên tách khi chúng thực sự có mục đích, vòng đời hoặc quy tắc thay đổi khác nhau. Với bài toán CRUD rất đơn giản, tách quá mức sẽ làm contract nặng nề.

Làm sao biết một nhóm field đã đủ điều kiện trở thành value object?

Nếu nhóm field đó có tên nghiệp vụ rõ, có luật riêng và xuất hiện lặp lại ở nhiều command, event hoặc query, đó là dấu hiệu tốt để tách thành value object.

Event khác gì với trạng thái hiện tại của object?

Trạng thái mô tả object đang ở đâu tại thời điểm hiện tại, còn event mô tả điều gì đã xảy ra để dẫn đến trạng thái đó. Event phù hợp cho audit, workflow và tích hợp.

Vì sao không nên dùng chung một schema cho command và query?

Vì mục tiêu ghi và mục tiêu đọc thường khác nhau. Dùng chung schema dễ khiến API lẫn lộn field nào được ghi, field nào chỉ để hiển thị hoặc tổng hợp.