Ba bài trước ta dựng Clean Architecture và Hexagonal — cả hai đều cần một chỗ nối dây (wiring): tạo các thành phần và tiêm chúng vào nhau theo đúng thứ tự phụ thuộc. Với một hệ nhỏ, viết tay là đủ. Nhưng khi đồ thị phụ thuộc lớn — chục service, mỗi cái cần vài thứ, mỗi thứ lại cần thứ khác — nối dây thủ công thành một hàm dài và mong manh. Java giải bằng framework DI dùng reflection lúc chạy (Spring). Go, đúng triết lý của nó, chọn cách khác: sinh mã lúc biên dịch qua wire. Bài này đo thật cả hai lối và điểm mạnh của wire.

Chuỗi phụ thuộc và nối dây thủ công

Xét một chuỗi điển hình: Config → DB → Repo → Service. Mỗi thành phần có một constructor nhận đúng thứ nó cần:

func NewConfig() *Config          { ... }
func NewDB(c *Config) *DB          { ... }
func NewRepo(db *DB) *Repo         { ... }
func NewService(r *Repo) *Service  { ... }

Nối dây thủ công nghĩa là tự gọi các constructor này theo đúng thứ tự, truyền kết quả cái trước vào cái sau:

func khoiTaoThuCong() *Service {
	cfg := NewConfig()
	db := NewDB(cfg)     // phải tự biết DB cần Config
	repo := NewRepo(db)
	return NewService(repo)
}

Đo thật (Go 1.23): chạy ra mở DB: postgres://demo rồi Service chạy với DSN: postgres://demo. Cách này rõ ràng và không phụ thuộc thư viện ngoài — bạn đọc là hiểu mọi thứ nối vào nhau ra sao. Nhược điểm chỉ lộ khi hệ lớn: một hàm khởi tạo có 50 dòng, và thêm một phụ thuộc mới vào giữa chuỗi buộc bạn sửa tay đúng chỗ.

Ảnh chụp đoạn mã Go nền tối minh hoạ dependency injection trong Go thủ công vs wire sinh mã, chuỗi phụ thuộc Config sang DB sang Repo sang Service func NewConfig trả con trỏ Config func NewDB c con trỏ Config trả con trỏ DB func NewRepo db con trỏ DB trả con trỏ Repo func NewService r con trỏ Repo trả con trỏ Service, cách 1 DI thủ công tự gọi đúng thứ tự func khoiTaoThuCong trả con trỏ Service cfg bằng NewConfig db bằng NewDB cfg phải tự biết thứ tự đúng repo bằng NewRepo db return NewService repo rõ ràng không phụ thuộc ngoài nhưng tay viết dễ mệt khi lớn, cách 2 wire chỉ liệt kê provider không cần thứ tự go build wireinject file này bị loại khi build thật func InitService trả con trỏ Service wire Build NewConfig NewDB NewRepo NewService return nil wire tự suy thứ tự từ kiểu tham số wire ba chấm sinh wire_gen go, wire_gen go sinh ra bằng đúng mã bạn viết tay func InitService trả con trỏ Service config bằng NewConfig db bằng NewDB config wire khớp kiểu để nối repo bằng NewRepo db service bằng NewService repo return service mã Go thường 0 reflection đọc debug được như tay viết

Hình 1: Chuỗi Config → DB → Repo → Service. Nối dây thủ công gọi constructor đúng thứ tự. wire chỉ liệt kê provider trong wire.Build, để wire tự suy thứ tự từ kiểu tham số và sinh ra wire_gen.go — đúng mã bạn viết tay.

wire: khai báo provider, để công cụ nối

wire (google/wire) đảo ngược công việc: bạn khai báo tập provider và để công cụ sinh mã nối dây. Trong một file có build tag đặc biệt wireinject, bạn viết một hàm "injector" chỉ liệt kê các constructor:

//go:build wireinject

func InitService() *Service {
	wire.Build(NewConfig, NewDB, NewRepo, NewService) // chỉ LIỆT KÊ, không cần thứ tự
	return nil
}

Chú ý: bạn không viết thứ tự gọi — chỉ liệt kê provider. Chạy wire ./..., công cụ đọc chữ ký các constructor, khớp kiểu (ai trả *DB, ai cần *DB) để suy ra đồ thị phụ thuộc, rồi sinh file wire_gen.go:

func InitService() *Service {
	config := NewConfig()
	db := NewDB(config)   // wire tự suy: DB cần Config
	repo := NewRepo(db)
	service := NewService(repo)
	return service
}

Đây chính xác là mã bạn sẽ viết tay. wire không phải phép màu runtime — nó là trình sinh mã: kết quả là Go thường, đọc và debug được như code người viết.

Đo thật: sinh mã, an toàn lúc biên dịch, 0 reflection

Ba đo thật làm rõ vì sao wire hợp với Go:

