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
}

Ảnh chụp đoạn mã Go nền tối minh hoạ Repository pattern tách logic khỏi chi tiết lưu trữ bằng interface, interface repo hợp đồng không nói CSDL nào var ErrKhongTonTai bằng errors New không tìm thấy sản phẩm lỗi domain type SanPhamRepo interface TheoID id int trả con trỏ SanPham error Luu sp con trỏ SanPham error Xoa id int error DanhSach trả slice con trỏ SanPham error, hiện thực cụ thể in-memory trả lỗi chuẩn domain type MemRepo struct data map int con trỏ SanPham next int func r con trỏ MemRepo TheoID id int trả con trỏ SanPham error sp ok bằng r data id nếu không ok return nil ErrKhongTonTai không lộ chi tiết lưu trữ return sp nil SqlRepo sau này đổi sql ErrNoRows sang ErrKhongTonTai tầng trên không cần biết bên dưới là map Postgres hay Redis, logic nghiệp vụ chỉ biết interface type DichVu struct repo SanPhamRepo phụ thuộc trừu tượng func s con trỏ DichVu ThemVaTang ten string gia int trả con trỏ SanPham error sp bằng SanPham Ten ten Gia gia nếu err bằng s repo Luu sp err khác nil return nil err return sp nil, tầng trên xử lý theo lỗi domain nếu errors Is err ErrKhongTonTai không phụ thuộc sql ErrNoRows trả 404 cho client

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:

Ảnh chụp bảng kết quả đo thật nền tối repo trả lỗi domain errors Is ở tầng trên service test bằng fake, go run cộng go test Go 1.23 arm64 10 core, CRUD chạy thật qua interface Thêm ID 1 Ten Cà phê Gia 25000 ID 2 Ten Trà sữa Gia 30000 TheoID 1 ID 1 Ten Cà phê Gia 25000 TheoID 99 không tìm thấy sản phẩm lỗi domain không phải map miss Sau xóa ID 1 còn 1 sản phẩm, errors Is tầng trên phân biệt lỗi mà không biết CSDL errors Is ErrKhongTonTai bằng true trả 404 cho client repo dịch lỗi lưu trữ map miss sql ErrNoRows thành lỗi domain duy nhất HTTP gRPC handler xử theo lỗi domain đổi CSDL không phải sửa handler, service test bằng fake repo không cần Mem SQL thật type fakeRepo struct luuGoi int last con trỏ SanPham giả lập func f con trỏ fakeRepo Luu sp con trỏ SanPham error f luuGoi cộng cộng go test run TestThemVaTang chấm ok example com repo 0.001s kiểm service gọi repo đúng, một interface nhiều hiện thực đổi được MemRepo test dev SanPhamRepo SqlRepo Postgres prod RedisRepo cache đổi hiện thực chỉ sửa nơi khởi tạo service cộng handler y nguyên, cốt lõi interface hợp đồng CRUD giấu chi tiết lưu trữ lỗi domain repo dịch lỗi CSDL sang sentinel error chung errors Is tầng trên xử lỗi domain độc lập CSDL test service dùng fake repo không I O thật đánh đổi repo giấu tối ưu CSDL join batch transaction

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ề

  1. 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ật errors.Is(ErrKhongTonTai)=true ở tầng trên, nên đổi CSDL không phải sửa handler.
  2. Tách bằng interface cho khả năng test và đổi hiện thực: đo thật, service test bằng fakeRepo chạy ok 0.001s khô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.
  3. 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.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho đoạn mã:

var ErrKhongTonTai = errors.New("không tìm thấy sản phẩm")

func (r *MemRepo) TheoID(id int) (*SanPham, error) {
	sp, ok := r.data[id]
	if !ok { return nil, ErrKhongTonTai }
	return sp, nil
}

Vì sao MemRepo.TheoID không trả thẳng một lỗi kiểu "map miss" mà lại trả ErrKhongTonTai? Lợi ích cụ thể là gì khi đổi từ in-memory sang Postgres?

Đáp án

Vì repository có nhiệm vụ dịch lỗi lưu trữ cụ thể (map miss ở đây, hoặc sql.ErrNoRows với Postgres) thành một lỗi domain chung (ErrKhongTonTai). Nhờ vậy tầng trên (service, HTTP/gRPC handler) chỉ cần kiểm errors.Is(err, ErrKhongTonTai) mà không cần biết bên dưới là map, Postgres hay Redis. Khi đổi từ in-memory sang Postgres, chỉ SqlRepo cần dịch sql.ErrNoRows sang ErrKhongTonTai — handler không đổi một dòng.

