Generic CRUD Pattern

The B12 SIS backend uses Go generics extensively to reduce boilerplate code while maintaining type safety. This pattern provides consistent CRUD operations across all modules.

Overview

The generic pattern consists of three layers:

BaseRepository[T]
       ↓
BaseService[T, CreateInput, UpdateInput]
       ↓
BaseController[T, CreateInput, UpdateInput]

Each layer builds on the one below, providing increasing levels of abstraction.

BaseModel

All domain entities embed BaseModel:

type BaseModel struct {
    Id          string         `gorm:"type:char(36);primary_key" json:"id"`
    Name        string         `gorm:"size:255;index" json:"name"`
    Description string         `gorm:"type:text" json:"description"`
    TeamId      string         `gorm:"size:36;index" json:"team_id"`

    CreatedAt   time.Time      `json:"created_at"`
    UpdatedAt   time.Time      `json:"updated_at"`
    DeletedAt   gorm.DeletedAt `gorm:"index" json:"deleted_at"`

    CreatedById string         `gorm:"size:36;index" json:"created_by_id"`
    UpdatedById string         `gorm:"size:36;index" json:"updated_by_id"`
    DeletedById string         `gorm:"size:36;index" json:"deleted_by_id"`

    AssignedToId *string       `gorm:"size:36;index" json:"assigned_to_id"`
}

BeforeCreate Hook

func (m *BaseModel) BeforeCreate(tx *gorm.DB) error {
    if m.Id == "" {
        m.Id = uuid.New().String()
    }
    return nil
}

BaseRepository

The base repository provides generic database operations:

type BaseRepository[T any] struct {
    DB        *gorm.DB
    Preloads  []string
    TableName string
}

func NewBaseRepository[T any](tableName string, preloads []string) *BaseRepository[T] {
    return &BaseRepository[T]{
        DB:        database.GetDB(),
        Preloads:  preloads,
        TableName: tableName,
    }
}

Key Methods

// Create inserts a new record
func (r *BaseRepository[T]) Create(ctx context.Context, entity *T) error

// FindByID retrieves a record by ID with preloads
func (r *BaseRepository[T]) FindByID(ctx context.Context, id string) (*T, error)

// FindAll retrieves records with pagination and filtering
func (r *BaseRepository[T]) FindAll(ctx context.Context, params ListParams) ([]T, int64, error)

// Update modifies an existing record
func (r *BaseRepository[T]) Update(ctx context.Context, id string, updates map[string]interface{}) error

// Delete soft-deletes a record
func (r *BaseRepository[T]) Delete(ctx context.Context, id string) error

// FindAllWithFilter applies complex filter conditions
func (r *BaseRepository[T]) FindAllWithFilter(ctx context.Context, params ListParams, filter FilterGroup) ([]T, int64, error)

Query Building

The repository builds queries with automatic team filtering:

func (r *BaseRepository[T]) buildQuery(ctx context.Context) *gorm.DB {
    query := r.DB.WithContext(ctx).Model(new(T))

    // Apply team filter unless disabled
    var entity T
    if !entity.DisableTeamFilter() {
        teamID := utils.GetTeamIDFromContext(ctx)
        teamIDs := utils.GetTeamAndChildrenIDs(ctx, teamID)
        query = query.Where("team_id IN ?", teamIDs)
    }

    // Apply preloads
    for _, preload := range r.Preloads {
        query = query.Preload(preload)
    }

    return query
}

BaseService

The base service provides business logic operations:

type BaseService[T any, CreateInput any, UpdateInput any] struct {
    Repository *repositories.BaseRepository[T]
    ModuleName string
}

func NewBaseService[T any, CI any, UI any](
    repo *repositories.BaseRepository[T],
    moduleName string,
) *BaseService[T, CI, UI] {
    return &BaseService[T, CI, UI]{
        Repository: repo,
        ModuleName: moduleName,
    }
}

Key Methods

// Create validates input and creates a record
func (s *BaseService[T, CI, UI]) Create(ctx context.Context, input CI) (*T, error)

// GetByID retrieves and unsanitizes a record
func (s *BaseService[T, CI, UI]) GetByID(ctx context.Context, id string) (*T, error)

