Mục lục Series | Chương trước: Part 2: Thực Tế Chi Phí FinOps → | Chương tiếp theo: Part 4: Đơn Giản Hóa CI/CD & Atomic Deployments →

📖 Xem bản tiếng Anh trên tanhdev.com: DDD Module Boundaries & Decoupling Modular Monoliths

Answer-first: Modular Monolith ngăn ngừa thoái hóa mã nguồn (Big Ball of Mud) bằng cách áp dụng ranh giới Bounded Context trong Domain-Driven Design, cô lập schema PostgreSQL (billing, inventory), thực thi cấm import chéo ở cấp compile qua gói internal Go cùng arch-go, và sử dụng Transactional Outbox in-process cho sự kiện bất đồng bộ.


1. Nỗi Sợ “Spaghetti Monolith” và Lời Giải Domain-Driven Design

Lý do lớn nhất khiến các kỹ sư và kiến trúc sư e ngại kiến trúc Monolith bắt nguồn từ những ký ức đau thương trong quá khứ với các “Spaghetti Monolith” hoặc “Big Ball of Mud” (Đống bùn hỗn độn). Trong những hệ thống này:

  • Hàm xử lý của module Thanh toán (Billing) gọi trực tiếp vào bảng cơ sở dữ liệu của module Giỏ hàng (Cart).
  • Logic nghiệp vụ đan xen chằng chịt, một thay đổi nhỏ ở trường status của đơn hàng có thể làm sập quy trình xuất hóa đơn thuế.
  • Không ai dám xóa bỏ hay sửa đổi một hàm vì không thể xác định được bao nhiêu file khác đang phụ thuộc vào nó.

Để tận dụng ưu thế tốc độ và chi phí cực rẻ của Monolith mà vẫn duy trì tốc độ phát triển độc lập như Microservices, giải pháp bắt buộc là phải xây dựng một Modular Monolith. Chìa khóa then chốt nằm ở việc áp dụng triệt để các nguyên lý của Domain-Driven Design (DDD) và thiết lập những “đường biên giới thép” (hard boundaries) ngay trong cấu trúc mã nguồn và tầng lưu trữ dữ liệu.

sequenceDiagram
    autonumber
    participant OrderModule as "Order Bounded Context"
    participant EventBus as "In-Memory EventBus (Worker Pool)"
    participant BillingModule as "Billing Bounded Context"
    participant InventoryModule as "Inventory Bounded Context"
    
    OrderModule->>OrderModule: Khởi tạo & Ghi đơn hàng (Local TX)
    OrderModule->>EventBus: Publish OrderCreated Event (Go Struct con trỏ)
    par Xử lý thanh toán song song
        EventBus-->>BillingModule: Worker Pool dispatch sự kiện (In-Process)
        BillingModule->>BillingModule: Thực thi thanh toán qua ACL Adapter
    and Cập nhật tồn kho song song
        EventBus-->>InventoryModule: Worker Pool dispatch sự kiện (In-Process)
        InventoryModule->>InventoryModule: Giữ chỗ hàng trong kho (Reserve Stock)
    end

2. Ba Trụ Cột Thiết Kế Ranh Giới Nghiệp Vụ: Bounded Contexts, Aggregate Roots và ACL

Trong Microservices, nếu Service A muốn lấy dữ liệu từ Service B, nó buộc phải gọi qua HTTP API hoặc gRPC. Ranh giới mạng tạo ra sự cô lập tự nhiên. Nhưng trong Modular Monolith, vì tất cả code đều nằm chung trong cùng một không gian bộ nhớ (single process space), sự kỷ luật của lập trình viên là không đủ. Chúng ta cần các quy tắc thiết kế mang tính bắt buộc:

2.1 Aggregate Roots Là Ranh Giới Biến Đổi Trạng Thái Duy Nhất

