Repository
repository.Repo[M] — a generic, GORM-backed repository. The context is bound once at New, so query methods never take a ctx, and the fluent chain is copy-on-write so a base query can be branched safely.
import "gitlab.finema.co/finema/idin-core/v2/repository"
user, err := repository.New[User](ctx).Where("email = ?", email).FindOne()This page is construction and the rules that apply everywhere. The rest of the API is split by what you are doing:
| Page | |
|---|---|
| Querying | conditions, joins, projections, scopes, raw SQL |
| Writing | create, update, delete, upsert, batches, RowsAffected |
| Relations | preload, joins, associations, N+1 |
| Transactions | scope, locking, retries, the outbox |
| Pagination | PageOptions, Page[T], keyset paging |
| Recipes | complete endpoint and service shapes |
| Testing | fakes, sqlite, postgres, transactions |
The model
M must satisfy core.IModel, which is one method:
type User struct {
ID string `json:"id" gorm:"column:id;primaryKey"`
Email string `json:"email" gorm:"column:email"`
Name string `json:"name" gorm:"column:name"`
Status string `json:"status" gorm:"column:status"`
CreatedAt *time.Time `json:"created_at" gorm:"column:created_at"`
UpdatedAt *time.Time `json:"updated_at" gorm:"column:updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"column:deleted_at;index"`
}
func (User) TableName() string { return "users" }TableName on the value is what makes New[User] work rather than New[*User]. Everything else is ordinary GORM tagging — the struct describes a table a migration already created, it does not create one (why).
Creating one
repo := repository.New[User](ctx) // ctx = core.IContext
repo := repository.NewWithDB[User](ctx, tx) // inside a transaction
repo := repository.NewWithDB[User](ctx, ctx.DBS("readonly")) // a named connectionNew takes the connection from ctx.DB() and the deadline from ctx. NewWithDB swaps the connection but keeps the context — which is what makes it the right way to bind a transaction, since the transaction handle still has to respect the request being cancelled.
Constructing a repository is cheap: it allocates a struct and a GORM session. It is not a resource, so there is nothing to close and no reason to keep one on a service struct rather than making one where it is used.
Copy-on-write
Every chainable method returns a new repository over an isolated GORM session. Branches never contaminate each other:
base := repository.New[User](ctx).Where("tenant_id = ?", tenantID)
active, _ := base.Where("status = ?", "active").Count()
inactive, _ := base.Where("status = ?", "inactive").Count()
// both scoped to the tenant; neither sees the other's conditionIn v1 the same code needed NewSession() in the right places, and forgetting one leaked conditions into the next query — silently, and usually only under load. Here it is the default.
The practical consequence: a Repo[M] value is a query, not a connection. Pass one around, store one on a struct, reuse one across goroutines — a chain built from it cannot change it.
// a shared base query is safe as a field
type UserService struct{ users *repository.Repo[User] }Errors
Every finisher returns core.IError, so nothing has to be translated on the way to the HTTP layer. A missing row is errmsgs.NotFound:
user, err := repo.Where("id = ?", id).FindOne()
switch {
case errors.Is(err, errmsgs.NotFound):
return c.NewError(err, errmsgs.UserNotFound) // 404, your message
case err != nil:
return err // already an IError
}Count, Exists and FindAll do not report emptiness as an error — zero rows is an answer. Only FindOne, Take and Last can miss.
The method surface
Chainable (copy-on-write): Where, Or, Not, Order, Group, Having, Limit, Offset, Select, Omit, Distinct, Preload, Joins, InnerJoins, Table, Unscoped, Attrs, Assign, Clauses, Scopes.
Read: FindOne, Take, Last, FindAll, FindInBatches, Count, Exists, Pluck, Scan, Row, Rows, Pagination.
Write: Create, CreateInBatches, Save, Update, Updates, Delete, HardDelete, FindOneOrInit, FindOneOrCreate, Association.
Raw & tx: Raw, Exec, Transaction.
Escape hatches: DB, WithContext.
The escape hatch — DB()
For any GORM feature the repository does not wrap, DB() returns the underlying *gorm.DB bound to the context and carrying the scope built so far:
err := repository.New[User](ctx).
Where("status = ?", "active").
DB(). // *gorm.DB, ctx-bound and scoped
Clauses(clause.OnConflict{DoNothing: true}).
CreateInBatches(rows, 100).ErrorThat is the design: the repository covers the common path and hands the rest back rather than becoming a second, worse GORM.
Custom timeout
Long work started from a request context dies when the request does. When that is wrong — an export that must finish, a job kicked off from a handler — bind another context:
bg, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
rows, err := repo.WithContext(bg).FindAll()This is an escape hatch, not a habit. Detaching a query from the request means a client that hangs up no longer stops the work it paid for.
Interface segregation
Depend on the narrow interfaces rather than the concrete *Repo[M], and a unit test needs no database:
type Users interface {
repository.Reader[User]
repository.Writer[User]
}
type UserService struct{ users Users } // *repository.Repo[User] satisfies itSee Mocks & fakes for what to substitute in a test, and Testing: Database for running the real thing against sqlite or postgres.