Skip to content

Day 1 — Foundations

Version: 1.0 | Status: written against code 2026-09-16 (medyzen-backend at e0b914b) | Course: README

Today you learn how the backend is laid out, what happens between go run ./cmd/server and the first request being served, how config and the database layer work, and the shape every domain module follows. By the end you should be able to boot the server on your own laptop without leaking a single real credential, and read any module in the repo without getting lost.

Paths below are relative to medyzen-backend/ unless written out in full. Line numbers are correct for the commit above. If they have drifted, search for the symbol name.


Goals

By the end of Day 1 you can:

  1. Say what lives in cmd/, internal/, internal/pkg/, deploy/, ops/, docs/ and skills/, and which of those are out of date.
  2. Walk through cmd/server/main.go from flag.Parse() to sqlDB.Close(), and explain why the steps come in that order.
  3. Explain how config.Load() decides a value (process env, then .env, then default), and why that makes local runs dangerous.
  4. Describe the database layer: NewGormDB, the DB provider, RunInTx / RunInTxJoining / DBFromContext, and how golang-migrate applies numbered SQL files.
  5. Trace one request through a whole module (internal/leads): module → routes → handler → service → repository → model/DTO, then back out through internal/errors and internal/pkg/response.
  6. Run the server against a local Postgres with every secret blanked, apply migrations, and hit /health.
  7. Name the four foundation traps (.env leakage, skipped lower migrations, microsecond timestamps, GORM default:true) and show where each one lives in the code.

Reading order

Open these in order. Keep this lesson open next to them.

#FileWhat to look for
1CLAUDE.mdThe intended layering (Routes → Handlers → Services → Repositories → Models). Note which parts have drifted from the code (see "Docs that lie" below).
2docs/CODE_STANDARDS.mdTypes vs logic files, capabilities vs scoping, "no external I/O inside a transaction", money as decimal(12,2).
3cmd/server/main.go lines 88–151Flags, config load, Sentry, the one-shot commands that return before the server is built, and the startup validators.
4internal/config/config.go lines 325–453Load(): viper, AutomaticEnv, AllowEmptyEnv(true), the defaults, the malformed-.env error that hides the line.
5internal/config/isolation_test.goThe test that proves an exported empty value beats .env. This is the key to safe local runs.
6cmd/server/main.go lines 153–255Redis, the DB pool, -seed-admin, R2 clients, and the two PII ciphers (patient keys are mandatory).
7internal/database/db.go, provider.go, tx.go, errors.goGORM setup, logger hardening, the multi-pool provider, the context-carried transaction.
8cmd/server/main.go lines 257–521Module construction order, two background contexts, setter-based wiring for cycles.
9cmd/server/main.go lines 523–696Health check, route collector, RBAC catalog sync, the middleware chain, the loopback admin server.
10cmd/server/main.go lines 698–869Workers, signal handling, the single shared shutdown deadline.
11cmd/server/main.go lines 871–947runMigrations, forceMigrationVersion, reconcile027Collision.
12internal/leads/ (all non-test files, ~600 lines)The worked example module. Read module.go first, then routes, handlers, services, repositories, models, dtos.
13internal/errors/errors.go, log.goServiceError, codes, categories, sentinels, StatusCode, LogAndWrap.
14internal/pkg/response/response.go, pkg/handler/handler.go, pkg/request/request.go, pkg/validator/struct.goThe response envelope, Decode[T], Bind[T], pagination clamping, validation messages.
15internal/pkg/logger/logger.go, pkg/pagination/cursor.go, pkg/router/router.go, pkg/dbmodel/dbmodel.go, pkg/dbrepo/dbrepo.go, pkg/worker/supervise.goThe small shared libraries every module leans on.
16Makefile, docker-compose.yml, DockerfileHow you build, test, migrate and containerise.

Explanation

1. Repo tour

medyzen-backend/
├── cmd/
│   ├── server/                      the API binary: main.go, health.go, preflight.go
│   ├── testlab/                     the testlab CLI (Day 7)
│   ├── erase-careers-application/   one-off operator tool (Day 6)
│   └── migrate-employee-kyc-storage/ one-off data move
├── internal/
│   ├── config/                      env loading + startup validators
│   ├── database/                    GORM setup, tx helpers, migrations/, testdb/
│   ├── errors/                      ServiceError taxonomy shared by all modules
│   ├── middleware/                  auth, RBAC whitelist, CORS, rate limits, audit... (Day 2)
│   ├── pkg/                         cross-module libraries (response, request, logger, ...)
│   ├── docs/                        generated Swagger (make swagger)
│   └── <domain modules>             human, assets, inventory, cart, order, prescription,
│                                    dashboard, hospitalcatalog, procurement, notification,
│                                    finance, leads, careers, hiring, deliverykyc, hr, audit,
│                                    realtime, testlab
├── deploy/aws/                      Terraform: vpc, ecs, rds, alb, iam, secrets, runner... (Day 7)
├── ops/pending-migrations/          SQL deliberately kept OUT of the auto-migrate path
├── docs/<module>/                   per-module flow docs; docs/infrastructure/ for the plumbing
├── skills/                          three "how to" guides (stale, see below)
├── Makefile, Dockerfile, docker-compose.yml, go.mod, .env.example

A few things worth knowing up front:

  • One binary, many modes. cmd/server is the API server, the migration runner, the admin seeder, the payment reconciler, the PII backfill and the preflight check. Flags pick the mode (section 2).
  • internal/pkg is shared plumbing, internal/<module> is business logic. A module may import internal/pkg/..., internal/errors, internal/database and internal/middleware. Modules talk to each other through small interfaces ("ports") that main.go fills in, not by reaching into each other's repositories.
  • ops/pending-migrations/ holds SQL that must not run automatically: data fixes that need a human to fill in a value, and CREATE INDEX CONCURRENTLY statements that cannot run inside golang-migrate. Read its index.md, and see Trap 2 before following it.
  • Migrations directory holds _test.go files too. internal/database/migrations/ has 639 files: NNN_name.up.sql/.down.sql pairs plus Go tests for many RBAC seed migrations (for example 075_rbac_test.go). golang-migrate's file source only picks up names matching its version_name.up|down.ext pattern, so the .go files are ignored by the runner.

