Kỹ Thuật Chuyên Sâu • 19 phút đọc • 06/11/2026

Modular Monolith + Hexagonal + DDD: Chia Ranh Giới Vừa Đủ, Trước Khi Chia Service

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Tổ ong lục giác, từng ô ranh giới rõ ràng sát vào nhau
Ảnh: Joydeep · CC BY-SA 3.0

Có hai cách phổ biến để chết với backend. Cách thứ nhất: chia microservice ngay từ ngày đầu, khi chưa ai biết ranh giới domain nằm ở đâu. Kết quả là một hệ thống phân tán mà mỗi lần đổi một field phải sửa ba service, release theo thứ tự, và debug bằng cách đọc log của bốn nơi. Cách thứ hai: một monolith mọc lên không có luật, nơi OrderService gọi thẳng vào bảng của UserService, và không ai dám xóa một cột.

Cả hai cùng một gốc: ranh giới sai, hoặc không có ranh giới. Microservice chỉ làm ranh giới sai trở nên đắt hơn, vì mỗi ranh giới bây giờ là một cái mạng. Monolith không luật thì làm ranh giới biến mất.

Bài này nói về con đường ở giữa: modular monolith. Một deployable, nhiều module, ranh giới thật và được máy kiểm tra chứ không bằng lời hứa trong buổi họp. Hexagonal giữ luật phụ thuộc bên trong mỗi module. DDD, dùng vừa đủ, giúp tìm ra chỗ cần cắt. Mọi ví dụ cài hai lần: Go/Gin và NestJS, trên cùng một domain đặt tour du lịch có hai kênh bán (B2C cho khách lẻ, B2B cho đại lý), để thấy ý tưởng không phụ thuộc ngôn ngữ, còn công cụ ép luật thì có.

Cách đọc bài này: mỗi mảnh kiến trúc là một lời đáp cho một vấn đề cụ thể. Nếu bạn chưa gặp vấn đề đó, mảnh đó chưa đáng giá. Phần “cái giá phải trả” ở cuối không phải để cho đủ bộ.

Modular monolith: ranh giới là sản phẩm

Modular monolith không phải “monolith có thư mục đẹp”. Nó là monolith với ba luật cứng:

  1. Module sở hữu dữ liệu của nó. Bảng của module booking chỉ booking được đọc ghi. Module khác cần dữ liệu thì hỏi qua giao diện công khai, không JOIN xuyên module, không tạo foreign key xuyên module.
  2. Module chỉ nói chuyện qua bề mặt công khai. Mỗi module có một cửa: vài interface, vài kiểu dữ liệu, vài event. Mọi thứ còn lại là nội bộ.
  3. Ranh giới được máy kiểm tra. Vi phạm phải làm build hoặc lint đỏ. Quy ước trong README sống được đúng đến lần deadline đầu tiên.

Đơn vị chia là bounded context của DDD: một vùng mà trong đó mỗi từ có đúng một nghĩa. “Buyer” trong booking là người đặt tour (khách lẻ hoặc đại lý); trong payments là chủ thẻ với phương thức thanh toán đã lưu; trong agency là một đại lý có hợp đồng, hoa hồng và hạn mức công nợ. Ba thứ này có thể chung ID nhưng không nên chung struct. Khi bạn thấy mình cố ép ba nghĩa vào một class, đó là đường biên đang kêu cứu.

Lý do Martin Fowler gọi chiến lược này là “Monolith First”: hầu hết các hệ thống microservice thành công đều bắt đầu từ một monolith rồi tách ra sau khi ranh giới đã lộ rõ. Đi chiều ngược lại, tách trước khi hiểu domain, thì bạn đang đoán ranh giới và trả tiền hạ tầng cho mỗi lần đoán sai. Modular monolith cho phép đoán sai rẻ: đổi ranh giới là refactor trong một repo, không phải migration giữa các service.

Hexagonal: một luật duy nhất

Hexagonal Architecture (Alistair Cockburn gọi là Ports and Adapters) thường được vẽ bằng hình lục giác và nghe như triết lý. Thực chất nó là một luật phụ thuộc:

Code nghiệp vụ không import gì từ thế giới bên ngoài. Thế giới bên ngoài import nghiệp vụ.

   [HTTP / Gin / Nest controller]        [Postgres repo]        [Payments client]
              │  (adapter vào)                 ▲ (adapter ra)          ▲
              ▼                                │                        │
        ┌─────────────────────────────────────────────────────────────────────┐
        │  application: use case   ── gọi ──▶  PORT (interface do module định nghĩa)
        │  domain: entity, value object, event, rule                          │
        └─────────────────────────────────────────────────────────────────────┘
  • Port là interface do module nghiệp vụ định nghĩa, theo ngôn ngữ của nghiệp vụ: Guarantee.Secure(...), không phải StripeClient.CreatePaymentIntent(...).
  • Adapter là code cụ thể ở rìa: controller HTTP, repository Postgres, client gọi module khác. Nó implement hoặc gọi port, và dịch giữa hai thế giới.