Mọi thao tác thay đổi dữ liệu trong một Bounded Context bắt buộc phải đi qua Aggregate Root (ví dụ: OrderAggregate). Các module bên ngoài tuyệt đối không được phép thao tác trực tiếp lên các thực thể con (ví dụ: OrderItem hay PaymentDetail). Điều này đảm bảo các ràng buộc bất biến (business invariants) và quy tắc kiểm tra hợp lệ của nghiệp vụ luôn được thực thi trọn vẹn.

2.2 Public Interface Trong Gói Gốc

Mỗi module xuất bản (export) một tập hợp các Go Interface đại diện cho Public API của nó (ví dụ: OrderService, BillingService) đặt tại file api.go ngay dưới thư mục gốc của module. Mọi struct cài đặt chi tiết (implementation structs) đều được giấu kín bên trong các package con không export hoặc package internal/.

2.3 Lớp Chống Thoái Hóa (Anti-Corruption Layer - ACL)

Khi Module Thanh toán (Billing) tiếp nhận dữ liệu từ Module Đơn hàng (Order), nó không được phép sử dụng trực tiếp struct thực thể của Order. Một lớp Anti-Corruption Layer (ACL) đóng vai trò thông dịch viên, chuyển đổi các DTO bên ngoài thành các Value Object thuần khiết của domain Billing. Nhờ đó, nếu Order module thay đổi cấu trúc bảng hay định dạng dữ liệu, domain Billing hoàn toàn không bị ảnh hưởng.

package billing

import "fmt"

// ExternalOrderDTO là cấu trúc dữ liệu thô do Order module phát hành qua EventBus
type ExternalOrderDTO struct {
	OrderID   string `json:"order_id"`
	RawAmount int64  `json:"raw_amount"` // Lưu trữ dưới dạng đơn vị tiền tệ nhỏ nhất (cents)
	Currency  string `json:"currency"`
}

// PaymentValueObject là biểu diễn nghiệp vụ sạch sẽ bên trong domain Billing
type PaymentValueObject struct {
	ID       string
	Total    float64
	Currency string
}

// OrderACLAdapter đóng vai trò cầu nối chuyển đổi DTO sang Value Object
type OrderACLAdapter struct{}

func (a *OrderACLAdapter) ToPaymentVO(dto ExternalOrderDTO) (PaymentValueObject, error) {
	if dto.RawAmount < 0 {
		return PaymentValueObject{}, fmt.Errorf("số tiền thanh toán không hợp lệ: %d", dto.RawAmount)
	}

	return PaymentValueObject{
		ID:       dto.OrderID,
		Total:    float64(dto.RawAmount) / 100.0,
		Currency: dto.Currency,
	}, nil
}

3. Ranh Giới Cơ Sở Dữ Liệu: Cô Lập Schema PostgreSQL và Bảo Mật Môi Trường

Điểm kết nối nguy hiểm nhất làm phá vỡ kiến trúc Monolith chính là tầng cơ sở dữ liệu. Việc một truy vấn SQL JOIN chéo giữa bảng orders.order_items và bảng billing.payments sẽ xóa sạch tính độc lập của module và biến hệ thống thành một khối dính chặt không thể tách rời.

3.1 Phân Vùng Schema PostgreSQL Độc Lập

Thay vì phải vận hành nhiều máy chủ database riêng biệt làm tăng chi phí và gánh nặng quản trị, Modular Monolith áp dụng kỹ thuật phân chia namespace bằng các PostgreSQL Schemas độc lập trên cùng một cụm CSDL.

Đặc biệt, để tuân thủ các tiêu chuẩn bảo mật DevSecOps và quy định kiểm toán mã nguồn, mọi thông tin xác thực mật khẩu kết nối tuyệt đối không được hardcode mà phải được tham số hóa thông qua biến môi trường:

-- Tạo các schema nghiệp vụ biệt lập trong cùng một PostgreSQL cluster
CREATE SCHEMA IF NOT EXISTS billing;
CREATE SCHEMA IF NOT EXISTS inventory;
CREATE SCHEMA IF NOT EXISTS orders;

