go-repository
npx skills add https://github.com/cristiano-pacheco/ai-tools --skill go-repository
Agent 安装分布
Skill 文档
Go Repository
Generate repository port interfaces and implementations for Go modular architecture conventions.
Two-File Pattern
Every repository requires two files:
- Port interface:
internal/modules/<module>/ports/<entity>_repository.go - Repository implementation:
internal/modules/<module>/repository/<entity>_repository.go
Port Interface Structure
Location: internal/modules/<module>/ports/<entity>_repository.go
package ports
import (
"context"
"github.com/cristiano-pacheco/pingo/internal/modules/<module>/model"
)
// EntityRepository defines entity persistence operations.
//
// Add a comprehensive comment here describing the purpose of the repository,
// what domain concept it represents, and any non-obvious behavior.
type EntityRepository interface {
FindAll(ctx context.Context) ([]model.EntityModel, error)
FindByID(ctx context.Context, id uint64) (model.EntityModel, error)
Create(ctx context.Context, entity model.EntityModel) (model.EntityModel, error)
Update(ctx context.Context, entity model.EntityModel) (model.EntityModel, error)
Delete(ctx context.Context, id uint64) error
}
Pagination variant:
FindAll(ctx context.Context, page, pageSize int) ([]model.EntityModel, int64, error)
Custom methods: Add domain-specific queries as needed (e.g., FindByName, FindBySKU).
Repository Implementation Structure
Location: internal/modules/<module>/repository/<entity>_repository.go
package repository
import (
"context"
"errors"
brickserrs "github.com/cristiano-pacheco/bricks/pkg/errs"
"github.com/cristiano-pacheco/bricks/pkg/otel/trace"
"github.com/cristiano-pacheco/pingo/internal/modules/<module>/model"
"github.com/cristiano-pacheco/pingo/internal/modules/<module>/ports"
"github.com/cristiano-pacheco/pingo/internal/shared/database"
"gorm.io/gorm"
)
type EntityRepository struct {
*database.PingoDB
}
var _ ports.EntityRepository = (*EntityRepository)(nil)
func NewEntityRepository(db *database.PingoDB) *EntityRepository {
return &EntityRepository{PingoDB: db}
}
Note: The constructor MUST use named field initialization
{PingoDB: db}, not positional{db}.
Method Implementations
FindAll (Simple)
func (r *EntityRepository) FindAll(ctx context.Context) ([]model.EntityModel, error) {
ctx, span := trace.Span(ctx, "EntityRepository.FindAll")
defer span.End()
entities, err := gorm.G[model.EntityModel](r.DB).Find(ctx)
if err != nil {
return nil, err
}
return entities, nil
}
FindAll (Paginated with dynamic filters)
When you need optional WHERE filters or pagination, fall back to raw GORM â gorm.G does not support dynamic multi-condition builds. Use r.DB.WithContext(ctx).Model(...) for these cases:
func (r *EntityRepository) FindAll(
ctx context.Context,
filter dto.EntityFilter,
paginationParams paginator.Params,
) ([]model.EntityModel, int64, error) {
ctx, span := trace.Span(ctx, "EntityRepository.FindAll")
defer span.End()
baseQuery := r.DB.WithContext(ctx).Model(&model.EntityModel{})
if filter.Status != "" {
baseQuery = baseQuery.Where("status = ?", filter.Status)
}
if filter.Name != nil && strings.TrimSpace(*filter.Name) != "" {
baseQuery = baseQuery.Where("name ILIKE ?", "%"+strings.TrimSpace(*filter.Name)+"%")
}
var totalCount int64
if err := baseQuery.Count(&totalCount).Error; err != nil {
return nil, 0, err
}
query := baseQuery.Order("id DESC")
if paginationParams.Limit() > 0 {
query = query.Limit(paginationParams.Limit())
}
if paginationParams.Offset() > 0 {
query = query.Offset(paginationParams.Offset())
}
results := make([]model.EntityModel, 0)
if err := query.Find(&results).Error; err != nil {
return nil, 0, err
}
return results, totalCount, nil
}
FindAll (JOIN query)
For queries that require JOINs, also use raw GORM:
func (r *EntityRepository) FindByRelatedID(
ctx context.Context,
relatedID uint64,
) ([]model.EntityModel, error) {
ctx, span := trace.Span(ctx, "EntityRepository.FindByRelatedID")
defer span.End()
var results []model.EntityModel
err := r.DB.WithContext(ctx).
Model(&model.EntityModel{}).
Joins("JOIN related_table rt ON rt.entity_id = entities.id").
Where("rt.related_id = ?", relatedID).
Order("rt.id ASC").
Find(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
FindByID
func (r *EntityRepository) FindByID(ctx context.Context, id uint64) (model.EntityModel, error) {
ctx, span := trace.Span(ctx, "EntityRepository.FindByID")
defer span.End()
entity, err := gorm.G[model.EntityModel](r.DB).
Where("id = ?", id).
Limit(1).
First(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.EntityModel{}, brickserrs.ErrRecordNotFound
}
return model.EntityModel{}, err
}
return entity, nil
}
Create
func (r *EntityRepository) Create(ctx context.Context, entity model.EntityModel) (model.EntityModel, error) {
ctx, span := trace.Span(ctx, "EntityRepository.Create")
defer span.End()
err := gorm.G[model.EntityModel](r.DB).Create(ctx, &entity)
return entity, err
}
When the module defines a conflict error, map gorm.ErrDuplicatedKey:
func (r *EntityRepository) Create(ctx context.Context, entity model.EntityModel) (model.EntityModel, error) {
ctx, span := trace.Span(ctx, "EntityRepository.Create")
defer span.End()
err := gorm.G[model.EntityModel](r.DB).Create(ctx, &entity)
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.EntityModel{}, errs.ErrEntityNameConflict
}
return model.EntityModel{}, err
}
return entity, nil
}
Update
For updates where all fields are non-zero, use the gorm.G Updates pattern:
func (r *EntityRepository) Update(ctx context.Context, entity model.EntityModel) (model.EntityModel, error) {
ctx, span := trace.Span(ctx, "EntityRepository.Update")
defer span.End()
rowsAffected, err := gorm.G[model.EntityModel](r.DB).
Where("id = ?", entity.ID).
Updates(ctx, entity)
if err != nil {
return model.EntityModel{}, err
}
if rowsAffected == 0 {
return model.EntityModel{}, brickserrs.ErrRecordNotFound
}
updated, err := gorm.G[model.EntityModel](r.DB).Where("id = ?", entity.ID).Limit(1).First(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.EntityModel{}, brickserrs.ErrRecordNotFound
}
return model.EntityModel{}, err
}
return updated, nil
}
Update (Zero-Value Fields)
GORM’s Updates() skips zero values (false, 0, ""). When any updated field may be zero, use one of two patterns:
Option A â map[string]any (when fields are heterogeneous or sparse):
func (r *EntityRepository) Update(ctx context.Context, entity model.EntityModel) (model.EntityModel, error) {
ctx, span := trace.Span(ctx, "EntityRepository.Update")
defer span.End()
updates := map[string]any{
"name": entity.Name,
"is_active": entity.IsActive, // bool: would be skipped by plain Updates()
"count": entity.Count, // int: would be skipped when 0
}
result := r.DB.WithContext(ctx).
Model(&model.EntityModel{}).
Where("id = ?", entity.ID).
Updates(updates)
if result.Error != nil {
return model.EntityModel{}, result.Error
}
if result.RowsAffected == 0 {
return model.EntityModel{}, brickserrs.ErrRecordNotFound
}
updated, err := gorm.G[model.EntityModel](r.DB).Where("id = ?", entity.ID).Limit(1).First(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.EntityModel{}, brickserrs.ErrRecordNotFound
}
return model.EntityModel{}, err
}
return updated, nil
}
Option B â Select(fields).Updates(&entity) (when updating a fixed set of columns):
result := r.DB.WithContext(ctx).
Model(&model.EntityModel{}).
Where("id = ?", entity.ID).
Select("name", "slug", "is_active").
Updates(&entity)
Single-Field Targeted Update
For methods that set one field by ID and return no model (e.g., MarkEmailConfirmed, SetTOTPEnabled), raw GORM is correct â this is intentional, not a deviation:
func (r *EntityRepository) MarkConfirmed(ctx context.Context, id uint64) error {
ctx, span := trace.Span(ctx, "EntityRepository.MarkConfirmed")
defer span.End()
return r.DB.WithContext(ctx).Model(&model.EntityModel{}).
Where("id = ?", id).
Update("confirmed", true).Error
}
Delete
func (r *EntityRepository) Delete(ctx context.Context, id uint64) error {
ctx, span := trace.Span(ctx, "EntityRepository.Delete")
defer span.End()
rowsAffected, err := gorm.G[model.EntityModel](r.DB).
Where("id = ?", id).
Delete(ctx)
if err != nil {
return err
}
if rowsAffected == 0 {
return brickserrs.ErrRecordNotFound
}
return nil
}
Bulk Cleanup Delete
For DeleteExpired-style operations, zero rows deleted is not an error â discard rowsAffected:
func (r *EntityRepository) DeleteExpired(ctx context.Context) error {
ctx, span := trace.Span(ctx, "EntityRepository.DeleteExpired")
defer span.End()
_, err := gorm.G[model.EntityModel](r.DB).
Where("expires_at < ?", time.Now().UTC()).
Delete(ctx)
return err
}
Custom Query (by field)
func (r *EntityRepository) FindByName(ctx context.Context, name string) (model.EntityModel, error) {
ctx, span := trace.Span(ctx, "EntityRepository.FindByName")
defer span.End()
entity, err := gorm.G[model.EntityModel](r.DB).
Where("name = ?", name).
Limit(1).
First(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.EntityModel{}, brickserrs.ErrRecordNotFound
}
return model.EntityModel{}, err
}
return entity, nil
}
Transaction (relationship operations)
func (r *EntityRepository) AssignRelated(ctx context.Context, entityID uint64, relatedIDs []uint64) error {
ctx, span := trace.Span(ctx, "EntityRepository.AssignRelated")
defer span.End()
tx := r.DB.Begin()
_, err := gorm.G[model.EntityRelationModel](tx).
Where("entity_id = ?", entityID).
Delete(ctx)
if err != nil {
tx.Rollback()
return err
}
var relations []model.EntityRelationModel
for _, relatedID := range relatedIDs {
relations = append(relations, model.EntityRelationModel{
EntityID: entityID,
RelatedID: relatedID,
})
}
err = gorm.G[model.EntityRelationModel](tx).CreateInBatches(ctx, &relations, len(relations))
if err != nil {
tx.Rollback()
return err
}
if commitErr := tx.Commit().Error; commitErr != nil {
return commitErr
}
return nil
}
Fx Wiring
Add to internal/modules/<module>/fx.go:
fx.Provide(
fx.Annotate(
repository.NewEntityRepository,
fx.As(new(ports.EntityRepository)),
),
),
Anti-Patterns (Do NOT Do These)
Missing .Limit(1) before .First() â BAD
// BAD: missing Limit(1) â always add it before First()
entity, err := gorm.G[model.EntityModel](r.DB).
Where("id = ?", id).
First(ctx) // â wrong
// GOOD
entity, err := gorm.G[model.EntityModel](r.DB).
Where("id = ?", id).
Limit(1). // â required
First(ctx)
Wrong span variable name â BAD
// BAD: using 'span' instead of 'span'
ctx, span := trace.Span(ctx, "EntityRepository.FindByID")
defer span.End()
// GOOD
ctx, span := trace.Span(ctx, "EntityRepository.FindByID")
defer span.End()
Redundant method comments â BAD
// BAD: comment that just restates the method name
// FindByID finds an entity by ID.
func (r *EntityRepository) FindByID(ctx context.Context, id uint64) (model.EntityModel, error) {
// BAD: comment that just restates the constructor
// NewEntityRepository creates a new entity repository.
func NewEntityRepository(db *database.PingoDB) *EntityRepository {
// GOOD: no comment on self-evident methods
func (r *EntityRepository) FindByID(ctx context.Context, id uint64) (model.EntityModel, error) {
// GOOD: comment only when behavior needs explanation
// FindByPriority resolves a template using collection+category, then category, then global fallback.
func (r *AIPromptTemplateRepository) FindByPriority(...)
Positional constructor initialization â BAD
// BAD: positional â fragile if struct fields change
return &EntityRepository{db}
// GOOD: named field
return &EntityRepository{PingoDB: db}
Critical Rules
- No standalone functions: When a file contains a struct with methods, do not add standalone functions. Use private methods on the struct instead.
- Struct: Embed
*database.PingoDBonly. - Constructor: MUST return pointer
*EntityRepositoryand use named field init:{PingoDB: db}. - Interface assertion: Add
var _ ports.EntityRepository = (*EntityRepository)(nil)below the struct. - Tracing: Every method MUST start with
ctx, span := trace.Span(ctx, "Repo.Method")anddefer span.End(). Always name the variablespan, neverspan. .Limit(1)before.First(): Every single-record lookup MUST have.Limit(1)immediately before.First(ctx). No exceptions.- Not found: Return
brickserrs.ErrRecordNotFoundwhenerrors.Is(err, gorm.ErrRecordNotFound). - Delete rowsAffected: Check
rowsAffected == 0and returnbrickserrs.ErrRecordNotFoundfor targeted deletes. For bulk cleanup (DeleteExpired, etc.), discard rowsAffected â zero rows is not an error. - Zero-value updates: Use
map[string]anyorSelect(fields).Updates(&model)when any field may be a zero value (false,0,""). PlainUpdates(entity)silently skips zero values. - Complex queries: Use
gorm.G[Model](r.DB)for simple queries. Fall back tor.DB.WithContext(ctx).Model(...)only whengorm.Gis insufficient: dynamic multi-condition WHERE, JOINs, subqueries, or.Select()with raw SQL fragments. - Module-specific errors: Prefer module-defined errors (e.g.,
errs.ErrEntityNotFound) over the genericbrickserrs.ErrRecordNotFoundwhen the module’serrs/package defines them. Mapgorm.ErrDuplicatedKeyto a module conflict error when one exists. - No redundant method comments: Do not add comments above methods that merely restate the method name (e.g.,
// FindByID finds an entity by ID.). Only add comments where the logic or behavior is non-obvious. - Comments on interfaces: Port interfaces MUST have a comprehensive doc comment on the type explaining its purpose and any non-obvious behavior.
- Validation: Run
make lintandmake nilawayafter generation.
Workflow
- Create port interface in
ports/<entity>_repository.go - Create repository implementation in
repository/<entity>_repository.go - Add Fx wiring to module’s
fx.go - Run
make lintto verify - Run
make nilawayfor static analysis