System Architecture
Purpose and document map
This document defines the system's architectural boundaries, dependency direction, and the reasons behind the implemented structure. Read it in three parts:
- Implemented feature boundaries describe the catalog, order, checkout, email, and identity slices as they exist today.
- Architecture baseline defines the repository, backend, frontend, database, configuration, and testing responsibilities that every slice follows.
- Evolution rules explain dependency enforcement, simplicity, ADR usage, and how the architecture may change.
Implementation commands belong in the development guide, test selection belongs in the testing strategy, and completion evidence belongs in the Definition of Done. Specifications and ADRs are historical contracts, not living substitutes for this guide.
Implemented feature boundaries
Product catalog boundary
The MVP product slice persists catalog products behind a ProductRepository application boundary.
PostgreSQL/Drizzle owns stable UUID identity, customer-facing name, gross price in integer SEK minor
units, VAT percentage in integer basis points, and an optional explicit kitchen label. The
application service exposes list, create, identity-preserving update, and hard-delete use cases;
Fastify maps them to the authenticated GET /api/products, CSRF-protected POST /api/products,
PATCH /api/products/{productId}, and DELETE /api/products/{productId} contracts inside the
existing revocable session scope. The shared product policy trims names and explicit labels,
requires a non-empty name, requires integer non-negative price within PostgreSQL integer range, and
requires VAT basis points from 0 through 10,000. It runs before UUID assignment for creation and
before repository access for updates; the update path preserves the UUID identity and returns a safe
not-found result for an absent row. The create and update requests accept the canonical integer
storage units, while the browser form converts human-facing SEK and percentage input by multiplying
by 100 and rounding to the nearest integer. A missing or null label is persisted as null; consumers
use the product name as the kitchen-facing fallback. The protected /products page supports
creation, inline editing, and confirmed hard deletion, invalidates the catalog query after each
successful mutation, and keeps the server authoritative for validation. Product deletion makes a
product unavailable to future catalog reads. Finalized orders keep their own immutable product
snapshots without a product foreign key, so later catalog edits or deletion cannot rewrite order
contents. No role model, tenant, or ownership inference is introduced.
The protected /products page uses the normal page registry and route guard. Its catalog query is
owned by the authenticated TanStack Query namespace, so established login, expiry, password-change,
and logout cleanup also remove product data. The page combines the existing loading, empty, and
failure states with creation, inline edit, and inline confirmation controls; successful mutations
invalidate the catalog query and leave stored integer values unchanged for display. Invalid server
issues are mapped through a trusted field-ID table to translated inline feedback,
aria-describedby, and first-invalid-field focus; unknown server fields and issue messages are not
rendered. Empty success, pending, and failure states remain distinct, and the browser formats stored
integer values for display without changing their API semantics.
Order entry boundary
The authenticated /kds route is the order-entry region of the tablet kitchen display system. It is
an ordinary page declaration in the frontend registry marked access: "authenticated", so it
renders inside the shared application shell behind the existing revocable session guard; navigation
visibility remains presentation, not authorization. The route owns a page-local, unpersisted current
order: it is always present and starts empty, there is no start-draft action, and the persisted
catalog is rendered as large, keyboard-reachable product card tap targets. Each card activation
immediately adds one unit; repeated activations increment the same product line by stable product
UUID, while different product UUIDs create separate lines. The cart control shows the sum of all
quantities, and the current-order screen shows each quantity-derived line price plus the summed SEK
gross total. Review controls increase or decrease a line without allowing decrement below one, and
remove a line explicitly; every change immediately updates the item count, line price, and total. An
inline confirmation distinguishes clearing the entire draft from finalization and states that it
does not send or record anything. All calculations retain the catalog's integer minor-unit
representation until display formatting. The same review owns optional queue-number and order-note
strings plus an explicit required eatHere/takeAway choice. Queue number is a bounded display
string, never parsed or used as identity, so leading zeroes and reuse across separate orders remain
valid. Queue number is limited to 20 characters and note to 500; empty strings represent absent
optional values in the local form and finalization normalizes them to nullable persistence values.
Confirmed clear resets items and metadata together. The review offers two explicit finalization
actions that send the same product identities, quantities, and metadata to two authenticated,
CSRF-protected boundaries: commercial finalization, which charges through the neutral checkout, and
non-commercial finalization, which bypasses it. Success on either path clears the local draft
immediately and exposes the next already-ready empty cart; failure retains every reviewed value for
retry. While either action is in flight both are disabled, so a repeated tap cannot start a second
charge for the same current order. The browser never chooses a payment method, because the neutral
checkout contract does not carry one.
Commercial checkout initiation uses a browser-generated UUID that remains attached to the reviewed
draft across transport retries. Before any provider call, PostgreSQL inserts an append-only
checkout_operations reservation containing that identity and a fingerprint of the priced request.
The primary key admits one initiator across concurrent application instances; a racing duplicate
cannot cross the provider boundary. A duplicate after successful finalization safely replays the
same order, an unresolved duplicate replays the unresolved result, and a still-pending duplicate is
reported as CHECKOUT_IN_PROGRESS. Reusing an identity for different contents is rejected. The UI
also disables both finalization controls while the request is pending, but that presentation guard
is only the first layer. These guarantees cover the application and deterministic fake; provider-
side idempotency remains explicitly unclaimed until Surfboard discovery and adapter work establish
it.
The identity is minted with crypto.getRandomValues rather than crypto.randomUUID, because
randomUUID exists only in a secure context while the tablet reaches this application over the
truck's plain-HTTP network. getRandomValues is the one Crypto member usable from an insecure
context, so the payment control cannot fail on the device it is built for.
Operational read failures are visible without discarding last-known data. If a queue or current-day sales refresh fails after a successful read, the KDS keeps that data on screen and labels it as potentially stale; an initial failure has no invented empty value. Failure to read unresolved checkout attempts is shown explicitly and can never be interpreted as proof that no unresolved payment exists. Failed mutations retain the reviewed draft and show no success, offline acceptance, deferred synchronization, or provider verdict. There is no offline queue or automatic replay.
The page reads the same catalog as product administration through GET /api/products and the same
productsQueryKey, so a created, edited, or deleted product is observed here through one
authenticated TanStack Query namespace that existing logout and session cleanup already own. The
current cart is owned by KdsPage and is discarded on unmount or reload. The first tap for a
product captures its name, kitchen label, gross price, and VAT in a local line; later taps increment
only the quantity and do not refresh that first-tap snapshot. The server remains authoritative at
finalization: it reads current catalog rows by UUID inside the creation transaction and writes the
durable snapshots. Loading, empty-catalog, and failure states reuse the shared asynchronous-state
components and the same safe error mapping; a catalog failure never removes the current order.
Stored integer money and VAT units are formatted for display by
frontend/src/app/format/product-format.ts, which product administration shares.
KdsPage is the route composition root: it owns the page-local cart, operational metadata, and
checkout orchestration, while feature-local modules own catalog and current-order presentation,
operational status panels, and queue reads/mutations. Pure cart operations remain in kds/cart.ts;
the sales, unresolved-checkout, and queue reads share the KDS-specific 15-second polling cadence.
These module boundaries do not change state lifetime, query keys, or the HTTP/API ownership.
The route deliberately reuses the standard shell and its single-column content width. The three-region tablet composition (approximately 10% sales ticker, 45% order entry, 45% kitchen queue) and any separately configurable Order/Queue/Statistics screens are out of this slice, so no layout percentage, split-screen configuration, or tablet-only shell is introduced yet.
Active kitchen queue boundary
OrderRepository.listActive is the single read of the active kitchen queue, and
OrderService.listActiveQueue exposes it as the application use case. The authenticated
GET /api/orders/active is a read: it requires the session but no CSRF token and no Origin
header, because it mutates nothing, and it rejects unexpected query parameters as an explicit
400 INVALID_REQUEST. The response is { "orders": [...] }, or an empty array when nothing is
active.
Queue-entry time is the only ordering authority. The repository performs one inner join of
orders and order_items ordered by orders.created_at ASC, orders.id ASC. The created_at
column is the database time at which the order entered the queue; the id tie-breaker only makes
the result deterministic when two orders share a timestamp, which PostgreSQL now() can produce
across statements. The optional queue number is deliberately absent from the ordering and from every
predicate: it is neither unique nor mandatory, so ordering by it would put the queue in the wrong
sequence and filtering by it would drop valid orders. Delivery removes an order from the active set
rather than from history, and the set of active statuses lives in one exported constant,
ACTIVE_ORDER_STATUSES, so a later fulfillment task extends the definition in a single place.
Each queue entry carries the order's immutable item snapshots, so a later catalog edit or deletion cannot change what the operator is asked to cook. The HTTP layer adds no ordering and the frontend does not re-sort, so the sequence the server returns is the sequence the operator sees.
Delivered orders remain available through the authenticated GET /api/orders/history read model.
The repository orders them by delivery transition time (newest first), joining immutable item and
finalization snapshots with status, metadata, and checkout chronology for the expandable history
view. This read is separate from listActive; delivered orders never re-enter the kitchen queue.
KdsPage renders a "Kitchen queue" region beside the order-entry region and shows the next upcoming
orders as complete slips in the queue-entry order the server returned: the optional queue number,
every item with its captured kitchen label, the optional note, and the fulfillment state. A missing
queue number renders nothing in the number position, so no placeholder or fabricated number can be
mistaken for a real one. Each slip is complete on its own — no drill-down, no abbreviation, no
collapsed content — so an upcoming order is understandable at a glance.
Item lines name the captured kitchen label, not the live catalog. Finalization resolves an operator's optional kitchen label to the product name when none is set, and the slip shows that historical snapshot, so a later catalog rename, relabel, or deletion cannot change what the operator is asked to cook. A quantity of one is omitted as the ordinary case; a quantity above one is always spelled out.
The slip count is a presentation window, not a queue limit. The read is never truncated or
filtered, and the response order is the queue order. The region shows the next three complete slips
on the tablet target, which is the documented intent, and the application's existing md breakpoint
adds more complete slips on a roomier viewport rather than compacting any of them. Every order
beyond the window still reaches the operator through the compact representation below. This task
owns no state controls or live push. Refresh is polling: the queue query uses a 15-second
refetchInterval so an order finalized in another browser becomes visible without a reload, and it
is additionally invalidated immediately after a successful finalization on the same screen. A later
synchronization task may replace polling.
Every order beyond the window keeps its own compact entry. KitchenQueueCompactList renders one
list item per remaining active order, so a long queue is compacted rather than truncated: no order
disappears, and no maximum is assumed. The detailed window and the compact list are a split of the
same read, so their union is exactly the active queue.
The entry prioritizes the optional queue number: when the order has one, the number is the whole
entry, formatting preserved verbatim (01 stays 01), because that is what the operator calls out
at the counter. When it has none, the entry shows a short fingerprint of the order's own captured
items instead. orderItemFingerprint in frontend/src/app/format/order-fingerprint.ts builds that
fingerprint deterministically from the immutable kitchen-label snapshots in snapshot order, never
from the live catalog and never from internal order identity. A quantity above one is spelled out
(2x HALLOUMI) and a single unit is bare, matching the detailed slip.
Truncation is safe, not lossy. A fingerprint longer than fingerprintMaxLength is cut on
code-point boundaries so no character is split in half, a trailing separator is dropped rather than
left dangling before the marker, and a trailing … states plainly that the line is shortened. An
order with no items yields an empty fingerprint rather than a placeholder; the queue read
inner-joins items, so that state is not reachable through it, and nothing is invented to cover it.
No expansion control is offered: the entry is complete as shown, and no interaction is required to
read it.
The visible active count comes from the same read as the representations. Once the active-queue
read resolves, the queue heading shows orders.length, including zero, and announces later changes
politely. It is not a separately maintained counter, so the number cannot drift from the detailed
slips plus compact entries: those two presentation sets are slices whose combined length is the
visible count. Fifteen orders is an expected service load, not a limit; a sixteenth order extends
the compact list and increments the count. The UI renders every active order without pagination and
relies on ordinary page scrolling to reach later entries. External changes become visible on the
existing 15-second poll, while same-screen finalization keeps its immediate query invalidation.
Finalized operational metadata correction boundary
The authenticated PATCH /api/orders/{orderId}/metadata operation replaces only the queue number
and order-level note of an active finalized order. Empty strings normalize to absence and queue
number formatting remains verbatim. The repository locks the active order, updates its current
values, and appends one database-timestamped order_metadata_changes fact per field that actually
changed in the same transaction. Concurrent corrections therefore serialize and each recorded old
value is the immediately preceding committed value; unchanged submissions append nothing.
The history table is protected by a database BEFORE UPDATE OR DELETE trigger. Item snapshots,
checkout facts, classification, fulfillment mode, status, queue-entry time, and the original
order_finalizations fact are outside this update. Missing and delivered identities share the same
safe rejection, and there is no general finalized-order editor.
OrderStatusChange is the domain vocabulary for fulfillment transitions: a named { from, to }
pair rather than a bare target status, because the state an order must currently hold is half of the
rule. QUEUED_TO_PREPARING, PREPARING_TO_READY, and READY_TO_DELIVERED are the forward
transitions; PREPARING_TO_QUEUED and READY_TO_PREPARING are the only backward corrections. Each
application use case passes its one named pair to the repository; there is no "set this order's
status" input anywhere in the application.
The transition is conditional, and that is the entire concurrency guarantee.
PostgresOrderRepository.transitionStatus runs
UPDATE orders SET status = <to> WHERE id = <id> AND status = <from> RETURNING *, and appends the
order_status_transitions row in the same transaction. The source-state predicate is evaluated by
PostgreSQL while holding the row lock, so of two concurrent requests for the same transition exactly
one updates the row; the other matches nothing, writes nothing, and is reported as
notInExpectedState. The history row is therefore written only when the state change actually
committed, which makes "history and current state agree" true by construction rather than by
convention: a repeated tap, a double-click, or a second browser cannot append a duplicate fact or
drive an order past a state the transition does not allow.
The domain rejects a missing order and a wrong state identically. The application maps every
non-committed outcome to OrderTransitionRejectedError without carrying a reason, and the HTTP
layer reports 409 CONFLICT for all of them. A caller therefore cannot use the endpoint to discover
which order identities exist or what state they are in. That is also the correct operator outcome:
the card simply has no forward control left, and the queue read — not the transition response — is
the authoritative current state.
The transition is named by the path, not by a request body. The three forward operations plus
POST /api/orders/{orderId}/requeue and POST /api/orders/{orderId}/resume-preparing are CSRF- and
Origin-protected mutations inside the existing session scope. Each accepts no body and returns only
its committed transition. The response names the state change and the database time it happened; it
never echoes order contents. A 409 documents the conditional behavior for API consumers.
order_status_transitions is append-only at the database level. A BEFORE UPDATE OR DELETE
trigger rejects any attempt to rewrite or remove a row, exactly as the order_items immutability
trigger does, so a later correction must append rather than replace even when it comes from outside
the application repository. from_status and to_status are drawn from the same status vocabulary
as orders.status, so history can never claim a state the order could not hold, and
transitioned_at is stamped by the database — the same clock that records queue entry — so a
recorded time can be neither a client clock nor a second, disagreeing reading.
Advancing changes what the kitchen is doing; delivering changes what is in the queue.
Preparing and Ready are active statuses, so an advanced order keeps its queue-entry ordering,
stays counted in the visible active total, and keeps its own representation. Delivered is the one
non-active status, and it is reached through the same named pair, the same conditional update, and
the same append-only history insert as every other transition. Removal from the queue is achieved
by that state change, never by deleting a row: the active read simply stops matching the order,
while the finalized order, its item snapshots, its checkout facts, and its complete history remain
durable and readable. The queue-entry time is never rewritten by a transition, so an order's
position reflects when it was ordered rather than how far along the kitchen is.
Backward correction is restricted to active states. Preparing may return to Queued, and Ready
may return to Preparing. Both corrections use the same conditional transaction as forward
movement, so current state changes while every earlier fact remains in its original order.
READY_TO_DELIVERED has no counterpart in this milestone: no transition leaves Delivered, and no
endpoint reverses a hand-off. A mistaken hand-off is an operational event that needs an explicit
recovery flow, and silently appending the opposite fact to an order the customer may already have
taken would corrupt the audit trail the history exists to provide.
The UI offers one full-width primary forward control per detailed slip and, for Preparing and Ready,
a full-width outlined backward correction. The primary button advances Queued to Preparing,
Preparing to Ready, or hands a Ready order over, after which the slip is no longer part of the
queue at all rather than showing a spent control. The button is disabled while its own request is in
flight, which affects only that slip. On success the queue read is invalidated immediately rather
than waiting for the 15-second poll, so the slip shows the new state at once and a delivered order
disappears with the count already corrected. There is no optimistic state rewrite: the server read
remains authoritative, and a rejected conflict leaves the current rendering in place with a plain
translated notice until polling reconciles it. Live push remains out of scope.
The explicit detailed state selector is a modal dialog, not a gesture. Each detailed slip also
carries one full-width text control that opens KitchenStateOptions, a dialog listing the whole
fulfillment vocabulary: the order's current state is marked aria-current, every state reachable by
a named operation of that current state is an enabled choice, and every other state is rendered as
an aria-disabled row naming why it is unavailable. A dialog was chosen over a long-press or swipe
because a gesture hides the choices from exactly the keyboard and assistive-technology users who
have to read them; focus moves into the dialog, Escape and the backdrop close it, and focus returns
to the control that opened it. An unavailable row is shown rather than hidden, so the operator
learns which states exist and that this order may not enter them, instead of wondering whether the
selector is incomplete — and activating it does nothing at all, so no request is sent and no
transition is guessed.
The selector adds no new transition path. KitchenQueueCard renders the fast controls, the
correction, and the selector's choices from one exhaustively typed stateOperationsByStatus table
keyed by the active-status union, and every enabled choice calls the very same onAdvance or
onCorrect callback the corresponding fast control calls. It therefore cannot offer a transition
the fast controls would refuse, and it is not a state editor: there is no input that names a target
status, and the server's conditional update remains the only thing that decides whether a transition
applies. Choosing a state closes the dialog immediately so the slip stays the single place that
reports the pending and the resulting state. The compact overflow entries are unchanged: they own no
controls, so the selector exists only where a detailed slip does.
Finalized order boundary
Both finalization paths share one use case, one request shape, and one transactional persistence
boundary; they differ only in whether commercial checkout is invoked. There is no parallel
commercial order type. OrderService validates a non-empty, duplicate-free selection, positive
whole quantities, nullable bounded metadata, and an explicit fulfillment choice identically for both
paths, then generates the internal order UUID. PostgresOrderRepository.create(id, input, checkout)
reads all selected products and inserts the orders row, the append-only order_finalizations
fact, every order_items snapshot, and any confirmed checkout attempt in one PostgreSQL
transaction. A missing product returns no order, so partial headers, partial item sets, and orphaned
finalization facts cannot commit. The durable classification is derived from that same call: an
attempt is present only after a confirmed payment, so its presence is what makes the order
commercial and its absence keeps it nonCommercial.
The order row records initial status = Queued, optional queue display string and note, fulfillment
mode, and database creation time. Queue display strings are neither parsed nor unique. Item rows
capture product UUID, customer name, effective kitchen label, unit gross minor amount, VAT basis
points, and quantity. They do not reference the mutable catalog row. A database trigger rejects item
updates and deletes, making finalized contents and snapshots immutable even outside the application
repository. There is no item/history mutation API.
Durable snapshots and significant history
orders is current state, not history. Its status is rewritten by every fulfillment
transition, and from MVP-TASK-024 its queue_number and note are edited in place. A column that
is expected to change cannot be the record of what an order originally was, so history lives beside
the order rather than inside it. This is normal current state plus sufficient append-only facts, not
event sourcing: there is no event log, no aggregate rehydration, and no replay path.
Four durable fact tables back a finalized order, and each is protected by its own
BEFORE UPDATE OR DELETE trigger so a correction must append rather than replace, even from a
caller outside the application repository:
order_finalizations— one row per finalized order, written in the same transaction as the order: theclassificationandfulfillment_modethe order was finalized with, the queue number and note exactly as captured (formatting such as01preserved verbatim), andfinalized_at. That timestamp is stamped by the database inside the same transaction as theordersinsert, so it is the same transaction time asorders.created_at— the queue-entry time — and can be neither a client clock nor an independently read value. The primary key makes a second "finalization" of one order impossible.order_items— the immutable per-product snapshots and quantities. It deliberately holds no foreign key toproducts, so a catalog edit, relabel, reprice, VAT reassignment, or deletion can neither cascade into an order nor be read back through one.order_status_transitions— the appended fulfillment facts, in commit order. A backward correction is another row, soQueued → Preparing → Ready → Preparingremains four facts rather than two.order_checkout_attempts— the confirmed checkout facts: the application-ownedoperationId, thesucceededoutcome, the provider's optionalproviderReference, the chargedgrossAmountMinor, the currency, and the database time the attempt was recorded. All of it is associated with the internal order identity by primary/foreign key and is now append-only, so the recorded time, outcome, and reference cannot drift from each other or be quietly rewritten.order_checkout_unresolved_attempts— the unconfirmed checkout attempts. It records the application-ownedoperationId(unique), theunknownoutcome, the provider's optionalproviderReference, the requestedgrossAmountMinor, the currency, and the database time. It is append-only and intentionally has no foreign key toorders, because an indeterminate attempt finalizes no order — which is exactly what keeps it out of the kitchen queue and out of commercial sales. See Commercial finalization.
OrderRepository.findFinalization reads the finalization fact back and
OrderService.finalizationHistory exposes it as the application use case; it resolves to null for
an unknown identity rather than inventing a record, and it mutates nothing. findCheckoutAttempt
returns the recorded time alongside the other attempt facts. Neither read is exposed over HTTP and
there is no history or search surface: the KDS still needs none, and a future statistics surface
will query these durable facts directly.
Product edits and product deletion cannot rewrite history because no finalized fact is derived from
the catalog after finalization. Delivery cannot either: Ready → Delivered appends a transition and
moves the order to a state the active-queue read excludes, so the order leaves the operator's view
without any row being removed.
Commercial finalization
OrderService.finalizeCommercial sequences price, charge, then one atomic commit.
PostgresOrderRepository.grossTotalMinor prices the selection from the current catalog first, so an
unavailable product, an empty cart, a free cart, or a total outside the chargeable integer range is
rejected as a plain finalization error before any provider is contacted. The service then calls
CheckoutService.charge with the internal order UUID reused as the stable application-owned
operationId — one identity for the internal order and its checkout operation, never a provider
identifier and never the queue number. Only a confirmed succeeded result reaches persistence.
rejected and cancelled are definitive provider decisions that certainly captured no money, so
they raise OrderCheckoutDeclinedError and create no order, no sales contribution, and no attempt
fact; the HTTP layer reports that as 409 with the stable code CHECKOUT_NOT_PAID, and the
operator may state that continuing is safe (MVP-TASK-028).
unknown is the third case and is deliberately not a flavour of either the refusal or the generic
incomplete error. Before reporting it, OrderService.finalizeCommercial records it through
OrderRepository.recordUnresolvedCheckoutAttempt and then raises OrderCheckoutUnresolvedError,
which HTTP reports as 409 CHECKOUT_UNRESOLVED (MVP-TASK-029). If that record cannot be written the
persistence failure propagates rather than being reported as a clean unresolved result, because
losing the fact while claiming it was kept would be the silent downgrade the case exists to prevent.
A confirmed charge that could not be committed is the remaining case and raises
OrderCheckoutIncompleteError. Keeping the three separate is what lets the copy differ honestly: a
refusal may promise "unpaid and safe to pay again", a confirmed-but-uncommitted charge may promise
nothing, and an unresolved attempt must claim neither and must not be retried.
order_checkout_unresolved_attempts records what is not known. It holds the application-owned
operationId (unique), the unknown outcome, the provider's optional providerReference, the
gross amount that was requested, the currency, and the database time. It has no foreign key to
orders and is protected by the same append-only trigger as the other fact tables. The absence of
an order is the mechanism, not an omission: because no order, no finalization fact, and no item
snapshot exist, an unresolved attempt cannot enter the kitchen queue and cannot join a commercial
sales figure, which is reconstructed from commercial finalizations joined to item snapshots
(MVP-SALES-001-S07). A database check constraint restricts the stored outcome to unknown,
mirroring the succeeded constraint on order_checkout_attempts.
Nothing resolves, retries, or settles such an attempt. Reconciliation semantics do not exist yet, so
no repository or HTTP operation attempts one, and the KDS disables paying again for the affected
draft. The read-only GET /api/checkout/unresolved exists so the uncertainty survives a reload, a
second screen, and a restart; a store that has never had an indeterminate attempt simply returns an
empty list.
The confirmed attempt is written to order_checkout_attempts in the same transaction as the order
and its items, so a committed attempt and a queued commercial order cannot diverge. The row keeps
the application-owned operationId (unique), the succeeded outcome, the provider's optional
providerReference, the charged grossAmountMinor, and the currency, all associated with the
internal order identity through its primary/foreign key. A database check constraint restricts the
stored outcome to succeeded, so no other outcome class can ever be durable. A provider reference
proves that an attempt exists; it is never proof of payment and never changes the outcome.
Commercial sales aggregation selects only classification = commercial; the durable classification
therefore excludes non-commercial gross amounts and product quantities by construction, and a
confirmed commercial order contributes exactly once through its own immutable snapshots. This slice
exposes the current-day gross ticker by summing those snapshots once per finalized order. PostgreSQL
derives a half-open local-day interval from its clock and STORE_TIME_ZONE; fulfillment state and
session-local state do not participate.
The same read also groups the day's snapshots by order_items.product_id and returns one
per-product quantity beside the gross. Identity is the product UUID, never a display value, so a
catalog rename, relabel, or reprice relabels an existing line instead of splitting one product's
quantity into unrelated counters; the browser is given productId alongside the label so it cannot
regroup by text either. The displayed name and kitchenLabel come from the product's latest
snapshot inside the day, ordered by finalized_at DESC, order_id DESC: that is a deliberate display
policy — a mid-day rename shows the newest name the kitchen actually used while the summed quantity
still covers every earlier sale — and the order_id tie-breaker only keeps it deterministic when
two orders share a now() timestamp. kitchenLabel is the effective kitchen-facing label, resolved
once at finalization as kitchenLabel ?? name, so the label preference and the name fallback are
decided in one place rather than re-derived per consumer. The headline total and the breakdown are
produced by that single grouped read, so they can never disagree even when an order is finalized
mid-request; a day with no commercial sale is a zero total and an empty breakdown, never a list of
zero rows. Rows are ordered by descending quantity with product_id as the tie-breaker, and the
HTTP layer adds no ordering and the browser does not re-sort. A sales history screen remains a later
slice.
Crash and atomicity assumption (requires follow-up). The provider charge necessarily happens
outside the database transaction, so the charge and the commit are two separate durable effects. A
process crash after a confirmed charge but before the commit — or a product deleted between pricing
and the commit — leaves captured money with no queued order and no attempt row. The service reports
OrderCheckoutIncompleteError rather than claiming a finalized sale, so the failure is visible and
never a false paid order, but nothing durably records the unresolved attempt yet. Reconciling that
window (a durable unresolved-attempt record and a safe retry policy) belongs to MVP-TASK-029 and
MVP-TASK-030 and must not be treated as solved by this slice.
Checkout boundary
Commercial checkout is expressed through the narrow, provider-neutral CheckoutGateway contract in
backend/src/application/checkout.ts. The request carries a stable application-owned operationId,
a gross integer minor-unit amount, and the single supported currency. It deliberately carries no
line items, VAT breakdown, payment method, customer reference, or provider field, because
MVP-TECH-002 discovery has not established that any provider supports them; adding them later must
not be a guess. The result is one of four provider-neutral outcome classes — succeeded,
rejected, cancelled, and unknown — plus a providerReference that is null when no stable
reference is available. A confirmed adapter may also return the original provider payment reference
and neutral payment method; those facts are persisted with the confirmed attempt for the refund
flow. Missing facts on legacy orders are not reconstructed or guessed, and make those orders
ineligible for refund. A reference proves that an attempt may exist; it is never proof that money
was captured and never changes the outcome.
CheckoutService in backend/src/application/checkout-service.ts owns the boundary. It validates
the neutral request before any adapter is contacted, delegates to the injected CheckoutGateway,
and normalizes the result. A thrown adapter failure, a malformed adapter result, or an outcome
outside the contract all become an explicit unknown result, never a success and never a silent
definitive failure. The service holds no persistence: order finalization, sales contribution, and
the durable attempt record belong to the finalization slice that calls it.
backend/src/integrations/ holds every adapter and the single composition point,
createCheckoutGateway, which maps validated configuration onto an adapter. The deterministic
FakeCheckoutGateway returns the configured outcome with no network access, timing, or randomness,
and derives its stable reference from the operation identity, so local and automated acceptance are
reproducible without a provider account. It additionally accepts optional amount-keyed refusals, so
one deterministic process can serve a definitively declined checkout and a confirmed sale in the
same run; a refusal is a pure function of the requested gross total, never of call order, and both
settings are absent unless an acceptance run opts in. CHECKOUT_MODE=disabled resolves to
UnavailableCheckoutGateway, which reports unknown because no provider was contacted; that keeps
unavailable checkout visible and incapable of creating a false paid order. Hosted staging and
production configurations reject CHECKOUT_MODE=fake at startup, so the fake cannot be enabled by
accident. Discovery is complete: ADR 004 records
the dated Surfboard evidence, the environment topology, and the two-port design — the existing
neutral payment port plus a smallest separate fiscal port (FiscalGateway) for register state,
shift lifecycle, end-of-day, and receipt facts. MVP-TASK-034 now contains the repository-side
payment adapter and signed webhook ingress, but remains blocked on its human-owned Demo/ECR
prerequisites and real smoke evidence; no fiscal adapter or receipt write is claimed. The selected
topology is deterministic fake for local/test and automated acceptance, Surfboard Demo for staging,
and the same provider adapter with independent Live configuration for a future production release —
configuration, never a source edit. Surfboard payment, ECR, receipt, terminal, and webhook payloads
remain in backend/src/integrations/; payment and fiscal capabilities cross into application code
only as neutral requests, outcomes, references, and lifecycle facts. Provider base URLs, keys,
secrets, merchant/store/terminal/register identifiers, and webhook secrets are server-side
deployment configuration, never source constants, browser data, or log fields. Card data never
enters the application.
Refund-operation persistence foundation
Epic 007 adds the provider-neutral refund contracts in backend/src/domain/refund.ts and a durable
claim/result boundary in RefundOperationRepository. PostgresRefundOperationRepository locks the
order while checking commercial classification and the confirmed checkout references, so concurrent
claims serialize before any application caller may contact a provider. A partial unique index also
prevents more than one pending, unresolved, or successful operation per order. Pending and unknown
operations block re-issuance; a definitive refusal permits a new numbered attempt. The refund
operation record stores only neutral reasons/outcomes, stable provider references/error codes, and
database timestamps.
Only a confirmed successful result changes current order status to terminal Refunded; that update,
the operation result, and the appended fulfillment transition are committed in one database
transaction. The order's original fulfillment history is retained, and Refunded remains outside
active queue reads. Unknown outcomes persist without completing the operation and cannot be retried
as a new money-moving request.
RefundApplicationService owns issuance and status resolution. It validates the provider-neutral
reason, asks the repository to atomically claim the order and return immutable checkout/line facts,
then calls the gateway associated with the original checkout. When a provider reference becomes
available, the service persists it before waiting for a terminal provider result. Repeated claims
reuse the existing durable operation; they do not call the money-moving adapter again. Resolution
uses only the status-lookup port and an already-persisted provider reference, so it cannot issue a
second refund.
The authenticated POST /api/orders/{orderId}/refunds route is protected by the same Origin/CSRF
mutation guard as other state changes. Authenticated GET reads an operation, while the separate
protected /resolve POST performs status-only reconciliation. HTTP inputs cannot choose a provider,
amount, payment reference, or order contents. Public operation responses omit provider references
and raw provider messages; only stable error codes are retained.
The durable order-history read includes both delivered and refunded terminal orders. It appends
redacted refund attempt facts to the original fulfillment and checkout history, omitting provider
payment/order references and payment method. deliveredAt is nullable for an order refunded before
delivery. The OpenAPI schema and frontend history projection follow that same redaction boundary.
The active queue read also supplies a server-computed refund-eligibility flag and a redacted summary of the latest refund operation. Eligibility requires a commercial order, confirmed checkout, supported original payment facts, and an exact match between the recorded total and immutable item snapshots. Provider references never enter the queue response. The KDS renders the refund action only inside the order's detailed state-options dialog, after explicit confirmation; the fast fulfillment controls remain refund-free. Pending and unknown attempts suppress new issuance, and a resolvable attempt can call only the status-resolution endpoint. Confirmed success invalidates both queue and history reads; the order stays visible until the server confirms the refunded state on its next queue read.
The delivered-history read uses the same server-side eligibility policy and safe operation summary. Only a delivered commercial order that is not already refunded and has complete supported payment facts can offer Refund, and the control exists only while its details are expanded. Refunded history remains durable: its gross total is visibly struck through with an accessible refund icon, a prominent message identifies the status, and the timeline includes the confirmed refund time. History mutations invalidate that read so the order remains visible with its authoritative refunded state.
Transactional email boundary
Application use cases depend on the narrow EmailSender contract in
backend/src/application/email.ts. The Resend adapter and its single injected outbound HTTP
transport live in backend/src/integrations/; server.ts selects disabled, real, or local Mocking
Bird transport at startup. The browser cannot select a provider destination, sender, headers, or
body. Mocking Bird intercepts only registered email traffic at that seam and stores a redacted,
bounded summary. Its synthetic preview is a separate capped local/test memory collection restricted
to configured fixture recipients. Neither collection is persisted.
The Mocking Bird console is a normal authenticated page declaration compiled only into explicitly enabled developer builds. Its frontend flag controls bundle and navigation visibility only. The backend independently registers console endpoints only for local/test mock mode with explicit Mocking Bird enablement. Reads expose bounded safe projections; Origin, CSRF, authentication, and rate limits protect scenario/reset mutations. Logout and route cleanup cancel and remove console queries under the authenticated TanStack Query root.
Password recovery is an application use case behind a dedicated repository contract. It generates a
256-bit opaque credential with Node cryptographic randomness, persists only its SHA-256 digest, and
composes the browser link from validated APP_PUBLIC_URL; request headers never influence the link.
The raw value is present only in the URL fragment and the deliberately guarded local/test synthetic
preview. The reset page captures it into component memory, removes the fragment with replaceState
before rendering, and never places it in query state, Redux, or browser storage.
PostgreSQL holds at most one reset row per user: issuance locks the user, verifies the credential version, and replaces any earlier row. Password change locks/mutates the same user and deletes reset rows. Reset locks in the same user-first order, then atomically marks the valid unexpired row used, replaces the Argon2id hash, increments the credential version, and deletes every session. This makes issuance safe against concurrent password mutation and permits only one simultaneous consumer. Request responses use one generic acknowledgement and a minimum asynchronous padding budget across known/unknown outcomes. This reduces simple enumeration signals but cannot equalize provider, database, network, process scheduling, or multi-instance limiter behavior exactly.
Identity and session boundary
Global users and revocable sessions are persisted through PostgreSQL/Drizzle. The application user service owns login-address normalization, password policy, Argon2id hashing and safe user mapping. Fastify owns cookie signing, the PostgreSQL session-store adaptation, CSRF/origin enforcement and HTTP translation. Session rows contain only plugin/session state, a user ID, and a credential version; credential hashes never enter session data or HTTP DTOs. Current-user lookup always rechecks that the durable user still exists, is enabled, and has the session's credential version. Password replacement conditionally updates the still-current hash, increments that version, deletes every account session, and invalidates reset credentials in one concrete repository transaction. Authenticated session writes revalidate the credential version, so a late login/session save cannot recreate a valid revoked session. Identity does not imply business ownership, tenancy, membership, role, or authorization. See ADR 002.
registerAuthHttp is the Fastify composition point for authentication HTTP concerns. It delegates
session, account, recovery, and developer-email routes to internal route-family registrars, each
typed only with the collaborators it consumes; product and order routes remain in their existing
registration path. Shared session authentication, origin enforcement, account-attempt throttling,
and session-save behavior live in one auth HTTP support module where route families require the same
semantics. The registrars are implementation details, not package API, and composition order
preserves the existing hook, route, cookie, and diagnostics behavior.
1. Architecture baseline
This document defines the software architecture of the Foodtruck Order application.
It describes:
- The major application components.
- The responsibility of each component.
- Dependency and communication rules.
- Data flow through the system.
- Backend layering.
- Frontend/backend boundaries.
- Database access boundaries.
- Testing boundaries.
- Architectural principles that should remain stable as the application grows.
This document is an architectural source of truth.
Implementation details that are likely to change should not be documented here unless they have
architectural significance. Technology choices and their rationale are documented separately in
docs/techstack.md.
2. Architectural Goals
The architecture should support the following goals:
Simple development
- The system should be understandable by a small development team and by AI coding agents.
- Common functionality should follow predictable patterns.
Clear separation of concerns
- UI, HTTP, application logic, persistence, and infrastructure concerns should not become unnecessarily coupled.
Incremental development
- Features should be implementable as vertical slices.
- The architecture must work for a very small application and scale as more functionality is added.
Testability
- Business and application logic should be testable without requiring the entire system.
- Integration with external systems and infrastructure should be testable separately.
- Important user workflows should be testable end-to-end.
Replaceability
- Infrastructure-specific details should not unnecessarily spread throughout the application.
- Replacing a database implementation, external service, or UI implementation should not require rewriting unrelated business logic.
Operational simplicity
- The initial system should avoid unnecessary infrastructure.
- Additional infrastructure should be introduced only when there is a demonstrated need.
AI maintainability
- Architectural boundaries should be explicit enough that AI coding agents can reliably determine where new functionality belongs.
3. High-Level System Architecture
The application consists of three primary runtime components:
┌──────────────────────────────────────────┐
│ Browser │
│ │
│ React Frontend │
└────────────────────┬─────────────────────┘
│
│ HTTP / JSON
▼
┌──────────────────────────────────────────┐
│ Backend │
│ │
│ API → Application → Repository │
│ │
└────────────────────┬─────────────────────┘
│
│ Database protocol
▼
┌──────────────────────────────────────────┐
│ PostgreSQL │
└──────────────────────────────────────────┘
Additional infrastructure or external services may be added later.
Examples include:
- Payment/POS providers.
- Real-time communication infrastructure.
- Background job processing.
- Caching.
- Analytics or prediction services.
These are not part of the architectural requirements of the first vertical slice.
The architecture should not introduce such components until a concrete requirement justifies them.
4. Repository Architecture
The project is a pnpm monorepo.
The repository contains separate applications and shared project infrastructure.
The expected high-level structure is:
/
├── frontend/
├── backend/
├── packages/db/
├── docs/
├── tests/
├── docker/
├── .github/
├── package.json
├── pnpm-workspace.yaml
└── ...
The exact directory structure may evolve.
The important architectural rule is that directory organization must reflect responsibility and dependency boundaries.
The following principles apply:
- Frontend code belongs to the frontend application.
- Backend code belongs to the backend application.
- Database-specific code belongs to the backend repository implementation or the
packages/dbworkspace, which owns schema, client, and tooling. - Cross-application end-to-end tests belong in the appropriate test area.
- CI configuration belongs under
.github/. - Architecture and development documentation belongs under
docs/.
If the project later introduces shared packages, they should have a clearly defined responsibility and dependency direction.
A shared package must not become a dumping ground for unrelated functionality.
5. Backend Architecture
The backend uses explicit logical layers. Runtime calls pass from HTTP to the application service
and repository implementation. Source dependencies point at application/domain contracts; domain
code does not import infrastructure. The composition root in backend/src/server.ts wires
implementations.
The intended dependency direction is:
HTTP/API -> Application -> Domain rules (when needed)
|
v
Repository contract
^ implements
|
Infrastructure implementation -> Database / External Systems
However, the project should not introduce domain abstractions merely for the sake of having a "domain layer".
For simple functionality, application and domain logic may be colocated when that keeps the implementation clearer.
The important requirement is separation of responsibilities, not the number of directories.
The documented boundaries have a blocking executable baseline in pnpm architecture:check. ESLint
rejects frontend production imports of backend/database code; application/domain imports of Fastify,
provider adapters, or PostgreSQL implementations; and page/component use of global fetch or raw
API request/base-URL functions. It deliberately permits composition-root wiring, HTTP adapters,
provider/repository implementations, feature-level frontend API operations, and test fakes. This
narrow gate supplements rather than replaces review of responsibilities and dependency direction.
5.1 API / HTTP Layer
The API layer is responsible for communication with HTTP clients.
Responsibilities include:
- Defining routes/endpoints.
- Reading HTTP requests.
- Validating HTTP-level input.
- Authentication/authorization handling when authentication is introduced.
- Calling the appropriate application functionality.
- Translating application results into HTTP responses.
- Translating known application errors into appropriate HTTP responses.
The API layer must not contain substantial business logic.
It must not directly perform arbitrary database operations.
For example, avoid:
HTTP route
↓
SQL query
Prefer:
HTTP route
↓
Application service
↓
Repository
↓
Database
HTTP-specific concepts such as request/response objects, status codes, and HTTP headers should not leak into the domain/application logic unless there is a compelling architectural reason.
5.2 Application Layer
The application layer coordinates application use cases.
Responsibilities include:
- Implementing application use cases.
- Coordinating domain operations.
- Applying business/application rules.
- Calling repositories and infrastructure abstractions.
- Coordinating multiple operations when necessary.
- Returning application-level results.
The application layer should describe what the application is trying to accomplish rather than how HTTP or SQL works.
For example:
GetCurrentOrder
CreateOrder
UpdateOrderStatus
ListProducts
are appropriate application-level concepts.
The application layer should not need to know whether the request came from:
- a browser,
- a tablet,
- a QR-based frontend,
- an administrative interface,
- or another API client.
Those are interface concerns.
5.3 Domain Logic
Domain logic represents rules that are intrinsic to the foodtruck ordering domain.
Examples that may eventually belong here include:
- Valid order state transitions.
- Product availability rules.
- Order status rules.
- Queue-related calculations.
- Business constraints.
Domain logic should not depend on:
- HTTP.
- React.
- Fastify.
- PostgreSQL.
- Docker.
- specific external payment providers.
Not every operation needs a domain object or domain service.
Simple CRUD functionality does not automatically require a complex domain model.
Introduce domain abstractions when they clarify and protect actual business rules.
5.4 Data Access / Repository Layer
The repository/data-access layer is responsible for persistence.
Responsibilities include:
- Reading data from the database.
- Writing data to the database.
- Mapping database representations to application representations.
- Managing database-specific queries and mechanisms.
- Handling persistence-related errors.
Database-specific implementation details must remain within this boundary as much as reasonably possible.
Application logic should not contain raw SQL or depend directly on database client implementation details.
The repository layer should expose operations meaningful to the application rather than exposing the entire database API.
For example:
getOrderById(id)
createOrder(order)
updateOrderStatus(id, status)
is preferable to exposing arbitrary database operations throughout the application.
Order persistence keeps one OrderRepository contract and one PostgresOrderRepository adapter.
Independent active-queue and current-day-sales projections live in the adapter's private
postgres-order-read-models.ts query module; this does not create new application capabilities.
Keep writes whose invariants commit together inside the transactional adapter: finalization with its
snapshots and confirmed checkout fact, status with transition history, and metadata with change
history. Checkout-operation claims remain committed before provider calls, and unresolved attempts
remain independent facts with no order. Do not split these capabilities into repositories per table
or a public read/write topology without evidence that ownership changes justify it.
6. Frontend Architecture
The frontend is responsible for the user interface and interaction with the backend API.
At a high level:
UI Components
↓
Pages / Features
↓
API / Data Access
↓
HTTP
↓
Backend API
The exact frontend structure may evolve as the application grows.
The frontend should separate:
- Presentation.
- User interaction.
- Application/UI state.
- API communication.
- Data transformation that is specific to the frontend.
React components should not become containers for arbitrary networking and business logic.
Prefer a structure in which API communication is centralized or encapsulated in an appropriate frontend data-access layer.
For example:
OrderPage
↓
orderApi
↓
HTTP
↓
Backend API
rather than having multiple unrelated components independently implement HTTP requests.
The current implementation makes this boundary concrete with two parts:
- A small transport module (
frontend/src/app/api/api-client.ts) owns the HTTP-specific concerns: the API base URL, URL construction, request headers, making the request, JSON parsing, response status checking, and failure normalization.ApiErrorretains recognized stable codes, safe field issues, and a request ID from the backward-compatible error body.ApiNetworkError,ApiRequestCancelledError, andApiResponseErrordistinguish connectivity, expected cancellation, and malformed successful responses. - The same transport reports unexpected network, malformed-success, and 5xx outcomes once per observed operation through a sanitized console reporter. Expected API failures and cancellation remain normal user behavior. Reporting never receives a raw error, response body, full URL, credentials, form state, or browser storage.
- Feature-facing endpoint modules next to it expose application-level operations built on the
transport, for example
frontend/src/app/api/message-api.tswithgetMessage()forGET /api/message.
Pages and components call the endpoint functions. They must not call fetch, construct backend
URLs, inspect HTTP status codes, or parse raw responses themselves. Endpoint operations explicitly
choose JSON or 204 no-content transport; a 204 is never cast to an arbitrary response type. The API
boundary does not depend on React or backend implementation details. Successful JSON bodies are not
runtime-schema validated, but invalid JSON is normalized as ApiResponseError.
Order HTTP routes retain one registerOrderHttp composition point and delegate to internal
registrars for active queue/fulfillment, finalization, sales, and unresolved checkout. Each
registrar receives only the order-service operations and HTTP collaborators it needs. The frontend
mirrors those capabilities in active-order-api.ts, order-finalization-api.ts, sales-api.ts,
and unresolved-checkout-api.ts; each query key stays beside its corresponding read. These are
application-level module boundaries, not a shared frontend/backend contract package.
6.1 Frontend state and localization ownership
TanStack Query is the sole owner of API/server state, including the database-backed message and the
current user under the stable auth/me query key. User-owned feature queries use the explicit
authenticated key root. Login/logout cancel session-owned work before identity transitions;
private data is removed immediately, and confirmed transitions also remove current identity. Thus a
late response from an old user cannot restore private cache data. A session 401 clears private cache
data and transient CSRF state; transport/server failure remains a recoverable error rather than
being represented as logout. Redux Toolkit owns device and currently applied language (en, sv,
or hu) and appearance (light, dark, or system) values, but never a duplicate user DTO. Only
device values are persisted in localStorage. Anonymous startup uses stored device, supported browser
language, then default values. When authenticated, each non-null account value from the TanStack
current-user DTO overrides its device value; null keeps the device value. A current-user refetch
reapplies this merge in the background. Confirmed logout/password change restores device values, so
one account's choices never become another account's device defaults. AppProviders resolves system
appearance changes and supplies the matching central MUI theme and reset. Local UI state such as
drawer visibility and form drafts stays in components or a narrow React context. Server responses,
request status, errors, credentials, and current-user data must not be copied into Redux or
localStorage.
Transient notifications use the narrow notification context in AppProviders; a new notification
replaces the current one rather than creating a global event queue. Expected query failures remain
inline and are not duplicated in a snackbar. Shared loading, empty, inline-error,
validation-summary, and field-error components provide distinct semantics. Validation summaries
focus the first invalid labeled field through a caller-controlled field-ID mapping. A localized
React error boundary and React Router error surface provide safe recovery for unexpected
rendering/route failures.
AppProviders composes isolated Redux, i18next, and TanStack Query instances. Redux is
authoritative for the effective language; one provider synchronization component applies it to
i18next and the document language/title without dispatching back to Redux. i18next resources are
bundled with the frontend, use stable semantic keys, and fall back to English. Database/backend
content crosses the API boundary unchanged and is not translated by the browser.
Locale resources are grouped first by locale (en, sv, hu) and then into matching
feature-oriented source modules (shell, auth, products, kds, developer, shared). Locale
indexes compose these branches into the existing single i18next translation namespace in its
stable key order; resources.ts composes the locale set and retains the TranslationResources and
TranslationKey types. These files are eagerly bundled and do not change fallback behavior or
translation keys.
6.2 Shell, theme, and page registration
The mobile-first shell uses MUI and Emotion. One static identity configuration supplies the product name, fallback mark, base document title, Azeret Mono font family, and theme palette inputs; one theme factory owns palette, typography, spacing, focus, touch, and reduced-motion defaults. Font files are self-hosted through Fontsource. Pages consume theme values rather than distributing brand constants.
frontend/src/app/pages.tsx is the single typed page declaration. It supplies route path, localized
label key, optional navigation icon/visibility, route element, access metadata, and optional
developer-only metadata. React Router routes and visible navigation are derived from it. Hidden,
detail, and fallback routes remain declarations without menu entries; menu visibility is not an
authorization boundary.
Authenticated declarations are nested under a small React Router guard. It resolves /auth/me
before rendering protected content, redirects a known unauthenticated session to the hidden public
/login page with a validated same-origin return destination, and renders recoverable session
failures separately. The shell filters authenticated navigation entries from the same declaration;
the backend remains the authorization authority. Settings is protected while Home and unknown-route
handling remain public.
The shell uses a temporary modal drawer below MUI md and a permanent, offset drawer from md
upward. The main document owns page scrolling. Drawer state stays local, while route changes close
mobile navigation and move focus to the main landmark.
The product page keeps create and edit components in frontend/src/pages/ProductForm.tsx, with
shared required-field and server-issue mapping in the adjacent product-form-validation.ts.
Component exports retain their descriptive CreateProductForm and EditProductForm names. Local
presentation constants use lower camel case; uppercase is reserved for exported invariants.
7. Frontend/Backend Boundary
The frontend communicates with the backend through the documented API.
The backend is the authoritative source for business data and business rules.
The frontend may:
- Render data.
- Maintain UI state.
- Perform UI-specific validation.
- Provide immediate user feedback.
- Transform data for presentation.
The frontend must not be treated as the authoritative implementation of business rules.
If a rule affects the correctness of an order or other business operation, the backend must enforce it.
Frontend validation may still be used for usability.
For example:
Frontend:
"The user cannot currently submit an empty form."
Backend:
"An order cannot be created without at least one valid item."
The backend rule must remain valid regardless of which client sends the request.
8. API Contract
The API is a contract between the frontend and backend.
API behavior should be:
- Explicit.
- Predictable.
- Versionable where necessary.
- Covered by automated tests.
- Independent of frontend implementation details.
The API should use appropriate HTTP semantics and structured data formats.
The initial vertical slice should establish the project's conventions for:
- endpoint naming.
- HTTP methods.
- request validation.
- response structures.
- error responses.
- status codes.
These conventions should be documented more extensively if the API grows.
8.1 GET /api/message — retrieve the vertical-slice message
The first vertical slice establishes the concrete API contract below.
- Endpoint:
/api/message - HTTP method:
GET - Request requirements: none (no path, query, header, or body parameters)
- Content type:
application/json
Successful response:
Status:
200 OKBody:
{ "message": "Hello from the database!" }The
messagevalue originates from PostgreSQL via the application service and the message repository. It is never hard-coded into the HTTP layer.
Relevant error responses (all in the standard API error format from §13):
- Status:
400 Bad Request— invalid client input. The endpoint accepts no request input, so any query parameter is rejected. Body:{ "error": "Invalid request", "code": "INVALID_REQUEST" } - Status:
404 Not Found— the database contains no message. Body:{ "error": "Message not found", "code": "NOT_FOUND" } - Status:
500 Internal Server Error— the message cannot be retrieved, for example when the database cannot be reached. Body:{ "error": "Internal Server Error", "code": "INTERNAL_ERROR" }
The contract is covered by automated API tests (backend/src/app.test.ts) and by a real-database
integration test that exercises the complete path
HTTP → application service → repository → PostgreSQL.
The authoritative machine-readable contract for this endpoint is the version-controlled OpenAPI 3.1
document docs/api/openapi.yaml; the description above is its human-readable summary, and Swagger
UI renders it in the generated documentation site. An automated contract test
(backend/src/openapi-contract.test.ts) keeps the document and the implemented routes in agreement.
Tooling and update workflow: docs/documentation.md.
8.2 GET /health and CORS
Anonymous backend GET /health returns 200 {"status":"ok"}. It checks HTTP process liveness only,
without database/seed access. The local gateway routes only /api/* to backend; gateway /health
reaches frontend SPA fallback, so backend liveness must be checked at the backend origin/container.
Optional @fastify/cors allowlisting is configured from validated CORS_ALLOWED_ORIGINS.
Unset/empty disables CORS; configured exact origins permit GET/HEAD and Accept/Content-Type without
credentials. CORS is not authentication. Details belong in development §6.1 and OpenAPI. Unknown
/api and /api/* routes use the safe standard 404 error body. Non-API backend 404 behavior and
the gateway-owned SPA fallback remain unchanged.
9. Database Architecture
PostgreSQL is the primary application database.
The database is responsible for durable application state.
Database schema changes must be reproducible.
Schema changes should be represented through migrations or the equivalent mechanism selected by the project.
A new developer or CI environment should be able to create the database schema from a clean database without manually reproducing historical changes.
The database is not treated as a general-purpose application logic layer.
Business rules should not be implemented in database triggers, stored procedures, or other database-specific mechanisms unless there is a clear architectural reason to do so.
Database constraints should nevertheless be used where they provide strong data-integrity guarantees.
For example:
NOT NULL- unique constraints
- foreign keys
- appropriate check constraints
Database integrity and application business logic are complementary rather than mutually exclusive.
10. External Services
External services are infrastructure dependencies.
Examples include:
- Payment providers.
- POS systems.
- Messaging services.
- Analytics services.
- Future real-time communication infrastructure.
External-service-specific APIs must not become pervasive throughout the application.
Where an external service represents an important application capability, the backend should introduce an appropriate abstraction around it.
Conceptually:
Application
↓
Payment capability
↓
Payment provider adapter
↓
External provider
rather than:
Application
↓
Surfboard-specific API calls everywhere
The exact abstraction should be proportional to the complexity of the capability.
Do not create elaborate abstractions for integrations that do not yet exist.
11. Real-Time Communication
The product vision includes functionality where order status changes may need to be visible on other screens.
Real-time communication is therefore a future architectural concern.
It is not required for the first vertical slice.
When introduced, the implementation should preserve the existing architectural boundaries.
For example:
Order state change
↓
Application layer
↓
Persistence
↓
Real-time notification mechanism
↓
Connected clients
The application should not make business logic dependent on a particular browser communication mechanism.
The exact technology for real-time communication should be selected when the requirement is implemented.
12. Dependency Rules
Dependencies must point toward stable, higher-level application concepts rather than causing business logic to depend directly on infrastructure.
The following conceptual dependency direction should be preserved:
Browser frontend -- HTTP contract --> Backend API
|
v
Application -> Domain
|
v
Repository contract
^ implements
|
Infrastructure -> Database
The following are prohibited unless explicitly justified:
Domain → HTTP framework
Domain → React
Domain → PostgreSQL client
Domain → Docker
Domain → external provider SDK
Similarly:
Frontend component → direct SQL
Frontend component → backend database
must never occur.
The goal is not to eliminate every dependency. The goal is to prevent infrastructure concerns from spreading into unrelated parts of the system.
13. Error Handling
Errors should be handled at the layer where they can be meaningfully interpreted.
For example:
Database error
↓
Repository
↓
Application-level interpretation
↓
API-level HTTP response
The API layer is responsible for converting application errors into HTTP responses.
Internal implementation details such as SQL statements, stack traces, or database credentials must not be exposed to clients.
The system distinguishes the currently handled errors and should preserve these categories as new behavior is introduced:
- expected business/application errors.
- invalid client requests.
- infrastructure failures.
- unexpected programming errors.
The Message API establishes the project's API error-handling convention. Future API endpoints follow the same pattern:
Application/API errors, including unknown API routes, use one backward-compatible JSON shape:
{ "error": "<short safe description>", "code": "<optional stable code>", "issues": [{ "field": "<allowlisted field>", "code": "<stable issue code>" }], "requestId": "<server-generated correlation ID>" }Only
erroris required for backward compatibility, but this server suppliesrequestIdon its API failures and the matchingX-Request-IDresponse header on successes and failures. Incoming request-ID headers are ignored; the server always generates a fresh UUID. Descriptions and optional issue messages never contain stack traces, SQL, raw schema diagnostics, received values, or internal exceptions.Common codes are
INVALID_REQUEST,VALIDATION_FAILED,UNAUTHENTICATED,INVALID_CREDENTIALS,RATE_LIMITED,NOT_FOUND,CONFLICT, andINTERNAL_ERROR. Feature endpoints may add documented codes following this convention. Rate-limit retry metadata belongs in safe response headers; it does not cause automatic mutation retries.Error responses are produced in a single place: the central error handler of the HTTP layer. No route builds error responses itself.
Expected application errors (for example
MessageNotFoundError) are defined in the application layer without any HTTP information. The HTTP error handler recognizes them and maps them to their HTTP status.JSON form routes validate at the HTTP boundary using explicit strict Zod schemas, size limits, and route-owned field/code allowlists. Malformed JSON, unknown properties, field failures, and input beyond the 16 KiB API body limit are normalized without returning parser or schema internals. Application services do not contain HTTP status or header objects.
Fastify's built-in Pino logger writes canonical JSON. Automatic framework request logs are disabled because they include raw URLs. An allowlisted completion event records request ID, method, safe route template, status, and duration; unexpected failures add a stable safe operation/category without serializing exceptions or cause chains. Serializer redaction is a second line of defense for accidental sensitive fields. Raw URLs/query strings, bodies, headers, cookies, account/email values, database URLs, hashes, provider keys, and mail payloads are outside the event contract.
Framework-independent operation context and diagnostic event types let future authentication and email application contracts carry an optional request ID and report allowlisted operation/outcome/duration/provider metadata without importing Fastify or Pino. No tracing or remote telemetry system is introduced.
The frontend maps known codes to bundled translations and uses a safe generic fallback for unknown, legacy, or malformed bodies. UI code never displays arbitrary server error/issue messages or field values. Business data such as the database message remains literal. Unexpected React/route and transport failures use one sanitized console reporter containing only category, public app version, and a bounded correlation ID when available.
14. Configuration and Environment
Configuration must be externalized from application code.
Examples include:
- Database connection information.
- API configuration.
- Environment-specific URLs.
- External service credentials.
- Feature configuration.
Secrets must never be committed to the repository.
Development, test, CI, staging, and production environments may use different configuration values, but they should follow the same application configuration model wherever practical.
Environment-specific behavior should not be hidden in arbitrary application code.
15. Testing Architecture
Testing follows the architectural boundaries.
The test strategy is documented in docs/test-strategy.md.
The conceptual testing structure is:
Unit tests
↓
Component/module tests
↓
Integration tests
↓
API tests
↓
End-to-end tests
Each level has a different purpose.
Unit tests
Verify isolated logic without requiring the complete application.
Component/module tests
Verify interactions within an application component where isolated unit tests are insufficient.
Integration tests
Verify that real components work together.
Examples include:
Backend
↓
Repository
↓
PostgreSQL
API tests
Verify the externally visible backend HTTP contract.
End-to-end tests
Verify important user-visible workflows against a running system.
The testing strategy should avoid duplicating the same test at every level without reason.
The lowest practical test level should normally be preferred for isolated behavior.
16. First Vertical Slice
The first architectural milestone is deliberately minimal.
Its purpose is to prove that the complete architecture works.
The first slice should follow this flow:
PostgreSQL
↓
Repository
↓
Application service
↓
API endpoint
↓
Frontend API client
↓
React page
↓
Browser
The feature itself can be trivial.
For example:
Database:
Message(id, text)
API:
GET /api/message
Frontend:
Displays the message
The value of this feature is not the message itself.
The value is proving that:
- the database works.
- database migrations work.
- backend database access works.
- application logic works.
- the API works.
- frontend API communication works.
- the frontend renders real backend data.
- the appropriate automated tests work.
- Docker can run the complete system.
- CI can reproduce the validation process.
No payment functionality, external integration, authentication, multi-device functionality, or significant business logic is required for this milestone.
17. Vertical Slice Principle
The project should generally be developed through vertical slices.
A vertical slice should cross all application layers necessary to deliver a coherent piece of behavior.
For example:
Feature
│
├── Database schema
├── Repository
├── Application logic
├── API
├── Frontend data access
├── UI
├── Tests
└── Documentation
Avoid implementing an entire architectural layer before implementing any real functionality.
For example, avoid:
Build entire database layer
↓
Build entire backend
↓
Build entire frontend
↓
Finally connect them
Prefer:
Tiny feature
↓
DB → Backend → API → Frontend
↓
Tests
↓
Docker
↓
CI
and then repeat this pattern for subsequent functionality.
This approach provides earlier feedback about architectural problems.
18. Simplicity and Abstraction
The architecture should be as simple as possible while preserving useful boundaries.
Do not introduce abstractions solely because they are common in architectural patterns.
For example, a trivial operation does not automatically require:
Controller
→ UseCase
→ Service
→ DomainService
→ RepositoryInterface
→ Repository
→ Mapper
→ Entity
→ DTO
if most of these provide no meaningful separation.
Instead, introduce abstractions when they solve a concrete problem such as:
- isolating infrastructure.
- protecting business rules.
- improving testability.
- separating independently changing concerns.
- supporting multiple implementations.
- preventing an external dependency from spreading through the system.
The architecture should evolve when the requirements demonstrate the need.
19. Architectural Decision Records
Significant architectural decisions should be recorded in docs/decisions/.
An architectural decision record should normally contain:
Context
Decision
Rationale
Consequences
Examples of decisions that may deserve an ADR:
- Changing the backend architectural model.
- Introducing a new infrastructure component.
- Introducing a real-time communication mechanism.
- Choosing a strategy for external service integration.
- Introducing a shared package between frontend and backend.
- Replacing a major technology.
Do not create an ADR for every small implementation choice.
The purpose of ADRs is to preserve significant architectural reasoning that would otherwise be lost.
20. Architecture Evolution
This architecture is intentionally designed to evolve.
The initial vertical slice is not expected to contain every future architectural component.
Future requirements may introduce:
- authentication and authorization.
- multiple clients.
- real-time updates.
- payment/POS integrations.
- background processing.
- caching.
- analytics.
- queue prediction.
- administrative workflows.
When these requirements appear, extend the architecture based on the actual problem.
Do not introduce infrastructure in anticipation of hypothetical future requirements unless the expected cost of introducing it later clearly justifies doing so.
Architectural complexity should be driven by demonstrated requirements.
21. Source of Truth
The following documents have distinct responsibilities:
| Document | Purpose |
|---|---|
AGENTS.md |
AI-agent rules and high-level project conventions |
docs/architecture.md |
System architecture and architectural boundaries |
docs/techstack.md |
Technology choices and rationale |
docs/development.md |
Local development and development workflow |
docs/definition-of-done.md |
Completion requirements and proportional validation |
docs/test-strategy.md |
Testing strategy and test technologies |
docs/ci.md |
CI pipeline and quality gates |
docs/docker.md |
Docker and containerization |
docs/current-state.md |
Current implementation state and next steps |
docs/decisions/ |
Significant architectural decisions |
When information belongs in one of these documents, do not duplicate the complete explanation in another document.
AGENTS.md may summarize a rule and point to this document, but this document is the authoritative
source for the detailed architecture.