-- Thiết lập role phân quyền nghiêm ngặt theo nguyên tắc đặc quyền tối thiểu
-- Sử dụng tham số hóa biến môi trường hoặc Secret Manager injection:
CREATE ROLE inventory_user WITH LOGIN PASSWORD '${DB_INVENTORY_PASSWORD}';

-- Cấp quyền thao tác riêng trên schema inventory
GRANT USAGE, CREATE ON SCHEMA inventory TO inventory_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA inventory TO inventory_user;

-- Thu hồi toàn bộ quyền truy cập vào schema billing đối với inventory_user
REVOKE ALL ON SCHEMA billing FROM inventory_user;
REVOKE ALL ON ALL TABLES IN SCHEMA billing FROM inventory_user;

Nếu module Đơn hàng cần kiểm tra lượng hàng tồn kho, nó bắt buộc phải gọi hàm interface Go được module Kho hàng cung cấp (inventoryService.CheckStock(ctx, itemID)). Dữ liệu được tập hợp trong bộ nhớ RAM của ứng dụng thay vì thực thi lệnh JOIN thô bạo tại tầng cơ sở dữ liệu.


4. Kiểm Soát Kiến Trúc Tự Động: arch-go và Quy Tắc Thư Mục internal/

Các quy ước bằng văn bản hay quy định truyền miệng luôn bị xói mòn khi dự án chịu áp lực tiến độ gấp. Các đội ngũ kỹ sư chuyên nghiệp biến các quy ước ranh giới thành các bài kiểm tra tự động chạy trong pipeline CI/CD.

4.1 Cơ Chế Native Package internal/ Của Go

Ngôn ngữ Go hỗ trợ cơ chế bảo vệ ranh giới gói tin ở cấp độ trình biên dịch: Bất kỳ package nào nằm trong thư mục mang tên internal/ chỉ có thể được import bởi các package có cùng cây thư mục cha.

4.2 Cấu Hình Phân Tích Tĩnh Với arch-go

Để kiểm soát chi tiết hơn, file cấu hình arch-go.yml được đặt tại thư mục gốc của repository để kiểm tra cây cú pháp trừu tượng (AST) của toàn bộ dự án:

# arch-go.yml: Khung ràng buộc ranh giới phụ thuộc kiến trúc
version: "1"
dependencies:
  # Module Billing không được phép import code triển khai nội bộ của Inventory và Orders
  - package: "github.com/myrepo/internal/billing/..."
    forbidden:
      - "github.com/myrepo/internal/inventory/impl/..."
      - "github.com/myrepo/internal/orders/impl/..."

  # Quy tắc toàn cục: Không package nào được import các gói impl riêng tư của nhau
  - package: "github.com/myrepo/internal/..."
    forbidden:
      - "github.com/myrepo/internal/*/impl/..."

Khi chạy lệnh arch-go trong quá trình kiểm thử CI (go test ./...), nếu có một kỹ sư cố tình import tắt một struct riêng tư của module khác, bài test sẽ thất bại ngay lập tức, ngăn chặn hoàn toàn nguy cơ sinh ra mã nguồn rác.


5. Event Storming & Luồng Sự Kiện Bất Đồng Bộ In-Process

Để các module hoàn toàn không bị ràng buộc thời gian (temporal coupling), hệ thống áp dụng kỹ thuật Event Storming để nhận diện các sự kiện nghiệp vụ quan trọng.

stateDiagram-v2
    [*] --> SubmitOrder : Khách hàng bấm Đặt hàng (Command)
    SubmitOrder --> OrderCreated : Tạo đơn thành công (Domain Event)
    state OrderCreated {
        [*] --> ProcessPayment : Kích hoạt trừ tiền (Command)
        ProcessPayment --> PaymentCaptured : Thu tiền thành công (Event)
        ProcessPayment --> PaymentFailed : Giao dịch bị từ chối (Event)
    }
    PaymentCaptured --> UpdateInventory : Xuất kho tự động (Command)
    UpdateInventory --> InventoryReserved : Hàng đã giữ chỗ (Event)

