Architecture
Commerce ships as three deploy targets in one repo (services/).
Components
services/
├── commerce-api/ FastAPI :8010 Python Hlavní backend
├── email-renderer/ Fastify :8011 Node 20 MJML compile
└── outbox-dispatcher/ background Python LISTEN/NOTIFY worker
commerce-api
The single source of truth. Owns the database, exposes ~129 HTTP
endpoints across two prefixes (/admin/2026-01/... and
/storefront/2026-01/...), publishes events, enqueues outbox tasks.
Stack: FastAPI 0.115 + SQLAlchemy 2.0 async + asyncpg + Pydantic 2.10
- Python 3.13. Database is Supabase Postgres 17.6 with row-level
security per
account_id. Workspace-managed via uv.
email-renderer
Stateless Node service. Compiles MJML templates with Handlebars ctx
interpolation, returns final HTML + plain-text fallback + subject.
Has its own Dockerfile and unit-tested in tests/render.test.js.
The commerce-api calls it over internal HTTP (default
http://localhost:8011/render). No DB access.
outbox-dispatcher
Long-running Python worker. Polls outbox_dispatches with
SELECT FOR UPDATE SKIP LOCKED for safe parallelism, listens on
the outbox_new channel for instant wakeups. Each task is dispatched
to a registered handler — currently task / send_email is the only
one shipped, more reserved for connectors and webhooks.
Workers can scale independently of commerce-api (one API pod,
many workers).
Run-time topology
storefront app dashboard / scripts Resend
│ X-Storefront-Key │ Bearer JWT/PAT │ webhooks
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ commerce-api (FastAPI :8010) │
│ /admin/2026-01/... /storefront/2026-01/... │
│ /webhooks/resend │
│ │
│ ▼ │
│ Supabase Postgres 17.6 (~50 tables, RLS-isolated) │
└──────────┬─────────────────────────────────────┬───────────────┘
│ outbox_new NOTIFY │
▼ │
outbox-dispatcher (Python) │
│ │
├──▶ email-renderer (Node :8011) │
│ MJML + Handlebars │
│ │
└──▶ Resend API ──────────────────────┘
Data isolation
Every domain table has account_id BIGINT NOT NULL and a row-level
security policy:
USING (public.is_admin_mode() OR account_id = public.current_account_id())
WITH CHECK (public.is_admin_mode() OR account_id = public.current_account_id())
The nevios_app Postgres role can't bypass it. commerce-api sets
current_account_id per request via SELECT public.set_account_context(...)
before any other queries run. RLS enforces that even a bug in
application code can't leak across tenants.
Event log + outbox
Every state change publishes an immutable events row (audit log)
and inserts outbox_dispatches rows for downstream consumers (email
sender, webhooks, future integrations). Both happen in the same DB
transaction as the underlying mutation, so events can never fall
out of sync with state.
The dispatcher reads from outbox_dispatches with retry/backoff
(30s → 2h cap → status='dead').
See Events & outbox for the schema.
What runs where
| Concern | Where |
|---|---|
| All HTTP routes | commerce-api |
| All DB writes | commerce-api |
| Pricing engine (CZ VAT locked) | commerce_api/services/pricing.py |
| Email rendering | email-renderer (Node) |
| Email sending | outbox-dispatcher → Resend |
| Webhook ingest (Resend events) | commerce-api /webhooks/resend |
| Inventory reservations TTL | commerce-api on checkout create + DB cleanup cron |
Next: Authentication.