// GetAll retrieves records with pagination
func (s *BaseService[T, CI, UI]) GetAll(ctx context.Context, params ListParams) (*ListResult[T], error)

// Update validates and updates a record
func (s *BaseService[T, CI, UI]) Update(ctx context.Context, id string, input UI) (*T, error)

// Delete removes a record with audit
func (s *BaseService[T, CI, UI]) Delete(ctx context.Context, id string) error

// CreateMultiple bulk creates records
func (s *BaseService[T, CI, UI]) CreateMultiple(ctx context.Context, inputs []CI) ([]T, error)

Input Mapping

Services map input structs to models using reflection:

func (s *BaseService[T, CI, UI]) mapInputToEntity(input CI) (*T, error) {
    entity := new(T)
    inputVal := reflect.ValueOf(input)
    entityVal := reflect.ValueOf(entity).Elem()

    for i := 0; i < inputVal.NumField(); i++ {
        fieldName := inputVal.Type().Field(i).Name
        if entityField := entityVal.FieldByName(fieldName); entityField.IsValid() {
            entityField.Set(inputVal.Field(i))
        }
    }

    return entity, nil
}

BaseController

The base controller provides HTTP handlers:

type BaseController[T any, CreateInput any, UpdateInput any] struct {
    Service     *services.BaseService[T, CreateInput, UpdateInput]
    ModuleName  string
    FilterMixin *FilterMixin
}

Standard Handlers

// Create handles POST /{module}
func (c *BaseController[T, CI, UI]) Create(ctx *gin.Context)

// GetByID handles GET /{module}/:id
func (c *BaseController[T, CI, UI]) GetByID(ctx *gin.Context)

// GetAll handles POST /{module}/list
func (c *BaseController[T, CI, UI]) GetAll(ctx *gin.Context)

// Update handles PUT /{module}/:id
func (c *BaseController[T, CI, UI]) Update(ctx *gin.Context)

// Delete handles DELETE /{module}/:id
func (c *BaseController[T, CI, UI]) Delete(ctx *gin.Context)

// CreateMultiple handles POST /{module}/bulk
func (c *BaseController[T, CI, UI]) CreateMultiple(ctx *gin.Context)

// DeleteMultiple handles DELETE /{module}/bulk
func (c *BaseController[T, CI, UI]) DeleteMultiple(ctx *gin.Context)

// ExportExcel handles POST /{module}/export
func (c *BaseController[T, CI, UI]) ExportExcel(ctx *gin.Context)

// Import handles POST /{module}/import
func (c *BaseController[T, CI, UI]) Import(ctx *gin.Context)

Creating a New Module

Step 1: Define the Model

// internal/models/department.go
package models

type Department struct {
    BaseModel
    Code       string `gorm:"size:50;index;uniqueIndex" json:"code"`
    HeadId     *string `gorm:"size:36;index" json:"head_id"`
    Head       *Teacher `gorm:"foreignKey:HeadId" json:"head"`
    FacilityId *string `gorm:"size:36;index" json:"facility_id"`
    Facility   *Facility `gorm:"foreignKey:FacilityId" json:"facility"`
}

type DepartmentInput struct {
    Name        string  `json:"name" ctype:"text" crequired:"true"`
    Description string  `json:"description" ctype:"textarea"`
    Code        string  `json:"code" ctype:"text" creatable:"true" editable:"false"`
    HeadId      *string `json:"head_id" ctype:"relate" cmodule:"Teacher"`
    FacilityId  *string `json:"facility_id" ctype:"relate" cmodule:"Facility"`
}

func (d *Department) GetListViewFields() []string {
    return []string{"name", "code", "head_id", "facility_id"}
}

func (d *Department) GetFieldsForSearch() []string {
    return []string{"name", "code"}
}

func (d *Department) DisableTeamFilter() bool {
    return false
}

Step 2: Create Repository

// internal/repositories/department_repository.go
package repositories

type DepartmentRepository struct {
    *BaseRepository[models.Department]
}

func NewDepartmentRepository() *DepartmentRepository {
    preloads := []string{"Head", "Facility"}
    return &DepartmentRepository{
        BaseRepository: NewBaseRepository[models.Department]("departments", preloads),
    }
}