Lợi ích thực tế không phải “đổi database dễ” (hiếm ai đổi). Là hai thứ khác: test use case không cần mạng hay DB (thay port bằng fake), và framework không lọt vào domain nên nâng version Gin hay Nest không động đến rule nghiệp vụ.

Hexagonal trả lời câu “code trong một module sắp xếp thế nào”. Modular monolith trả lời câu “các module đứng cạnh nhau thế nào”. Hai câu hỏi khác nhau, và chúng cộng hưởng: adapter ra chính là chỗ module này gọi sang module kia, và ở đó bạn dịch ngôn ngữ giữa hai context.

DDD vừa đủ: lấy phần nào, bỏ phần nào

DDD có hai nửa. Strategic design (bounded context, ngôn ngữ chung, context map) là nửa cho ra lợi nhuận lớn nhất và gần như miễn phí: nó chỉ là cuộc trò chuyện với người hiểu nghiệp vụ và một bản vẽ. Tactical design (entity, value object, aggregate, domain event, repository) là bộ công cụ cài trong code, và là nơi dễ over-engineer.

Bộ tactical vừa đủ cho phần lớn module có logic thật:

  • Aggregate với một cửa vào: mọi thay đổi trạng thái đi qua method của nó, nên rule bất biến nằm ở một chỗ. Booking.Confirm() kiểm tra trạng thái trước khi đổi, không ai set status = "confirmed" từ ngoài.
  • Value object cho khái niệm nhỏ có luật riêng: Terms (điều khoản giá của một kênh bán), Money. Tạo qua constructor kiểm tra, sau đó bất biến.
  • Domain event khi một thay đổi có hệ quả ở module khác: BookingConfirmed thay vì để booking biết cách gửi email.

Phần nên bỏ nếu chưa có lý do: event sourcing, CQRS tách read/write model, saga framework. Đây là công cụ cho vấn đề cụ thể (audit đầy đủ, read model rất khác write model, giao dịch phân tán). Nếu cần saga thật sự, bài thiết kế booking với saga pattern đi sâu vào phần đó.

Và quan trọng hơn: không phải module nào cũng cần tactical DDD. Module quản lý danh mục tour là CRUD thì cứ để CRUD. Phần cuối bài quay lại điểm này.

Domain ví dụ: đặt tour, hai kênh bán

Ví dụ này là mô hình đơn giản hóa để minh họa kiến trúc, không phải thiết kế của một hệ thống thật. Chọn du lịch vì nó có một tình huống rất hay gặp: cùng một khái niệm, hai bộ luật. Khách lẻ (B2C) và đại lý (B2B) cùng “đặt tour”, nhưng:

B2C (khách lẻ)B2B (đại lý)
GiáGiá bán lẻGiá net: bán lẻ trừ hoa hồng theo hợp đồng
Bảo đảm thanh toánGiữ tiền trên thẻGiữ hạn mức công nợ của đại lý
Ai biết luậtpaymentsagency

Cách sai thường gặp: một class Booking có cờ isB2B và if rải khắp nơi. Cách đúng hơn: lõi Booking giữ các rule chung (không đặt tour đã khởi hành, số chỗ hợp lệ, vòng đời trạng thái), còn phần khác biệt đi qua hai điểm mở: Terms (giá) và một port bảo đảm thanh toán với hai adapter. Thêm một kênh thứ ba, ví dụ đối tác OTA, là thêm Terms và adapter, không sửa lõi. Với những hệ thống đặt vé du lịch lớn hơn nhiều, bài phỏng vấn system design hệ thống đặt vé du lịch nhìn từ góc độ khác.

Bốn module, đủ nhỏ để đọc được, đủ thật để có ranh giới đáng bàn:

  • booking: tạo và xác nhận đặt tour. Có rule: không đặt tour đã khởi hành, ít nhất một chỗ, chỉ xác nhận booking đang chờ.
  • payments: giữ tiền trên thẻ cho khách lẻ.
  • agency: hợp đồng đại lý (hoa hồng) và hạn mức công nợ.
  • notifications: gửi thông báo khi booking được xác nhận.

Luồng: người mua đặt tour → booking xác định điều khoản giá theo kênh (đại lý thì hỏi agency lấy hoa hồng) → tính tổng tiền → yêu cầu bảo đảm thanh toán (payments cho B2C, agency cho B2B) → xác nhận, lưu → phát event BookingConfirmed → notifications lắng nghe và gửi tin. booking không biết notifications tồn tại.

Bản Go/Gin

Go có một công cụ ép ranh giới miễn phí mà ít người dùng hết mức: thư mục internal lồng nhau. Quy tắc của compiler: package nằm dưới x/internal/... chỉ import được bởi code nằm trong cây thư mục gốc là x/. Đặt internal bên trong mỗi module thì phần ruột của module bị khóa với phần còn lại của repo, ở mức compile.