Docs that lie (read with care)

The code is the source of truth. These were checked against the code today and are wrong or stale:

DocumentClaimReality
CLAUDE.md Tech StackConfig via joho/godotenvinternal/config/config.go uses spf13/viper
CLAUDE.md "Adding a New Feature"Migration named YYYYMMDD_feature.up.sql; files go under internal/human/...Migrations are NNN_name (latest is 295_...). Each module has its own models/, dtos/, etc.
CLAUDE.md "API Response Standard"response.Success(c, data), response.BadRequest(...), response.InternalError(...)Signatures are response.Success(w, data), response.Error(w, r, status, msg), response.ServiceError(w, r, err). There is no BadRequest or InternalError helper.
skills/add-auth-middleware.mdGin router, RequireAuth(), RequireRole(...), c.Get("user_id")The server uses net/http ServeMux. Auth is middleware.Auth(jwtSecret, blacklist). CODE_STANDARDS.md forbids new role-list gates.
skills/database-migration.md, skills/add-api-endpoint.mdYYYYMMDD_ names; UUID primary keys with gorm.DeletedAtSequential NNN_. Many tables use SERIAL id + document_id UUID (see leads).
docs/infrastructure/server-startup.mdOld module constructor signatures; -seed-admin "start server + seed"-seed-admin seeds and then returns (main.go:186-189). Constructors now take many more arguments.

When a doc and the code disagree, trust the code, and flag the doc.


2. The boot sequence (cmd/server/main.go)

main() runs from line 88 to line 869. It is long, but it does things in a strict order, and the order matters.

2.1 Flags (lines 89–100)

FlagWhat it doesNeeds DB?
-migrate up / -migrate down -steps NRuns golang-migrate and exits. down refuses N<=0 (lines 905–908).Yes (URL only)
-migrate-force NClears a dirty schema_migrations row by forcing version N.Yes
-preflightAsserts flags, rows, buckets, vendors for this deployment; exit 1 on failure (cmd/server/preflight.go:27).Yes
-encrypt-patient-pii [-commit] [-limit N]Backfills ciphertext + blind indexes. Dry run unless -commit.Yes
-seed-admin EMAIL / -revoke-admin EMAILCreates or promotes an employee to super_admin, or revokes it.Yes
-reconcile-payments YYYY-MM-DDReconciles one IST day against the gateway. Calls Razorpay when configured.Yes
-prune-endpoint-catalogSyncs route catalog and deactivates rows no route serves.Yes

Look at where each one-shot flag returns. -migrate returns before the JWT validators and before any module is built (lines 128–131). That is deliberate: a migration task in CI/ECS does not need JWT_SECRET or PII keys to be valid. -seed-admin returns after the DB pool exists but before modules are built. -reconcile-payments needs the fully constructed order module, so it returns after line 340. -prune-endpoint-catalog needs every route registered, so it returns after line 587.

2.2 Config, logging, Sentry (lines 102–116)

config.Load() builds a *Config (section 3). logger.Init(cfg.LogLevel) replaces the package-level logrus logger with a JSON logger at the chosen level (internal/pkg/logger/logger.go:24). observability.Init returns nil straight away when SENTRY_DSN is empty (internal/pkg/observability/sentry.go:46-48), so Sentry is off locally by default. Note SendDefaultPII: false and a BeforeSend scrubber: Sentry is treated as a place PHI must not reach.

2.3 Refuse-to-start validators (lines 138–151)

The server prefers crashing at boot to running insecurely:

  • config.ValidateJWTSecret (config.go:213): refuses empty, refuses the built-in default change-me-in-production, refuses anything under 32 bytes.
  • config.ValidateTrustedProxySecret (config.go:239): empty is fine, but a non-empty value under 32 characters is refused.
  • config.ValidateCORSOrigins (config.go:253): refuses *.

You will see this pattern again and again: empty = feature off (with a warning), set-but-weak = Fatal.

2.4 Redis (lines 153–171)

Redis is optional. If REDIS_URL is set:

  • OTP_PEPPER must be set too, or boot fails (OTPs would sit in Redis as plaintext).
  • A failed connection is Fatal. The server will not quietly fall back to per-instance memory when you asked for Redis.
  • The OTP store, token blacklist and (if RATE_LIMIT_BACKEND=redis) the rate limiter become shared across instances.

If it is unset, you get a warning that says: do not run more than one instance. That is the normal local setup.

2.5 Database pool and provider (lines 173–184)

database.NewGormDB opens the pool and pings it; database.NewDB(primary) wraps it in the provider; db := dbProvider.Primary() is the plain *gorm.DB handed to every module. Section 4 covers both.

2.6 R2 clients and PII ciphers (lines 196–255)

Four optional R2 (Cloudflare object storage) clients are created only when their bucket env vars are set. A nil client means "that feature degrades", and main.go logs a warning further down (for example lines 402–407 for HR documents).

Then the two ciphers. Order matters here, and the comment at line 216 says why: GORM hooks on the models encrypt and decrypt through these, so they must be installed before any module can issue a query.

  • Employee KYC cipher: optional. Unset means Aadhaar and bank numbers stay plaintext, with a warning.
  • Patient cipher: mandatory. PATIENT_ENCRYPTION_KEY and PATIENT_BLIND_INDEX_KEY must both be set. Only one set is Fatal. Neither set is also Fatal (line 257), because the models no longer map the plaintext columns. Each key is 64 hex characters (32 bytes, internal/pkg/crypto/aead.go:17-34). This is the first thing that stops a fresh local boot.

2.7 Two background contexts (lines 257–278)

go
reconcilerCtx, stopReconciler := context.WithCancel(context.Background())
poolCtx, stopPools := context.WithCancel(context.Background())
  • reconcilerCtx is passed to modules that start cleanup goroutines while being constructed (rate limiters, OTP store, product cache), and to every ticked worker. Cancelled the moment a shutdown signal arrives.
  • poolCtx is used only by the two bounded job pools (prescription extraction, hiring calendar sync). It is cancelled after the HTTP servers have drained. Why: a request that is still being served during the drain may enqueue a job. If the pool had already stopped, that job would be silently lost.

