System architecture¶
The platform follows Clean Architecture: handler → usecase → repository →
domain. The business core does not import the web framework, and the domain
layer imports nothing internal.
Two repositories, one platform¶
graph LR
subgraph app["gerege-app-mn (this repo)"]
M["backend/cmd/api/main.go<br/>~30 lines"]
F["frontend/ — Next.js BFF"]
end
subgraph core["platform-core (shared module)"]
S["cmd/api/server — composition root"]
U["core/business/usecases — 25 modules"]
R["core/datasources — pgx + redis"]
G["migrations/ — embedded SQL"]
end
M --> S
S --> U --> R
S --> G
F -->|"server→server"| S
The backend is the reference deployment of the foundation: it adds no routes
of its own, every capability comes from the module. The extension point is
marked in main.go:
server.ServiceName = "gerege-app"
app, err := server.NewApp()
// app.Router().Route("/api/xxx", xxx.Routes(app.Pool()))
app.Run()
Why so thin?
Security patches, RLS policies, the audit chain and OIDC all live in one
module. When a new platform-core version ships, Dependabot picks it up and
CI re-runs every gate.
Request path¶
Internet
│
▼ edge nginx (TLS, HSTS, rate limits)
├─ /oauth2/*, /.well-known/*, /userinfo ─► Go API — OIDC issuer
├─ /rp/sign/* ─► eID sign relay
├─ /admin/api/v1/* ─► OAuth client admin API (loopback)
└─ everything else ─► Next.js BFF (:3000)
│ BACKEND_URL
▼
Go API (:8080)
│
internal network: db · redis
The browser never talks to the Go API directly — only to same-origin Next.js routes. See Frontend (BFF).
Layers¶
┌──────────────────────────────────────────────────────────────┐
│ HTTP layer │
│ cmd/api/server → middleware stack → core/http/handlers/v1 │
│ core/http/{routes, datatransfers, middlewares, auth} │
│ + core/provider/{adminapi, adminkeys, devapps, signrelay} │
├──────────────────────────────────────────────────────────────┤
│ Usecase layer — core/business/usecases/* (25 contexts) │
├──────────────────────────────────────────────────────────────┤
│ Repository layer — core/datasources/repositories/ │
│ {interface, postgres} — hand-written SQL, RLS transactions │
├──────────────────────────────────────────────────────────────┤
│ Domain layer — core/business/domain (no internal imports) │
└──────────────────────────────────────────────────────────────┘
Rule: usecases depend only on repositories/interface (package
_interface) and never import the postgres adapters. That is what lets usecase
tests run against mocks with no database.
Middleware stack¶
Global middleware is installed in server.go in this order — the order
matters:
| # | Middleware | What it does |
|---|---|---|
| 1 | Tracing | Opens an OTel span per request (so trace_id exists before the request-ID logger) |
| 2 | Request ID | Creates X-Request-ID, puts it in the context and logger |
| 3 | Recoverer | Catches downstream panics and returns a clean 500 (with request_id) |
| 4 | Metrics | Prometheus counters and latency |
| 5 | Security headers | HSTS, CSP, nosniff, frame options, COOP/COEP/CORP |
| 6 | CORS | ALLOWED_ORIGINS allow-list (wildcards only in dev) |
| 7 | Body size limit | Global cap (individual routes tighten it) |
| 8 | Access log | One structured line per request |
| 9 | Timeout | 30s generally; 50s on /api/v1/ai/* |
Per-group middleware:
- Auth — validates the JWT bearer, puts
CurrentUserin the context and establishes the RLS identity (rls.WithAdmin/rls.WithUser). - Service RLS context — installed on the anonymous
/authgroup so that pre-login flows (eID upsert, refresh identity lookup) run under the trustedserviceRLS role. - RBAC —
RequirePermission/RequireAdmin/RequireSuperAdmin. If the resolver errors it fails closed. - Observability gate — protects
/metricsand/swagger/doc.json. - Rate limiters — four separate ones:
| Limiter | Limit | Where |
|---|---|---|
| auth | ~5/min | /v1/auth/* (4 KiB body cap) |
| ai | ~20/min (burst 10) | /v1/ai/* — live translation streams ~8 chunks/min |
| eID poll | ~60/min (burst 30) | long polling |
| gov writes | ~30/min (burst 15) | gov / assets / gspace / eID profile |
clientIP() trusts X-Forwarded-For only from TRUSTED_PROXIES — by default
it trusts nothing (fail-safe).
Response shape¶
Handlers have the signature func(w, r) error and are wrapped by v1.Wrap:
func (h *handler) Something(w http.ResponseWriter, r *http.Request) error {
var req requests.Something
if err := v1.DecodeBody(r, &req); err != nil { return err }
if err := validators.ValidatePayloads(req); err != nil { return err }
out, err := h.usecase.Do(r.Context(), req.ToDomain())
if err != nil { return err } // apperror → HTTP status
return v1.NewSuccessResponse(w, out)
}
Usecases return apperror.*, which handler_base_response.go maps to HTTP
statuses. Internal causes are wrapped with apperror.InternalCause so library
errors never reach clients.
Ops endpoints¶
| Path | What | In production |
|---|---|---|
/health |
Liveness | public |
/metrics |
Prometheus | gated by OBSERVABILITY_TOKEN bearer |
/swagger/* |
Swagger UI + JSON | same gate |
Migrations¶
Numbered SQL files in platform-core/migrations/ (N_name.up.sql +
.down.sql), embedded into the Go binary. The compose migrate service runs
on every up. Currently 88 files (44 migrations) — covering RBAC, RLS,
gateway, registry, relay, the OIDC provider and the pgvector knowledge base.
Numbering range
App-specific migrations must be numbered outside the platform-core
range (see the migrations/RANGE file) or numbers will collide.
Further reading¶
- Frontend (BFF) — the browser side and token isolation
- Data model & RLS — row-level protection
- API reference — every endpoint