internal/
  booking/
    booking.go                  // bề mặt công khai: NewModule, RegisterRoutes
    internal/
      domain/                   // Booking, Terms, event, lỗi nghiệp vụ
      app/                      // use case + port
      httpadapter/              // Gin handler
      postgres/                 // repository
      paymentsadapter/          // dịch sang module payments (B2C)
      agencyadapter/            // dịch sang module agency (B2B)
  payments/
    payments.go                 // interface Service + request/response công khai
    internal/...
  agency/
    agency.go
    internal/...
  notifications/
    notifications.go
    internal/...
cmd/api/main.go                 // composition root: nơi duy nhất lắp mọi thứ

Từ notifications hay payments, viết import ".../internal/booking/internal/domain" là lỗi compile (“use of internal package not allowed”). Không cần lint, không cần kỷ luật.

Domain: không import gì ngoài stdlib

package domain

import (
	"errors"
	"time"
)

var (
	ErrNoSeats           = errors.New("seats must be at least 1")
	ErrDeparturePassed   = errors.New("departure is in the past")
	ErrInvalidCommission = errors.New("commission must be in [0, 100)")
	ErrInvalidTransition = errors.New("invalid status transition")
)

type ID string

type Kind string

const (
	KindRetail Kind = "retail" // B2C
	KindAgency Kind = "agency" // B2B
)

// Terms là value object: điều khoản giá của một kênh bán.
type Terms struct {
	kind          Kind
	commissionPct int64
}

func RetailTerms() Terms { return Terms{kind: KindRetail} }

func AgencyTerms(commissionPct int64) (Terms, error) {
	if commissionPct < 0 || commissionPct >= 100 {
		return Terms{}, ErrInvalidCommission
	}
	return Terms{kind: KindAgency, commissionPct: commissionPct}, nil
}

func (t Terms) Kind() Kind { return t.kind }

// Price: B2C trả giá bán lẻ, B2B trả giá net sau hoa hồng.
func (t Terms) Price(unitVND int64, seats int) int64 {
	gross := unitVND * int64(seats)
	return gross - gross*t.commissionPct/100
}

type Status string

const (
	StatusPending   Status = "pending"
	StatusConfirmed Status = "confirmed"
)

type Booking struct {
	id        ID
	buyerID   string
	terms     Terms
	departure time.Time
	seats     int
	total     int64
	status    Status
	events    []Event
}

func Book(id ID, buyerID string, terms Terms, departure time.Time, seats int,
	unitVND int64, now time.Time) (*Booking, error) {
	if seats < 1 {
		return nil, ErrNoSeats
	}
	if !departure.After(now) {
		return nil, ErrDeparturePassed
	}
	return &Booking{
		id: id, buyerID: buyerID, terms: terms, departure: departure, seats: seats,
		total: terms.Price(unitVND, seats), status: StatusPending,
	}, nil
}

func (b *Booking) Confirm() error {
	if b.status != StatusPending {
		return ErrInvalidTransition
	}
	b.status = StatusConfirmed
	b.events = append(b.events, BookingConfirmed{BookingID: b.id, BuyerID: b.buyerID, Kind: b.terms.Kind()})
	return nil
}

func (b *Booking) ID() ID       { return b.id }
func (b *Booking) Total() int64 { return b.total }

// PullEvents trả về event đã ghi nhận và xóa khỏi aggregate.
func (b *Booking) PullEvents() []Event {
	evs := b.events
	b.events = nil
	return evs
}

type Event interface{ EventName() string }

type BookingConfirmed struct {
	BookingID ID
	BuyerID   string
	Kind      Kind
}

func (BookingConfirmed) EventName() string { return "booking.confirmed" }

Field của Booking viết thường: bên ngoài package không set trực tiếp được, chỉ đi qua Book và Confirm. Đó là “aggregate có một cửa vào” trong Go, không cần thêm khái niệm nào. Toàn bộ khác biệt B2C/B2B trong domain gói gọn trong Terms.Price: không có if isB2B trong Booking.

Application: use case và port, port nằm ở phía dùng

Go có một quy ước hợp hẳn với Hexagonal: interface định nghĩa ở nơi dùng nó, không ở nơi implement. Port nằm ngay trong package app.

package app

import (
	"context"
	"errors"
	"fmt"
	"time"

	"example.com/shop/internal/booking/internal/domain"
)

var (
	ErrUnknownChannel   = errors.New("unknown sales channel")
	ErrGuaranteeRefused = errors.New("payment guarantee refused")
)

type Repository interface {
	Save(ctx context.Context, b *domain.Booking) error
}

// Guarantee là ngôn ngữ của booking: "bảo đảm thanh toán". Thẻ hay công nợ là chuyện của adapter.
type Guarantee interface {
	Secure(ctx context.Context, buyerID string, amountVND int64) (GuaranteeRef, error)
}

type GuaranteeRef string

