go-doc/data/docs/go-bananas-architecture.md
2026-10-07 15:05:48 +02:00

354 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- go-doc:generated:begin source=go-bananas:docs/architecture.md signature=7d8b696ba971c1cbd2e19854d26de304 generated=2026-10-05 18:14 UTC -->
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 │
<link>, 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) │
└─────────<E29480><E29480>──────────┘
│
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.
<!-- go-doc:generated:end -->