5.1 Kênh Truyền Sự Kiện In-Process Bằng Go Channels

Dưới đây là một mô hình Event Bus sử dụng Go channels và cơ chế non-blocking select để chống tắc nghẽn hàng đợi:

package main

import (
	"fmt"
	"sync"
	"time"
)

type Event struct {
	Topic string
	Data  interface{}
}

type EventBus struct {
	mu   sync.RWMutex
	subs map[string][]chan Event
}

func NewEventBus() *EventBus {
	return &EventBus{
		subs: make(map[string][]chan Event),
	}
}

func (eb *EventBus) Subscribe(topic string) chan Event {
	eb.mu.Lock()
	defer eb.mu.Unlock()
	ch := make(chan Event, 100) // Buffer 100 events chống tràn
	eb.subs[topic] = append(eb.subs[topic], ch)
	return ch
}

func (eb *EventBus) Publish(e Event) {
	eb.mu.RLock()
	defer eb.mu.RUnlock()
	if channels, found := eb.subs[e.Topic]; found {
		for _, ch := range channels {
			select {
			case ch <- e:
			default:
				// Non-blocking drop hoặc ghi log cảnh báo khi buffer subscriber bị đầy
			}
		}
	}
}

6. Kiến Trúc Hoàn Thiện: Go 1.25 Bounded Worker Pool Event Bus (Code Thực Chiến)

Một lỗ hổng kiến trúc nghiêm trọng thường thấy trong các bài hướng dẫn đơn giản là việc sử dụng goroutine không giới hạn (go func() { ... }()) trên mỗi lần phát sự kiện. Khi một đợt flash sale với 50.000 đơn hàng ập đến, hệ thống sẽ đồng thời tạo ra hơn 100.000 goroutines unmanaged, làm tê liệt bộ lập lịch Go runtime, gây nghẽn CPU và kích hoạt lỗi Out-Of-Memory (OOM) làm sập toàn bộ ứng dụng Monolith.

Để giải quyết triệt để bài toán này, chúng ta xây dựng kiến trúc Go 1.25 Bounded Worker Pool Event Bus chuẩn mực sản xuất với các tiêu chí:

  1. Hàng đợi công việc có giới hạn (Bounded Queue): Cố định số lượng sự kiện chờ (ví dụ: 4096 buffer slots).
  2. Hồ Worker cố định: Giới hạn số lượng Goroutine worker hoạt động song song dựa trên số nhân CPU vật lý (runtime.NumCPU() * 2).
  3. Lan truyền ngữ cảnh (Context Propagation): Hỗ trợ timeout và hủy bỏ tác vụ khi dịch vụ dừng hoạt động.
  4. Dừng hoạt động mềm dẻo (Graceful Drain & Shutdown): Xử lý sạch sẽ các sự kiện đang tồn đọng trong queue trước khi tắt tiến trình.
package main

import (
	"context"
	"errors"
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
	"time"
)

var (
	ErrEventBusQueueFull = errors.New("hàng đợi sự kiện đã đầy, kích hoạt cơ chế backpressure")
	ErrEventBusClosed    = errors.New("event bus đã đóng, từ chối nhận thêm sự kiện")
)

// DomainEvent đại diện cho một sự kiện nghiệp vụ xảy ra trong hệ thống
type DomainEvent struct {
	ID           string            `json:"id"`
	Name         string            `json:"name"`
	Timestamp    time.Time         `json:"timestamp"`
	Payload      interface{}       `json:"payload"`
	TraceContext map[string]string `json:"trace_context,omitempty"`
}

// EventHandler là chữ ký hàm xử lý sự kiện có hỗ trợ context
type EventHandler func(ctx context.Context, evt DomainEvent) error