// Add custom methods if needed
func (r *DepartmentRepository) FindByCode(ctx context.Context, code string) (*models.Department, error) {
    var dept models.Department
    err := r.buildQuery(ctx).Where("code = ?", code).First(&dept).Error
    return &dept, err
}

Step 3: Create Service

// internal/services/department_service.go
package services

type DepartmentService struct {
    *BaseService[models.Department, models.DepartmentInput, models.DepartmentInput]
    repo *repositories.DepartmentRepository
}

func NewDepartmentService() *DepartmentService {
    repo := repositories.NewDepartmentRepository()
    return &DepartmentService{
        BaseService: NewBaseService[models.Department, models.DepartmentInput, models.DepartmentInput](
            repo.BaseRepository,
            "Department",
        ),
        repo: repo,
    }
}

// Override Create to add custom logic
func (s *DepartmentService) Create(ctx context.Context, input models.DepartmentInput) (*models.Department, error) {
    // Generate code if empty
    if input.Code == "" {
        code, _ := NewAutoCodeService().GenerateCode("Department")
        input.Code = code
    }

    return s.BaseService.Create(ctx, input)
}

Step 4: Create Controller

// internal/controllers/department_controller.go
package controllers

type DepartmentController struct {
    *BaseController[models.Department, models.DepartmentInput, models.DepartmentInput]
    service *services.DepartmentService
}

func NewDepartmentController() *DepartmentController {
    service := services.NewDepartmentService()
    return &DepartmentController{
        BaseController: NewBaseController[models.Department, models.DepartmentInput, models.DepartmentInput](
            service.BaseService,
            "Department",
        ),
        service: service,
    }
}

// Add custom handlers if needed
func (c *DepartmentController) GetByCode(ctx *gin.Context) {
    code := ctx.Param("code")
    dept, err := c.service.repo.FindByCode(utils.GetContext(ctx), code)
    if err != nil {
        utils.ErrorResponse(ctx, http.StatusNotFound, "Department not found", err.Error())
        return
    }
    utils.SuccessResponse(ctx, "Department retrieved", dept)
}

Step 5: Register Routes

// internal/routes/routes.go

departmentController := controllers.NewDepartmentController()
departments := protected.Group("/departments")
{
    departments.POST("", departmentController.Create)
    departments.POST("/bulk", departmentController.CreateMultiple)
    departments.DELETE("/bulk", departmentController.DeleteMultiple)
    departments.POST("/list", departmentController.GetAll)
    departments.POST("/export", departmentController.ExportExcel)
    departments.GET("/:id", departmentController.GetByID)
    departments.GET("/code/:code", departmentController.GetByCode)  // Custom endpoint
    departments.PUT("/:id", departmentController.Update)
    departments.DELETE("/:id", departmentController.Delete)
}

Input Struct Tags

The input struct uses tags for form generation and validation:

Tag

Description

Example

ctype

Field type

ctype:"text", ctype:"relate", ctype:"enum"

cmodule

Related module name

cmodule:"Teacher"

crequired

Required field

crequired:"true"

coptions

Enum options

coptions:"active;inactive"

creatable

Can be set on create

creatable:"true"

editable

Can be modified on update

editable:"false"

label

English label

label:"Department Name"

labelvi

Vietnamese label

labelvi:"Tên phòng ban"

populateidfor

Populate IDs for relation

populateidfor:"Teachers"

Model Interface Methods

Models can implement these optional methods:

// GetListViewFields returns fields shown in list view
func (m *Model) GetListViewFields() []string

// GetFieldsForSearch returns fields used for search
func (m *Model) GetFieldsForSearch() []string

// GetFieldsForRelatedSearch returns fields for related search
func (m *Model) GetFieldsForRelatedSearch() []string

// DisableTeamFilter returns true to skip team filtering
func (m *Model) DisableTeamFilter() bool

// GetSummaryName returns display name for dropdowns
func (m *Model) GetSummaryName() string

// GetRelatedJoins returns JOIN clauses for related filtering
func (m *Model) GetRelatedJoins() map[string]string