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.
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.