// eventJob đóng gói sự kiện cùng context lan truyền tới worker pool
type eventJob struct {
	ctx   context.Context
	event DomainEvent
}

// HardenedEventBus triển khai Event Bus luồng an toàn với Bounded Worker Pool
type HardenedEventBus struct {
	mu          sync.RWMutex
	handlers    map[string][]EventHandler
	jobQueue    chan eventJob
	workerCount int
	isClosed    atomic.Bool
	wg          sync.WaitGroup
	ctx         context.Context
	cancel      context.CancelFunc
}

// NewHardenedEventBus khởi tạo Event Bus với kích thước hàng đợi và số worker xác định
func NewHardenedEventBus(queueSize int) *HardenedEventBus {
	// Giới hạn worker pool dựa trên số lõi CPU vật lý
	workers := runtime.NumCPU() * 2
	if workers < 4 {
		workers = 4
	}

	ctx, cancel := context.WithCancel(context.Background())

	eb := &HardenedEventBus{
		handlers:    make(map[string][]EventHandler),
		jobQueue:    make(chan eventJob, queueSize),
		workerCount: workers,
		ctx:         ctx,
		cancel:      cancel,
	}

	// Khởi chạy nhóm worker xử lý nền cố định
	for i := 0; i < workers; i++ {
		eb.wg.Add(1)
		go eb.workerLoop(i)
	}

	return eb
}

// Subscribe đăng ký hàm xử lý cho một loại sự kiện cụ thể
func (eb *HardenedEventBus) Subscribe(eventName string, handler EventHandler) {
	eb.mu.Lock()
	defer eb.mu.Unlock()
	eb.handlers[eventName] = append(eb.handlers[eventName], handler)
}

// Publish gửi sự kiện vào hàng đợi với cơ chế Non-blocking fallback
func (eb *HardenedEventBus) Publish(ctx context.Context, event DomainEvent) error {
	if eb.isClosed.Load() {
		return ErrEventBusClosed
	}

	job := eventJob{
		ctx:   ctx,
		event: event,
	}

	select {
	case eb.jobQueue <- job:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	default:
		// Backpressure: Trả lỗi ngay lập tức khi queue đầy thay vì làm tắc nghẽn luồng gọi
		return ErrEventBusQueueFull
	}
}

// workerLoop là vòng lặp thực thi của từng worker trong pool
func (eb *HardenedEventBus) workerLoop(workerID int) {
	defer eb.wg.Done()

	for {
		select {
		case <-eb.ctx.Done():
			// Khi nhận tín hiệu dừng, giải phóng sạch các job còn tồn trong channel (Non-blocking drain)
			for {
				select {
				case job, ok := <-eb.jobQueue:
					if !ok {
						return
					}
					eb.dispatchToHandlers(job)
				default:
					return
				}
			}

		case job, ok := <-eb.jobQueue:
			if !ok {
				return
			}
			eb.dispatchToHandlers(job)
		}
	}
}

// dispatchToHandlers kích hoạt toàn bộ subscriber của sự kiện với cơ chế bắt panic
func (eb *HardenedEventBus) dispatchToHandlers(job eventJob) {
	eb.mu.RLock()
	handlers, exists := eb.handlers[job.event.Name]
	eb.mu.RUnlock()

	if !exists {
		return
	}

	for _, handler := range handlers {
		func() {
			defer func() {
				if r := recover(); r != nil {
					fmt.Printf("[Worker Alert] Bắt được Panic khi xử lý sự kiện %s: %v\n", job.event.Name, r)
				}
			}()
			_ = handler(job.ctx, job.event)
		}()
	}
}

