go-doc/data/docs/go-bananas-architecture.md

355 lines
16 KiB
Markdown
Raw Normal View History

2026-10-07 15:05:48 +02:00
<!-- 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 -->