The design record for Bloom's backend: the data model, the layering rule, and the
rationale behind each decision. This is the "why" document — for how to run the
project, see README.md.
Where docs and code disagree, the code and its Alembic migrations win; this file is kept in sync with them.
| Layer | Choice |
|---|---|
| Language | Python only (no Go — decision closed) |
| API | FastAPI |
| ORM | SQLAlchemy 2.x (typed, Mapped[...]), sync |
| Migrations | Alembic |
| Validation | Pydantic v2 |
| Database | PostgreSQL 16 (psycopg 3) |
| Auth | JWT (OAuth2 password flow), argon2id |
| Packaging | uv + pyproject.toml |
| Local dev | Docker Compose |
On Go: an earlier goal was to learn Go by re-implementing this API. That is no longer planned — Bloom is Python-only. The domain logic is still kept as framework-free pure functions, now purely for testability and clean separation, not portability.
Modelled on Mealie: a single backend package
named after the project, a reserved frontend/, tests outside the package, and Alembic
for migrations.
bloom/
├── bloom/ # backend package (the core)
│ ├── main.py # FastAPI app factory, lifespan (admin bootstrap), /health
│ ├── core/ # cross-cutting concerns
│ │ ├── config.py # settings (pydantic-settings, reads env/.env)
│ │ ├── security.py # argon2 password hashing, JWT create/verify
│ │ ├── email.py # SMTP delivery + Jinja rendering
│ │ └── dependencies.py # FastAPI deps: get_db, get_current_user, require_admin
│ ├── templates/email/ # Jinja templates for the transactional emails
│ ├── db/
│ │ ├── base.py # SQLAlchemy DeclarativeBase
│ │ ├── session.py # engine + session factory
│ │ └── models/ # ORM models (one concern per file)
│ │ ├── user.py bean.py bean_lot.py brew.py brew_method.py equipment.py tasting.py
│ ├── schemas/ # Pydantic v2 DTOs (request/response)
│ │ ├── user.py bean.py brew.py tasting.py lookups.py
│ ├── domain/ # PURE functions — no ORM, no FastAPI imports
│ │ ├── calculations.py # brew ratio, extraction yield, diagnostics
│ │ └── constants.py # brew categories + control-chart target ranges
│ ├── repositories/ # DB access layer (queries live here, not in routes)
│ │ ├── users.py beans.py brews.py tastings.py lookups.py
│ ├── services/ # business logic orchestrating repos + domain
│ │ ├── auth_service.py users_service.py bean_service.py
│ │ ├── email_service.py # composes the emails; delivery lives in core/email.py
│ │ ├── brew_service.py tasting_service.py lookups_service.py
│ │ ├── access.py # ownership helper (owns_or_admin)
│ │ └── errors.py # NotFoundError (framework-agnostic)
│ └── routes/ # FastAPI routers (thin — validate, delegate, return)
│ ├── auth.py users.py beans.py brews.py
│ ├── tastings.py brew_methods.py equipment.py
├── frontend/ # React SPA; its API client is generated from openapi.json
├── scripts/ # dump_openapi.py (schema for the frontend's codegen)
├── alembic/ # migration environment (env.py, versions/)
├── tests/ # pytest, outside the package
│ ├── conftest.py
│ ├── domain/ # fast unit tests for the pure functions
│ └── api/ # endpoint tests against a disposable test DB
├── docker/docker-compose.yml
├── docs/ARCHITECTURE.md # this file
└── README.md
Requests flow routes → services → repositories → database, with domain called by services for any calculation. Dependencies only ever point downward:
- routes are thin: parse input, call a service, shape the response. No SQL here.
- services hold business logic and orchestrate repositories + domain functions.
- repositories are the only place that talks to the database.
- domain is pure and imports nothing from the layers above — it never sees a session,
a request, or an ORM object. This is what keeps
extraction_yieldunit-testable in isolation.
Bloom is multi-user with two roles:
| Role | Capabilities |
|---|---|
| admin | Everything a standard user can do, plus manage users (create, promote/demote, deactivate) and manage shared lookup data (brew_method, equipment). May edit/delete anyone's data. |
| user | Add beans, brew from any bean, and taste any brew — all shared and readable by everyone. May edit/delete only their own beans, brews and tastings. Read shared lookup data. |
Design points:
- The role is a column on
user(role TEXT CHECK (role IN ('admin','user'))), not a separate permissions table — two roles don't justify RBAC machinery yet. - On startup (FastAPI lifespan, see
bloom/db/init_db.py) the app waits for the DB, applies pending migrations (alembic upgrade head, guarded by adb_is_at_headcheck so restarts are cheap), and bootstraps the admin. Bloom is deployed as a single instance, so migrating in-process is simple and race-free — no separate migration Job needed. - The first admin is bootstrapped from env vars (
BLOOM_ADMIN_EMAIL/BLOOM_ADMIN_PASSWORD), created only if it does not exist. There is no public sign-up: admins create further accounts, which default touser. - Each account has a unique
usernamehandle (unique onlower(username)) alongside its email; the login form's single field accepts either an email or a handle. Handles are set by an admin today and will be supplied by the IdP (Keycloak/Authentik) once automated provisioning lands. Beans, brews and tastings embed their creator as a nested{ id, username }object (owner/author) so the UI can name who added, pulled or scored a cup without reading the admin-only user list. - Everything is a shared log (household/café model, 11). Any authenticated user can
read all beans, brews and tastings, and can add beans, brew from any bean, and taste any
brew. Each row records who created it —
bean.user_id(owner),brew.user_id(author),tasting.user_id(taster) — and only that creator (or an admin) may edit or delete it (a non-creator write returns403). Because tastings carry their taster, several people can each score the same brew (realizing 6). - Users are soft-deleted via an
is_activeflag — never hard-deleted — so history is always preserved. - Auth is JWT via the OAuth2 password flow (access token only). Passwords are hashed with argon2id, never stored in plain text. Users recover a forgotten password by email (decision 21); admins can create an account without choosing a password at all.
Eight tables. brew is the central, highest-volume entity.
Entities:
roaster 1 ──< bean 1 ──< bean_lot (1:N — one bag bought each)
│ 1
│ └──< brew >── 1 brew_method
│ │ └─ 1 equipment (grinder, nullable)
│ └─── 1 bean_lot (lot brewed, nullable)
│
└ (brew) ──< tasting (1:N)
Ownership (who created each row):
user 1 ──< bean (owner)
user 1 ──< bean_lot (buyer)
user 1 ──< brew (author)
user 1 ──< tasting (taster)
Every row carries who created it: bean.user_id (owner), bean_lot.user_id (buyer),
brew.user_id (author) and tasting.user_id (taster). These are often different people — one
member buys a bag (lot buyer), another brews from it (brew author), and several may score that
brew (tasters).
| Table | Purpose |
|---|---|
user |
Accounts with a role (admin / user) and a unique username handle. Owns beans, authors brews, makes tastings. |
bean |
A coffee (farm/lot from a roaster): its stable identity. Shared; user_id is the owner (see 1). |
bean_lot |
One physical bag bought of a bean: roast/purchase date, weight, price, is_finished. Shared; user_id is the buyer. |
roaster |
Who roasted the bean. User-creatable, unique on lower(name) (see 13). |
brew_method |
Lookup: V60, Espresso, AeroPress… with a category. |
equipment |
Grinders, machines, kettles — one table, type discriminator. |
brew |
A single extraction: the objective parameters. Central entity; user_id is the author, lot_id the optional lot brewed. |
tasting |
A subjective evaluation of a brew (1:N — one per user, several per brew across different users); user_id is the taster. |
The live schema is owned by Alembic migrations (alembic/versions/); the ORM models in
bloom/db/models/ are the source of truth for each table's shape. To eyeball the current
schema as plain SQL, dump it straight from the database — always accurate, never drifts:
docker exec bloom-db pg_dump -U bloom -d bloom --schema-onlyEach decision was made explicitly.
A bean is a coffee (a farm/lot from a roaster) — its stable identity: name, roaster,
origin, variety, process, roast level, roast type (filter/espresso/omni), single-origin vs
blend, altitude, tasting notes, an optional website, and an overall rating (1–5 stars, null =
unrated) of the coffee itself, independent of any brew's tasting. Each physical bag bought is a
bean_lot, carrying the per-purchase data (roast date, purchase date, weight, price,
is_finished). One coffee bought several times has several lots, so brews stay aggregated
under a single bean instead of scattering across a new row per bag.
Originally a bean row was one bag (the fields now on bean_lot lived on bean). It split
once buying the same coffee repeatedly started forcing duplicate beans. Brews still reference
the bean (brew.bean_id, required); they may additionally name the specific brew.lot_id
(optional) they came from — the only behaviour tied to a lot is the finished-bag warning, which
now fires when a brew names a finished lot. A lot is a shared, creator-owned row like a bean:
anyone reads it, only its buyer (or an admin) edits it.
ratiois computed in the domain layer, never stored.tds_percentandextraction_yield_percentare stored — real refractometer measurements, not derivations. When only TDS is measured, EY is computed once at write time and stored, so analytics read a single column.
Rule: per-row math → domain layer; aggregates over many rows → SQL.
New methods without a migration.
Specialty processes evolve constantly; TEXT + CHECK gives integrity with a one-line
change to extend, avoiding the rigidity of Postgres enums.
Grinders, machines and kettles share enough shape. brew references it as grinder_id.
The same extraction can be scored by more than one person, so keeping tasting separate keeps
objective parameters clean from subjective scores. Each tasting records its taster
(tasting.user_id), so several users can score the same brew (see 11). A unique constraint
on (brew_id, user_id) caps it at one tasting per user per brew: a brew is a single
extraction, so a user evaluates it once and edits that tasting to refine it — a second
POST /brews/{id}/tastings returns 409 rather than stacking a duplicate. Different users
still each get their own tasting.
Every grinder has its own scale; a number would lose meaning across grinders.
A 1–5 "stars" scale (null = unrated) matching how the UI presents them — enough
resolution, no false precision, valid data guaranteed at the DB level. The bean's own
rating (SMALLINT, nullable) uses the same 1–5 stars scale with null = unrated, so a
bean can be logged now and rated later.
Flavor notes are a list. A controlled vocabulary (SCA flavor wheel join table) is a possible future upgrade.
user, bean, brew, tasting, brew_method, equipment all present from the start
(bean_lot came later, when bean split into coffee + lot — see 1).
The instance is a single shared log — a household, or a café bar with several baristas.
Every authenticated user can read all beans, brews and tastings; can add beans,
brew from any bean, and taste any brew; and may edit/delete only what they created
(a non-creator write returns 403). Each row records its creator: bean.user_id (owner),
bean_lot.user_id (buyer), brew.user_id (author), tasting.user_id (taster).
This evolved in two steps:
- Originally
user_idlived only onbeanand a brew's owner was derived through it — which made "my partner brews from my bag" impossible to attribute. So beans became shared andbrewgained its own author (brew.user_id). - Then, so that several baristas can each score the same pour, brews were opened for reading
by anyone and
tastinggained its ownuser_id(the taster), realizing 6.
Alternative considered: a full household/team grouping entity to isolate one group from
another — deferred as overkill for a small, trusted, self-hosted instance; it is the natural
next step if per-group isolation is ever needed.
bean_lot.is_finished marks a used-up bag, but it is treated as informational, not a hard
constraint. A brew that names a finished lot is permitted — you often finish a bag and only
then log a brew you pulled earlier (retroactive logging) — and the brew service emits a
WARNING (Brew N created on a finished lot) rather than rejecting the request. A hard
block (409) was rejected as too rigid for a personal/café tracking app.
bean.roaster was free text; it is now bean.roaster_id → roaster.
Free text made every typo a new roaster ("Nomad" / "Nomad Coffee" / "nomad coffee"), which
silently splits any per-roaster grouping, and it offers nowhere to hang the roaster's own
metadata (country, city, website). But curating the table like brew_method / equipment
(admin-only writes) was rejected too: those are small closed sets — the ~10 brewing
methods, the gear physically in the house — whereas roasters are an open set that grows with
every bag bought. Admin-gating them would block a user from logging a bean until an admin
pre-registered the roaster.
So: any user creates a roaster, implicitly. POST /beans still takes roaster as a
name; the service resolves it with a get-or-create, matching case-insensitively against a
unique index on lower(name) (with whitespace trimmed and collapsed by
domain/naming.normalize_name). The first spelling seen becomes canonical. Beans read the
roaster back as a nested object.
Case folding happens in the database, never in Python. The index is lower(name) under
Postgres' collation, and str.lower() does not always agree with it (Turkish İ); folding
one side in Python would let a lookup miss a row the index still rejects as a duplicate.
Every write that can collide with the unique index is savepoint-guarded (add_if_absent,
try_update, try_delete in the repository): losing a race against a concurrent insert or
rename is a 409, never an IntegrityError escaping as a 500. The same applies to the FK:
bean.roaster_id is RESTRICT and Roaster.beans is passive_deletes, so the database — not
a check-then-delete in the service, which can go stale — is what refuses to strand a bean.
What the table buys, beyond identity:
- Rename once, every bean follows (
PATCH /roasters/{id}, admin) — impossible with free text. - Merge duplicates (
POST /roasters/{id}/merge, admin): the source's beans are reassigned to the target and the source is deleted. The duplicate is often the one somebody filled in properly, so the target adopts the source's metadata for any field it left empty (its own values always win). This is the escape hatch for variants that slipped in before someone noticed, and the reasonDELETEof an in-use roaster is a409rather than a cascade — beans are never silently detached from their roaster. - Somewhere to put roaster metadata (country, city, website, notes).
Abandoned roasters are reaped. Fixing a typo with PATCH /beans/{id} would otherwise leave
the misspelled roaster in the picker forever — the very mess the table exists to prevent. When a
bean moves away and its old roaster has no beans left and no metadata, it is deleted: it held
nothing anyone entered. A roaster with metadata is always kept, beans or not — someone typed
those details on purpose.
Every router is mounted under API_V1_STR (/api/v1) so the API can evolve without breaking
an installed UI. /health deliberately stays at the root: container healthchecks probe it,
and a liveness check is not part of the versioned contract.
The web UI's API client is derived from the backend, never hand-written, so a route or schema change surfaces as a TypeScript error rather than a runtime 404. Two things make that work:
- Operation ids are
<tag>-<handler>(generate_unique_id_function), which is what gives the generated client readable names (beansCreateBean, notcreate_bean_beans_post). scripts/dump_openapi.pywritesopenapi.jsonwithout needing a database, so codegen runs offline and in CI.
The Docker build compiles the SPA in a Node stage and copies dist/ into bloom/static, which
app.frontend() serves (FastAPI ≥ 0.139) with an index.html fallback so client-side routes
survive a refresh. API routes, /docs and /health are matched first; missing assets and
non-GET requests still return a real 404.
The alternative — a second nginx image — was rejected because Vite inlines the API URL at
build time: a published frontend image would only work for whoever's API happened to sit at
the baked-in URL. Serving both from one origin means the UI calls the API with relative
paths, so a single image works at localhost, on a LAN IP, or behind a reverse proxy with no
rebuild and no configuration. It also makes CORS unnecessary in production.
CORS therefore exists only for development, where Vite runs the UI on its own origin (:5173)
and proxies /api to the backend: FRONTEND_HOST (plus any BACKEND_CORS_ORIGINS) lists the
origins allowed through the middleware. In a source checkout bloom/static does not exist and
the app simply serves no UI.
frontend/src/components/ui/ holds the actual source of every primitive, copied in rather than
imported from a package. Restyling a button is editing a file in this repo, not fighting a
library's theme API — which is the whole point for a project meant to be easy to pick up later.
The cost is that upstream fixes do not arrive by npm update; that trade is deliberate.
The reference (fastapi/full-stack-fastapi-template) uses Chakra; its architecture was kept
(Vite, TanStack Router + Query, generated client, route guards) and only the component layer
swapped.
DataTable, ResourceDialog, DeleteAlert and RowActions (src/components/data/) are
shared, and a page supplies only three things: column definitions, a zod form schema, and the
generated query/mutation hooks. routes/_app/roasters/index.tsx is the smallest complete example.
Resources that benefit from a drill-down add a $id.tsx detail route beside index.tsx
(roasters/, beans/, brews/), each mirroring the next — a roaster lists its beans, a bean
lists its brews, a brew lists its tastings. Long pick-lists (beans) use the searchable
Combobox (components/data/combobox.tsx) rather than a plain Select; both the filters and
the row navigation are client-side, consistent with 20.
Two API rules are centralised rather than re-derived per page, because getting either wrong is silent:
- Ownership —
canEdit(row, user)(src/lib/auth.ts) mirrorsservices/access.py. The UI hides what it must, and the API enforces it regardless. - PATCH clears nullable fields with
null, omits the rest —patchBody()(src/lib/format.ts) sends an explicitnullfor a cleared nullable field (so it is actually blanked) and omits everything else, so it never nulls a NOT NULL-backed field — whichreject_nullturns into a 422 (brewed_at/tasted_atare guarded this way too, as they are NOT NULL with a server default). Create still usesstripEmpty()(omit empties, let defaults apply). Each dialog declares itsCLEARABLEset = nullable columns not inreject_null.
The API issues a single JWT access token (lifetime ACCESS_TOKEN_EXPIRE_HOURS, defaulting to a
generous 48h for self-hosted convenience — lower it via env if you want shorter sessions) and
has no refresh endpoint, so the UI stores the token in localStorage, attaches it on every
request, and on a 401 clears it and returns to /login. An expired session means logging in
again.
A password change invalidates every outstanding access token: get_current_user refuses a
token whose issue time predates user.password_changed_at, via the same millisecond iat_ms
comparison used for reset links (decision 21). So changing a password logs out all active
sessions on their next request.
localStorage is readable by any script on the origin, so this leans on the app shipping no
third-party JavaScript (the CSP-free, self-hosted, single-origin setup makes that tractable).
A refresh flow is deliberately deferred — overkill for the current self-hosted threat model.
If the threat model ever widens, the fix is a refresh flow with an httpOnly cookie — a backend
change, not a UI one.
Deletes cascade server-side (a bean takes its brews and its tastings with it) and rows
cross-reference each other, so a blanket invalidateQueries() after any write is both the
simplest and the most correct refresh. It is affordable precisely because the lists are small
and unpaginated — see 20. Revisit together with pagination, not before.
The API returns whole tables (no limit/offset anywhere), so the UI sorts and filters what it
already has. This is honest for a household-sized log and wrong at scale; the day a list gets
long, pagination is a backend change first and the tables follow.
A reset link carries a JWT rather than a row in a password_reset_token table: the signing key
already exists, and a self-hosted instance gains little from being able to list or revoke
pending resets. Two properties that a token table would have given for free have to be bought
back explicitly:
- A reset token is not an access token. Every JWT carries a
typeclaim (access/reset) anddecode_tokendemands the expected one, so a link mailed to an inbox cannot be turned around and used as a bearer credential — and a stolen access token cannot change a password. Without the claim the two are interchangeable, since both are just a signedsub. - A reset token is single-use.
user.password_changed_atis stamped on every password change, and a token whose issue time predates it is refused. Spending a link therefore invalidates it, and it also invalidates every older link for that user.
The issue time is compared at millisecond precision via a private iat_ms claim, because
the standard iat is whole seconds — too coarse here. An invite mints its token in the same
request that creates the row, so a second-granularity comparison sees the token as older than
the account's password_changed_at and refuses it on first use. Both timestamps come from the
app clock (the column's Python-side default, not the DB now()) so they are comparable.
Mail is sent from a FastAPI BackgroundTask, keeping the SMTP round-trip off the request path;
the route does that wiring so email_service stays framework-agnostic. Delivery uses stdlib
smtplib — the app is sync throughout, and a mail library would have added an async stack for
two templates. With no SMTP host configured, links are logged instead of sent, so the app boots
and the tests run with no mail config at all.
POST /auth/recover-password always answers 202 with the same body, whether or not the
address is registered, so it cannot be used to enumerate accounts. It is not rate-limited:
real limiting needs shared state across workers, which is infrastructure this project does not
otherwise have.
- Two roles only (
admin/user) as a column onuser; no RBAC tables yet. - First admin via env vars on startup; further accounts are admin-created and default
to
user(no public registration), and are invited by email. - JWT / OAuth2 password flow, access token only; passwords hashed with argon2id.
- Password reset by emailed link, stateless and single-use (decision 21).
- Shared read across the instance; each row edited/deleted only by its creator (or an
admin), tracked by
bean.user_id/bean_lot.user_id/brew.user_id/tasting.user_id. - Soft-delete of users (
is_active) instead of hard deletion.
TIMESTAMPTZeverywhere, never naiveTIMESTAMP.NUMERICfor all weights and measures, never floating point (the domain layer usesDecimalend to end for the same reason).ON DELETEpolicies:bean→brew→tastingandbean→bean_lot: CASCADE. Note: because beans are shared, deleting a bean removes every user's brews and lots on it (only the owner can trigger this).brew.method_id: RESTRICT (a method in use cannot be deleted).bean.roaster_id: RESTRICT (a roaster with beans is merged away, never deleted — see 13).brew.grinder_idandbrew.lot_id: SET NULL (deleting a grinder, or a lot, preserves brew history — the brew keeps its measurements, just loses the reference).user→bean(owner),user→bean_lot(buyer),user→brew(author),user→tasting(taster): all RESTRICT, paired with soft-delete of users (anis_activeflag). Accounts are never hard-deleted, so history is never silently destroyed.
All constraints and delete policies were executed against PostgreSQL 16 during design and behave as documented.
Pure functions, framework-free, in bloom/domain/. Inputs and outputs are Decimal
(ints accepted); floats are intentionally unsupported to avoid silent precision loss.
brew_ratio(dose, yield, water, category):
reference = yield if category == 'espresso' else (water or yield)
return reference / dose # None if dose <= 0 or reference missing
extraction_yield = (tds_percent * yield_grams) / dose_grams
yield_grams is beverage mass in the cup. Stored at write time when only TDS was measured.
Classify a brew against the brewing control chart. The strength (TDS %) band depends on the
category — espresso is far more concentrated than filter — while the extraction-yield band
is shared. Each axis is reported as below / within / above (or None when the
measurement is missing). Ranges live in domain/constants.py:
STRENGTH_RANGE_FILTER = (1.15, 1.35) # TDS %, filter / immersion
STRENGTH_RANGE_ESPRESSO = (8.0, 12.0) # TDS %, espresso
EY_RANGE = (18.0, 22.0) # extraction yield %, all categories