// Contracts: booking chỉ cần biết hoa hồng, không cần biết hợp đồng đại lý chứa gì khác.
type Contracts interface {
	CommissionPct(ctx context.Context, agencyID string) (int64, error)
}

type EventPublisher interface {
	Publish(ctx context.Context, events ...domain.Event) error
}

type BookCommand struct {
	BuyerID      string
	Kind         domain.Kind
	Departure    time.Time
	Seats        int
	UnitPriceVND int64
}

type BookTour struct {
	Repo      Repository
	Retail    Guarantee // B2C: giữ tiền trên thẻ
	Agency    Guarantee // B2B: giữ hạn mức công nợ
	Contracts Contracts
	Events    EventPublisher
	Now       func() time.Time
	NewID     func() domain.ID
}

func (uc *BookTour) Handle(ctx context.Context, cmd BookCommand) (domain.ID, error) {
	terms, guarantee, err := uc.channel(ctx, cmd)
	if err != nil {
		return "", err
	}
	b, err := domain.Book(uc.NewID(), cmd.BuyerID, terms, cmd.Departure, cmd.Seats, cmd.UnitPriceVND, uc.Now())
	if err != nil {
		return "", err
	}
	if _, err := guarantee.Secure(ctx, cmd.BuyerID, b.Total()); err != nil {
		return "", fmt.Errorf("secure payment: %w", err)
	}
	if err := b.Confirm(); err != nil {
		return "", err
	}
	if err := uc.Repo.Save(ctx, b); err != nil {
		return "", err
	}
	if err := uc.Events.Publish(ctx, b.PullEvents()...); err != nil {
		return "", err
	}
	return b.ID(), nil
}

// channel chọn điều khoản giá và cách bảo đảm thanh toán theo kênh bán.
func (uc *BookTour) channel(ctx context.Context, cmd BookCommand) (domain.Terms, Guarantee, error) {
	switch cmd.Kind {
	case domain.KindRetail:
		return domain.RetailTerms(), uc.Retail, nil
	case domain.KindAgency:
		pct, err := uc.Contracts.CommissionPct(ctx, cmd.BuyerID)
		if err != nil {
			return domain.Terms{}, nil, fmt.Errorf("load contract: %w", err)
		}
		terms, err := domain.AgencyTerms(pct)
		return terms, uc.Agency, err
	default:
		return domain.Terms{}, nil, ErrUnknownChannel
	}
}

Đây là điểm đáng nhìn kỹ của Hexagonal: một port Guarantee, hai adapter. Use case không biết thẻ hay công nợ, chỉ biết “bảo đảm thanh toán”. Now và NewID được inject nên test use case có thể cố định thời gian. Để ý một điều không đẹp: nếu Save lỗi sau khi Secure thành công, khoản giữ tiền hoặc hạn mức vẫn còn. Đây là vấn đề giao dịch giữa hai module, và mình quay lại ở phần module nói chuyện với nhau.

Adapter: Gin chỉ là cái vỏ

package httpadapter

type Handler struct{ bookUC *app.BookTour }

func New(uc *app.BookTour) *Handler { return &Handler{bookUC: uc} }

func (h *Handler) Register(r gin.IRouter) {
	r.POST("/bookings", h.book(domain.KindRetail))
	r.POST("/agency/bookings", h.book(domain.KindAgency))
}

type bookRequest struct {
	BuyerID      string    `json:"buyerId" binding:"required"` // thực tế lấy từ token đăng nhập
	Departure    time.Time `json:"departure" binding:"required"`
	Seats        int       `json:"seats" binding:"required"`
	UnitPriceVND int64     `json:"unitPriceVnd" binding:"gte=0"`
}

func (h *Handler) book(kind domain.Kind) gin.HandlerFunc {
	return func(c *gin.Context) {
		var req bookRequest
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		id, err := h.bookUC.Handle(c.Request.Context(), app.BookCommand{
			BuyerID: req.BuyerID, Kind: kind, Departure: req.Departure,
			Seats: req.Seats, UnitPriceVND: req.UnitPriceVND,
		})
		switch {
		case err == nil:
			c.JSON(http.StatusCreated, gin.H{"id": id})
		case errors.Is(err, domain.ErrNoSeats), errors.Is(err, domain.ErrDeparturePassed):
			c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
		case errors.Is(err, app.ErrGuaranteeRefused):
			c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
		default:
			c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
		}
	}
}

Chỗ duy nhất biết “lỗi nghiệp vụ nào thành mã HTTP nào” và “route nào là kênh nào” là adapter. Đổi Gin sang gRPC thì viết adapter mới, use case không đổi dòng nào.

Cạnh giữa hai module: adapter dịch ngôn ngữ

payments và agency mỗi bên công khai một interface nhỏ trong file gốc của module. booking không dùng chúng trực tiếp trong use case mà bọc qua adapter:

package paymentsadapter

type Card struct{ svc payments.Service }

func New(svc payments.Service) Card { return Card{svc: svc} }

