Architecture¶
What this is¶
A workplace-safety management system. It tracks incidents, hazards, investigations, corrective actions, and safety observations, and it notifies people when something needs their attention. It is multi-tenant: several organizations share one deployment.
Shape of the system¶
One Go binary. One Next.js frontend. One PostgreSQL database.
Browser
│
▼
Next.js (SSR pages, some server-side API routes that proxy to the backend)
│ HTTPS, JSON
▼
Go binary (Fiber HTTP server)
├─ middleware: recovery, tenant resolution, CORS, request logging, auth
├─ handlers (thin — parse request, call a service, shape the response)
├─ services (business logic, one per domain)
├─ GORM (ORM, with tenant-stamping hooks)
└─ two background goroutines: a daily reminder scan, a daily analytics rollup
│
▼
PostgreSQL
Everything ships as one deployable. There's no message broker, no separate worker process, no service mesh. The background jobs are goroutines started at boot inside the same binary that serves HTTP.
Why one binary, not several services¶
The obvious alternative is splitting incidents, investigations, notifications, and reporting into separate services. That wasn't done, and for this system's actual scale that's the right call:
- Every domain shares one relational model. An incident links to an investigation, which links to corrective actions, which trigger notifications. Splitting these into services means either distributed transactions or eventual consistency for what is fundamentally one write. Neither is free, and nothing here needs the isolation that would justify it.
- One team, one deploy cadence. There's no organizational reason to let different domains version and release independently.
- The traffic doesn't demand it. Nothing here needs to scale a single domain's read or write path independently of the rest.
The boundaries that matter — one responsibility per module, dependencies expressed as interfaces, no service reaching into another's tables directly — are enforced in code instead, at the service layer:
services/holds one file (or package) per domain: incidents, hazards, investigations, corrective actions, notifications, employees, tenants, analytics.- Each service depends on its peers through an interface (
interfaces/<domain>.go), not a concrete type. A service takes*gorm.DBfor its own tables and an interface for anything it needs from another domain. - Handlers depend on service interfaces the same way. This is what makes services independently testable with mocked dependencies, and it's what would let a service be pulled out into its own process later without touching its callers.
So the modularity a services-based architecture would buy you is already present — as compile-time interfaces instead of network boundaries. What's missing is independent deployability and independent scaling, and neither is needed yet.
Request path¶
- Tenant resolution (middleware, first). Maps the request's
Hostheader to a tenant. Seemulti-tenancy.md. - CORS. Origin allowlist, extended to accept any active tenant's subdomain.
- Request logging. Structured logs; also feeds a bounded queue that writes API usage into an analytics table (workers drop new entries if the queue is full, rather than blocking the request).
- Auth (per route group, not global). Validates a JWT, extracts user id / role / tenant, checks the token's tenant claim against the resolved tenant, rejects on mismatch.
- Handler. Parses the request into a typed DTO, calls one or more services, maps the result to a response DTO. Handlers hold no business logic.
- Service. Runs the actual logic against the database through GORM, inside the tenant scope established above.
Data layer¶
- PostgreSQL, accessed through GORM.
- Models and API-facing DTOs are separate types. A handler never serializes a raw model to JSON.
- Schema changes go through hand-written, numbered SQL migrations (
migrations/NNNN_name.up.sql/.down.sql), run before GORM's auto-migration. Auto-migration alone can't express the changes this system has needed (nullable-then-not-null column rollouts, concurrent index builds, dropping legacy constraints), so the two run in sequence: SQL migrations first, auto-migrate for anything simpler layered on top. - JSONB columns hold naturally variable-shaped data (witness lists, environmental conditions, investigation methods) instead of forcing that into rigid relational columns.
Background work¶
Two long-running goroutines, started once at boot:
- A daily reminder scan: finds unresolved incidents and overdue or soon-due corrective actions, and sends a notification (DB row + email) for each.
- A daily analytics rollup: aggregates the request logs collected during the day into summary tables.
Neither is a job queue. Neither retries on failure. See alerting.md for why that matters and what it costs.
What I'd change now¶
In order of how much it would improve the system for the effort involved:
-
Give background notifications a durable queue. Today an incident gets assigned, and sending the notification email is a fire-and-forget goroutine with no retry (see
alerting.md). A dropped email is invisible — nobody gets paged, nothing shows up as failed. Anotification_outboxtable (row written in the same transaction as the triggering write, a worker polls and marks each row sent or failed with backoff) would cost one table and one poller, and it would make delivery visible and retryable instead of silent. -
Finish tenant-scoping the remaining reads, then turn on PostgreSQL row-level security as the enforced backstop behind the application-level checks. Both are already planned and partly built (
multi-tenancy.md); today a bug in a service'sWHERE tenant_id = ?is the only thing standing between one tenant and another's data. RLS makes that a database-level guarantee instead of a per-query discipline. -
Move reference-number generation off
MAX(reference_number) + 1queries. Every incident, hazard, and safety-observation record gets its number from aSELECT ... ORDER BY ... LIMIT 1scan across the whole table — not even scoped to the current tenant — inside aBeforeCreatehook. That's a race under concurrent inserts and it leaks how many records other tenants have created, which the tenancy design explicitly tries to avoid everywhere else. Atenant_sequencestable already exists for exactly this and is seeded during tenant onboarding — it's just not wired into the three hooks that need it. This is a half-finished migration, not a design gap, and it's the fastest of these three to close. -
Collapse the frontend's several separate API clients into one. The UI currently instantiates multiple HTTP clients pointed at the same backend from different files. Functionally harmless, but it means retry/auth/error handling logic is duplicated instead of centralized.
-
Delete the dead incident service package. There are two implementations of incident logic in the tree; only one is wired into the running server. Confirmed dead code should be removed, not kept "in case."