2.8 Module construction and wiring (lines 280–521)

Modules are plain structs built by NewModule(...) functions. main.go is the only place that knows about all of them. Construction order follows dependencies:

(Arrows mean "is passed into the constructor of". Simplified; read lines 284–521 for the exact arguments.)

Three wiring techniques appear:

  1. Constructor injection of ports. leads.NewModule(..., humanMod.Access) passes human's access service, but leads only sees it as its own tiny interface services.Access { IsSuperAdmin(...) bool } (internal/leads/services/lead.go:27-29). hospitalcatalog does the same with services.HospitalAccess in internal/hospitalcatalog/services/ports.go.
  2. Setters for cycles. When A needs B and B needs A, one side is wired after both exist: orderMod.OrderService.SetSalesReturnWriter(procurementMod.SalesReturnWriter) (line 347), prescriptionMod.ReviewService.SetOrderWarehouseResolver(orderMod.OrderService) (line 394), careersMod.Application.SetApplicationNotifier(hiringMod.ApplicationNotifier) (line 511, with a comment explaining that hiring imports careers and never the reverse).
  3. Adapters in main.go. When a port's shape does not match the provider's, a small adapter type at the bottom of main.go translates: prescriptionPageAdapter (line 1073), hrAccountControl (line 1095), procurementNotifier (line 1154), and the inline subBridge (lines 356–374).

Why this style instead of a DI framework: everything is visible in one file, the compiler checks every edge, and a module's dependencies are exactly its constructor arguments.

2.9 Mux, routes, RBAC catalog (lines 523–622)

  • GET /health is registered directly (line 552). It pings the DB and, when Redis is configured, Redis. Results are cached briefly (cmd/server/health.go). A Redis failure makes health fail, because in production nobody can log in without Redis.
  • router.NewCollector(mux) (line 561) wraps the mux. Each module registers through collector.Module("name"), which records METHOD path → module before calling mux.Handle (internal/pkg/router/router.go:60-63). That record feeds the RBAC endpoint catalog.
  • NewEndpointSyncService(db).Sync(...) (line 601) upserts the catalog into the database. Failure is logged, not fatal.
  • mw.ValidateWhitelistMode refuses to boot with role-level authorization disabled unless explicitly allowed. Default RBAC_WHITELIST_MODE is enforce (config.go:341). The permission store is loaded from the DB (line 613), so the server needs a migrated database to boot.

Day 2 covers what the whitelist does per request. For today: middleware.Auth calls checkWhitelist (internal/middleware/auth.go:118), so every authenticated route is checked against role_permissions unless its pattern is exempt.

2.10 Admin server and the middleware chain (lines 631–696)

/swagger/, /metrics and /debug/pprof/ are never on the public mux. They go on a second server bound to 127.0.0.1:ADMIN_PORT (default 6060), started only if at least one of SWAGGER_ENABLED, METRICS_ENABLED, PPROF_ENABLED is true. The comment at line 631 explains why: pprof heap dumps would expose decrypted patient data and the JWT secret.

The public handler is one nested expression (line 674):

go
handler := mw.SecurityHeaders(mw.CORS(cfg.CORSOrigins)(mw.RequestID(observability.Middleware(
    mw.Recovery(mw.Timeout(cfg.RequestTimeout)(mw.Logging(mw.Audit(auditWriter)(mw.Metrics(mux)))))))))

Read it outside-in: SecurityHeaders → CORS → RequestID → Sentry → Recovery → Timeout → Logging → Audit → Metrics → mux. Per-route middleware (Auth, rate limiters) is applied inside each module's routes file. The comment at lines 663–673 explains a real bug: Metrics must sit inside Audit, or every route was recorded as "unmatched". Day 2 goes deeper.

The main http.Server sets ReadTimeout 15s, WriteTimeout 30s, IdleTimeout 60s (lines 677–683).

2.11 Workers (lines 701–779)

startWorker(name, start) launches each ticked loop under worker.Supervise(reconcilerCtx, ...) and records a done channel. worker.Supervise (internal/pkg/worker/supervise.go:35) restarts a worker that returns or panics, with exponential backoff from 1s to 30s. It resets the backoff after a run of at least 2 minutes, and sends at most one Sentry panic report per worker per 5 minutes.

Workers started here: order.reconciler, finance.payout_scheduler, four prescription sweepers, human.refresh_token_purge (every 6h, line 1050), and conditionally hiring.calendar_health_refresher and careers.retention_sweeper. The two job pools were already started during module construction. Only their Done() channels join the wait set.

2.12 Graceful shutdown (lines 797–869)

The key idea is one shared deadline (GRACEFUL_TIMEOUT_SECONDS, default 10). ECS kills the task at its own stop timeout (the comment at line 806 says 30s in this deploy), so stacking a fresh timeout per step could run past that, and the last steps would never happen.


3. Config (internal/config/config.go)

Config is one flat struct with mapstructure tags naming env vars (lines 14–206). Load() (line 325):

  1. viper.SetConfigFile(".env"): a relative path, so it reads .env in the current working directory.
  2. viper.AutomaticEnv(): process environment variables are consulted for every key.
  3. viper.AllowEmptyEnv(true): an env var that is set but empty counts as a value. This one line is what makes safe local runs possible.
  4. SetDefault(...) for about 90 keys (lines 332–422).
  5. ReadInConfig(): a missing .env is fine. A malformed .env returns an error that deliberately does not quote the bad line, because that line could be a secret (lines 424–437).
  6. Unmarshal, then derive durations (JWTExpiry, GracefulTimeout, RequestTimeout, pool lifetimes).

Precedence, highest first:

SourceExample
Exported process env, including emptyexport ZEPTO_API_KEY=""
.env in the current directoryZEPTO_API_KEY=live...
SetDefaultZEPTO_API_KEY""

Defaults worth memorising:

