Level 2 – Engineering · Nếu chỉ được nhớ một điều từ toàn bộ tài liệu, hãy nhớ chương này.


1. Phát biểu

Source code dependencies must point only inward, toward higher-level policies. Phụ thuộc mã nguồn chỉ được trỏ vào trong, về phía chính sách cấp cao hơn.

“Biết” ở đây nghĩa là bất kỳ dạng nào sau: câu lệnh import; dùng kiểu dữ liệu của vòng ngoài trong chữ ký hàm/field; tham chiếu tên hàm, hằng số, biến; và cả những dạng không có import: hiểu ngầm về format JSON của API, về schema bảng, về tên topic Kafka, về mã lỗi HTTP. Dependency Rule cấm tất cả — vòng trong phải có thể compile, test, và đọc hiểu mà không cần vòng ngoài tồn tại.

2. Vì sao chỉ cần một luật này là đủ

Mọi lợi ích của Clean Architecture đều suy ra được từ luật này, như định lý suy từ tiên đề:

  • Testability: domain không biết hạ tầng → thay hạ tầng bằng fake trong RAM → unit test thuần. (suy ra trực tiếp)
  • Độc lập framework: framework ở vòng ngoài → domain không import nó → upgrade/thay framework không chạm domain.
  • Nhiều delivery: HTTP, gRPC, CLI đều ở ngoài trỏ vào → dùng chung use case.
  • Khoanh vùng thay đổi: thay đổi chỉ lan theo chiều mũi tên phụ thuộc; mũi tên chụm vào trong → thay đổi vòng ngoài không lan đi đâu, thay đổi vòng trong lan ra có kiểm soát (compiler chỉ đích danh nơi phải sửa).

Và ngược lại: vi phạm luật ở một điểm là đủ phá chuỗi lợi ích. Một import net/http trong domain nghĩa là toàn bộ domain giờ dính vòng đời của HTTP layer.

3. Cách hoạt động: vượt ranh giới hai chiều

Chiều vào (ngoài → trong): gọi thẳng

Handler gọi use case, dùng kiểu của use case — hợp lệ, vì mũi tên trỏ vào:

// httpapi (vòng 3) import order (vòng 2): ĐÚNG chiều
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
	var req createOrderRequest // DTO của tầng HTTP
	json.NewDecoder(r.Body).Decode(&req)

	o, err := h.svc.PlaceOrder(r.Context(), req.CustomerID, toDomainItems(req.Items))
	...
}

Chiều ra (trong → ngoài): qua interface do vòng trong sở hữu

Use case cần lưu DB — control flow chạy ra ngoài, nhưng source dependency vẫn trỏ vào trong nhờ DIP (chương 1.3). Đây là điểm ảo diệu duy nhất, và nó chỉ là cơ chế đã học:

   control flow:      usecase ──────────────▶ postgres.Repo.Save()
   source dep:        usecase ◀────────────── postgres (import order để implement order.Repository)

Dữ liệu vượt ranh giới

Quy tắc: dữ liệu vượt ranh giới phải ở dạng thuận tiện cho vòng trong. Use case nhận/trả kiểu domain hoặc struct input/output đơn giản do chính nó định nghĩa — không bao giờ nhận *gin.Context, *sql.Rows, protobuf message. Việc dịch (mapping) là trách nhiệm của vòng ngoài — đó chính là nghĩa của từ “adapter”.

4. Cưỡng chế bằng công cụ — kiến trúc phải được compiler/CI bảo vệ

Kiến trúc chỉ tồn tại trong code khi có cơ chế chặn vi phạm tự động. Ba tầng phòng thủ trong Go:

Tầng 1 — internal/: package trong internal/ chỉ import được bởi cây thư mục cha. Dùng để chặn module khác xâm nhập chi tiết của module này.

Tầng 2 — linter theo luật import:

# .golangci.yml
linters-settings:
  depguard:
    rules:
      domain:
        files: ["**/internal/*/domain/**", "**/internal/*/usecase/**"]
        deny:
          - pkg: "database/sql"
          - pkg: "net/http"
          - pkg: "github.com/gin-gonic"
          - pkg: "gorm.io"
          - pkg: "github.com/segmentio/kafka-go"
            # domain/usecase không được biết bất kỳ hạ tầng nào

Tầng 3 — test kiến trúc: viết test duyệt go list -deps và fail nếu package domain kéo theo dependency cấm:

func TestDomainPurity(t *testing.T) {
	out, err := exec.Command("go", "list", "-deps", "./internal/order/domain/...").Output()
	if err != nil { t.Fatal(err) }
	for _, banned := range []string{"database/sql", "net/http", "gorm.io"} {
		if strings.Contains(string(out), banned) {
			t.Errorf("domain package phu thuoc %s — vi pham Dependency Rule", banned)
		}
	}
}

5. Các vi phạm kinh điển và cách nhận diện

Vi phạmDấu hiệuVì sao nguy hiểm
Domain import ORMgorm:"column:..." tag trong entitySchema DB định hình domain; đổi ORM = đổi domain
Use case nhận *gin.Contextchữ ký use case có kiểu frameworkUse case chết dính vào Gin; gRPC/CLI phải giả lập Context
Domain trả lỗi HTTPerrors.New("404 not found") trong domainDomain biết giao thức; thêm gRPC là dịch ngược lỗi
Interface ở package hạ tầngbusiness import postgres để lấy interfaceInversion giả (chương 1.3, mục 3.3)
Entity serialize thẳng ra APIjson:"..." tag trên entity dùng làm responseĐổi tên field nội bộ = breaking change API công khai; format API đóng băng domain
“Đi tắt” qua DBhai module đọc chung bảngCoupling vô hình với compiler (chương 1.1, 8.3)

Về JSON tag trên entity: đây là vi phạm mức nhẹ và nhiều team chấp nhận có chủ đích ở API nội bộ để đỡ mapping. Điều quan trọng là quyết định có ý thức: API công khai/versioned → bắt buộc DTO riêng; API nội bộ ít rủi ro → có thể dùng chung và ghi rõ trong ADR (Architecture Decision Record).

6. Trade-off của việc tuân thủ nghiêm

  • Mapping tax: mỗi ranh giới nghiêm cần code dịch DTO ↔ domain. Với entity 30 field, đó là boilerplate thật. Giải pháp: chỉ đặt ranh giới nghiêm ở nơi format ngoài thực sự biến động độc lập với domain.
  • Sự cám dỗ của ranh giới mềm: cho phép “tạm import cái này đã” là bắt đầu của mục nát — vi phạm đầu tiên hợp thức hóa vi phạm thứ hai. Kinh nghiệm: giữ luật tuyệt đối cho domain/usecase, thực dụng ở adapter.
  • Không phải mọi mũi tên đều đáng đảo: giữa các package hạ tầng với nhau (postgres ↔ migration tool) không cần Dependency Rule — luật chỉ áp trên trục trong–ngoài.

Tóm tắt

  • Một luật duy nhất: phụ thuộc trỏ vào trong; “phụ thuộc” bao gồm cả kiến thức ngầm, không chỉ import.
  • Chiều ra vượt bằng interface do vòng trong sở hữu; dữ liệu vượt ranh giới ở dạng của vòng trong.
  • Luật phải được cưỡng chế bằng internal/ + linter + test kiến trúc, không bằng lời hứa.

Chương tiếp theo: Bốn vòng — Entities, Use Cases, Interface Adapters, Frameworks & Drivers trong Go