func (g Card) Secure(ctx context.Context, buyerID string, amountVND int64) (app.GuaranteeRef, error) {
	hold, err := g.svc.Hold(ctx, payments.HoldRequest{Payer: buyerID, Amount: amountVND})
	if err != nil {
		return "", err
	}
	return app.GuaranteeRef(hold.HoldID), nil
}
package agencyadapter

type Adapter struct{ svc agency.Service }

func New(svc agency.Service) Adapter { return Adapter{svc: svc} }

// Secure implement app.Guarantee cho kênh B2B: giữ hạn mức công nợ.
func (a Adapter) Secure(ctx context.Context, buyerID string, amountVND int64) (app.GuaranteeRef, error) {
	r, err := a.svc.ReserveCredit(ctx, agency.ReserveRequest{AgencyID: buyerID, Amount: amountVND})
	if errors.Is(err, agency.ErrLimitExceeded) {
		return "", app.ErrGuaranteeRefused // lỗi của agency thành lỗi của booking
	}
	if err != nil {
		return "", err
	}
	return app.GuaranteeRef(r.ReservationID), nil
}

// CommissionPct implement app.Contracts.
func (a Adapter) CommissionPct(ctx context.Context, agencyID string) (int64, error) {
	c, err := a.svc.Contract(ctx, agencyID)
	return c.CommissionPct, err
}

Đây chính là anti-corruption layer của DDD, nhỏ đến mức chỉ là vài struct mười dòng. Nếu payments đổi tên Payer thành Account, chỉ file đầu vỡ. Nếu agency đổi cách báo lỗi hết hạn mức, chỉ file thứ hai vỡ.

Composition root

// cmd/api/main.go
func main() {
	db := mustOpenDB()
	bus := eventbus.New() // in-process

	paySvc := payments.NewModule(db).Service()
	agencySvc := agency.NewModule(db).Service()
	bookingMod := booking.NewModule(db, paySvc, agencySvc, bus)
	notifications.NewModule(bus) // đăng ký handler trên bus

	r := gin.Default()
	bookingMod.RegisterRoutes(r)
	_ = r.Run(":8080")
}

main là nơi duy nhất biết tất cả module. Đó là chủ ý: sự phụ thuộc giữa module nhìn thấy được trong một file.

Bản NestJS

NestJS có sẵn khái niệm module và DI, nhưng không có thứ gì ép ranh giới ở compile time. Module Nest chỉ ép ở runtime: provider không được exports thì module khác inject sẽ lỗi lúc khởi động. Còn import TypeScript giữa các file thì ai cũng gõ được. Vì vậy với Nest, phần kiểm tra bằng lint là bắt buộc, không phải trang trí (xem phần sau).

src/modules/
  booking/
    index.ts                       // bề mặt công khai: BookingModule, event type
    booking.module.ts
    domain/
      terms.ts
      booking.ts
      events.ts
    application/
      ports.ts
      book-tour.use-case.ts
    infrastructure/
      typeorm-booking.repository.ts
      payments-facade.gateway.ts   // B2C
      agency-facade.gateway.ts     // B2B
      nest-event.publisher.ts
    presentation/
      booking.controller.ts
  payments/
    index.ts                       // PaymentsModule, PaymentsFacade
  agency/
    index.ts                       // AgencyModule, AgencyFacade
  notifications/

Domain: TypeScript thuần

// domain/terms.ts
export class InvalidCommissionError extends Error {}

export type Kind = 'retail' | 'agency';

// Value object: điều khoản giá của một kênh bán.
export class Terms {
  private constructor(
    readonly kind: Kind,
    private readonly commissionPct: number,
  ) {}

  static retail(): Terms {
    return new Terms('retail', 0);
  }

  static agency(commissionPct: number): Terms {
    if (commissionPct < 0 || commissionPct >= 100) throw new InvalidCommissionError();
    return new Terms('agency', commissionPct);
  }

  // B2C trả giá bán lẻ, B2B trả giá net sau hoa hồng.
  price(unitVnd: number, seats: number): number {
    const gross = unitVnd * seats;
    return gross - Math.floor((gross * this.commissionPct) / 100);
  }
}
// domain/booking.ts
export class NoSeatsError extends Error {}
export class DeparturePassedError extends Error {}
export class InvalidTransitionError extends Error {}

type Status = 'pending' | 'confirmed';

export class Booking {
  private events: DomainEvent[] = [];

  private constructor(
    readonly id: string,
    readonly buyerId: string,
    readonly terms: Terms,
    readonly departure: Date,
    readonly seats: number,
    readonly total: number,
    private status: Status,
  ) {}

  static book(
    p: { id: string; buyerId: string; terms: Terms; departure: Date; seats: number; unitPriceVnd: number },
    now: Date,
  ): Booking {
    if (p.seats < 1) throw new NoSeatsError();
    if (p.departure <= now) throw new DeparturePassedError();
    const total = p.terms.price(p.unitPriceVnd, p.seats);
    return new Booking(p.id, p.buyerId, p.terms, p.departure, p.seats, total, 'pending');
  }