KeyDefaultWhy it matters
DATABASE_URLpostgres://postgres:postgres@localhost:5432/medyzen?sslmode=disabledocker-compose publishes Postgres on 5434, not 5432
JWT_SECRETchange-me-in-productionRefused by the validator, so you must set a real one
PAYMENT_PROVIDERmockbut .env.example sets razorpay
RBAC_WHITELIST_MODEenforceneeds a migrated DB at boot
COOKIE_SECUREtrueset false for plain-http local panels
SERVER_PORT / ADMIN_PORT8080 / 6060admin binds loopback only
JWT_EXPIRY_HOURS1
CAREERS_RETENTION_SWEEP_ENABLEDfalsea scratch run must not start hard-deleting rows
SENTRY_ENVIRONMENTdevelopment

The validators (ValidateJWTSecret, ValidateTrustedProxySecret, ValidateCORSOrigins, ValidateAdminPort) live in the same file and are called from main.go, not from Load(). So a -migrate run never trips them.


4. Database layer (internal/database)

4.1 NewGormDB (db.go:56)

  • Opens Postgres through GORM with PrepareStmt: true (prepared statements are cached per connection, so a transaction-mode pooler like PgBouncer would break it).
  • Applies pool limits from config (defaults 20 open, 5 idle, 30 min lifetime, 5 min idle time).
  • Pings and fails fast.
  • Logger: warn level, slow threshold 200ms, ParameterizedQueries: true, so slow-query logs show $1 instead of patient data.
  • init() (lines 27–41) also overrides the global gormlogger.RecorderParamsFilter. Without that, .Raw(...).Scan(...) calls bypass the setting above and write bound values into logs. This is a GORM quirk. The fix is process-wide by design.

4.2 The provider (provider.go)

DB holds a primary pool, an optional replica, and optional per-tenant pools. Resolve(ctx) picks, in order: the transaction carried in ctx → a tenant pool → the replica if ContextReadOnly(ctx) → primary. Today main.go builds it with only a primary (database.NewDB(primary)) and passes Primary() to modules. The replica and tenant options exist but are not wired in main.go (not verified whether anything else uses Resolve).

4.3 Transactions carried in context (tx.go)

go
func RunInTx(ctx context.Context, db *gorm.DB, fn func(ctx context.Context) error) error
func RunInTxJoining(ctx context.Context, db *gorm.DB, fn func(ctx context.Context) error) error
func DBFromContext(ctx context.Context, fallback *gorm.DB) *gorm.DB
func HasTx(ctx context.Context) bool

The transaction handle travels inside ctx. Repositories never take a tx argument. They always start with database.DBFromContext(ctx, r.db), as in internal/leads/repositories/lead.go:42. So:

  • A service opens RunInTx, calls three repositories with txCtx, and all three writes commit or roll back together.
  • The same repository method works inside or outside a transaction with no code change.
  • RunInTxJoining lets service A call service B, and B joins A's transaction if one is open instead of starting its own.

Rule from docs/CODE_STANDARDS.md: no network I/O inside RunInTx. Commit locally, call the gateway afterwards, and reconcile. Day 3 shows the payment outbox that follows this rule.

errors.go has one helper, IsUniqueViolation(err), which string-matches SQLSTATE 23505.

4.4 Migrations and golang-migrate

  • Files: internal/database/migrations/NNN_description.up.sql and .down.sql. Latest today: 295_add_orders_order_no_trgm_index. Version 290 is unused (a harmless gap).
  • Runner: runMigrations in cmd/server/main.go:886 uses migrate.New("file://internal/database/migrations", dbURL). That path is relative to the working directory, so run it from the repo root. The Docker image copies the directory to /app/internal/database/migrations for the same reason (Dockerfile).
  • State: golang-migrate keeps a single row in schema_migrations(version, dirty). Up() applies every file whose version is greater than the stored version, in order. It does not keep a list of applied files.
  • A failed migration leaves dirty = true, and further runs refuse. -migrate-force N clears it once you have repaired the schema by hand.
  • reconcile027Collision (line 920) is a one-off repair for databases that applied the procurement migrations under old numbers 027/028. It only acts when the version is 27/28, procurement_requests exists and documents does not.
  • Integration tests use the same files: internal/database/testdb/testdb.go runs m.Up() against TEST_DATABASE_URL.
  • In production, migrations auto-run on deploy (Day 7). That is why ops/pending-migrations/ exists.

Look at a real pair: 038_create_leads.up.sql creates leads with id SERIAL PRIMARY KEY, document_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), status VARCHAR(20) NOT NULL DEFAULT 'new', TIMESTAMPTZ timestamps, and three indexes. 038_create_leads.down.sql is DROP TABLE IF EXISTS leads;.


5. The module pattern, worked through internal/leads

leads captures contact-form submissions from the marketing site and lets super admins list, export and update them. It is small (about 600 lines without tests) but uses every layer.

internal/leads/
├── module.go                 NewModule + RegisterRoutes
├── routes/lead.go            URL patterns + per-route middleware
├── handlers/lead.go          HTTP in/out only
├── services/lead.go          business rules, ports (Mailer, Access)
├── repositories/lead.go      GORM queries, LeadFilters
├── models/lead.go, base.go   GORM model + status constants
└── dtos/lead.go              request DTOs, LeadView, builders

5.1 module.go

go
func NewModule(ctx context.Context, db *gorm.DB, mailer services.Mailer, rateLimit float64,
    rateBurst int, trustProxy bool, jwtSecret string, access services.Access) *Module {
    svc := services.NewLeadService(db, mailer, access)
    return &Module{
        Handler:   handlers.NewLeadHandler(svc),
        limiter:   middleware.NewRateLimiter(ctx, rate.Limit(rateLimit), rateBurst, trustProxy),
        jwtSecret: jwtSecret,
        blacklist: common.NewTokenBlacklist(),
    }
}

The module is the composition root for its own layers: it builds the service (which builds its repository), the handler, and the per-module rate limiter. ctx is reconcilerCtx from main.go, so the limiter's cleanup goroutine stops at shutdown. RegisterRoutes(mux router.Registrar) hands everything to routes.Register.

