Backend Development
Setup
Requires the Go version pinned in backend/go.mod (install Go) — deliberately pinned, not floated; see the supported runtime matrix.
cd backend
cp .env.example .env # then edit with your values
go mod tidy
Running Locally
source .env
go run main.go
The server starts on HOST_PORT (default 8080). Migrations run automatically.
Adding a New Endpoint
- Define an input DTO in
models/with validation tags. - Add a controller function in
controllers/. - Register the route in
routes/routes.go. - Add the validation schema to the middleware registration if needed.
Database Migrations
Creating a Migration
make migrate-create NAME=add_foo_column
This creates two files in database/migrations/: NNNNNN_add_foo_column.up.sql and .down.sql.
Running Migrations
Migrations run automatically on startup. To apply or roll back manually:
make migrate-up
make migrate-down
make migrate-status
make migrate-force # operator-only recovery for an interrupted (dirty) migration — prompts first
The migration gates are fail-closed (issue #439): the server refuses to start on a dirty database, a database ahead of this binary’s schema, or a sub-floor database, each with a typed error naming the state and its recovery (see docs/upgrade-compatibility.md → Refusal states). migrate-force is the only path that clears a dirty flag, and it requires explicit operator confirmation — never automatic.
Models and DTOs
GORM models (Contact, Activity, etc.) live alongside input DTOs (ContactInput, etc.) in models/. DTOs are what controllers receive after validation while models are what GORM persists.
All models include UserID uint for tenant isolation.
Services
Complex business logic lives in services/. Controllers call services; services own multi-step operations (e.g. sending emails). Services receive a *gorm.DB and any needed config, they do not access *gin.Context.
Structured logging
logger/ wraps zerolog. Operational code (scheduler jobs, sync services, notification/webhook dispatch, migrations, backup/restore) uses the standard field vocabulary in logger/fields.go — event, component, operation, duration_ms, result, error — instead of ad-hoc keys, and threads a context.Context so a correlation_id bound upstream rides along. logger.Ctx(ctx) returns the context-bound logger (falling back to the global); logger.Op(ctx, "<event>") times an operation and emits one standardized completion line. Significant state transitions also persist a models.SystemEvent row (issue #424) that surfaces on the admin /system-events timeline. Operator-facing detail is in docs/operations/observability.md; the design rationale is ADR 0005.
Testing
go test ./...
Tests that touch persistence run against the real migrated schema — never GORM AutoMigrate (CLAUDE.md backend trap #1). Use internal/dbtest.New(t), which hands each test an isolated copy of a per-binary migrated template (issue #632); the database package’s own tests call database.InitDB(filepath.Join(t.TempDir(), "x.db")) directly. Which layer a test belongs to is decided in Testing — the explicit test pyramid.