- 1. Tại sao Các Dự án Phần mềm Doanh nghiệp Nhanh chóng Biến thành "Big Ball of Mud" Dù Có Phân tách Thư mục?
- 2. Thiết kế Chiến lược (Strategic DDD): Phân ranh giới Bounded Context, Ngôn ngữ Chung (Ubiquitous Language) và Context Mapping
- Ma trận Phân bổ Kiến trúc theo Loại Miền (Core Domain vs. Supporting vs. Generic Subdomain)
- 3. Quy tắc Phụ thuộc (The Dependency Rule) trong Clean Architecture và Kiến trúc Lục giác (Ports & Adapters)
- 3.1. Giải pháp Đảo ngược Phụ thuộc (Dependency Inversion Principle - DIP) tại Ranh giới Cổng
- 3.2. Cấu trúc Thư mục Chuẩn Production cho Dịch vụ Go 1.24+ (Theo Ngữ cảnh Nghiệp vụ)
- 4. Thiết kế Chiến thuật (Tactical DDD) bằng Go 1.24+: Value Objects, Rich Aggregate Root và Domain Events
- 4.1. Xây dựng Value Object Bất biến (Money) và Aggregate Root Bảo vệ Toàn vẹn Nghiệp vụ (Order)
- 5. Điều phối Giao dịch Nguyên tử ở Tầng Application với Mẫu Unit of Work và CQRS
- 5.1. Định nghĩa Cổng Giao dịch UnitOfWork bằng Closure Function trong Go 1.24+
- 5.2. Tách biệt Đường Ghi và Đường Đọc với CQRS (Command Query Responsibility Segregation)
- 6. Thực thi Driven Adapter trên PostgreSQL 17 (Kiểm soát Optimistic Concurrency & Tách biệt Persistence Model)
- 7. Kiểm chứng Tự động Quy tắc Kiến trúc (Architecture Fitness Functions) trong Pipeline CI/CD
- 8. Tài liệu Tham khảo & Nguồn Đối chiếu Chính thống (Primary References)
1. Tại sao Các Dự án Phần mềm Doanh nghiệp Nhanh chóng Biến thành "Big Ball of Mud" Dù Có Phân tách Thư mục?
Trong giai đoạn khởi đầu của một sản phẩm công nghệ, mô hình Transaction Script hoặc Active Record (nơi các hàm Controller gọi trực tiếp ORM để đọc/ghi bảng cơ sở dữ liệu và nhúng toàn bộ câu lệnh if-else nghiệp vụ ngay trong tầng HTTP Handler) cho phép ra mắt tính năng rất nhanh. Tuy nhiên, khi hệ thống mở rộng quy mô lên hàng chục lập trình viên cùng phát triển trên nền kiến trúc Microservices với Docker và Kubernetes thực chiến, cách tiếp cận thiếu ranh giới kiến trúc này sớm dẫn tới bốn hệ lụy kỹ thuật nghiêm trọng:
- Mô hình Miền Nghèo nàn (Anemic Domain Model) và Phân tán Quy tắc Nghiệp vụ: Các cấu trúc dữ liệu (
struct/class) chỉ đóng vai trò túi chứa thuộc tính rỗng với các hàmGetter/Settercông khai, trong khi quy tắc bất biến (Business Invariants)—ví dụ: "Không được phép xuất hóa đơn cho đơn hàng chưa thanh toán hoặc đã bị khóa gian lận"—bị sao chép lặp lại ở 6 dịch vụ khác nhau. Chỉ cần một lập trình viên quên kiểm tra điều kiện ở một API mới, dữ liệu tài chính trên Production sẽ lập tức bị hỏng. - Sự Phụ thuộc Ngược vào Cơ sở Dữ liệu và Framework (Framework & ORM Coupling): Khi các thẻ đánh dấu của ORM (
gorm:"...",sqlx, Hibernate annotations) hoặc kiểu dữ liệu của giao thức vận chuyển (HTTP Request/Response, Protobuf structs trong xây dựng High-Performance gRPC Microservices với Go) tràn vào tận lõi tính toán nghiệp vụ, bạn không thể viết Unit Test cho logic cốt lõi nếu không khởi chạy cả cơ sở dữ liệu thật. - Vi phạm Ranh giới Giao dịch Giữa các Thực thể (Cross-Aggregate Transaction Contention): Việc liên kết trực tiếp hàng chục bảng bằng khóa ngoại và cập nhật đồng thời cả
Order,Inventory,CustomerLoyaltyvàAuditLogtrong cùng một giao dịch cơ sở dữ liệu kéo dài gây ra hiện tượng khóa dòng dây chuyền đã phân tích tại bài xử lý chịu tải MySQL với ProxySQL và Sharding. - Lạm dụng Trừu tượng Hóa Giả tạo (Over-Engineering & Boilerplate Bloat): Ở thái cực ngược lại, nhiều đội ngũ áp dụng máy móc "Clean Architecture" bằng cách tạo ra 4 tầng thư mục cho những bảng danh mục tĩnh chỉ có thao tác
CRUDđơn giản, buộc mỗi trường dữ liệu mới phải sửa qua 8 tệp ánh xạ (Entity->DTO->Model->ViewModel) mà không mang lại bất kỳ giá trị bảo vệ nghiệp vụ nào.
Bài viết chuyên sâu này cung cấp phương pháp luận và mã nguồn triển khai thực chiến Clean Architecture (Hexagonal / Ports & Adapters) kết hợp Domain-Driven Design (DDD) theo tiêu chuẩn kỹ thuật năm 2026: từ phân định chiến lược Bounded Context và Context Mapping, thiết kế chiến thuật Aggregate Root, Value Object, Domain Events, quản lý giao dịch nguyên tử với mẫu Unit of Work trong Go 1.24+, cho tới kiểm chứng tự động quy tắc phụ thuộc kiến trúc (Architecture Fitness Functions) trong pipeline CI/CD.
2. Thiết kế Chiến lược (Strategic DDD): Phân ranh giới Bounded Context, Ngôn ngữ Chung (Ubiquitous Language) và Context Mapping
Sai lầm lớn nhất khi áp dụng Domain-Driven Design là bỏ qua Strategic Design (Thiết kế Chiến lược) mà nhảy ngay vào viết các lớp Entity, Repository trên một cơ sở dữ liệu dùng chung khổng lồ. Trong một doanh nghiệp thương mại điện tử, khái niệm "Sản phẩm" (Product) không thể được biểu diễn bằng một lớp Product duy nhất dùng chung cho mọi phòng ban:
- Trong Catalog Context (Ngữ cảnh Danh mục & Tìm kiếm):
Productlà tên hiển thị, hình ảnh, mô tả SEO, thuộc tính phân loại và đánh giá của người mua. - Trong Inventory & Warehouse Context (Ngữ cảnh Kho vận):
Productlà một mãSKUgắn với kích thước vật lý, trọng lượng, vị trí kệ hàng (Bin Location), số lượng tồn kho khả dụng (Available-to-Promise) và lô hạn sử dụng. - Trong Billing & Invoicing Context (Ngữ cảnh Kế toán & Thuế):
Productchỉ là một dòng hàng hóa (Line Item) gắn với đơn giá, mã biểu thuế GTGT (VAT Rate) và tài khoản định khoản doanh thu.
Nếu gộp cả ba góc nhìn này vào một bảng products duy nhất với 90 cột dữ liệu, mọi thay đổi nhỏ của đội Marketing cũng có nguy cơ làm lỗi luồng xuất hóa đơn điện tử của đội Kế toán. Bounded Context (Ngữ cảnh Giới hạn) giải quyết triệt để vấn đề này bằng cách thiết lập ranh giới ngữ nghĩa rõ ràng: bên trong mỗi Bounded Context, mọi thuật ngữ trong Ubiquitous Language (Ngôn ngữ Chung) đều có một ý nghĩa duy nhất, chính xác và không mơ hồ.
Ma trận Phân bổ Kiến trúc theo Loại Miền (Core Domain vs. Supporting vs. Generic Subdomain)
Không phải toàn bộ hệ thống đều cần viết theo DDD đầy đủ. Kỹ sư Kiến trúc (Software Architect) sử dụng bảng phân loại dưới đây để quyết định mức độ đầu tư kỹ thuật cho từng dịch vụ:
| Phân loại Miền (Subdomain Type) | Đặc điểm Nghiệp vụ & Lợi thế Cạnh tranh | Kiến trúc Bên trong Khuyến nghị (2026) | Ví dụ Thực tế trong Hệ thống |
|---|---|---|---|
| Core Domain (Miền Cốt lõi) | Độ phức tạp quy tắc nghiệp vụ rất cao, thay đổi thường xuyên, tạo ra lợi thế cạnh tranh trực tiếp và doanh thu chính cho doanh nghiệp. | Clean Architecture + Tactical DDD Đầy đủ (Rich Aggregate Root, Value Objects, Domain Events, Unit of Work, CQRS). |
Động cơ định giá động (Dynamic Pricing), Sổ cái thanh toán & Đối soát tài chính, Thuật toán phân bổ kho thông minh. |
| Supporting Subdomain (Miền Hỗ trợ) | Phục vụ cho hoạt động của Core Domain nhưng nghiệp vụ tương đối chuẩn hóa, ít biến thiên phức tạp. | Vertical Slice Architecture hoặc Modular Layered CRUD (Sử dụng sqlc / ORM trực tiếp trong Use Case kèm kiểm tra đầu vào chặt chẽ). |
Quản lý danh mục địa giới hành chính, Quản lý hồ sơ nhà cung cấp, Cấu hình biểu mẫu nội bộ. |
| Generic Subdomain (Miền Phổ thông) | Bài toán kỹ thuật đã có chuẩn chung trên toàn ngành, không mang tính đặc thù riêng của doanh nghiệp. | Tích hợp Giải pháp Có sẵn (SaaS / Open-Source) thông qua lớp Anti-Corruption Layer (ACL). | Xác thực định danh OAuth 2.1 / OIDC (Keycloak), Cổng gửi Email/SMS, Hệ thống lưu trữ đối tượng S3. |
3. Quy tắc Phụ thuộc (The Dependency Rule) trong Clean Architecture và Kiến trúc Lục giác (Ports & Adapters)
Khi đi vào bên trong một Core Domain, Clean Architecture (do Robert C. Martin hệ thống hóa, đồng nhất về bản chất với Hexagonal Architecture / Ports & Adapters của Alistair Cockburn và Onion Architecture của Jeffrey Palermo) chỉ đặt ra một luật tối thượng duy nhất: Mọi mũi tên phụ thuộc mã nguồn (import / include) chỉ được phép hướng từ lớp bên ngoài vào lớp bên trong; lớp bên trong tuyệt đối không được biết bất kỳ thông tin gì về lớp bên ngoài.
flowchart TD
subgraph InfraLayer["Tầng 4: Infrastructure & Frameworks (Driving & Driven Adapters)"]
HTTP["Driving Adapter: HTTP/REST & gRPC Handlers<br/>(Chi/Gin/gRPC Protobuf Mapping)"]
CONSUMER["Driving Adapter: Kafka KRaft / RabbitMQ 4.0 Consumer"]
PG_REPO["Driven Adapter: PostgreSQL 17 / MySQL 8.4 Repository<br/>(SQL Queries, Transaction Manager, Outbox)"]
REDIS_ADAPT["Driven Adapter: Redis 8.0 Cache & Distributed Lock"]
end
subgraph AppLayer["Tầng 2: Application / Use Cases Layer (Điều phối Luồng Nghiệp vụ)"]
CMD["Command & Query Handlers (Application Services)"]
PORTS_IN["Input Ports (Use Case Interfaces)"]
PORTS_OUT["Output Ports (Repository, UnitOfWork, PaymentGateway Interfaces)"]
end
subgraph DomainLayer["Tầng 1: Domain Core (Trái tim Nghiệp vụ - Zero External Dependencies)"]
AGG["Aggregate Roots & Entities<br/>(Order, OrderLine - Bảo vệ Business Invariants)"]
VO["Immutable Value Objects<br/>(Money, OrderStatus, TaxCode)"]
DEVENT["Domain Events & Domain Errors<br/>(OrderPaidEvent, ErrInsufficientBalance)"]
end
HTTP -->|"Gọi qua"| PORTS_IN
CONSUMER -->|"Gọi qua"| PORTS_IN
PORTS_IN --> CMD
CMD -->|"Thao tác trên"| AGG
AGG --> VO
AGG --> DEVENT
CMD -->|"Gọi giao diện trừu tượng"| PORTS_OUT
PG_REPO -.->|"Thực thi (Dependency Inversion)"| PORTS_OUT
REDIS_ADAPT -.->|"Thực thi (Dependency Inversion)"| PORTS_OUT
3.1. Giải pháp Đảo ngược Phụ thuộc (Dependency Inversion Principle - DIP) tại Ranh giới Cổng
Làm thế nào để tầng Application Use Case (Tầng 2) gọi lưu dữ liệu xuống cơ sở dữ liệu PostgreSQL (Tầng 4) mà không vi phạm quy tắc Tầng 2 không được import Tầng 4?
- Định nghĩa Output Port (Giao diện Đầu ra) ngay tại Tầng Nội bộ (Domain / Application): Tầng nội bộ tự định nghĩa một
interface OrderRepositorytheo đúng ngôn ngữ nghiệp vụ của nó (chỉ nhận và trả vềdomain.Order, không chứa bất kỳ kiểu*sql.Txhaypgx.Rowsnào). - Triển khai Driven Adapter ở Tầng Infrastructure: Gói
infrastructure/postgressẽimportgóidomain, khai báo cấu trúcPostgresOrderRepositorythỏa mãn giao diệnOrderRepository. Tại thời điểm khởi động chương trình (cmd/api/main.go— Composition Root), hệ thống tiêm (Inject) bản thực thiPostgresOrderRepositoryvàoPlaceOrderUseCase.
3.2. Cấu trúc Thư mục Chuẩn Production cho Dịch vụ Go 1.24+ (Theo Ngữ cảnh Nghiệp vụ)
Thay vì chia thư mục cấp cao nhất theo khái niệm kỹ thuật (controllers/, models/, views/), cấu trúc dự án chuẩn năm 2026 sử dụng cơ chế internal/ của trình biên dịch Go để khóa chặt quyền truy cập chéo và tách biệt rõ 3 tầng domain, application, và infrastructure:
order-service/
├── cmd/
│ └── order-api/
│ └── main.go # Composition Root: Khởi tạo Dependency Injection & Graceful Shutdown
├── internal/
│ └── ordering/ # Bounded Context: Quản lý Đơn hàng
│ ├── domain/ # TẦNG 1: DOMAIN CORE (Chỉ dùng Go Standard Library)
│ │ ├── money.go # Value Object: Money (Kiểm tra cùng loại tiền tệ, chống số âm)
│ │ ├── order.go # Aggregate Root: Order + OrderLine + Business Invariants
│ │ ├── events.go # Domain Events: OrderSubmittedEvent, OrderPaidEvent
│ │ ├── errors.go # Domain Errors: ErrOrderAlreadyPaid, ErrEmptyOrderLines
│ │ └── repository.go # Output Port: Giao diện OrderRepository
│ ├── application/ # TẦNG 2: APPLICATION USE CASES
│ │ ├── unit_of_work.go # Output Port: Giao diện Transactional UnitOfWork
│ │ ├── submit_order.go # Command Handler: SubmitOrderCommand & SubmitOrderHandler
│ │ └── pay_order.go # Command Handler: PayOrderCommand & PayOrderHandler
│ └── infrastructure/ # TẦNG 3 & 4: ADAPTERS & INFRASTRUCTURE
│ ├── postgres/ # Driven Adapter: Thực thi SQL, Outbox & UnitOfWork trên PostgreSQL 17
│ │ ├── order_repository.go
│ │ └── uow.go
│ └── transport/ # Driving Adapter: HTTP REST / gRPC Handlers
│ └── http_handler.go
└── go.mod
4. Thiết kế Chiến thuật (Tactical DDD) bằng Go 1.24+: Value Objects, Rich Aggregate Root và Domain Events
4.1. Xây dựng Value Object Bất biến (Money) và Aggregate Root Bảo vệ Toàn vẹn Nghiệp vụ (Order)
Trong Tactical DDD, một Aggregate là một cụm các đối tượng miền (Entities và Value Objects) được coi là một đơn vị nhất quán giao dịch duy nhất. Mọi thao tác thay đổi trạng thái bên trong cụm bắt buộc phải đi qua thực thể đứng đầu gọi là Aggregate Root (Order). Các trường trạng thái bên trong (status, lines, totalAmount, version) đều được đặt phạm vi truy cập đóng (private/unexported fields trong Go) để không một lớp bên ngoài nào có thể tự ý gán order.Status = "PAID" mà bỏ qua bước kiểm tra số tiền thanh toán:
// File: internal/ordering/domain/order.go
// Go 1.24+ Tactical DDD: Value Object (Money) và Rich Aggregate Root (Order) bảo vệ Invariants
package domain
import (
"errors"
"fmt"
"time"
)
var (
ErrInvalidMoneyAmount = errors.New("domain: money amount cannot be negative")
ErrCurrencyMismatch = errors.New("domain: cannot operate on different currencies")
ErrEmptyOrderLines = errors.New("domain: order must contain at least one line item")
ErrInvalidStateTransition = errors.New("domain: invalid order state transition")
ErrPaymentAmountMismatch = errors.New("domain: payment amount does not match order total")
)
// Money là một Value Object bất biến (Immutable): định danh bởi giá trị, không có ID riêng
type Money struct {
amountMinor int64 // Lưu ở đơn vị nhỏ nhất (ví dụ: đồng VND hoặc cent USD) để tránh sai số dấu phẩy động float64
currency string // ISO 4217: "VND", "USD"
}
func NewMoney(amountMinor int64, currency string) (Money, error) {
if amountMinor < 0 {
return Money{}, ErrInvalidMoneyAmount
}
if len(currency) != 3 {
return Money{}, errors.New("domain: currency must be a 3-letter ISO-4217 code")
}
return Money{amountMinor: amountMinor, currency: currency}, nil
}
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrCurrencyMismatch
}
return Money{amountMinor: m.amountMinor + other.amountMinor, currency: m.currency}, nil
}
func (m Money) Multiply(qty int32) (Money, error) {
if qty <= 0 {
return Money{}, errors.New("domain: quantity must be positive")
}
return Money{amountMinor: m.amountMinor * int64(qty), currency: m.currency}, nil
}
func (m Money) Equals(other Money) bool {
return m.amountMinor == other.amountMinor && m.currency == other.currency
}
func (m Money) AmountMinor() int64 { return m.amountMinor }
func (m Money) Currency() string { return m.currency }
type OrderStatus string
const (
OrderStatusSubmitted OrderStatus = "SUBMITTED"
OrderStatusPaid OrderStatus = "PAID"
OrderStatusCancelled OrderStatus = "CANCELLED"
)
type OrderLine struct {
skuID string
quantity int32
unitPrice Money
lineTotal Money
}
type DomainEvent interface {
EventName() string
OccurredAt() time.Time
}
type OrderPaidEvent struct {
OrderID string
CustomerID string
PaidAmountVND int64
TransactionID string
Timestamp time.Time
}
func (e OrderPaidEvent) EventName() string { return "ordering.order_paid.v1" }
func (e OrderPaidEvent) OccurredAt() time.Time { return e.Timestamp }
// Order là Aggregate Root duy nhất của cụm đơn hàng
type Order struct {
id string
customerID string
status OrderStatus
lines []OrderLine
totalAmount Money
version int64 // Phục vụ Optimistic Concurrency Control (Chống ghi đè mất dữ liệu)
domainEvents []DomainEvent
}
// NewOrder khởi tạo Aggregate Root hợp lệ ngay từ thời điểm sinh ra (Always-Valid Domain Entity)
func NewOrder(orderID, customerID string, items []OrderLine, currency string) (*Order, error) {
if orderID == "" || customerID == "" {
return nil, errors.New("domain: orderID and customerID are required")
}
if len(items) == 0 {
return nil, ErrEmptyOrderLines
}
total, err := NewMoney(0, currency)
if err != nil {
return nil, err
}
copiedLines := make([]OrderLine, 0, len(items))
for _, item := range items {
total, err = total.Add(item.lineTotal)
if err != nil {
return nil, err
}
copiedLines = append(copiedLines, item)
}
return &Order{
id: orderID,
customerID: customerID,
status: OrderStatusSubmitted,
lines: copiedLines,
totalAmount: total,
version: 1,
}, nil
}
// MarkAsPaid thực thi quy tắc bất biến nghiệp vụ và phát sinh Domain Event nội tại
func (o *Order) MarkAsPaid(paidAmount Money, paymentTxID string, now time.Time) error {
if o.status != OrderStatusSubmitted {
return fmt.Errorf("%w: cannot pay order in status %s", ErrInvalidStateTransition, o.status)
}
if !o.totalAmount.Equals(paidAmount) {
return ErrPaymentAmountMismatch
}
o.status = OrderStatusPaid
o.domainEvents = append(o.domainEvents, OrderPaidEvent{
OrderID: o.id,
CustomerID: o.customerID,
PaidAmountVND: paidAmount.AmountMinor(),
TransactionID: paymentTxID,
Timestamp: now.UTC(),
})
return nil
}
// PullDomainEvents lấy toàn bộ sự kiện miền để lưu vào Transactional Outbox và xóa bộ đệm tạm
func (o *Order) PullDomainEvents() []DomainEvent {
events := o.domainEvents
o.domainEvents = nil
return events
}
func (o *Order) ID() string { return o.id }
func (o *Order) CustomerID() string { return o.customerID }
func (o *Order) Status() OrderStatus { return o.status }
func (o *Order) TotalAmount() Money { return o.totalAmount }
func (o *Order) Version() int64 { return o.version }
5. Điều phối Giao dịch Nguyên tử ở Tầng Application với Mẫu Unit of Work và CQRS
Một câu hỏi thực tế mà mọi kỹ sư Backend đều gặp phải khi áp dụng Clean Architecture là: "Nếu tầng Application Use Case không được phép biết tới *sql.Tx của cơ sở dữ liệu, thì làm sao để đảm bảo việc cập nhật Order và ghi DomainEvents vào bảng outbox_events (như đã trình bày trong bài thiết kế Event-Driven Architecture với Apache Kafka và RabbitMQ) diễn ra trong cùng một giao dịch ACID?"
sequenceDiagram
autonumber
participant HTTP as HTTP/gRPC Driving Adapter
participant UC as PayOrderUseCase (Application)
participant UOW as PostgresUnitOfWork (Driven Adapter)
participant AGG as Order Aggregate (Domain Core)
participant DB as PostgreSQL 17 / MySQL 8.4
HTTP->>UC: Execute(ctx, PayOrderCommand)
UC->>UOW: WithinTransaction(ctx, func(txStore))
UOW->>DB: BEGIN TRANSACTION
UC->>UOW: txStore.Orders().GetByIDForUpdate(ctx, orderID)
UOW->>DB: SELECT ... FROM orders WHERE id = $1 FOR UPDATE
DB-->>UC: Trả về Order Aggregate (status = SUBMITTED, version = 1)
UC->>AGG: order.MarkAsPaid(paidMoney, txID, time.Now())
AGG-->>UC: Hợp lệ -> Chuyển status = PAID & sinh OrderPaidEvent
UC->>UOW: txStore.Orders().Save(ctx, order)
UOW->>DB: UPDATE orders SET status='PAID', version=2 WHERE id=$1 AND version=1
UOW->>DB: INSERT INTO outbox_events (OrderPaidEvent)
UOW->>DB: COMMIT TRANSACTION
UOW-->>HTTP: Trả về 200 OK sau khi cả Aggregate và Outbox đã bền vững
5.1. Định nghĩa Cổng Giao dịch UnitOfWork bằng Closure Function trong Go 1.24+
Thay vì truyền đối tượng *sql.Tx lộ liễu qua tầng Application, chúng ta định nghĩa giao diện UnitOfWork nhận vào một hàm ẩn danh func(ctx context.Context, provider RepositoryProvider) error. Tầng Infrastructure sẽ mở giao dịch SQL trước khi gọi hàm này, tự động COMMIT nếu hàm trả về nil, và tự động ROLLBACK nếu hàm trả về bất kỳ lỗi nghiệp vụ nào:
// File: internal/ordering/application/pay_order.go
// Tầng Application Use Case: Hoàn toàn độc lập khỏi SQL Driver, HTTP và Message Broker
package application
import (
"context"
"time"
"infoteck.vn/order-service/internal/ordering/domain"
)
// OrderRepository là Output Port truy cập Aggregate Root Order
type OrderRepository interface {
GetByIDForUpdate(ctx context.Context, orderID string) (*domain.Order, error)
Save(ctx context.Context, order *domain.Order) error
}
// OutboxPublisher là Output Port ghi nhận các sự kiện miền trong cùng giao dịch
type OutboxPublisher interface {
RecordEvents(ctx context.Context, aggregateID string, events []domain.DomainEvent) error
}
// TransactionalScope gom nhóm các Repository cùng chia sẻ 1 giao dịch nguyên tử
type TransactionalScope interface {
Orders() OrderRepository
Outbox() OutboxPublisher
}
// UnitOfWork trừu tượng hóa ranh giới giao dịch ACID khỏi tầng Application
type UnitOfWork interface {
ExecuteInTransaction(ctx context.Context, fn func(ctx context.Context, scope TransactionalScope) error) error
}
type PayOrderCommand struct {
OrderID string
PaidAmountMinor int64
Currency string
PaymentTxID string
}
type PayOrderUseCase struct {
uow UnitOfWork
clock func() time.Time
}
func NewPayOrderUseCase(uow UnitOfWork, clock func() time.Time) *PayOrderUseCase {
return &PayOrderUseCase{uow: uow, clock: clock}
}
func (uc *PayOrderUseCase) Handle(ctx context.Context, cmd PayOrderCommand) error {
paidMoney, err := domain.NewMoney(cmd.PaidAmountMinor, cmd.Currency)
if err != nil {
return err
}
return uc.uow.ExecuteInTransaction(ctx, func(txCtx context.Context, scope TransactionalScope) error {
// 1. Tải Aggregate Root từ kho lưu trữ
order, err := scope.Orders().GetByIDForUpdate(txCtx, cmd.OrderID)
if err != nil {
return err
}
// 2. Thực thi hành vi trên Domain Model (Kiểm tra toàn bộ Business Invariants)
if err := order.MarkAsPaid(paidMoney, cmd.PaymentTxID, uc.clock()); err != nil {
return err
}
// 3. Lưu trạng thái mới kèm kiểm tra Optimistic Locking (version)
if err := scope.Orders().Save(txCtx, order); err != nil {
return err
}
// 4. Trích xuất các Domain Events vừa sinh ra và ghi vào bảng Transactional Outbox
events := order.PullDomainEvents()
return scope.Outbox().RecordEvents(txCtx, order.ID(), events)
})
}
5.2. Tách biệt Đường Ghi và Đường Đọc với CQRS (Command Query Responsibility Segregation)
Trong thực tế vận hành, nếu bạn buộc các truy vấn báo cáo phân trang hoặc hiển thị danh sách đơn hàng phức tạp (yêu cầu JOIN 4 bảng và lọc theo 10 tiêu chí) phải đi qua OrderRepository rồi khôi phục lại toàn bộ đồ thị Order Aggregate Root, hiệu năng đọc sẽ sụp đổ.
Khi kết hợp DDD với CQRS:
- Đường Ghi (Command Side): Bắt buộc đi qua
Application Command Handler->Domain Aggregate Root->UnitOfWorkđể đảm bảo 100% tính toàn vẹn nghiệp vụ và kiểm soát khóa (kết hợp cùng kỹ thuật trong bài thiết kế kiến trúc Distributed Locking và Idempotency Key với Redis & PostgreSQL). - Đường Đọc (Query Side): Được phép bỏ qua hoàn toàn
Aggregate Root! MộtOrderQueryServicecó thể thực thi trực tiếp câu lệnhSELECTtối ưu hóa chỉ mục bao phủ (Covering Index) hoặc đọc trực tiếp từ Read Model trên tầng bộ nhớ đệm trong bài chiến lược Caching đa tầng với Redis Cluster và Cloudflare CDN để trả vềOrderSummaryDTOvới độ trễ dưới2ms.
6. Thực thi Driven Adapter trên PostgreSQL 17 (Kiểm soát Optimistic Concurrency & Tách biệt Persistence Model)
Ở tầng infrastructure/postgres, chúng ta tuyệt đối không gắn thẻ db:"..." hay json:"..." vào trực tiếp lớp domain.Order. Thay vào đó, Adapter sử dụng cấu trúc dữ liệu nội bộ orderRow để đọc/ghi cơ sở dữ liệu và kiểm tra khóa lạc quan (WHERE id = $1 AND version = $2) nhằm ngăn chặn hiện tượng Lost Update:
// File: internal/ordering/infrastructure/postgres/order_repository.go
// Driven Adapter: Thực thi giao diện application.OrderRepository trên PostgreSQL 17
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"infoteck.vn/order-service/internal/ordering/domain"
)
var ErrOptimisticLockConflict = errors.New("postgres: concurrent modification detected on order aggregate")
type TxOrderRepository struct {
tx *sql.Tx
}
func NewTxOrderRepository(tx *sql.Tx) *TxOrderRepository {
return &TxOrderRepository{tx: tx}
}
func (r *TxOrderRepository) Save(ctx context.Context, order *domain.Order) error {
total := order.TotalAmount()
res, err := r.tx.ExecContext(ctx, `
UPDATE orders
SET status = $1,
total_amount_minor = $2,
currency = $3,
version = version + 1,
updated_at = NOW()
WHERE id = $4 AND version = $5
`,
string(order.Status()),
total.AmountMinor(),
total.Currency(),
order.ID(),
order.Version(),
)
if err != nil {
return fmt.Errorf("postgres update order failed: %w", err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrOptimisticLockConflict
}
return nil
}
7. Kiểm chứng Tự động Quy tắc Kiến trúc (Architecture Fitness Functions) trong Pipeline CI/CD
Làm thế nào để ngăn chặn một lập trình viên mới vô tình viết lệnh import "database/sql" hoặc import "github.com/gin-gonic/gin" ngay bên trong thư mục internal/ordering/domain khi dự án phát triển qua nhiều năm?
Thay vì chỉ dựa vào việc duyệt mã thủ công (Code Review), quy trình chuẩn trong bài xây dựng CI/CD Pipeline với GitHub Actions và Docker tích hợp một bài kiểm định kiến trúc tự động (Architecture Fitness Function) bằng lệnh go list để chặn đứng mọi Pull Request vi phạm Dependency Rule ngay ở bước CI:
#!/usr/bin/env bash
# File: scripts/verify-clean-arch-dependencies.sh
# Mô tả: Kiểm tra tự động đảm bảo tầng Domain Core và Application không import thư viện Infrastructure
set -euo pipefail
echo "=== [1/2] Kiểm tra tầng Domain Core: Cấm tuyệt đối mọi phụ thuộc vào infrastructure, application, sql, http, grpc ==="
FORBIDDEN_IN_DOMAIN=$(go list -f '{{ join .Imports "n" }}' ./internal/.../domain/... |
grep -E '(infrastructure|application|database/sql|net/http|google.golang.org/grpc|github.com/)' || true)
if [ -n "${FORBIDDEN_IN_DOMAIN}" ]; then
echo "[FAIL] Phát hiện vi phạm Dependency Rule trong tầng Domain Core:"
echo "${FORBIDDEN_IN_DOMAIN}"
exit 1
fi
echo "[PASS] Tầng Domain Core hoàn toàn tinh khiết (Zero external/infrastructure imports)."
echo "=== [2/2] Kiểm tra tầng Application Use Cases: Cấm import trực tiếp gói infrastructure ==="
FORBIDDEN_IN_APP=$(go list -f '{{ join .Imports "n" }}' ./internal/.../application/... |
grep -E '(/infrastructure/|database/sql|net/http)' || true)
if [ -n "${FORBIDDEN_IN_APP}" ]; then
echo "[FAIL] Phát hiện tầng Application import trực tiếp thành phần Infrastructure:"
echo "${FORBIDDEN_IN_APP}"
exit 1
fi
echo "[PASS] Tầng Application tuân thủ 100% nguyên lý Dependency Inversion."
8. Tài liệu Tham khảo & Nguồn Đối chiếu Chính thống (Primary References)
- Eric Evans — Domain-Driven Design: Tackling Complexity in the Heart of Software & DDD Reference Catalog
- Robert C. Martin (Uncle Bob) — The Clean Architecture Specification
- Alistair Cockburn — Hexagonal Architecture (Ports and Adapters) Original Pattern
- Martin Fowler — Patterns of Enterprise Application Architecture: Unit of Work & Anemic Domain Model
- Microsoft Architecture Guide — Designing a DDD-Oriented Microservice (CQRS, Aggregates & Domain Events)
- Go Official Documentation — Organizing a Go Module & Internal Packages Specification