Patterns
What the inside of a domain looks like and why it's structured that way.
Domains start simple — a handler and some routes. As they grow, having a predictable place for each concern means you're not hunting for where a query lives or why an env var is hardcoded. The patterns here are what domain/media and domain/distribution already follow.
The shape of a domain
domain/your-domain/
├── models.go — GORM structs, TableName(), enums, constants
├── config.go — env vars, read once at startup
├── validation.go — input validation helpers (optional)
├── helper.go — domain utilities (optional)
├── handlers/
│ ├── handler.go — Handler struct + NewHandler()
│ └── handler_xxx.go — HTTP methods, split by concern
├── repositories/
│ └── your_repository.go — complex/reused GORM queries (optional)
└── routes/
├── wire.go — Setup() — the only function main.go calls
└── routes.go — URL mapping + middleware per route
Crucial files
handlers/handler.go
Defines the Handler struct and NewHandler(). This is where all dependencies land — database, repository, storage, config. Every handler method across all handler_*.go files shares these via the (h *Handler) receiver.
package handlers
import (
"gorm.io/gorm"
yourdomain "mashup.castis.io/playtelly/CoreAPI/domain/your-domain"
"mashup.castis.io/playtelly/CoreAPI/domain/your-domain/repositories"
)
type Handler struct {
db *gorm.DB
repo *repositories.YourRepository // optional — add when you have complex queries
cfg yourdomain.Config
}
func NewHandler(db *gorm.DB, cfg yourdomain.Config) *Handler {
return &Handler{
db: db,
repo: repositories.NewYourRepository(db),
cfg: cfg,
}
}
NewHandler is called once from wire.go — never from main.go directly. The database is injected, not fetched. See Database and Domain Setup.
Handler methods go in separate handler_xxx.go files — split by concern:
handlers/
├── handler.go — struct only
├── handler_query.go — List, GetByID, Search
├── handler_upload.go — upload flow
└── handler_process.go — background processing
All files share the same package handlers and the same Handler type.
routes/wire.go
The entry point for the domain. main.go calls Setup() and passes in everything the domain needs — database, storage client, JWT service. wire.go reads config, constructs the handler, and calls registerRoutes.
package routes
import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
yourdomain "mashup.castis.io/playtelly/CoreAPI/domain/your-domain"
"mashup.castis.io/playtelly/CoreAPI/domain/your-domain/handlers"
)
func Setup(r fiber.Router, db *gorm.DB) {
cfg := yourdomain.ConfigFromEnv() // ← env vars read once here
h := handlers.NewHandler(db, cfg)
registerRoutes(r, h)
}
wire.go is a pass-through — it doesn't contain logic, just wiring. If your domain needs middleware, add jwtService jwt.JWTService as a parameter and forward it to registerRoutes.
routes/routes.go
Maps URL paths to handler methods. This is also where middleware is applied — per route, not on the group.
package routes
import (
"github.com/gofiber/fiber/v2"
"mashup.castis.io/playtelly/CoreAPI/domain/your-domain/handlers"
"mashup.castis.io/playtelly/CoreAPI/pkg/middleware"
"mashup.castis.io/playtelly/CoreAPI/pkg/jwt"
)
func registerRoutes(r fiber.Router, h *handlers.Handler, jwtService jwt.JWTService) {
g := r.Group("/your-domain")
g.Get("/", middleware.Protected(jwtService), h.List)
g.Get("/:id", middleware.Protected(jwtService), h.GetByID)
g.Post("/", middleware.Protected(jwtService), middleware.HasAnyRole("ADMIN"), h.Create)
g.Delete("/:id", middleware.Protected(jwtService), middleware.HasAnyRole("ADMIN"), h.Delete)
}
routes.go is not exported — main.go never calls it. Only wire.go does.
See Middleware for the full wiring from main.go → wire.go → routes.go.
models.go
GORM structs, TableName(), enums, and domain-level constants. Used in two ways — by AutoMigrate to create tables, and by handlers when querying.
package yourdomain
import "gorm.io/gorm"
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
)
type YourModel struct {
gorm.Model
Name string `gorm:"type:text;not null"`
Status Status `gorm:"type:varchar(20);default:'active';index"`
}
func (YourModel) TableName() string { return "your_models" }
For registration with AutoMigrate, see Migrations.
config.go
Reads environment variables once at startup. Handlers access config via h.cfg.FieldName — they never call os.Getenv directly.
package yourdomain
import "os"
type Config struct {
BucketName string // MINIO_BUCKET
APIEndpoint string // SOME_SERVICE_ENDPOINT
}
func ConfigFromEnv() Config {
return Config{
BucketName: envOr("MINIO_BUCKET", "playtelly"),
APIEndpoint: envOr("SOME_SERVICE_ENDPOINT", "http://localhost:9000"),
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
ConfigFromEnv() is called once in wire.go and passed into NewHandler. This also serves as documentation — any developer can open config.go and see every env var the domain needs.
Optional files
repositories/
Extract GORM queries here when they're complex enough to clutter a handler, or when the same query is needed in more than one place. Simple one-off queries (h.db.First, h.db.Delete) stay in the handler directly.
// repositories/your_repository.go
package repositories
import "gorm.io/gorm"
import yourdomain "mashup.castis.io/playtelly/CoreAPI/domain/your-domain"
type YourRepository struct {
db *gorm.DB
}
func NewYourRepository(db *gorm.DB) *YourRepository {
return &YourRepository{db: db}
}
func (r *YourRepository) FindAll(status string) ([]yourdomain.YourModel, error) {
var results []yourdomain.YourModel
err := r.db.Where("status = ?", status).Order("created_at desc").Find(&results).Error
return results, err
}
Wire it in NewHandler using the same injected db — no separate connection needed.
domain/media uses this pattern — h.repo handles List and GetByID, while simple operations like Delete and Update go via h.db directly. Both coexist fine.
validation.go
Input validation helpers. Keep them small and domain-specific — not a framework, just functions your handlers call before acting on input.
helper.go
Domain-specific utilities that don't belong in handlers or models. URL builders, format converters, anything reused within the domain but not worth putting in pkg/.
Response — always use pkg/response
All handlers use pkg/response for consistency across domains:
import "mashup.castis.io/playtelly/CoreAPI/pkg/response"
// success
return response.OK(c, data)
// error
return response.Error(c, 404, "not found")
// paginated list
return response.Paginated(c, data, total, page, limit)
Never return raw c.JSON(...) — it breaks the response envelope that the frontend expects.
How it all connects
main.go
│ registers models, calls db.Init(), calls domain Setup()
▼
routes/wire.go Setup(r, db, jwtService)
│ reads config once, creates handler, calls registerRoutes
▼
routes/routes.go registerRoutes(r, h, jwtService)
│ maps URLs to handlers, applies middleware per route
▼
handlers/handler.go NewHandler(db, cfg)
│ stores db, cfg, repo — shared across all handler methods
▼
handlers/handler_xxx.go (h *Handler) List(c)
│ uses h.db or h.repo for queries, h.cfg for config
▼
PostgreSQL
Nothing flows upward. Each file only knows about the layer below it.
Quick reference
Opens both database connections at startup. Exposes getters that main.go passes into domains.
// pkg/db/db.go
var DB *gorm.DB // coreapi
var ZooDB *gorm.DB // johorzoo
func GetDB() *gorm.DB { return DB }
func GetZooDB() *gorm.DB { return ZooDB }
Only main.go ever calls these. Domains receive *gorm.DB — they never import pkg/db.
Registers models, connects the database, wires every domain.
// register before Init
db.RegisterModels(
&yourdomainmodels.YourModel{},
)
db.Init()
db.RunMigrationMain()
// wire domain — db decision made here
yourdomainroutes.Setup(v1, db.GetDB())
main.go decides which database each domain uses. That's its only domain-level responsibility.
The entry point main.go calls. Reads config, creates handler, calls registerRoutes.
func Setup(r fiber.Router, db *gorm.DB, jwtService jwt.JWTService) {
cfg := yourdomain.ConfigFromEnv() // ← env vars read once here
h := handlers.NewHandler(db, cfg) // ← handler created here
registerRoutes(r, h, jwtService) // ← routes registered here
}
wire.go is the boundary where everything enters the domain. Nothing is read from env or fetched from globals inside the domain after this point.
Maps URLs to handlers. Applies middleware per route.
func registerRoutes(r fiber.Router, h *handlers.Handler, jwtService jwt.JWTService) {
g := r.Group("/your-domain")
// public
g.Get("/:id", h.GetByID)
// authenticated
g.Get("/", middleware.Protected(jwtService), h.List)
// authenticated + role
g.Post("/", middleware.Protected(jwtService), middleware.HasAnyRole("ADMIN"), h.Create)
}
Not exported — only wire.go calls it. Middleware lives here, not in wire.go.
The Handler struct and constructor. Everything the domain needs, declared once.
type Handler struct {
db *gorm.DB // injected from main.go via wire.go
cfg yourdomain.Config // read in wire.go, never os.Getenv in handlers
}
func NewHandler(db *gorm.DB, cfg yourdomain.Config) *Handler {
return &Handler{db: db, cfg: cfg}
}
Called once from wire.go. Handler methods in handler_*.go access everything via h.db, h.cfg, h.repo, h.storage.
Reads env vars once. Documents what the domain needs.
type Config struct {
BucketName string // MINIO_BUCKET
APIEndpoint string // YOUR_SERVICE_ENDPOINT
}
func ConfigFromEnv() Config {
return Config{
BucketName: envOr("MINIO_BUCKET", "playtelly"),
APIEndpoint: envOr("YOUR_SERVICE_ENDPOINT", "http://localhost:9000"),
}
}
Called in wire.go, passed into NewHandler, accessed as h.cfg.FieldName. Opening config.go tells you every env var the domain depends on.