6
Lapisan Repository
Lapisan repository adalah satu-satunya tempat di proyek ini yang menyusun query GORM. Setiap struct repository menempelkan generic Repository[T] untuk operasi CRUD dasar, lalu menambahkan method yang benar-benar dibutuhkan entity-nya. Halaman ini memetakan setiap method ke operasi GORM yang dijalankannya dan ke scopes yang menyaring hasil pencarian.
Satu generic untuk tiga entity
Repository[T] hanya menyimpan satu field, DB *gorm.DB, dan tidak menyimpan handle transaksi apa pun. Tiga struct turunan menyematkan generic itu dengan parameter entity masing-masing: UserRepository memakai Repository[entity.User], ContactRepository memakai Repository[entity.Contact], dan AddressRepository memakai Repository[entity.Address] internal/repository/repository.go:5-71 internal/repository/user_repository.go:10-232 internal/repository/contact_repository.go:11-14 internal/repository/address_repository.go:10-31. Ketiga konstruktor NewUserRepository, NewContactRepository, dan NewAddressRepository hanya menerima *logrus.Logger, lalu dipanggil sekali saat bootstrap internal/config/app.go:28-32.
Karena generic itu disematkan, method seperti Create dan FindById otomatis tersedia pada ketiga repository tanpa ditulis ulang, dan tabel tujuan ditentukan oleh TableName() milik entity: users, contacts, addresses internal/entity/user_entity.go:4-166.
Pemetaan method ke operasi GORM
graph TD
subgraph G["Repository[T] generic"]
Create["Create(db, entity)"]
Update["Update(db, entity)"]
Delete["Delete(db, entity)"]
CountById["CountById(db, id)"]
FindById["FindById(db, entity, id)"]
end
subgraph C["Method khusus"]
Token["UserRepository.FindByToken"]
Owner["ContactRepository.FindByIdAndUserId"]
AddressOwner["AddressRepository.FindByIdAndContactId"]
AddressList["AddressRepository.FindAllByContactId"]
Search["ContactRepository.Search"]
end
Create --> O1["db.Create"]
Update --> O2["db.Save"]
Delete --> O3["db.Delete"]
CountById --> O4["db.Model + Where + Count"]
FindById --> O5["db.Where + Take"]
Token --> O6["WHERE token = ?"]
Owner --> O7["WHERE id = ? AND user_id = ?"]
AddressOwner --> O8["WHERE id = ? AND contact_id = ?"]
AddressList --> O9["WHERE contact_id = ?"]
Search --> O10["FilterContact + Offset + Limit"]
style Create fill:#f59e0b,color:#000
style Update fill:#f59e0b,color:#000
style Delete fill:#f59e0b,color:#000
style CountById fill:#f59e0b,color:#000
style FindById fill:#f59e0b,color:#000
style Token fill:#8b5cf6,color:#fff
style Owner fill:#8b5cf6,color:#fff
style AddressOwner fill:#8b5cf6,color:#fff
style AddressList fill:#8b5cf6,color:#fff
style Search fill:#8b5cf6,color:#fff
style O4 fill:#336791,color:#fff
style O5 fill:#336791,color:#fff
style O7 fill:#336791,color:#fff
%% fig: Pemetaan method repository ke operasi GORM
%% desc: Method generic dan method khusus per entity diterjemahkan menjadi klausa GORM yang berbeda, dari INSERT sampai WHERE berlapis.Lima method generic adalah inti lapisan ini. Create memanggil db.Create, Update memanggil db.Save, Delete memanggil db.Delete, sedangkan CountById dan FindById menambahkan filter id sebelum menghitung atau mengambil satu baris internal/repository/repository.go:9-297.
| Method | Signature | Operasi GORM | Efek |
|---|---|---|---|
Create | (db *gorm.DB, entity *T) error | db.Create(entity) | INSERT satu baris |
Update | (db *gorm.DB, entity *T) error | db.Save(entity) | UPDATE seluruh kolom baris |
Delete | (db *gorm.DB, entity *T) error | db.Delete(entity) | DELETE berdasarkan primary key |
CountById | (db *gorm.DB, id any) (int64, error) | db.Model(new(T)).Where("id = ?", id).Count(&total) | SELECT COUNT |
FindById | (db *gorm.DB, entity *T, id any) error | db.Where("id = ?", id).Take(entity) | SELECT dengan LIMIT 1 |
Query khusus di atas generic
Tiga method tambahan tidak bisa diturunkan dari generic karena menyaring lebih dari satu kolom. UserRepository.FindByToken mencari token = ?, ContactRepository.FindByIdAndUserId memasangkan id dengan user_id, dan AddressRepository menyediakan dua pembacaan: FindByIdAndContactId serta FindAllByContactId yang mengembalikan slice internal/repository/user_repository.go:10-232 internal/repository/contact_repository.go:22-248 internal/repository/address_repository.go:10-314.
Pola id plus kolom pemilik itu penting: setiap pembacaan Contact dan Address ikut membawa user_id atau contact_id, sehingga kepemilikan baris diperiksa oleh query, bukan oleh pengecekan terpisah di usecase. Pemanggilnya pun selalu meneruskan identitas pemilik tersebut, misalnya ContactUseCase.Update dan AddressUseCase.Update internal/usecase/contact_usecase.go:84-879 internal/usecase/address_usecase.go:105-10610.
| Repository | Method | Klausa penyaring | Pemanggil |
|---|---|---|---|
UserRepository | FindByToken | token = ? | UserUseCase saat autentikasi internal/usecase/user_usecase.go:49-5211 |
UserRepository | CountById (warisan generic) | id = ? | UserUseCase.Create untuk menolak ID duplikat internal/usecase/user_usecase.go:72-7612 |
ContactRepository | FindByIdAndUserId | id = ? AND user_id = ? | ContactUseCase.Update internal/usecase/contact_usecase.go:84-879 |
ContactRepository | Search | scope FilterContact | ContactUseCase.Search internal/usecase/contact_usecase.go:183-18713 |
AddressRepository | FindByIdAndContactId | id = ? AND contact_id = ? | AddressUseCase.Update internal/usecase/address_usecase.go:105-10610 |
AddressRepository | FindAllByContactId | contact_id = ? | AddressUseCase.List internal/usecase/address_usecase.go:203-20714 |
⚠️
FindByIdmemakaiTake, bukanFind. Ketika baris tidak ada, GORM mengembalikangorm.ErrRecordNotFoundsebagai error biasa, dan usecase yang menerjemahkannya menjadifiber.ErrNotFoundseperti padaContactUseCase.Updateinternal/repository/repository.go:27-2915 internal/usecase/contact_usecase.go:84-879. Repository sendiri tidak pernah menelan error itu.
Pagination lewat scopes
Hanya ContactRepository yang punya pencarian berhalaman. Search menjalankan dua query terpisah: satu Find dengan Offset dan Limit untuk mengambil satu halaman, satu Count untuk total seluruh baris yang cocok. Keduanya memakai scope yang sama, FilterContact, sehingga total tidak pernah menyimpang dari isi halaman internal/repository/contact_repository.go:26-3816.
graph LR
UC["ContactUseCase.Search"] --> Repo["ContactRepository.Search"]
Repo --> Scope["FilterContact(request)"]
Scope --> Page["db.Scopes + Offset + Limit + Find"]
Scope --> Total["db.Model + Scopes + Count"]
Page --> Rows["[]entity.Contact"]
Total --> Hitung["total int64"]
Rows --> Ctrl["ContactController.Search"]
Hitung --> Ctrl
Ctrl --> Meta["PageMetadata"]
style Scope fill:#f59e0b,color:#000
style Page fill:#336791,color:#fff
style Total fill:#336791,color:#fff
style Rows fill:#8b5cf6,color:#fff
style Hitung fill:#8b5cf6,color:#fff
style Meta fill:#8b5cf6,color:#fff
%% fig: Dua query dari satu scope di ContactRepository.Search
%% desc: FilterContact dipakai ulang oleh query halaman dan query hitung, lalu keduanya bertemu kembali di controller untuk menyusun PageMetadata.Offset dihitung sebagai (request.Page - 1) * request.Size, dan Limit memakai request.Size apa adanya internal/repository/contact_repository.go:26-3816. Nilai Page dan Size berasal dari SearchContactRequest yang divalidasi min=1 untuk Page serta min=1,max=100 untuk Size internal/model/contact_model.go:31-3817. Batas 100 itu yang menahan satu request menarik seluruh tabel.
Scope FilterContact sendiri menyusun klausa secara kondisional: user_id = ? selalu ditambahkan, sedangkan nama, telepon, dan email hanya ikut bila nilainya tidak kosong, masing-masing dengan pola %...% untuk LIKE internal/repository/contact_repository.go:40-6118.
| Field request | Kondisi | Klausa yang ditambahkan |
|---|---|---|
UserId | selalu | user_id = ? |
Name | Name != "" | first_name LIKE ? OR last_name LIKE ? dengan pola %name% |
Phone | Phone != "" | phone LIKE ? dengan pola %phone% |
Email | Email != "" | email LIKE ? dengan pola %email% |
Scope mengembalikan func(tx *gorm.DB) *gorm.DB, jadi ia bisa ditempelkan ke query apa pun. Itulah sebabnya query Count memakai db.Model(&entity.Contact{}) ditambah scope yang sama, bukan menghitung hasil Find di memori internal/repository/contact_repository.go:26-3816. Angka total dari repository lalu dipakai controller untuk menghitung TotalPage dengan math.Ceil(float64(total) / float64(request.Size)) internal/delivery/http/contact_controller.go:62-6719.
Kepemilikan transaksi ada di usecase
Setiap method repository menerima db *gorm.DB sebagai parameter pertama, bukan memakai r.DB. Konsekuensinya, repository tidak pernah tahu apakah ia sedang berjalan di dalam transaksi. Usecase yang membuka tx := c.DB.WithContext(ctx).Begin() beserta defer tx.Rollback(), meneruskan tx ke repository, lalu memanggil tx.Commit() sendiri internal/usecase/contact_usecase.go:38-3920 internal/usecase/contact_usecase.go:55-5821.
⚠️ Jangan mengubah satu method repository agar memakai
r.DBlangsung. Pemanggil saat ini selalu mengirimtxhasilBegindari usecase, sehingga perubahan itu akan memecah atomisitas rangkaianFindByIdAndUserIdlaluUpdateatauDeleteyang sekarang berjalan dalam satu transaksi internal/usecase/contact_usecase.go:84-879.