348 lines
16 KiB
Markdown
348 lines
16 KiB
Markdown
|
|
# 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.
|