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:
- Say what lives in
cmd/,internal/,internal/pkg/,deploy/,ops/,docs/andskills/, and which of those are out of date. - Walk through
cmd/server/main.gofromflag.Parse()tosqlDB.Close(), and explain why the steps come in that order. - Explain how
config.Load()decides a value (process env, then.env, then default), and why that makes local runs dangerous. - Describe the database layer:
NewGormDB, theDBprovider,RunInTx/RunInTxJoining/DBFromContext, and how golang-migrate applies numbered SQL files. - Trace one request through a whole module (
internal/leads): module → routes → handler → service → repository → model/DTO, then back out throughinternal/errorsandinternal/pkg/response. - Run the server against a local Postgres with every secret blanked, apply migrations, and hit
/health. - Name the four foundation traps (
.envleakage, skipped lower migrations, microsecond timestamps, GORMdefault:true) and show where each one lives in the code.
Reading order
Open these in order. Keep this lesson open next to them.
| # | File | What to look for |
|---|---|---|
| 1 | CLAUDE.md | The intended layering (Routes → Handlers → Services → Repositories → Models). Note which parts have drifted from the code (see "Docs that lie" below). |
| 2 | docs/CODE_STANDARDS.md | Types vs logic files, capabilities vs scoping, "no external I/O inside a transaction", money as decimal(12,2). |
| 3 | cmd/server/main.go lines 88–151 | Flags, config load, Sentry, the one-shot commands that return before the server is built, and the startup validators. |
| 4 | internal/config/config.go lines 325–453 | Load(): viper, AutomaticEnv, AllowEmptyEnv(true), the defaults, the malformed-.env error that hides the line. |
| 5 | internal/config/isolation_test.go | The test that proves an exported empty value beats .env. This is the key to safe local runs. |
| 6 | cmd/server/main.go lines 153–255 | Redis, the DB pool, -seed-admin, R2 clients, and the two PII ciphers (patient keys are mandatory). |
| 7 | internal/database/db.go, provider.go, tx.go, errors.go | GORM setup, logger hardening, the multi-pool provider, the context-carried transaction. |
| 8 | cmd/server/main.go lines 257–521 | Module construction order, two background contexts, setter-based wiring for cycles. |
| 9 | cmd/server/main.go lines 523–696 | Health check, route collector, RBAC catalog sync, the middleware chain, the loopback admin server. |
| 10 | cmd/server/main.go lines 698–869 | Workers, signal handling, the single shared shutdown deadline. |
| 11 | cmd/server/main.go lines 871–947 | runMigrations, forceMigrationVersion, reconcile027Collision. |
| 12 | internal/leads/ (all non-test files, ~600 lines) | The worked example module. Read module.go first, then routes, handlers, services, repositories, models, dtos. |
| 13 | internal/errors/errors.go, log.go | ServiceError, codes, categories, sentinels, StatusCode, LogAndWrap. |
| 14 | internal/pkg/response/response.go, pkg/handler/handler.go, pkg/request/request.go, pkg/validator/struct.go | The response envelope, Decode[T], Bind[T], pagination clamping, validation messages. |
| 15 | internal/pkg/logger/logger.go, pkg/pagination/cursor.go, pkg/router/router.go, pkg/dbmodel/dbmodel.go, pkg/dbrepo/dbrepo.go, pkg/worker/supervise.go | The small shared libraries every module leans on. |
| 16 | Makefile, docker-compose.yml, Dockerfile | How 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.exampleA few things worth knowing up front:
- One binary, many modes.
cmd/serveris 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/pkgis shared plumbing,internal/<module>is business logic. A module may importinternal/pkg/...,internal/errors,internal/databaseandinternal/middleware. Modules talk to each other through small interfaces ("ports") thatmain.gofills 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, andCREATE INDEX CONCURRENTLYstatements that cannot run inside golang-migrate. Read itsindex.md, and see Trap 2 before following it.- Migrations directory holds
_test.gofiles too.internal/database/migrations/has 639 files:NNN_name.up.sql/.down.sqlpairs plus Go tests for many RBAC seed migrations (for example075_rbac_test.go). golang-migrate's file source only picks up names matching itsversion_name.up|down.extpattern, so the.gofiles 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:
| Document | Claim | Reality |
|---|---|---|
CLAUDE.md Tech Stack | Config via joho/godotenv | internal/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.md | Gin 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.md | YYYYMMDD_ names; UUID primary keys with gorm.DeletedAt | Sequential NNN_. Many tables use SERIAL id + document_id UUID (see leads). |
docs/infrastructure/server-startup.md | Old 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)
| Flag | What it does | Needs DB? |
|---|---|---|
-migrate up / -migrate down -steps N | Runs golang-migrate and exits. down refuses N<=0 (lines 905–908). | Yes (URL only) |
-migrate-force N | Clears a dirty schema_migrations row by forcing version N. | Yes |
-preflight | Asserts 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 EMAIL | Creates or promotes an employee to super_admin, or revokes it. | Yes |
-reconcile-payments YYYY-MM-DD | Reconciles one IST day against the gateway. Calls Razorpay when configured. | Yes |
-prune-endpoint-catalog | Syncs 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 defaultchange-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_PEPPERmust 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_KEYandPATIENT_BLIND_INDEX_KEYmust 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)
reconcilerCtx, stopReconciler := context.WithCancel(context.Background())
poolCtx, stopPools := context.WithCancel(context.Background())reconcilerCtxis 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.poolCtxis 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:
- Constructor injection of ports.
leads.NewModule(..., humanMod.Access)passeshuman's access service, butleadsonly sees it as its own tiny interfaceservices.Access { IsSuperAdmin(...) bool }(internal/leads/services/lead.go:27-29).hospitalcatalogdoes the same withservices.HospitalAccessininternal/hospitalcatalog/services/ports.go. - 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 thathiringimportscareersand never the reverse). - Adapters in
main.go. When a port's shape does not match the provider's, a small adapter type at the bottom ofmain.gotranslates:prescriptionPageAdapter(line 1073),hrAccountControl(line 1095),procurementNotifier(line 1154), and the inlinesubBridge(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 /healthis 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 throughcollector.Module("name"), which recordsMETHOD path → modulebefore callingmux.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.ValidateWhitelistModerefuses to boot with role-level authorization disabled unless explicitly allowed. DefaultRBAC_WHITELIST_MODEisenforce(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):
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):
viper.SetConfigFile(".env"): a relative path, so it reads.envin the current working directory.viper.AutomaticEnv(): process environment variables are consulted for every key.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.SetDefault(...)for about 90 keys (lines 332–422).ReadInConfig(): a missing.envis fine. A malformed.envreturns an error that deliberately does not quote the bad line, because that line could be a secret (lines 424–437).Unmarshal, then derive durations (JWTExpiry,GracefulTimeout,RequestTimeout, pool lifetimes).
Precedence, highest first:
| Source | Example |
|---|---|
| Exported process env, including empty | export ZEPTO_API_KEY= → "" |
.env in the current directory | ZEPTO_API_KEY=live... |
SetDefault | ZEPTO_API_KEY → "" |
Defaults worth memorising:
| Key | Default | Why it matters |
|---|---|---|
DATABASE_URL | postgres://postgres:postgres@localhost:5432/medyzen?sslmode=disable | docker-compose publishes Postgres on 5434, not 5432 |
JWT_SECRET | change-me-in-production | Refused by the validator, so you must set a real one |
PAYMENT_PROVIDER | mock | but .env.example sets razorpay |
RBAC_WHITELIST_MODE | enforce | needs a migrated DB at boot |
COOKIE_SECURE | true | set false for plain-http local panels |
SERVER_PORT / ADMIN_PORT | 8080 / 6060 | admin binds loopback only |
JWT_EXPIRY_HOURS | 1 | |
CAREERS_RETENTION_SWEEP_ENABLED | false | a scratch run must not start hard-deleting rows |
SENTRY_ENVIRONMENT | development |
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$1instead of patient data. init()(lines 27–41) also overrides the globalgormlogger.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)
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) boolThe 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 withtxCtx, and all three writes commit or roll back together. - The same repository method works inside or outside a transaction with no code change.
RunInTxJoininglets 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.sqland.down.sql. Latest today:295_add_orders_order_no_trgm_index. Version 290 is unused (a harmless gap). - Runner:
runMigrationsincmd/server/main.go:886usesmigrate.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/migrationsfor 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 Nclears 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_requestsexists anddocumentsdoes not.- Integration tests use the same files:
internal/database/testdb/testdb.gorunsm.Up()againstTEST_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, builders5.1 module.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
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+
ServeMuxpatterns: method + path,{id}wildcards read withr.PathValue("id"). - URL convention:
/<module>/api/v1/<resource>. - The public POST is only rate limited. The rest require a JWT, and
Authalso 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.
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:
MailerandAccess. The service does not importhumanservices or the mailer package. Submitbuilds the model, callsrepo.Create, then emails founders. A mail failure is logged, not returned, so the lead is still saved.List/ExportcallrequireLiveSuperAdmin, which checks the token claims and asksAccess.IsSuperAdminlive, so a revoked admin with an unexpired token is refused.UpdateStatusvalidates the status but has no super-admin check in the service. Its only authorization is the route'sAuth+ RBAC whitelist. That is consistent withCODE_STANDARDS.md(gate mutations with capabilities/whitelist), but worth noticing.wrap(ctx, err)passes a*ServiceErrorthrough unchanged. Anything else is logged with the request id and replaced byErrInternal. Raw DB errors never reach the client.- Maps
dbrepo.ErrRecordNotFoundto the domain errorsvcerrors.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 (seeservices/lead_test.go). LeadFilterssits here.CODE_STANDARDS.mdsays filter structs belong inrepositories/filters.go.leadskeeps it inlead.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.ListForExportcaps atexportMaxRows = 50000.
5.6 models and dtos
models.Lead embeds three shared mixins from internal/pkg/dbmodel:
| Mixin | Column | JSON |
|---|---|---|
ColumnsPrimaryKeySerial | id int64 autoIncrement | "id" |
ColumnsDocumentID | document_id uuid, default gen_random_uuid() | "document_id" |
ColumnsTimestamps | created_at autoCreateTime, updated_at autoUpdateTime | yes |
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
type ServiceError struct {
Code int
Message string
ErrorCode string
Category string
Details any
}Codeis the HTTP status.ErrorCodeis a stable machine string such asNOT_FOUND_GENERICorAUTHZ_FORBIDDEN.Categoryis one ofvalidation, 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.goparseserrors.gowithgo/astand fails if any sentinel lacks a code, category or message. Frontends switch onerror.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:
{ "success": false, "data": null, "message": "lead not found",
"error": { "code": "NOT_FOUND_GENERIC", "message": "lead not found",
"category": "not_found", "correlationId": "<request id>" } }| Helper | Status | Use |
|---|---|---|
Success(w, data) | 200 | normal read/update |
Created(w, data) | 201 | the thing now exists |
Accepted(w, data) | 202 | an obligation is recorded, work not done yet |
Paginated(w, data, total, page, limit) | 200 | offset lists (flat: total, page, limit at top level) |
Cursor(w, data, nextCursor, limit) | 200 | cursor lists |
Error(w, r, status, msg) | any | handler-level failures (bad path param, bad body) |
ServiceError(w, r, err) | from err | anything 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 inhttp.MaxBytesReaderat 1 MiB, decodes JSON, runsvalidator.Struct, then callsValidate()if*TimplementsValidatable. 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 forErrBodyTooLarge.handler.OK,handler.Created,handler.Listcollapse 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 comparedecimal.Decimalas a float.
6.4 pkg/logger, pkg/pagination
logger.Logis a global logrus JSON logger on stdout. Uselogger.WithContext(ctx)inside request code sorequest_idis attached.pagination.Cursor{CreatedAt, ID}encodes as base64url of"<unixnano>:<id>".Decode("")returnsnil, nil(first page). A cursor built from an in-memorytime.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:
| Requirement | Where enforced |
|---|---|
| Reachable Postgres, migrated to head | NewGormDB ping; permission store load at main.go:613 |
JWT_SECRET ≥ 32 bytes, not the default | config.go:213 |
PATIENT_ENCRYPTION_KEY + PATIENT_BLIND_INDEX_KEY, 64 hex chars each | main.go:244-255, crypto/aead.go:25 |
No * in CORS_ORIGINS | config.go:253 |
If SMS_PROVIDER=twofactor, its key + template | main.go:1182 |
If REDIS_URL set, OTP_PEPPER set | main.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.
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
./serverThe 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, andunsetis not: an unset variable falls through to.env. - Treat "is the DB isolated?" and "is every other credential isolated?" as two separate checks.
-reconcile-paymentsand 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.mdtells you to "move both files back intointernal/database/migrations/and deploy". With prod at 295, moving201_...,221_...or254_...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 truncatednowdrives bothCreatedAtandAppliedAt, 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:
grep -rEn 'bool[[:space:]]+`[^`]*gorm:"[^"]*default:true' --include='*.go' internal | grep -v _testDefence: 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.
.envandfile://internal/database/migrationsare 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_URLpoints at 5432. -migrate downneeds-steps.make migrate-downuses the default-steps 1.N<=0is refused.-seed-adminsets the password tochangemefor a new employee (main.go:991). Local only.- Stale guides.
skills/*.mdand parts ofCLAUDE.mddescribe Gin and date-named migrations. Copying them gives you code that does not compile or migrations that sort wrong. - Wrapped errors.
response.ServiceErroruses a type assertion, noterrors.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)
- List every directory under
internal/and write one line per module saying what it owns. Check your guesses againstdocs/<module>/index.mdwhere one exists. - Open
cmd/server/main.goand, for each module in your list, find the line where it is constructed and the line whereRegisterRoutesis called. Which module is constructed but has noRegisterRoutescall in that block? (Hint: look forrealtime.) - Find three statements in
CLAUDE.mdorskills/that the code contradicts, beyond the ones in the "Docs that lie" table.
Exercise 2 — Prove the .env precedence (15 min, no server)
- Read
internal/config/isolation_test.go. - Run it:
go test ./internal/config -run TestExportedEmptyValueBeatsTheDotEnvFile -v. - In your own words: why does
unset ZEPTO_API_KEYnot protect you, whileexport ZEPTO_API_KEY=does? Which viper call makes the difference?
Exercise 3 — Boot the server safely (30 min)
- Follow the "Safe recipe" in section 7. Confirm the scratch directory has no
.env. - Before running migrations, start
./serveronce without the patient keys. Record the Fatal message and match it to the line inmain.go. - Set
JWT_SECRET=change-me-in-productionand start again. Record the message and match it toconfig.go. - Restore the good values, run
./server -migrate up, then./server. curl -s localhost:8080/health. You should get asuccess: trueenvelope.- Read the boot log and list every
WARNline. For each, find thelogger.Log.Warncall inmain.goand write down which feature is disabled. - 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:
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 areerror.codeanderror.category?- Send a valid body. Note the
idin the response. Is it the serialidor thedocument_id? Confirm withpsql 'postgres://postgres:postgres@localhost:5434/medyzen' -c 'select id, document_id, status from leads'. - The founders email was attempted with an empty
ZEPTO_API_KEY. What did the log say, and why did the request still return 201? curl -s localhost:8080/leads/api/v1/leadswith no token. Which middleware answered?- Send a body over 1 MiB (for example
head -c 1100000 /dev/zero | tr '\0' 'a'inside themessagefield). Which status comes back, and which line ofpkg/request/request.godecided it?
Exercise 5 — Reproduce the skipped migration (20 min, own clone, scratch DB)
- Create a throwaway database:
psql 'postgres://postgres:postgres@localhost:5434/postgres' -c 'create database mz_skip'. - Migrate it to head from your scratch directory with
DATABASE_URLpointing atmz_skip. Checkselect version, dirty from schema_migrations;(expect 295). - In the scratch copy of the migrations only, add
290_demo_skip.up.sqlcontainingCREATE TABLE demo_skip (id int);and a matching.down.sql. - Run
-migrate upagain. What did it print? Does\d demo_skipexist? - Explain in two sentences what would have to be true for 290 to apply, and how the team avoids this on real deploys.
- Delete the two demo files and
drop database mz_skip.
Exercise 6 — See the microsecond trap in SQL (10 min)
psql ... -c "select '2026-09-16 10:00:00.123456789+05:30'::timestamptz;". How many fractional digits come back?- In Go (scratch file outside the repo), print
time.Now()andtime.Now().Truncate(time.Microsecond)and compare. - 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)
- Run the grep from Trap 4.
- For each hit, search the module for a
Createcall that builds that model with the bool set tofalse. Mark each field "safe (never created false)", "safe (created via raw SQL)" or "suspect". - Open
gorm.io/gorm@v1.31.1/callbacks/create.goaround 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.
- 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. - What is the precedence between an exported empty env var,
.env, and aSetDefaultvalue, and which single viper call creates that behaviour? - Why must the patient PII cipher be registered before any module is constructed?
- What are
reconcilerCtxandpoolCtx, and why ispoolCtxcancelled only after the HTTP servers have shut down? - Why are
/metrics,/swagger/and/debug/pprof/on a separate server, and what address does it bind? - How does a repository participate in a transaction that a service opened, without taking a
txparameter? - In
internal/leads, where does a raw GORM error get turned into something safe for the client, and what does the client see? - Why do
leadsresponses exposedocument_idasidinstead of the serial primary key? - golang-migrate is at version 295. Someone merges
292_add_x.up.sqltoday. What happens on the next-migrate up? - A model has
IsActive bool \gorm:"column:is_active;default:true;not null"`. A service creates a row withIsActive: false`. What is stored, and what is the fix? - Why does a create endpoint that returns
CreatedAt: time.Now()sometimes disagree with a later GET of the same row? - You run
make runfrom the repo root withDATABASE_URLexported to a scratch DB and nothing else changed. What is the risk?
Answers
- For example
-migrate,-migrate-force,-preflight,-encrypt-patient-pii,-seed-admin,-revoke-admin,-reconcile-payments,-prune-endpoint-catalog.-preflight,-migrate-force,-migrateand-encrypt-patient-piiall return beforeValidateJWTSecret(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. - Exported (even empty) env beats
.env, which beatsSetDefault.viper.AllowEmptyEnv(true)(config.go:329) makes an empty exported value count as set. Without it, empty would fall through to.env. - GORM hooks on
users,patient_addresses,ordersandprescription_extractionsencrypt 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. reconcilerCtxgoverns cleanup goroutines and ticked workers and is cancelled as soon as the signal arrives.poolCtxgoverns 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. SostopPools()runs only after bothShutdowncalls return.- 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. database.RunInTxstores the*gorm.DBtransaction inctx. Repositories calldatabase.DBFromContext(ctx, r.db), which returns the transaction if present and the pool otherwise.- In
services/lead.gowrap(): a non-ServiceErrorgoes throughsvcerrors.LogAndWrap, which logs the original with the request id and returnsErrInternal.response.ServiceErrorthen writes 500 withcode: INTERNAL_ERROR,category: internal, message"internal server error", and thecorrelationId. - Serial ids are guessable and leak volume. The UUID
document_idis the stable public identifier. Lookups by path userequest.PathUUIDandWHERE document_id = ?. - 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.
true. GORMCreatereplaces the zero valuefalsewith the parsed tag default. Fix: removedefault:truefrom the GORM tag and keepDEFAULT truein the SQL migration.Save/Updatedo not have this problem.- Go's
time.Now()has nanosecond precision buttimestamptzstores microseconds, so the in-memory value has three extra digits compared with the stored one. Truncate with.Truncate(time.Microsecond)when you assign it. make runisgo run ./cmd/serverfrom the repo root, soconfig.Load()reads the real.envfor 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.