  confirm(): void {
    if (this.status !== 'pending') throw new InvalidTransitionError();
    this.status = 'confirmed';
    this.events.push(new BookingConfirmed(this.id, this.buyerId, this.terms.kind));
  }

  pullEvents(): DomainEvent[] {
    const evs = this.events;
    this.events = [];
    return evs;
  }
}
// domain/events.ts
export interface DomainEvent {
  readonly name: string;
}

export class BookingConfirmed implements DomainEvent {
  readonly name = 'booking.confirmed';
  constructor(
    readonly bookingId: string,
    readonly buyerId: string,
    readonly kind: Kind,
  ) {}
}

Các file này không có @Injectable, không import @nestjs/*, không import TypeORM. Domain test bằng Jest không cần Test.createTestingModule.

Port: abstract class làm injection token

Interface TypeScript biến mất sau khi compile, nên không dùng làm DI token được. Hai cách phổ biến là Symbol/string token hoặc abstract class, vừa là kiểu vừa là giá trị runtime. Vì có hai cách bảo đảm thanh toán nên cần hai token; chúng cùng hình dạng, và đó là một chút lặp lại phải chấp nhận khi DI theo class:

// application/ports.ts
export abstract class BookingRepository {
  abstract save(b: Booking): Promise<void>;
}

// B2C: giữ tiền trên thẻ.
export abstract class RetailGuarantee {
  abstract secure(buyerId: string, amountVnd: number): Promise<string>;
}

// B2B: giữ hạn mức công nợ.
export abstract class AgencyGuarantee {
  abstract secure(buyerId: string, amountVnd: number): Promise<string>;
}

export abstract class Contracts {
  abstract commissionPct(agencyId: string): Promise<number>;
}

export abstract class EventPublisher {
  abstract publish(events: DomainEvent[]): Promise<void>;
}

export class GuaranteeRefusedError extends Error {}
// application/book-tour.use-case.ts
@Injectable()
export class BookTourUseCase {
  constructor(
    private readonly repo: BookingRepository,
    private readonly retail: RetailGuarantee,
    private readonly agency: AgencyGuarantee,
    private readonly contracts: Contracts,
    private readonly events: EventPublisher,
  ) {}

  async execute(cmd: {
    buyerId: string;
    kind: Kind;
    departure: Date;
    seats: number;
    unitPriceVnd: number;
  }): Promise<string> {
    const { terms, guarantee } = await this.channel(cmd.kind, cmd.buyerId);
    const booking = Booking.book(
      { id: randomUUID(), buyerId: cmd.buyerId, terms, departure: cmd.departure, seats: cmd.seats, unitPriceVnd: cmd.unitPriceVnd },
      new Date(),
    );
    await guarantee.secure(cmd.buyerId, booking.total);
    booking.confirm();
    await this.repo.save(booking);
    await this.events.publish(booking.pullEvents());
    return booking.id;
  }

  // Chọn điều khoản giá và cách bảo đảm thanh toán theo kênh bán.
  private async channel(kind: Kind, buyerId: string) {
    switch (kind) {
      case 'retail':
        return { terms: Terms.retail(), guarantee: this.retail };
      case 'agency':
        return { terms: Terms.agency(await this.contracts.commissionPct(buyerId)), guarantee: this.agency };
    }
  }
}

@Injectable() là thứ duy nhất của Nest ở tầng này, và nó chỉ là metadata cho DI. Nếu muốn domain và application sạch hơn nữa, có thể bỏ cả decorator này và khai báo provider bằng useFactory trong module.

Adapter và wiring

// infrastructure/payments-facade.gateway.ts
@Injectable()
export class PaymentsFacadeGateway implements RetailGuarantee {
  constructor(private readonly payments: PaymentsFacade) {}

  async secure(buyerId: string, amountVnd: number): Promise<string> {
    const hold = await this.payments.hold({ payer: buyerId, amount: amountVnd });
    return hold.holdId; // dịch ngôn ngữ payments -> booking
  }
}
// infrastructure/agency-facade.gateway.ts
@Injectable()
export class AgencyFacadeGateway implements AgencyGuarantee, Contracts {
  constructor(private readonly agency: AgencyFacade) {}

  async secure(buyerId: string, amountVnd: number): Promise<string> {
    try {
      const r = await this.agency.reserveCredit({ agencyId: buyerId, amount: amountVnd });
      return r.reservationId;
    } catch (e) {
      if (e instanceof CreditLimitExceededError) throw new GuaranteeRefusedError(); // lỗi agency -> lỗi booking
      throw e;
    }
  }

  async commissionPct(agencyId: string): Promise<number> {
    return (await this.agency.contract(agencyId)).commissionPct;
  }
}
// infrastructure/nest-event.publisher.ts
@Injectable()
export class NestEventPublisher implements EventPublisher {
  constructor(private readonly emitter: EventEmitter2) {}

  async publish(events: DomainEvent[]): Promise<void> {
    for (const e of events) await this.emitter.emitAsync(e.name, e);
  }
}
// booking.module.ts
@Module({
  imports: [PaymentsModule, AgencyModule, TypeOrmModule.forFeature([BookingOrmEntity])],
  controllers: [BookingController],
  providers: [
    BookTourUseCase,
    { provide: BookingRepository, useClass: TypeOrmBookingRepository },
    { provide: RetailGuarantee, useClass: PaymentsFacadeGateway },
    { provide: AgencyGuarantee, useClass: AgencyFacadeGateway },
    { provide: Contracts, useExisting: AgencyGuarantee },
    { provide: EventPublisher, useClass: NestEventPublisher },
  ],
})
export class BookingModule {}

Đọc các dòng provide: là thấy ngay “port nào đang được cắm adapter nào”, gồm cả việc B2C và B2B đi hai đường khác nhau. useExisting cho Contracts dùng lại cùng instance adapter của AgencyGuarantee. PaymentsModule và AgencyModule phải exports đúng facade của mình và chỉ cái đó: đây là bề mặt công khai.

notifications lắng nghe event, không import use case của booking:

@Injectable()
export class BookingConfirmedHandler {
  @OnEvent('booking.confirmed')
  async handle(e: BookingConfirmed): Promise<void> {
    // gửi thông báo: khách lẻ nhận email xác nhận, đại lý nhận kèm hóa đơn công nợ
  }
}

Kiểu BookingConfirmed được booking xuất qua index.ts. Hướng phụ thuộc là notifications → booking (phía nghe tuân theo hợp đồng của phía phát), và không có chiều ngược lại.

Module nói chuyện với nhau thế nào

Hai kiểu giao tiếp, mỗi kiểu một mục đích:

  • Gọi đồng bộ qua facade/interface công khai: khi bạn cần câu trả lời ngay để đi tiếp (giữ tiền hoặc hạn mức xong mới xác nhận booking). Luôn qua adapter dịch ngôn ngữ như các ví dụ ở trên.
  • Domain event: khi bên phát không cần biết ai quan tâm và không đợi kết quả (gửi thông báo, cập nhật thống kê). Thêm người nghe không phải sửa bên phát.

Hai điều không bao giờ làm: dùng chung bảng, và FK xuyên module. Chúng là cửa sau lớn nhất, vì mọi ranh giới trong code đều vô nghĩa nếu hai module cùng ghi vào một bảng. Khi cần dữ liệu của module khác, hỏi qua facade hoặc giữ một bản sao read-only cập nhật bằng event. Ví dụ: booking không JOIN sang bảng hợp đồng của agency để lấy hoa hồng, mà hỏi qua Contracts.

Phải nói thẳng một điểm yếu: event in-process nghĩa là không có đảm bảo giao hàng. Nếu process chết giữa Save và Publish, event mất. Cách chuẩn là transactional outbox: ghi event vào bảng outbox cùng transaction với aggregate, một worker đọc bảng đó rồi phát. Chưa cần outbox thì cứ in-process, nhưng hãy biết mình đang chấp nhận rủi ro nào. Tương tự, khoản giữ tiền hay hạn mức đã giữ mà Save lỗi cần một bước hoàn tác (compensation), và đó là lúc ý tưởng saga xuất hiện.

Ép ranh giới bằng công cụ, không bằng văn hóa

Ranh giới chưa được máy kiểm tra thì chưa phải ranh giới. Đây là thứ mình sẽ cài ngay từ commit đầu tiên.

Go: thư mục internal lồng nhau như trên đã cho compile error miễn phí. Muốn thêm luật theo hướng (ví dụ domain không được import app, không import gin), dùng depguard của golangci-lint hoặc một công cụ kiểm tra kiến trúc như go-arch-lint, cấu hình danh sách package được phép import cho từng lớp.

NestJS: dùng ESLint. Dạng đơn giản nhất là rule có sẵn no-restricted-imports, áp riêng cho code ngoài module:

// eslint.config.js (trích)
{
  files: ['src/modules/payments/**', 'src/modules/agency/**', 'src/modules/notifications/**'],
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [{
        group: ['**/modules/booking/domain/*', '**/modules/booking/application/*',
                '**/modules/booking/infrastructure/*'],
        message: 'Chỉ import từ modules/booking (index.ts).',
      }],
    }],
  },
},

Nếu repo đã dùng Nx thì @nx/enforce-module-boundaries với tag làm việc này gọn hơn. eslint-plugin-boundaries và dependency-cruiser là lựa chọn khác. Công cụ nào cũng được; điều cần là CI đỏ khi vi phạm.

Thêm một luật nữa đáng kiểm tra: domain không import framework. Với Go đó là depguard chặn gin trong domain. Với Nest đó là chặn @nestjs/* và ORM trong thư mục domain.

Cái giá phải trả

Kiến trúc này không miễn phí, và bài viết nào quên phần này thì đang bán hàng.

  • Nhiều file, nhiều mapper. Một thao tác tạo bản ghi đơn giản có thể đi qua controller, DTO, use case, port, repository, mapper domain ↔ row. Với module chỉ có CRUD, đó là nghi lễ, không phải giá trị.
  • Dịch đi dịch lại. Mỗi cạnh giữa module có adapter dịch ngôn ngữ. Đúng về mặt thiết kế, nhưng là code phải viết và bảo trì.
  • Đường cong học. Người mới phải hiểu vì sao Booking không có setter. Nếu team chưa từng làm DDD, tactical pattern dùng sai tệ hơn không dùng.
  • Over-engineering là rủi ro thật. DDD sinh ra cho domain phức tạp. Áp lên ứng dụng quản trị dữ liệu thì bạn trả toàn bộ chi phí mà không nhận lại gì. Ngay ví dụ ở trên, nếu B2B chỉ khác B2C đúng một con số chiết khấu thì Terms và hai adapter là thừa; chúng chỉ đáng nếu hai kênh thực sự có luật khác nhau và sẽ còn khác thêm.
  • Ranh giới vẫn có thể sai. Modular monolith làm đổi ranh giới rẻ hơn, chứ không làm nó đúng. Vẫn cần người hiểu domain.

Nguyên tắc chọn: dùng tactical DDD và Hexagonal đầy đủ cho module có rule nghiệp vụ thật (trạng thái, bất biến, tính toán). Module CRUD cứ làm CRUD: controller → service → repository, vẫn nằm trong ranh giới module và vẫn bị ép import, chỉ không cần domain model phong phú. Một codebase có cả hai kiểu module là bình thường và lành mạnh.

Khi nào mới đáng tách microservice

Câu hỏi sai là “monolith hay microservice”. Câu hỏi đúng là: ranh giới này có đáng trả giá của một cái mạng không? Tín hiệu đáng cân nhắc:

  • Scale khác nhau rõ rệt: một module cần co giãn độc lập vì tải của nó khác hẳn phần còn lại (ví dụ cổng đặt tour cho khách lẻ chịu đột biến theo đợt khuyến mãi, còn cổng đại lý ổn định).
  • Nhịp release khác nhau và tổ chức đã tách đội: hai đội cần deploy độc lập mà không phối hợp.
  • Yêu cầu cô lập: tuân thủ, bảo mật (ví dụ tách phần chạm vào dữ liệu thẻ), hoặc blast radius mà một process chung không đáp ứng được.
  • Công nghệ khác: một phần cần runtime hoặc ngôn ngữ khác hẳn.

Điều hay của modular monolith là khi một trong các tín hiệu đó xuất hiện, bạn đã có ranh giới sẵn: module có bề mặt công khai rõ, không chung bảng, giao tiếp qua facade và event. Tách ra là thay facade bằng một client gọi mạng và thay event in-process bằng message broker. Nó là việc cơ học, không phải cuộc phẫu thuật.

Ngược lại, nếu đội nhỏ, một deploy pipeline, một database và chưa có tín hiệu nào ở trên, thì microservice chỉ thêm độ trễ mạng, tracing phân tán và vấn đề nhất quán dữ liệu mà không giải quyết vấn đề nào bạn đang có.

Kết

Gói lại thành ba câu:

  1. Ranh giới là thứ quý nhất; chia nó đúng và ép nó bằng công cụ trước khi nghĩ đến chuyện cắt thành service.
  2. Hexagonal là một luật: nghiệp vụ không import thế giới bên ngoài. Port nằm ở phía nghiệp vụ, adapter nằm ở rìa và dịch ngôn ngữ.
  3. DDD dùng theo cỡ vấn đề: strategic luôn đáng làm, tactical chỉ cho nơi có rule thật.

Kiến trúc tốt không phải kiến trúc có nhiều lớp nhất. Là kiến trúc có chi phí tương xứng với vấn đề đang có, và để ngỏ đường đi khi vấn đề lớn lên.

Tài liệu tham khảo

Các nguồn dưới đây là nền tảng cho những ý chính; hãy đọc bản gốc thay vì chỉ tin vào tóm tắt của mình.

  • Martin Fowler, “MonolithFirst” (2015), martinfowler.com/bliki/MonolithFirst.html
  • Alistair Cockburn, “Hexagonal architecture” (Ports and Adapters), alistair.cockburn.us/hexagonal-architecture
  • Eric Evans, Domain-Driven Design (2003): bounded context, aggregate, anti-corruption layer
  • Go command documentation, mục “Internal Directories”: pkg.go.dev/cmd/go#hdr-Internal_Directories
  • Kamil Grzybek, “Modular Monolith with DDD”, github.com/kgrzybek/modular-monolith-with-ddd
  • Shopify Engineering, “Deconstructing the Monolith” (2019)
  • NestJS documentation: Modules, Custom providers, Events (@nestjs/event-emitter)

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.