Skip to content

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 CurrentUser in the context and establishes the RLS identity (rls.WithAdmin / rls.WithUser).
  • Service RLS context — installed on the anonymous /auth group so that pre-login flows (eID upsert, refresh identity lookup) run under the trusted service RLS role.
  • RBACRequirePermission / RequireAdmin / RequireSuperAdmin. If the resolver errors it fails closed.
  • Observability gate — protects /metrics and /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