5.2 routes/lead.go

go
mux.Handle("POST /leads/api/v1/leads", limiter.Limit(http.HandlerFunc(h.Create)))
authMw := middleware.Auth(jwtSecret, blacklist)
mux.Handle("GET /leads/api/v1/leads", authMw(http.HandlerFunc(h.List)))
mux.Handle("PATCH /leads/api/v1/leads/{id}/status", authMw(http.HandlerFunc(h.UpdateStatus)))
mux.Handle("GET /leads/api/v1/leads/export", authMw(http.HandlerFunc(h.Export)))
  • Go 1.22+ ServeMux patterns: method + path, {id} wildcards read with r.PathValue("id").
  • URL convention: /<module>/api/v1/<resource>.
  • The public POST is only rate limited. The rest require a JWT, and Auth also runs the RBAC whitelist check.

5.3 handlers/lead.go

A handler does four things: parse, identify the caller, call the service, write the response. No SQL, no business rules.

go
func (h *LeadHandler) Create(w http.ResponseWriter, r *http.Request) {
    req, ok := handler.Decode[dtos.CreateLeadRequest](w, r)
    if !ok {
        return
    }
    out, err := h.svc.Submit(r.Context(), req)
    if err != nil {
        response.ServiceError(w, r, err)
        return
    }
    response.Created(w, dtos.BuildLeadView(out))
}

List shows the other helpers: request.Actor(r) builds a common.Actor from JWT claims, request.Pagination(r) clamps page (1..10000) and limit (1..100, default 20), and handler.List(...) writes either the error or a paginated envelope. Export streams CSV through csvsafe.NewWriter, which guards against spreadsheet formula injection (Day 5).

5.4 services/lead.go

  • Defines its own ports: Mailer and Access. The service does not import human services or the mailer package.
  • Submit builds the model, calls repo.Create, then emails founders. A mail failure is logged, not returned, so the lead is still saved.
  • List/Export call requireLiveSuperAdmin, which checks the token claims and asks Access.IsSuperAdmin live, so a revoked admin with an unexpired token is refused.
  • UpdateStatus validates the status but has no super-admin check in the service. Its only authorization is the route's Auth + RBAC whitelist. That is consistent with CODE_STANDARDS.md (gate mutations with capabilities/whitelist), but worth noticing.
  • wrap(ctx, err) passes a *ServiceError through unchanged. Anything else is logged with the request id and replaced by ErrInternal. Raw DB errors never reach the client.
  • Maps dbrepo.ErrRecordNotFound to the domain error svcerrors.ErrLeadNotFound.

5.5 repositories/lead.go

  • Exposes an interface (LeadRepository) and a private implementation (leadRepo). Services depend on the interface, so unit tests can fake it (see services/lead_test.go).
  • LeadFilters sits here. CODE_STANDARDS.md says filter structs belong in repositories/filters.go. leads keeps it in lead.go, a small deviation.
  • Every query starts database.DBFromContext(ctx, r.db).WithContext(ctx), which makes it transaction-aware and cancellable.
  • dbrepo.ValidateInsertResult / ValidateSingleResult (internal/pkg/dbrepo/dbrepo.go) turn "0 rows" into explicit errors.
  • ListForExport caps at exportMaxRows = 50000.

5.6 models and dtos

models.Lead embeds three shared mixins from internal/pkg/dbmodel:

MixinColumnJSON
ColumnsPrimaryKeySerialid int64 autoIncrement"id"
ColumnsDocumentIDdocument_id uuid, default gen_random_uuid()"document_id"
ColumnsTimestampscreated_at autoCreateTime, updated_at autoUpdateTimeyes

There is also ColumnsPrimaryKeyUUID, whose id is tagged json:"-" (hidden). CODE_STANDARDS.md warns about that for sub-resources that clients need to address.

The public id is the UUID document_id, never the serial id. Look at dtos.BuildLeadView: ID: l.DocumentID.String(). The handler reads {id} with request.PathUUID and the repository looks up WHERE document_id = ?. Serial ids stay internal, so they cannot be enumerated.

DTOs carry validate:"required,email" tags. The model is never returned directly. LeadView is the response contract, so changing it is a contract change (contract-keeper in CLAUDE.md).

5.7 One request, end to end


6. Errors and responses

6.1 internal/errors

go
type ServiceError struct {
    Code      int
    Message   string
    ErrorCode string
    Category  string
    Details   any
}
  • Code is the HTTP status. ErrorCode is a stable machine string such as NOT_FOUND_GENERIC or AUTHZ_FORBIDDEN. Category is one of validation, auth, authz, not_found, conflict, rate_limit, upstream, internal.
  • Sentinels are package-level vars, for example ErrForbidden (line 196), ErrNotAuthenticated (169), ErrLeadNotFound (311), ErrInvalidLeadStatus (312).
  • StatusCode(err) (line 464) returns 500 for anything that is not a *ServiceError.
  • LogAndWrap(ctx, original, svcErr) logs the real error with the request id and returns the safe one.
  • taxonomy_test.go parses errors.go with go/ast and fails if any sentinel lacks a code, category or message. Frontends switch on error.code, so an empty one is a bug.

Note that services compare with == against sentinels (err == dbrepo.ErrRecordNotFound) and use type assertions (err.(*svcerrors.ServiceError)) rather than errors.As. A wrapped ServiceError would therefore be treated as internal.

6.2 internal/pkg/response

Every JSON response uses one envelope:

json
{ "success": false, "data": null, "message": "lead not found",
  "error": { "code": "NOT_FOUND_GENERIC", "message": "lead not found",
             "category": "not_found", "correlationId": "<request id>" } }
HelperStatusUse
Success(w, data)200normal read/update
Created(w, data)201the thing now exists
Accepted(w, data)202an obligation is recorded, work not done yet
Paginated(w, data, total, page, limit)200offset lists (flat: total, page, limit at top level)
Cursor(w, data, nextCursor, limit)200cursor lists
Error(w, r, status, msg)anyhandler-level failures (bad path param, bad body)
ServiceError(w, r, err)from erranything a service returned