// Shutdown đóng Event Bus an toàn và đợi toàn bộ công việc hoàn tất
func (eb *HardenedEventBus) Shutdown(ctx context.Context) error {
	if !eb.isClosed.CompareAndSwap(false, true) {
		return nil
	}

	close(eb.jobQueue)
	eb.cancel()

	done := make(chan struct{})
	go func() {
		eb.wg.Wait()
		close(done)
	}()

	select {
	case <-done:
		return nil
	case <-ctx.Done():
		return fmt.Errorf("timeout khi chờ worker pool dừng: %w", ctx.Err())
	}
}

func main() {
	// Khởi tạo Event Bus với buffer 1024 slots
	bus := NewHardenedEventBus(1024)

	// Đăng ký subscriber cho domain Billing
	bus.Subscribe("OrderPlaced", func(ctx context.Context, evt DomainEvent) error {
		fmt.Printf("[Billing Handler] Đang xử lý sự kiện %s cho Đơn hàng ID: %v\n", evt.ID, evt.Payload)
		return nil
	})

	ctx := context.Background()

	// Phát sự kiện từ domain Order
	err := bus.Publish(ctx, DomainEvent{
		ID:        "evt_001",
		Name:      "OrderPlaced",
		Timestamp: time.Now(),
		Payload:   "ORD-2026-9912",
	})
	if err != nil {
		fmt.Printf("Gửi sự kiện thất bại: %v\n", err)
	}

	// Dừng hệ thống êm dịu (Graceful Shutdown)
	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer shutdownCancel()

	if err := bus.Shutdown(shutdownCtx); err != nil {
		fmt.Printf("Lỗi dừng Event Bus: %v\n", err)
	} else {
		fmt.Println("Event Bus đã dừng hoàn toàn an toàn!")
	}
}

❓ Câu Hỏi Thường Gặp (FAQ)

Aggregate Roots thực thi ranh giới Bounded Context như thế nào trong Modular Monolith?

Aggregate Root là điểm nhập cảnh duy nhất cho mọi thao tác biến đổi trạng thái trong một Bounded Context. Bằng cách cấm các module bên ngoài truy cập hoặc sửa đổi trực tiếp các entity con và chỉ cung cấp các phương thức nghiệp vụ rõ ràng, các quy tắc kiểm tra tính hợp lệ và ràng buộc bất biến luôn được bảo vệ trọn vẹn.

Tại sao nên sử dụng PostgreSQL Schema Isolation thay vì tách nhiều cụm CSDL trong giai đoạn đầu?

Sử dụng các schema biệt lập trong PostgreSQL (billing, inventory) tạo ra ranh giới dữ liệu cứng mà không phát sinh chi phí duy trì máy chủ hay sự phức tạp của giao dịch phân tán. Điều này ngăn chặn triệt để các câu lệnh SQL JOIN chéo bảng, đồng thời vẫn giữ được khả năng cam kết giao dịch nguyên tử (Atomic ACID) trong cùng một tiến trình ứng dụng.

Anti-Corruption Layer (ACL) bảo vệ domain nghiệp vụ khỏi sự thay đổi của các module khác ra sao?

Lớp ACL đóng vai trò một bộ chuyển đổi dữ liệu (Adapter) chuyên biệt, thông dịch các DTO hoặc sự kiện từ module bên ngoài thành các Value Object thuần khiết thuộc domain nội bộ. Khi module phát hành thay đổi định dạng dữ liệu, lập trình viên chỉ cần cập nhật logic chuyển đổi tại lớp ACL mà không phải sửa đổi mã nguồn cốt lõi của domain tiếp nhận.

Tại sao cần kết hợp cả thư mục internal của Go và công cụ phân tích tĩnh arch-go?

Thư mục internal/ của Go chỉ hỗ trợ phân quyền ở cấp độ thư mục cha chung. Trong khi đó, arch-go cho phép định nghĩa các quy tắc phụ thuộc kiến trúc chi tiết hơn trên cây AST, chẳng hạn như cấm một module nghiệp vụ import các implementation riêng tư của module khác, tự động từ chối các vi phạm ngay trong quá trình kiểm thử CI.

🔗 Chuyên Đề Tiếp Theo