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)
}
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