ServiceError does three safety things: a non-ServiceError 5xx has its message replaced with "internal server error", the real error is logged (Error level for 5xx, Info below), and correlationId comes from the request id so support can find the log line.

6.3 pkg/handler, pkg/request, pkg/validator

  • request.Bind[T] wraps the body in http.MaxBytesReader at 1 MiB, decodes JSON, runs validator.Struct, then calls Validate() if *T implements Validatable. Decode errors become a generic "invalid request body", so parser details are not echoed.
  • handler.Decode[T] turns a bind failure into 400, or 413 for ErrBodyTooLarge.
  • handler.OK, handler.Created, handler.List collapse the usual "if err → ServiceError else success" pattern.
  • validator (go-playground v10) uses the json tag name in messages ("email must be a valid email") and returns only the first failing field. It also teaches the validator to compare decimal.Decimal as a float.

6.4 pkg/logger, pkg/pagination

  • logger.Log is a global logrus JSON logger on stdout. Use logger.WithContext(ctx) inside request code so request_id is attached.
  • pagination.Cursor{CreatedAt, ID} encodes as base64url of "<unixnano>:<id>". Decode("") returns nil, nil (first page). A cursor built from an in-memory time.Now() holds nanoseconds, but the row in Postgres holds microseconds (Trap 3).

7. Running locally

What you need: Go 1.26, Docker, psql and openssl.

What the code requires before main() will serve:

