Imported from docs/architecture.md in the go-bananas repository. --- # Architecture How go-bananas is put together, and why each decision was made. The reasoning behind the load-bearing choices lives in [the ADRs](adr/); this document is the map. --- ## Contents - [The shape of the system](#the-shape-of-the-system) - [Packages](#packages) - [The dependency rule](#the-dependency-rule) - [Request lifecycle](#request-lifecycle) - [The token engine](#the-token-engine) - [The render cache](#the-render-cache) - [Publishing](#publishing) - [The database](#the-database) - [The stack](#the-stack) - [Cross-cutting concerns](#cross-cutting-concerns) - [Performance](#performance) - [What is deliberately absent](#what-is-deliberately-absent) --- ## The shape of the system ``` ┌────────────────────────────────────────────┐ browser ──────────────▶│ /rt/v1 runtime public, cached │ , fetch, import │ /api/v1 admin authenticated │ │ /auth/* OIDC login flow │ │ /metrics Prometheus │ │ /healthz /readyz /livez │ │ /* admin SPA (static) │ └───────────────────┬────────────────────────┘ │ chi router (v5) │ ┌─────────────────────┼─────────────────────┐ │ │ │ ┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │ middleware │ │ router │ │ handlers │ │ 13 layers │ │ route tree │ │ no logic │ └─────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ ┌─────▼──────┐ │ ┌──────▼──────┐ │ auth │ │ │ tokenengine │ │ OIDC·keys │ │ │ colour·CSS │ │ ·RBAC │ │ │ ·validate │ └─────┬──────┘ │ └──────┬──────┘ │ │ │ └──────────┬──────────┴─────────────────────┘ │ ┌─────────▼──────────┐ │ domain │ entities, invariants └─────────┬──────────┘ errors, validation rules │ ┌─────────▼──────────┐ │ store │ the only package with SQL │ SQLite (WAL) │ └─────────��──────────┘ │ SQLite file on disk ``` --- ## Packages | Package | Responsibility | May import | | --- | --- | --- | | `cmd/go-bananas` | `main()`. Signal handling and an exit code. Nothing else. | `app` | | `app` | Dependency injection, the process lifecycle, static file serving. | everything except `cmd` | | `config` | Environment parsing, validation, defaults. | stdlib | | `domain` | Entities, invariants, sentinel errors, slug rules. | stdlib | | `tokenengine` | Colour maths, validation, CSS and JSON rendering, palettes. | `domain` | | `store` | The only package that writes SQL. Migrations, repositories. | `domain` | | `auth` | OIDC, session cookies, role resolution. | `config`, `domain` | | `httpapi` | Routing, middleware, request and response encoding. | `domain`, `store`, `tokenengine`, `auth`, `config` | | `logging` | slog configuration and the `Logger` interface. | stdlib | | `seed` | The four built-in themes, and their reconciliation rules. | `domain`, `tokenengine` | | `observability` | Reserved for OpenTelemetry wiring. | — | ### The dependency rule ``` cmd → app → httpapi → auth → config ↓ ↓ tokenengine store ↓ ↓ domain ←──┘ ``` Enforced by review, not by tooling, because a linter for this is more trouble than it is worth. The rule that matters most: **`store` is the only package that contains SQL**. A handler never writes a query, which is what makes replacing the database a change to one package. --- ## Request lifecycle A runtime stylesheet request, which is the overwhelming majority of traffic: ``` 1. RequestIDMiddleware assign or propagate a correlation id 2. RecoverMiddleware a panic becomes a 500, not a dead process 3. LoggingMiddleware one structured line, on completion 4. TrustedProxyMiddleware resolve the client address, believing X-Forwarded-For only from a configured proxy 5. CORSMiddleware preflight, for the admin API 6. SecurityHeaders CSP, nosniff, frame-deny, HSTS when TLS 7. BodyLimitMiddleware 2 MiB cap 8. AuthMiddleware resolve the caller; never rejects 9. MetricsMiddleware record route pattern, status and latency 10. RateLimitMiddleware fixed window, keyed by route class and client 11. Handler store.Get(slug) ← the only database access, on a cache miss cache.CSS(...) ← one map lookup on a cache hit renderDeclarations(...) ← only on a miss writeCacheable(...) ETag, Cache-Control, 304 when unchanged ``` Layers 1–3 are outermost so that everything after them is covered by the panic recovery. Layer 8 authenticates without rejecting, so a public endpoint can serve while a protected one responds 401 with a proper JSON body rather than a bare `net/http` rejection. The steady-state cost is one map lookup, one string write, and no database access. --- ## The token engine Pure functions over a token document. No I/O, no global state, safe for concurrent use — which is what lets the HTTP layer cache rendered themes aggressively and pre-warm them at start-up. ### Validation `Validate(doc) → ValidationResult` returns every issue, ordered by severity then path so the output is deterministic. Three tiers: - **Errors** block publication: an unknown group, a value of the wrong type, a malformed colour, a reserved key used as a group. - **Warnings** do not block: a missing recommended token, no dark scheme declared. - **Info** is advisory. ### Rendering `Render(theme, options) → string` produces the stylesheet: 1. Resolve the base document. 2. Deep-merge the requested appearance's overrides on top. 3. Flatten to leaves, in sorted path order. 4. Append derived tokens for values the author did not define. 5. Emit scoped selectors: the theme's default appearance, the alternate, a `.dark` ancestor, and optionally `prefers-color-scheme`. 6. Emit the component layer inside `@layer bananas`. **Sorted path order** is not cosmetic. It makes the output byte-stable, so the ETag is stable, so a re-render of an unchanged theme produces a cache hit at the CDN rather than a 200 with a fresh body. ### Colour Everything the engine needs is computed from WCAG 2.1 relative luminance: `ContrastRatio`, `BestForeground` (black or white, whichever wins), `Mix` with straight-alpha compositing, `HSLHex`, and `GenerateRamp` — the palette builder behind the editor's one-click ramp and the reason the built-in themes are tonally consistent. Non-`#rrggbb` syntaxes (`oklch()`, `color()`, CSS keywords) parse, render faithfully, and are excluded from contrast computation rather than guessed at. --- ## The render cache The single most important performance component. | | | | --- | --- | | Key | `slug \| appearance \| variant` | | Value | the rendered string, plus a content-hash ETag | | Invalidated | on write, for exactly one theme's entries | | Eviction | none: themes are bounded by human authorship | | Pre-warmed | both appearances × all three variants, at start-up | Rendering a theme traverses its whole token document. Serving the cached string is one map lookup. Because the key includes the version, publishing invalidates precisely that theme and nothing else, and a re-render after a restart produces byte-identical output, so a CDN's existing entry stays valid. The ETag is a content hash rather than a version counter. Two themes with different slugs can produce byte-identical CSS, and a content hash lets a shared CDN deduplicate them. It is also self-correcting: a validator can never disagree with the body it describes. --- ## Publishing ``` POST /api/v1/themes/{slug}/publish │ ├─ 1. load the theme ├─ 2. Validate(tokens) ← errors block; warnings do not ├─ 3. transaction: │ UPDATE themes SET status='published', version=version+1, … │ INSERT INTO theme_versions … ← immutable snapshot ├─ 4. cache.Invalidate(slug) ← exactly this theme └─ 5. audit append ← actor, action, target, IP ``` Every mutation bumps `version`, because the version is the cache key for the rendered CSS and must change whenever the rendered output could. History is **append-only**. A rollback restores an old snapshot as a *new* version rather than rewinding, so an application already deployed against version 7 can never have version 7 silently taken away. --- ## The database See [ADR 0001](adr/0001-embedded-sqlite.md) for the full argument. In brief: a single file, WAL journalling, `synchronous=NORMAL`, a 16 MiB page cache, mmap reads, and a deliberately small connection pool. Migrations are embedded with `go:embed`, applied at start-up, and tracked in a `schema_migrations` table. They are idempotent by construction. Timestamps are TEXT in RFC 3339, which sorts lexicographically, so `ORDER BY` and range filters need no date parsing and no timezone ambiguity. Token documents are JSON columns. They are always read and written whole, never queried into — which is exactly the case where a JSON column is the right shape and a join table would be ceremony with no benefit. --- ## The stack | Layer | Choice | Why | | --- | --- | --- | | Language | Go 1.27 | Static binaries, trivial cross-compilation, a good standard library | | Router | chi v5 | Small, `net/http`-compatible, middleware composition, no magic | | Database | `modernc.org/sqlite` | Pure Go: no cgo, static binary, no libc | | OIDC | `coreos/go-oidc` v3 | Discovery-driven: provider-agnostic from one issuer URL | | Session | HMAC-signed cookie | Replica-agnostic, no shared state | | Logging | `log/slog` | Standard library; no vendor in the graph | | Metrics | hand-written collector | ~200 lines, no dependency in a security-sensitive service | | Frontend | React 19 | The most common consumer, and the admin UI's own choice | | Build | Vite 8 | Fast, and the SDK is served rather than published | | Styling | Tailwind 4 | The admin UI consumes runtime variables through Tailwind's namespaces | | Data | TanStack Query 5 | Server-state management without inventing a cache | | Validation | Zod 4 | Runtime parsing at the network boundary | | Routing | React Router 7 | Data router; `lazy` route splitting | | Language | TypeScript 6.0.3 | Newest release with the compiler API the linters need — [ADR 0004](adr/0004-typescript-version.md) | Deliberately absent: no ORM (the queries are simple and an ORM would hide them), no DI framework (constructor injection is enough), no GraphQL (a stylesheet is a GET), no Redis (the cache is process-local), no component library in the admin UI (it would fight the runtime variables). --- ## Cross-cutting concerns ### Authentication Two mechanisms: an OIDC session cookie, and a development bypass that refuses to initialise outside development. The session cookie is the only credential. There is no bearer-token path, so a request carrying an `Authorization` header is treated exactly like one without: a stale or forged token cannot grant anything. Machine credentials were removed because there is no machine caller left — the runtime API is public and the administration API is for people. Roles come from provider groups. The mapping fails **closed**: a user in no configured group is a viewer. A mapping mistake must grant nothing, not everything. ### Authorisation Enforced in the server on every admin route. The UI hides what the caller cannot do, as a convenience — never as the control. ### Audit Every mutation records actor, action, target, timestamp and IP. The actor comes from the authenticated identity, never from the request body. A write failure is logged, not propagated: losing an audit row is bad, failing a publish because of it is worse. ### Observability Structured JSON logs with a correlation id on every line. Prometheus metrics with request counts by route pattern (never by concrete path — a thousand theme slugs must not become a thousand time series), a latency histogram, cache hit rate, and Go runtime gauges. Three probes: `/livez` for liveness, `/healthz` for liveness with build information, `/readyz` for readiness, which is the only one that touches the database. ### Errors One vocabulary. The domain returns sentinel errors; the store translates driver errors once; `writeProblem` maps sentinels to status codes once; the frontend client turns a non-2xx into an `ApiError` carrying the code and the per-field messages. A handler never chooses a status code itself, and no repository matches an error string. --- ## Performance What the runtime path actually costs, with a warm cache: | Operation | Cost | | --- | --- | | Stylesheet hit | one map lookup + one string write | | Stylesheet miss | token document traversal, ~100 µs for 200 tokens | | Manifest | one query, small result, cached by the CDN for 30 s | | Admin list | one indexed query | | Save | one transaction, two writes, one cache invalidation | Measured on the built-in themes, a cold stylesheet render is sub-millisecond, which is why the render cache is an optimisation rather than a requirement. It exists to remove the work entirely under load, and to make the first request after a deploy fast. The design consequence: **the hot path is not the database**. That is what makes an embedded database viable here, and it is why the connection pool is small on purpose. --- ## What is deliberately absent | Not used | Why | | --- | --- | | An ORM | The queries are ten lines each; an ORM would hide the SQL that matters for the hot path | | Redis | The render cache is process-local and loses nothing on restart. A second service to operate for a cache is a bad trade — until multi-node, where it becomes a good one | | DI framework | Constructor injection in `app.New` is clearer than a container | | GraphQL | A stylesheet is a GET. A schema, a server and a client library to deliver a JSON document is a poor trade | | A component library in the admin UI | It would fight the runtime variables, which are the point | | Server-side rendering of tokens per request | Every consumer would implement its own fetcher, cache, fallback and revalidation, and they would all get it subtly wrong | | A message queue | The write path is a human clicking publish | | Feature flags | The chart covers configuration; a flag system for four booleans is a second way to be wrong | Each of these would be the right answer under different constraints, and the ADR process is how one of them becomes the answer here.