Repository pattern là cách tách tầng truy cập dữ liệu khỏi logic nghiệp vụ: thay vì service gọi thẳng SQL, nó gọi qua một interface Repository giấu chi tiết lưu trữ. Đây là mẫu quen thuộc từ thế giới Java/C#, và trong Go nó khớp tự nhiên với interface ngầm định. Nhưng repository cũng là một trong những mẫu bị lạm dụng nhiều nhất — dễ trở thành lớp trừu tượng thừa chỉ gói lại db.Query. Bài này dựng một repository chạy được, đo thật ba lợi ích cụ thể, và nói thẳng chỗ nó phản tác dụng.
Interface repository là một hợp đồng
Cốt lõi: định nghĩa một interface mô tả các thao tác dữ liệu theo ngôn ngữ domain, không theo SQL:
var ErrKhongTonTai = errors.New("không tìm thấy sản phẩm") // lỗi domain
type SanPhamRepo interface {
TheoID(id int) (*SanPham, error)
Luu(sp *SanPham) error
Xoa(id int) error
DanhSach() ([]*SanPham, error)
}
Interface này là hợp đồng: nó nói "có thể tìm theo id, lưu, xóa, liệt kê" mà không nói lưu ở đâu. Một hiện thực cụ thể — ở đây là in-memory — thỏa hợp đồng đó:
type MemRepo struct{ data map[int]*SanPham; next int }
func (r *MemRepo) TheoID(id int) (*SanPham, error) {
sp, ok := r.data[id]
if !ok { return nil, ErrKhongTonTai } // dịch "map miss" thành lỗi DOMAIN
return sp, nil
}

Hình 1: Interface SanPhamRepo là hợp đồng CRUD giấu chi tiết lưu trữ. Hiện thực MemRepo dịch lỗi lưu trữ (map miss) thành lỗi domain ErrKhongTonTai. Service phụ thuộc interface, không phụ thuộc CSDL cụ thể.
Ba lợi ích đo được
Một, lỗi domain thay cho lỗi lưu trữ. Đây là lợi ích tinh tế mà nhiều người bỏ qua. Repository dịch lỗi cụ thể của tầng lưu trữ (map miss, sql.ErrNoRows, Redis nil) thành một lỗi domain chung ErrKhongTonTai. Nhờ đó tầng trên xử lý bằng errors.Is mà không cần biết CSDL:
if errors.Is(err, ErrKhongTonTai) { // không phụ thuộc sql.ErrNoRows
// trả 404 cho client
}
Đo thật (Go 1.23): errors.Is(err, ErrKhongTonTai) trả true, và handler HTTP/gRPC quyết định trả 404 dựa trên lỗi domain. Đổi từ Postgres sang MongoDB, handler không đổi một dòng — chỉ repository dịch lỗi khác.
Hai, service test được bằng fake. Vì service phụ thuộc SanPhamRepo (interface), test nó bằng một fake repo không cần CSDL thật:

Hình 2: CRUD chạy đúng qua interface (TheoID(99) trả lỗi domain, không phải map miss). errors.Is(ErrKhongTonTai)=true ở tầng trên. Service test bằng fakeRepo chạy ok 0.001s — không cần Mem/SQL thật.
Đo thật, go test -run TestThemVaTang với fakeRepo (đếm số lần gọi Luu) chạy ok 0.001s. Bạn kiểm được logic nghiệp vụ gọi repo đúng cách trong mili giây, không dựng database.
Ba, đổi hiện thực dễ dàng. Cùng một interface SanPhamRepo, bạn có MemRepo (test/dev), SqlRepo (Postgres prod), RedisRepo (cache) — đổi hiện thực chỉ sửa nơi khởi tạo, service và handler giữ nguyên. Đây là dependency inversion áp cho tầng dữ liệu.
Đánh đổi cần cân nhắc
Repository dễ giấu mất tối ưu của CSDL. Đây là điểm phản tác dụng lớn nhất, phải nói thẳng. Một interface CRUD chung (TheoID, DanhSach) không diễn đạt được các tối ưu đặc thù: JOIN nhiều bảng trong một truy vấn, chèn hàng loạt (batch insert), truy vấn có phân trang cursor, hay tận dụng chỉ mục đặc biệt. Nếu bạn ép mọi truy cập qua interface hẹp, bạn hoặc mất tối ưu, hoặc phải nhồi vào interface hàng chục phương thức đặc thù — lúc đó nó không còn là "trừu tượng" nữa mà chỉ là SQL đội lốt. Với truy vấn phức tạp, đôi khi gọi thẳng CSDL rõ ràng và nhanh hơn.
Đừng dựng repository generic "làm mọi thứ". Một cám dỗ phổ biến là viết Repository[T] với Find, FindAll, Where, OrderBy... — tái tạo lại một ORM tồi. Repository tốt trong Go là hẹp và theo domain: mỗi phương thức là một thao tác nghiệp vụ có nghĩa (TheoID, NhungSanPhamConHang), không phải một query builder tổng quát. Nếu bạn thấy mình dựng lại SQL bằng phương thức, hãy dừng lại.
Transaction vắt qua nhiều repository là chỗ khó. Khi một thao tác nghiệp vụ cần cập nhật nhiều bảng trong một transaction, interface repository per-entity không diễn đạt được điều đó tự nhiên (mỗi repo mở transaction riêng thì không nguyên tử). Giải pháp thường là truyền một đối tượng transaction/context vào các phương thức repo, hoặc dùng mẫu Unit of Work — thêm phức tạp. Cân nhắc trước khi tách repository nếu nghiệp vụ của bạn nhiều transaction đa bảng.
Ba ý mang về
- Repository giấu chi tiết lưu trữ sau interface theo ngôn ngữ domain: hiện thực cụ thể dịch lỗi lưu trữ (map miss,
sql.ErrNoRows) thành lỗi domain chung — đo thậterrors.Is(ErrKhongTonTai)=trueở tầng trên, nên đổi CSDL không phải sửa handler. - Tách bằng interface cho khả năng test và đổi hiện thực: đo thật, service test bằng
fakeRepochạyok 0.001skhông cần CSDL, và cùng interface có nhiều hiện thực (Mem/SQL/Redis) đổi được chỉ tại nơi khởi tạo. - Repository dễ thành lớp thừa: interface CRUD chung giấu mất tối ưu CSDL (JOIN, batch, cursor) và transaction đa bảng khó diễn đạt — giữ repository hẹp theo domain, đừng dựng query builder generic, và với truy vấn phức tạp đôi khi gọi thẳng CSDL rõ hơn.
Phần sau ta xét một mẫu tách biệt sâu hơn nữa giữa đọc và ghi: Phần sau mổ xẻ CQRS (Command Query Responsibility Segregation) trong Go — khi nào tách mô hình đọc khỏi mô hình ghi giúp ích, và khi nào nó chỉ nhân đôi công việc.