RequirementWhere enforced
Reachable Postgres, migrated to headNewGormDB ping; permission store load at main.go:613
JWT_SECRET ≥ 32 bytes, not the defaultconfig.go:213
PATIENT_ENCRYPTION_KEY + PATIENT_BLIND_INDEX_KEY, 64 hex chars eachmain.go:244-255, crypto/aead.go:25
No * in CORS_ORIGINSconfig.go:253
If SMS_PROVIDER=twofactor, its key + templatemain.go:1182
If REDIS_URL set, OTP_PEPPER setmain.go:155
Run from repo root (for .env and file://internal/database/migrations)config.go:326, main.go:887

About docker-compose.yml: the db service is useful (Postgres 17 on host port 5434). The api service sets JWT_SECRET: change-me-in-production and no patient keys, so by reading the code it would exit at ValidateJWTSecret, and then at the patient-key check. Not verified by running it. Use compose for the database and run the server yourself.

Safe recipe (used in the exercises): build the binary, then run it from a scratch directory that has no .env and a copy of the migrations. Then config.Load() cannot pick up a real key even if you forget to blank one.

bash
cd ~/src/medyzen-backend
docker compose up -d db
go build -o /tmp/mz-scratch/server ./cmd/server
mkdir -p /tmp/mz-scratch/internal/database
cp -R internal/database/migrations /tmp/mz-scratch/internal/database/
cd /tmp/mz-scratch
ls -a | grep -c '^\.env$'

export DATABASE_URL='postgres://postgres:postgres@localhost:5434/medyzen?sslmode=disable'
export JWT_SECRET="$(openssl rand -hex 32)"
export PATIENT_ENCRYPTION_KEY="$(openssl rand -hex 32)"
export PATIENT_BLIND_INDEX_KEY="$(openssl rand -hex 32)"
export COOKIE_SECURE=false PAYMENT_PROVIDER=mock SMS_PROVIDER=none
export REDIS_URL= ZEPTO_API_KEY= R2_ACCOUNT_ID= R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= \
  RAZORPAY_KEY_ID= RAZORPAY_KEY_SECRET= RAZORPAY_WEBHOOK_SECRET= TWOFACTOR_API_KEY= \
  GEMINI_API_KEY= SENTRY_DSN= EXPO_PUSH_ACCESS_TOKEN= PUSH_NOTIFICATIONS_ENABLED=false \
  DEV_MASTER_OTP= EMERGENCY_OTP= REVIEWER_OTP= DELIVERY_REVIEWER_OTP= TURNSTILE_SECRET_KEY= \
  HIRING_CALENDAR_SA_JSON= KYC_ENCRYPTION_KEY= KYC_BLIND_INDEX_KEY= OTP_PEPPER=

./server -migrate up
./server

The grep -c line should print 0. If you must run from the repo root with go run, export the blank list above first. The empty exports win over .env because of AllowEmptyEnv(true), which internal/config/isolation_test.go proves.

Useful Makefile targets: make build, make run, make test (race, no DB), make test-integration TEST_DATABASE_URL=... (runs every migration against that URL, so use a throwaway DB), make lint, make swagger / make swagger-check, make migrate-up, make seed-admin EMAIL=.... Note make run and make migrate-up are go run from the repo root, so they read .env.


Traps

Trap 1 — config.Load() reads the real .env

What: Load() reads .env from the working directory for every key the process environment does not set (config.go:326-329). The developer .env in this repo holds live third-party keys (email, storage, payments, SMS). A "scratch" server started from the repo root with only DATABASE_URL changed still dials out with those keys. This has happened: a scratch verification run made a real ZeptoMail call with the production key before anyone noticed.

Why it is easy to miss: the server boots, logs look normal, and nothing tells you which values came from .env.

Defence:

  • Run from a directory without .env (recipe above), or
  • export every secret-bearing variable as empty (export ZEPTO_API_KEY=). Setting it empty is enough, and unset is not: an unset variable falls through to .env.
  • Treat "is the DB isolated?" and "is every other credential isolated?" as two separate checks.
  • -reconcile-payments and anything else that calls a vendor should never run locally with a real key.

Trap 2 — golang-migrate silently skips lower-numbered migrations

What: golang-migrate stores one number in schema_migrations.version and Up() applies only files numbered above it. If 295_... reaches a database first, a 291_... committed later is never applied. No error, "no change", green deploy job, missing schema.

Where it bites here:

  • Two branches or sessions each adding migrations. The higher set deploys first and the lower set is dead on arrival.
  • ops/pending-migrations/index.md tells you to "move both files back into internal/database/migrations/ and deploy". With prod at 295, moving 201_..., 221_... or 254_... back applies nothing. Renumber above the current version first, or apply by hand through the documented one-off task (Day 7).

Defence: before pushing, compare the migration numbers in your unpushed commits with the target's current schema_migrations.version. Push parallel migration sets together, or lowest first. After a deploy, check the version number, not just the job colour.

Trap 3 — Postgres stores microseconds, Go has nanoseconds

What: time.Now() carries nanoseconds. timestamptz keeps microseconds. If a create path returns the in-memory struct and a later GET (or an idempotent retry) returns the DB row, the client sees two different timestamps for one event (.029703906 vs .029703). Tests comparing a created value with a read-back value fail in CI, which looks like flakiness.

Where the fix is applied: at the assignment site:

  • internal/hr/services/self_service.go:93, 333, 362: time.Now().Truncate(time.Microsecond)
  • internal/careers/services/application.go:438: one truncated now drives both CreatedAt and AppliedAt, so the two columns agree to the byte (see the comment at lines 428–437)
  • internal/careers/services/opening.go:235, 373

Also note pagination.Cursor.Encode uses UnixNano(). Build cursors from values read from the DB, not from a fresh time.Now().

Defence: when you set a timestamp in Go that is also returned or compared, truncate to time.Microsecond when you assign it. Don't loosen the test.

Trap 4 — GORM default:true bools can never be created as false

What: in GORM v1.31.1 Create, any field whose Go value is the zero value and whose tag has a parsed default: gets the default substituted (gorm.io/gorm@v1.31.1/callbacks/create.go:298-300: if ..., isZero = field.ValueOf(...); isZero { if field.DefaultValueInterface != nil { ... = field.DefaultValueInterface). For a bool, false is the zero value. So IsActive: false on a model tagged default:true is written as true, silently. Save/Update are not affected.

Real damage: HospitalBankDetail.IsPrimary once carried the tag, so every bank record created became a primary. It is now gorm:"column:is_primary;not null" (internal/assets/models/hospital_bank_detail.go:23), relying on the DB column default. ops/pending-migrations/201_... exists to repair order_items.requires_prescription rows hit by the same zero-value bug.

Still present: 18 non-test bool fields carry default:true today, for example internal/inventory/models/batch.go:25, inventory/models/product_variant.go:26, procurement/models/procurement_vendor.go:20, inventory/models/medicine_substitute.go:20. Find them with:

bash
grep -rEn 'bool[[:space:]]+`[^`]*gorm:"[^"]*default:true' --include='*.go' internal | grep -v _test

Defence: if a create path must store false, drop default: from the GORM tag and keep the default in the SQL migration. Tests that seed rows through the same model will not catch this, so assert with a raw SELECT.

Smaller traps from today's reading

  • Relative paths. .env and file://internal/database/migrations are both relative to the working directory. Running the binary from elsewhere means no .env (good) and "migration init error" (bad) unless you copy the directory.
  • Compose port. Compose publishes Postgres on 5434, but the default DATABASE_URL points at 5432.
  • -migrate down needs -steps. make migrate-down uses the default -steps 1. N<=0 is refused.
  • -seed-admin sets the password to changeme for a new employee (main.go:991). Local only.
  • Stale guides. skills/*.md and parts of CLAUDE.md describe Gin and date-named migrations. Copying them gives you code that does not compile or migrations that sort wrong.
  • Wrapped errors. response.ServiceError uses a type assertion, not errors.As. fmt.Errorf("...: %w", svcErr) turns a clean 404 into a 500.

Exercises

All exercises use a local Docker Postgres and a local build. Do not point anything at production or staging. Do not push. If you need to add files (exercise 5), do it in your own separate clone on a local branch and delete them afterwards, because other people edit the shared checkout in parallel.

Exercise 1 — Map the repo (20 min)

  1. List every directory under internal/ and write one line per module saying what it owns. Check your guesses against docs/<module>/index.md where one exists.
  2. Open cmd/server/main.go and, for each module in your list, find the line where it is constructed and the line where RegisterRoutes is called. Which module is constructed but has no RegisterRoutes call in that block? (Hint: look for realtime.)
  3. Find three statements in CLAUDE.md or skills/ that the code contradicts, beyond the ones in the "Docs that lie" table.

Exercise 2 — Prove the .env precedence (15 min, no server)

  1. Read internal/config/isolation_test.go.
  2. Run it: go test ./internal/config -run TestExportedEmptyValueBeatsTheDotEnvFile -v.
  3. In your own words: why does unset ZEPTO_API_KEY not protect you, while export ZEPTO_API_KEY= does? Which viper call makes the difference?

Exercise 3 — Boot the server safely (30 min)

  1. Follow the "Safe recipe" in section 7. Confirm the scratch directory has no .env.
  2. Before running migrations, start ./server once without the patient keys. Record the Fatal message and match it to the line in main.go.
  3. Set JWT_SECRET=change-me-in-production and start again. Record the message and match it to config.go.
  4. Restore the good values, run ./server -migrate up, then ./server.
  5. curl -s localhost:8080/health. You should get a success: true envelope.
  6. Read the boot log and list every WARN line. For each, find the logger.Log.Warn call in main.go and write down which feature is disabled.
  7. Stop with Ctrl-C and match the shutdown log lines to section 2.12.

Exercise 4 — Drive the leads module (25 min)

With the server from exercise 3 running:

  1. curl -s -X POST localhost:8080/leads/api/v1/leads -H 'Content-Type: application/json' -d '{"name":"Test","email":"not-an-email","message":"hi","source_page":"home"}'. Which layer produced the error, and what are error.code and error.category?
  2. Send a valid body. Note the id in the response. Is it the serial id or the document_id? Confirm with psql 'postgres://postgres:postgres@localhost:5434/medyzen' -c 'select id, document_id, status from leads'.
  3. The founders email was attempted with an empty ZEPTO_API_KEY. What did the log say, and why did the request still return 201?
  4. curl -s localhost:8080/leads/api/v1/leads with no token. Which middleware answered?
  5. Send a body over 1 MiB (for example head -c 1100000 /dev/zero | tr '\0' 'a' inside the message field). Which status comes back, and which line of pkg/request/request.go decided it?

Exercise 5 — Reproduce the skipped migration (20 min, own clone, scratch DB)

  1. Create a throwaway database: psql 'postgres://postgres:postgres@localhost:5434/postgres' -c 'create database mz_skip'.
  2. Migrate it to head from your scratch directory with DATABASE_URL pointing at mz_skip. Check select version, dirty from schema_migrations; (expect 295).
  3. In the scratch copy of the migrations only, add 290_demo_skip.up.sql containing CREATE TABLE demo_skip (id int); and a matching .down.sql.
  4. Run -migrate up again. What did it print? Does \d demo_skip exist?
  5. Explain in two sentences what would have to be true for 290 to apply, and how the team avoids this on real deploys.
  6. Delete the two demo files and drop database mz_skip.

Exercise 6 — See the microsecond trap in SQL (10 min)

  1. psql ... -c "select '2026-09-16 10:00:00.123456789+05:30'::timestamptz;". How many fractional digits come back?
  2. In Go (scratch file outside the repo), print time.Now() and time.Now().Truncate(time.Microsecond) and compare.
  3. Find one place in the codebase that sets a timestamp with plain time.Now() on a model that is returned from a create endpoint. Would it show the mismatch? (Not verified: there may or may not be one. Write down what you find.)

Exercise 7 — Audit the default:true fields (15 min, read only)

  1. Run the grep from Trap 4.
  2. For each hit, search the module for a Create call that builds that model with the bool set to false. Mark each field "safe (never created false)", "safe (created via raw SQL)" or "suspect".
  3. Open gorm.io/gorm@v1.31.1/callbacks/create.go around line 298 in your module cache (go env GOMODCACHE) and point at the exact branch that substitutes the default.

Self-check

Answer without looking, then check below.

  1. Name three one-shot flags of cmd/server, and say which one returns before the JWT secret is validated and why that ordering is useful.
  2. What is the precedence between an exported empty env var, .env, and a SetDefault value, and which single viper call creates that behaviour?
  3. Why must the patient PII cipher be registered before any module is constructed?
  4. What are reconcilerCtx and poolCtx, and why is poolCtx cancelled only after the HTTP servers have shut down?
  5. Why are /metrics, /swagger/ and /debug/pprof/ on a separate server, and what address does it bind?
  6. How does a repository participate in a transaction that a service opened, without taking a tx parameter?
  7. In internal/leads, where does a raw GORM error get turned into something safe for the client, and what does the client see?
  8. Why do leads responses expose document_id as id instead of the serial primary key?
  9. golang-migrate is at version 295. Someone merges 292_add_x.up.sql today. What happens on the next -migrate up?
  10. A model has IsActive bool \gorm:"column:is_active;default:true;not null"`. A service creates a row with IsActive: false`. What is stored, and what is the fix?
  11. Why does a create endpoint that returns CreatedAt: time.Now() sometimes disagree with a later GET of the same row?
  12. You run make run from the repo root with DATABASE_URL exported to a scratch DB and nothing else changed. What is the risk?

Answers

  1. For example -migrate, -migrate-force, -preflight, -encrypt-patient-pii, -seed-admin, -revoke-admin, -reconcile-payments, -prune-endpoint-catalog. -preflight, -migrate-force, -migrate and -encrypt-patient-pii all return before ValidateJWTSecret (main.go:118-140). A migration or preflight task can run without the full set of server secrets being valid, and a server-only misconfiguration cannot block a schema change.
  2. Exported (even empty) env beats .env, which beats SetDefault. viper.AllowEmptyEnv(true) (config.go:329) makes an empty exported value count as set. Without it, empty would fall through to .env.
  3. GORM hooks on users, patient_addresses, orders and prescription_extractions encrypt and decrypt through the registered cipher. A query before registration would read or write patient data unsealed. Also, the models no longer map the plaintext columns, so without keys no patient identifier can be read and nobody can log in, which is why missing keys are Fatal.
  4. reconcilerCtx governs cleanup goroutines and ticked workers and is cancelled as soon as the signal arrives. poolCtx governs the prescription-extraction and hiring-calendar job pools. Requests still draining after the signal can enqueue jobs, and a pool stopped early would accept the row write but never process the job. So stopPools() runs only after both Shutdown calls return.
  5. pprof can pin a CPU (DoS) and heap dumps would expose decrypted patient data and the JWT secret. Swagger and metrics are internal too. They bind to 127.0.0.1:ADMIN_PORT (default 6060), and only when one of the three flags is on.
  6. database.RunInTx stores the *gorm.DB transaction in ctx. Repositories call database.DBFromContext(ctx, r.db), which returns the transaction if present and the pool otherwise.
  7. In services/lead.go wrap(): a non-ServiceError goes through svcerrors.LogAndWrap, which logs the original with the request id and returns ErrInternal. response.ServiceError then writes 500 with code: INTERNAL_ERROR, category: internal, message "internal server error", and the correlationId.
  8. Serial ids are guessable and leak volume. The UUID document_id is the stable public identifier. Lookups by path use request.PathUUID and WHERE document_id = ?.
  9. Nothing. golang-migrate only applies versions above 295, so 292 is skipped with no error and the schema silently lacks it. Fix it by renumbering above the current version (or applying it deliberately by hand), and prevent it by pushing parallel migration sets together or lowest first.
  10. true. GORM Create replaces the zero value false with the parsed tag default. Fix: remove default:true from the GORM tag and keep DEFAULT true in the SQL migration. Save/Update do not have this problem.
  11. Go's time.Now() has nanosecond precision but timestamptz stores microseconds, so the in-memory value has three extra digits compared with the stored one. Truncate with .Truncate(time.Microsecond) when you assign it.
  12. make run is go run ./cmd/server from the repo root, so config.Load() reads the real .env for every other key: ZeptoMail, R2, Razorpay, 2Factor, Gemini, Sentry and so on. The "scratch" server can send real email or SMS, write real objects, or call the payment gateway. Blank every secret with an empty export, or run from a directory without .env.

Internal — written against the code, not the other way around.