Bài 2 (sửa lỗi). Đoạn mã sau khiến handler phải import package database/sql để kiểm tra lỗi, phá vỡ nguyên tắc "tầng trên không biết CSDL cụ thể":

func (r *SqlRepo) TheoID(id int) (*SanPham, error) {
	row := r.db.QueryRow("SELECT ... WHERE id=?", id)
	var sp SanPham
	err := row.Scan(&sp.Ten, &sp.Gia)
	return &sp, err // trả thẳng lỗi SQL ra ngoài
}

// ở handler:
if errors.Is(err, sql.ErrNoRows) { // handler phải biết đây là SQL!
	// 404
}

Sửa SqlRepo.TheoID để tuân theo mẫu repository pattern của bài viết.

Đáp án
func (r *SqlRepo) TheoID(id int) (*SanPham, error) {
	row := r.db.QueryRow("SELECT ... WHERE id=?", id)
	var sp SanPham
	err := row.Scan(&sp.Ten, &sp.Gia)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrKhongTonTai // dịch lỗi SQL sang lỗi domain NGAY TẠI repo
	}
	if err != nil {
		return nil, err
	}
	return &sp, nil
}

// ở handler, chỉ cần biết lỗi domain:
if errors.Is(err, ErrKhongTonTai) {
	// 404
}

Việc dịch lỗi phải xảy ra bên trong repository, không phải để lộ ra tầng trên — đúng lợi ích "một, lỗi domain thay cho lỗi lưu trữ" mà bài viết nêu.

Bài 3 (vận dụng). Viết một fakeRepo thỏa SanPhamRepo để test DichVu.ThemVaTang, có đếm số lần Luu được gọi (như đo thật go test -run TestThemVaTang chạy ok 0.001s).

Đáp án
type fakeRepo struct {
	luuGoi int
	last   *SanPham
}

func (f *fakeRepo) TheoID(id int) (*SanPham, error) { return nil, ErrKhongTonTai }
func (f *fakeRepo) Luu(sp *SanPham) error {
	f.luuGoi++
	f.last = sp
	return nil
}
func (f *fakeRepo) Xoa(id int) error                { return nil }
func (f *fakeRepo) DanhSach() ([]*SanPham, error)   { return nil, nil }

Nhờ duck typing, fakeRepo thỏa SanPhamRepo chỉ cần đúng bốn phương thức, không cần import CSDL thật — test DichVu chạy trong mili giây, kiểm được Luu có được gọi đúng số lần và đúng dữ liệu hay không.

Bài 4 (bẫy/đánh đổi). Đội của bạn cần một truy vấn báo cáo phức tạp: JOIN ba bảng, nhóm theo tháng, phân trang bằng cursor. Có nên ép truy vấn này đi qua interface SanPhamRepo hiện có (thêm một phương thức mới) không? Theo bài viết, vì sao?

Đáp án

Không nên ép cứng nhắc. Bài viết nói thẳng "repository dễ giấu mất tối ưu của CSDL": một interface CRUD chung không diễn đạt tốt JOIN nhiều bảng, batch, hay truy vấn cursor đặc thù. Nếu ép mọi thứ qua interface hẹp, hoặc mất tối ưu, hoặc phải nhồi hàng chục phương thức đặc thù vào interface — lúc đó nó không còn là trừu tượng mà chỉ là "SQL đội lốt". Với truy vấn phức tạp như vậy, bài viết khuyên đôi khi gọi thẳng CSDL rõ ràng và nhanh hơn, thay vì cố nhét vào repository.

Bài 5 (đọc hiểu). Vì sao bài viết cảnh báo không nên dựng một Repository[T] generic với Find, FindAll, Where, OrderBy?

Đáp án

Vì làm vậy là "tái tạo lại một ORM tồi" — thay vì repository hẹp và theo domain (mỗi phương thức là một thao tác nghiệp vụ có nghĩa như TheoID, NhungSanPhamConHang), nó biến thành một query builder tổng quát. Điều này đánh mất giá trị của repository pattern (hợp đồng rõ ràng, dễ hiểu theo domain) và thực chất chỉ là viết lại SQL bằng phương thức Go — dấu hiệu bài viết khuyên "hãy dừng lại" nếu thấy mình đang làm vậy.

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.