Ảnh chụp bảng kết quả đo thật nền tối wire sinh đúng mã tay viết bắt thiếu provider lúc sinh mã 0 reflection, go run cộng wire cộng go list Go 1.23 arm64 10 core, cả hai cách cho cùng kết quả DI thủ công mở DB postgres demo Service chạy với DSN postgres demo DI bằng wire sinh mã mở DB postgres demo Service chạy với DSN postgres demo y hệt, wire là sinh mã file nào thực sự build wire ba chấm wrote work t083 wire_gen go go list f GoFiles chấm components go main go wire_gen go wire go tag wireinject bị loại wire_gen go mới được build grep c reflect wire_gen go 0 0 reflection DI ở compile-time không phí lúc chạy, wire bắt thiếu provider lúc sinh mã không phải lúc chạy bỏ NewRepo khỏi wire Build rồi chạy wire wire ba chấm wire inject InitService no provider found for con trỏ di Repo needed by con trỏ di Service in provider NewService wire generate failed lỗi lộ ngay lúc sinh mã trước khi build khác DI runtime reflection chỉ nổ panic lúc khởi động server, cốt lõi thủ công tự gọi constructor đúng thứ tự rõ 0 phụ thuộc wire liệt kê provider sinh mã nối tự động theo kiểu sinh mã wire_gen go là Go thường 0 reflection đọc được an toàn thiếu provider bằng lỗi lúc sinh mã không panic runtime chọn thủ công khi nhỏ wire khi đồ thị phụ thuộc lớn

Hình 2: Cả hai cách cho cùng kết quả. go list cho thấy wire.go (tag wireinject) bị loại, chỉ wire_gen.go được build; grep reflect trả 0. Bỏ NewRepo khỏi provider set → wire báo lỗi no provider found for *di.Repo ngay lúc sinh mã.

Một, wire là sinh mã compile-time. File wire.go có tag wireinject nên bị loại khỏi build thật; chỉ wire_gen.go được biên dịch (đo thật go list cho [components.go main.go wire_gen.go]). Quan trọng: grep reflect wire_gen.go trả 0 — không một chút reflection. Khác hẳn framework DI runtime (dùng reflection để dựng đồ thị lúc khởi động), wire không tốn một chu kỳ CPU nào lúc chạy.

Hai, wire bắt lỗi lúc sinh mã. Bỏ NewRepo khỏi wire.Build, chạy wire báo ngay: inject InitService: no provider found for *di.Repo needed by *di.Service in provider "NewService" rồi generate failed. Đây là điểm mạnh cốt lõi: một phụ thuộc thiếu bị bắt lúc sinh mã, trước khi build, không phải panic lúc server khởi động trên production như các framework DI dựa reflection.

Ba, kết quả nhất quán. Cả khoiTaoThuCong() thủ công và InitService() do wire sinh cho ra output y hệt — vì wire chỉ sinh đúng mã bạn sẽ viết tay.

Đánh đổi cần cân nhắc

Dự án nhỏ và vừa: nối dây thủ công thường đủ. Đừng thêm wire chỉ vì nó "chuyên nghiệp". Với dưới chục thành phần, một hàm khoiTao() viết tay rõ ràng hơn, không cần cài công cụ, không có file sinh ra cần commit và regenerate. Cộng đồng Go phần lớn nối dây thủ công cho service cỡ vừa — và điều đó hoàn toàn ổn.

wire trả cổ tức khi đồ thị phụ thuộc lớn và hay đổi. Khi bạn có 30 provider và thường xuyên thêm/bớt thành phần, viết tay dễ sai thứ tự và mệt mỏi. wire tự suy đồ thị, nên thêm một provider chỉ là thêm một tên vào wire.Build rồi chạy lại wire. Nó cũng bắt phụ thuộc vòng và thiếu provider mà mắt người dễ bỏ sót.

wire là sinh mã, nên phải commit và regenerate. Giống stringer, wire_gen.go phải commit vào repo (kèm // Code generated ... DO NOT EDIT), và CI nên chạy wire rồi kiểm git diff sạch. Quên regenerate sau khi đổi provider là nguồn lỗi. Đây là chi phí kỷ luật mà framework DI runtime không có — đổi lại wire cho an toàn compile-time và 0 chi phí runtime.

Ba ý mang về

  1. DI trong Go có hai lối: nối dây thủ công (tự gọi constructor đúng thứ tự — rõ ràng, không phụ thuộc ngoài) và wire (khai báo tập provider, để công cụ suy đồ thị từ kiểu tham số và sinh mã nối) — đo thật cả hai cho cùng kết quả.
  2. wire là sinh mã compile-time, không reflection: đo thật wire_gen.go có 0 lần dùng reflect (khác framework DI runtime), và file khai báo wire.go bị loại khỏi build nhờ tag wireinject — DI không tốn chu kỳ CPU nào lúc chạy.
  3. wire bắt lỗi lúc sinh mã, không panic runtime: thiếu một provider → no provider found ngay khi chạy wire, trước khi build — nhưng chỉ đáng dùng khi đồ thị phụ thuộc lớn; dự án nhỏ nối dây thủ công là đủ, và phải commit + regenerate wire_gen.go.

Phần sau ta xét một mẫu cấu hình thanh lịch mà Go dùng khắp nơi để khởi tạo linh hoạt: Phần sau mổ xẻ functional options nâng cao — cách dùng closure làm tham số cấu hình, và các kỹ thuật ít người biết để làm API mở rộng mà vẫn tương thích ngược.