One binary, one image, one volume
No mandatory external service: no Redis, queues or object storage. Email and payments are optional.
A single Go binary (arenile) contains the REST API, the SSE stream, the job runner, the outbox and the two React
applications embedded with embed.FS (ADR 0001). The database is SQLite by default, PostgreSQL optionally (ADR 0002).
manager browser customer browser / host site (iframe, embed.js) │ /admin/ /api/admin/v1/* │ /b/{slug} /api/public/v1/{slug}/* ▼ ▼┌─────────────────────────────────────────────────────────────────────────────────────┐│ arenile binary ││ middleware: log, recover, request ID, session/keys, CSRF, rate limit, ││ idempotency, security headers (per-establishment frame-ancestors) ││ ││ internal/http/admin internal/http/public internal/http/webhooks http/sse ││ (thin handlers on the interfaces generated from api/openapi.yaml) ││ │ │ │ ▲ ││ ▼ ▼ ▼ │ ││ internal/domain/*: inventory · pricing · policy · booking · checkout · payment · ││ gateway · stripe · cash · fiscal · notify · online · customer · pass · mapping ... ││ │ │ ││ ▼ │ ││ internal/store (Bun, the only place with SQL) ──► outbox_events ──► internal/jobs ─┘ │└────────────────┼────────────────────────────────────────────────────────────────────┘ ▼ SQLite (WAL, /data/arenile.db) | PostgreSQL (DATABASE_URL)One binary, one image, one volume
No mandatory external service: no Redis, queues or object storage. Email and payments are optional.
Configuration as data
Every difference between establishments is expressed by resources, packs, rules and policies, never by conditions in the code.
API-first
The panel and the public page use only the documented APIs; the OpenAPI contract generates server and client.
Correctness first
A double booking is a blocking defect: availability is guaranteed by database constraints.
spot_allocations per (spot_id, date, slot). The unique(spot_id, date, slot) constraint
makes double occupation impossible, even with 100 parallel requests, on SQLite and PostgreSQL. kind ∈ hold,
booking, block, season; a full day = AM + PM.pool_usage(used, held) counters updated with UPDATE … WHERE used + held + :qty <= effective_capacity;
fewer rows updated than expected ⇒ rollback.available_online = min(free, quota − sold_online) per date and slot; they apply to the
online and api channels, not to desk.internal/domain/inventory): functional core, imperative shell. plan.go computes purely the
rows to insert and the pool demands; allocate.go applies them in the caller’s transaction with
INSERT … ON CONFLICT DO NOTHING and returns every conflicting half-day as a ConflictError.pricing.Quote(input, catalogue, rules) → PriceBreakdown touches no database, clock or randomness (ADR 0004): rules,
catalogue and reference date come in as input. Integer arithmetic in cents with half-even rounding. The output is
stored as price_snapshot with engine_version: re-computing with the same version gives the same total. The price
matrix and the simulator are the same computation.
Every status change is a method of internal/domain/booking, which writes audit log and outbox event in the same
transaction as the change. Public channels use dedicated methods (booking/online.go) with an audit.Actor
(customer or system) instead of an authenticated user.
Customer (iframe) Public API Domain / DB Stripe 1 dates and slots ── GET availability ─► inventory (read only, online quotas) 2 pack and spot ── POST quote ────────► pricing.Quote (pure) + policy (no writes) 3 confirm ── POST bookings ─────► checkout.Create ─ one transaction: held booking + resource holds + checkout_sessions(hash) ◄── booking_id, checkout_token, hold_expires_at 4 details ── PATCH bookings/{id} ► booking.AttachCustomer (terms version = current) 5 pay ── POST …/pay ────────► booking.StartPayment (pending_payment, hold ≤ 30 min) gateway.Start: payment_intents + CreatePayment ────────► Checkout Session ◄── redirect (top window) │ 6 POST /webhooks/payments/stripe/{establishment} ◄─────────┘ ParseWebhook (signature) ─ one transaction: webhook_events (dedup) · payments · paid_amount · booking confirmed · hold → booking · auto-assignment · audit + outbox booking.confirmed 7 polling ── GET bookings/{id} ─► status (never confirms) notify (outbox sink) → email with magic link, QR, .icsBEGIN change (bookings, allocations, …) INSERT audit_log INSERT outbox_events (type, payload v1)COMMIT ──► dispatcher job ──► ext.EventSink (extensions, notify) · outgoing webhooks · SSE brokerThe event exists if and only if the change is committed; delivery is at least once. The SSE bus is wrapped by a
ReplayBus that, on reconnection with Last-Event-ID, re-sends the missed events.
internal/app is the composition root: it builds store, services, router and job runner. Each consumer declares the
port it needs (ports & adapters) and internal/app/m3.go and m4.go check at compile time that the
implementations satisfy them. Payment drivers live in a registry (strategy): manual and stripe in the core,
others from extensions.
| Resource | Budget |
|---|---|
| First-screen JavaScript | < 150 KB gzip |
| CSS | < 25 KB |
embed.js |
< 3 KB |
| LCP on 4G | < 2.5 s |
| Minimum width | 320 px |
The steps after “When” and the map are lazy-loaded; npm run budget checks the limits in CI.
For the details: docs/architecture.md in the repository.