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
}

Ảnh chụp đoạn mã Go nền tối minh hoạ Clean Architecture trong Go hướng phụ thuộc chỉ vào trong, domain entity cộng interface port không import gì ngoài package domain type NguoiDung struct ID int Ten string port domain định nghĩa interface nó cần type KhoNguoiDung interface Tim id int trả con trỏ NguoiDung error Luu u con trỏ NguoiDung error, usecase logic phụ thuộc interface không phải infra type DichVu struct kho domain KhoNguoiDung trừu tượng func New kho domain KhoNguoiDung trả con trỏ DichVu func s con trỏ DichVu DoiTen id int ten string error u bằng s kho Tim id u Ten bằng ten return s kho Luu u gọi qua port không biết CSDL nào, infra hiện thực cụ thể phụ thuộc vào domain type MemKho struct data map int con trỏ domain NguoiDung func m con trỏ MemKho Tim và Luu MemKho tự động thỏa domain KhoNguoiDung duck typing, main dây nối nơi duy nhất biết cả 3 tầng kho bằng infra NewMemKho chọn hiện thực cụ thể svc bằng usecase New kho tiêm vào usecase qua interface svc DoiTen 1 Bình đổi infra không đụng usecase

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:

Ảnh chụp bảng kết quả đo thật nền tối mọi tầng trỏ vào domain compiler cấm đảo hướng usecase test bằng fake, go run cộng go list cộng go build cộng go test Go 1.23 arm64 10 core, chạy thật 3 tầng usecase đổi tên user 1 sang Bình main user 1 giờ tên bằng Bình, đồ thị phụ thuộc thật go list imports domain import không import package nội bộ nào trung tâm usecase import example com clean domain infra import example com clean domain usecase và infra đều trỏ vào domain mọi mũi tên trỏ vào trong dependency inversion, compiler cấm đảo hướng domain không được import infra thêm import infra vào domain rồi build package example com clean domain imports example com clean infra imports example com clean domain import cycle not allowed Go dùng lệnh cấm chu trình import để ép luật kiến trúc bạn không thể làm domain phụ thuộc hạ tầng compiler từ chối, lợi ích usecase test bằng fake không cần infra thật type fakeKho struct u con trỏ domain NguoiDung giả lập kho func f con trỏ fakeKho Tim thỏa interface go test usecase ok example com clean usecase 0.001s không đụng CSDL, cốt lõi quy tắc phụ thuộc chỉ trỏ vào trong domain ở trung tâm port domain định nghĩa interface infra hiện thực nó ép luật Go cấm import cycle đảo hướng lỗi biên dịch test usecase thay infra bằng fake qua interface đánh đổi nhiều tầng interface chỉ đáng khi hệ đủ lớn

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ề

  1. 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ì; usecase phụ thuộc interface đó; infra hiện thực nó; main là nơi duy nhất nối dây và tiêm hiện thực cụ thể.
  2. Go ép hướng phụ thuộc bằng lệnh cấm import cycle: đo thật, cho domain import infra → lỗi biên dịch import 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.
  3. Kiến trúc này đổi lấy khả năng thay thế và test: usecase test bằng fake không cần CSDL thật (đo thật ok 0.001s), đổi hạ tầng chỉ sửa main — 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.