Clean Architecture (Robert C. Martin) đặt ra một quy tắc trung tâm: hướng phụ thuộc luôn trỏ vào trong, về phía logic nghiệp vụ. Tầng ngoài (HTTP, CSDL, hàng đợi) phụ thuộc tầng trong (use case, entity), không bao giờ ngược lại. Nhiều ngôn ngữ chỉ khuyến nghị điều này bằng quy ước — vi phạm thì không có gì chặn. Go thì khác: một đặc tính của trình biên dịch — cấm chu trình import — biến quy tắc kiến trúc thành thứ compiler bắt buộc. Bài này dựng một ví dụ ba tầng chạy được, và đo thật cách Go ép hướng phụ thuộc.
Ba tầng, và interface là port
Cấu trúc tối giản có ba package: domain (trung tâm), usecase (logic nghiệp vụ), infra (hạ tầng cụ thể). Điểm mấu chốt là domain định nghĩa interface mà nó cần — gọi là port — chứ không import hạ tầng:
package domain
type NguoiDung struct { ID int; Ten string }
// Port: domain ĐỊNH NGHĨA interface nó cần, infra sẽ hiện thực
type KhoNguoiDung interface {
Tim(id int) (*NguoiDung, error)
Luu(u *NguoiDung) error
}
domain không import gì bên ngoài — nó là hạt nhân thuần túy. Tầng usecase chứa logic, và nó phụ thuộc vào interface domain.KhoNguoiDung, không phải một CSDL cụ thể:
type DichVu struct { kho domain.KhoNguoiDung } // phụ thuộc TRỪU TƯỢNG
func (s *DichVu) DoiTen(id int, ten string) error {
u, _ := s.kho.Tim(id)
u.Ten = ten
return s.kho.Luu(u) // gọi qua port, không biết đằng sau là CSDL nào
}

Hình 1: domain định nghĩa entity và interface KhoNguoiDung (port), không import gì. usecase phụ thuộc interface đó. infra hiện thực nó. main là nơi duy nhất nối cả ba tầng, tiêm hiện thực cụ thể vào usecase.
infra hiện thực port, main nối dây
Tầng infra cung cấp hiện thực cụ thể (ở đây là in-memory; thực tế là PostgreSQL, Redis...). Nhờ duck typing của Go, MemKho tự động thỏa domain.KhoNguoiDung chỉ bằng việc có đúng các phương thức — không cần khai implements:
type MemKho struct{ data map[int]*domain.NguoiDung }
func (m *MemKho) Tim(id int) (*domain.NguoiDung, error) { return m.data[id], nil }
func (m *MemKho) Luu(u *domain.NguoiDung) error { m.data[u.ID] = u; return nil }
main là nơi duy nhất biết cả ba tầng — nó chọn hiện thực cụ thể và tiêm vào usecase qua interface (dependency injection thủ công):
kho := infra.NewMemKho() // chọn hiện thực
svc := usecase.New(kho) // tiêm qua interface
svc.DoiTen(1, "Bình")
Đo thật (Go 1.23): chạy ra [usecase] đổi tên user 1 -> "Bình" rồi [main] user 1 giờ tên = "Bình". Muốn đổi từ in-memory sang PostgreSQL, chỉ sửa main và thêm một package infra mới — usecase và domain không đổi một dòng.
Compiler ép hướng phụ thuộc
Đây là phần Go làm khác biệt. Đo thật đồ thị import bằng go list:

Hình 2: go list cho thấy domain không import package nội bộ nào (trung tâm), usecase và infra đều trỏ vào domain. Thử cho domain import infra → compiler báo import cycle not allowed. usecase test bằng fakeKho không cần infra thật.
domain không import package nội bộ nào; usecase và infra đều trỏ vào domain. Mọi mũi tên hướng vào trong — đúng nguyên tắc Clean Architecture. Bây giờ thử vi phạm: cho domain import infra. Compiler từ chối thẳng:
package example.com/clean/domain
imports example.com/clean/infra
imports example.com/clean/domain: import cycle not allowed
Vì infra đã import domain, nếu domain import ngược lại infra thì tạo chu trình — điều Go cấm tuyệt đối. Đây là điểm đẹp: quy tắc "phụ thuộc chỉ trỏ vào trong" không còn là lời khuyên trên wiki mà là lỗi biên dịch. Bạn không thể vô tình làm hạt nhân nghiệp vụ phụ thuộc vào chi tiết hạ tầng.
Lợi ích trực tiếp: usecase test được bằng một fake thỏa interface, không cần CSDL thật. Đo thật, go test ./usecase với fakeKho chạy ok ... 0.001s — test logic nghiệp vụ trong mili giây, không dựng database.
Đánh đổi cần cân nhắc
Đừng áp Clean Architecture cho mọi dự án. Ba tầng + interface + dependency injection là chi phí thật: nhiều package hơn, nhiều gián tiếp hơn, khó theo dấu một lời gọi xuyên tầng hơn. Với một công cụ CLI nhỏ hay microservice một chức năng, cấu trúc phẳng (một package) đọc dễ hơn nhiều. Clean Architecture trả cổ tức khi hệ đủ lớn và đủ lâu dài để việc thay hạ tầng (đổi CSDL, thêm giao vận) là chuyện thực tế sẽ xảy ra.
Đừng định nghĩa interface ở phía hiện thực. Lỗi phổ biến: đặt interface KhoNguoiDung trong package infra. Khi đó usecase phải import infra để dùng interface — phá vỡ hướng phụ thuộc. Quy tắc Go: interface thuộc về nơi dùng nó (domain/usecase), không phải nơi hiện thực. "Accept interfaces, return structs" là câu thần chú.
Interface nhỏ, đúng nhu cầu. Go khuyến khích interface hẹp (một, hai phương thức). Đừng dựng một Repository khổng lồ với 20 phương thức rồi mỗi usecase chỉ dùng hai. Tách interface theo đúng thứ mỗi usecase cần — dễ test, dễ thay, và khớp với triết lý "interface càng nhỏ càng mạnh" của Go.
Ba ý mang về
- Clean Architecture đặt logic nghiệp vụ ở trung tâm, phụ thuộc chỉ trỏ vào trong:
domainđịnh nghĩa interface (port) và không import gì;usecasephụ thuộc interface đó;infrahiện thực nó;mainlà nơi duy nhất nối dây và tiêm hiện thực cụ thể. - Go ép hướng phụ thuộc bằng lệnh cấm import cycle: đo thật, cho
domainimportinfra→ lỗi biên dịchimport cycle not allowed; quy tắc kiến trúc trở thành thứ compiler bắt buộc, không thể vô tình vi phạm. - Kiến trúc này đổi lấy khả năng thay thế và test:
usecasetest bằng fake không cần CSDL thật (đo thậtok 0.001s), đổi hạ tầng chỉ sửamain— nhưng chi phí nhiều tầng chỉ đáng khi hệ đủ lớn; dự án nhỏ nên giữ phẳng.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho đoạn mã sau trong package domain:
package domain
type NguoiDung struct { ID int; Ten string }
type KhoNguoiDung interface {
Tim(id int) (*NguoiDung, error)
Luu(u *NguoiDung) error
}
Vì sao domain được coi là "hạt nhân thuần túy" trong Clean Architecture, và tại sao interface KhoNguoiDung lại được định nghĩa ở đây chứ không phải trong package infra?
Đáp án
domain là hạt nhân thuần túy vì nó không import bất kỳ package nội bộ nào (không phụ thuộc hạ tầng). KhoNguoiDung phải định nghĩa ở domain/nơi dùng nó (usecase) vì quy tắc Go là "interface thuộc về nơi dùng nó, không phải nơi hiện thực" — nếu đặt interface trong infra, usecase sẽ phải import infra để dùng nó, phá vỡ hướng phụ thuộc "chỉ trỏ vào trong".
Bài 2 (sửa lỗi). Một lập trình viên mới muốn domain tự kiểm tra dữ liệu bằng cách gọi thẳng CSDL, nên đã sửa như sau:
package domain
import "example.com/clean/infra"
type NguoiDung struct { ID int; Ten string }
func KiemTraTonTai(id int) bool {
kho := infra.NewMemKho()
u, _ := kho.Tim(id)
return u != nil
}
Đoạn mã này sẽ gặp lỗi gì khi build, và tại sao?
Đáp án
Build sẽ báo lỗi import cycle not allowed, vì infra đã import domain (để hiện thực KhoNguoiDung dùng *domain.NguoiDung), nên nếu domain import ngược lại infra sẽ tạo chu trình import — điều Go cấm tuyệt đối ở tầng compiler. Cách sửa đúng: giữ domain không import gì, để usecase gọi qua interface KhoNguoiDung như thiết kế ban đầu.
Bài 3 (vận dụng). Viết một struct fakeKho thỏa interface domain.KhoNguoiDung (có Tim và Luu) để dùng trong test cho usecase.DichVu, không cần import infra.
// Điền vào đây
Đáp án
type fakeKho struct {
u *domain.NguoiDung
}
func (f *fakeKho) Tim(id int) (*domain.NguoiDung, error) {
return f.u, nil
}
func (f *fakeKho) Luu(u *domain.NguoiDung) error {
f.u = u
return nil
}
Nhờ duck typing, fakeKho tự động thỏa domain.KhoNguoiDung chỉ cần có đúng chữ ký hai phương thức, không cần khai implements và không cần CSDL thật — đúng như bài đo thật go test ./usecase chạy ok 0.001s.
Bài 4 (bẫy/đánh đổi). Một đội đang xây một CLI nhỏ, một chức năng duy nhất, không có kế hoạch đổi CSDL. Họ vẫn quyết định dựng đủ ba tầng domain/usecase/infra theo Clean Architecture. Đây có phải lựa chọn hợp lý không? Vì sao?
Đáp án
Không hợp lý trong đa số trường hợp. Bài viết nói rõ: "Đừng áp Clean Architecture cho mọi dự án" — ba tầng + interface + dependency injection là chi phí thật (nhiều package hơn, nhiều gián tiếp hơn, khó theo dấu lời gọi xuyên tầng). Với CLI nhỏ một chức năng, cấu trúc phẳng (một package) đọc dễ hơn nhiều. Clean Architecture chỉ trả cổ tức khi hệ đủ lớn và đủ lâu dài để việc thay hạ tầng là chuyện thực tế sẽ xảy ra.
Bài 5 (đọc hiểu). Trong đoạn mã main:
kho := infra.NewMemKho()
svc := usecase.New(kho)
svc.DoiTen(1, "Bình")
Tại sao main được gọi là nơi duy nhất "biết cả ba tầng", và điều gì sẽ xảy ra với usecase/domain nếu sau này đổi infra.NewMemKho() thành một hiện thực PostgreSQL?
Đáp án
main là nơi duy nhất import cả domain, usecase và infra để nối chúng lại (dependency injection thủ công) — chọn hiện thực cụ thể (infra.NewMemKho()) và tiêm vào usecase.New qua interface KhoNguoiDung. Nếu đổi sang PostgreSQL, chỉ cần sửa main (thay lời gọi tạo kho) và thêm một package infra mới hiện thực KhoNguoiDung; usecase và domain không đổi một dòng vì chúng chỉ biết interface, không biết hiện thực cụ thể.
Phần sau ta xét một họ hàng gần của Clean Architecture, đặt tên tường minh cho ranh giới vào/ra: Phần sau mổ xẻ kiến trúc Hexagonal (ports và adapters) — cách mô hình hóa mọi thứ ngoài lõi thành adapter cắm vào port.