Product ownership
In productionWhat owning a production SaaS end to end actually involves.
Alongside apsis I build and operate a multi-tenant SaaS for clinical practice management: scheduling, patient records, session notes, invoicing and GDPR compliance for health practices in Spain. I own every layer of it: the domain model, the API and the frontend, the infrastructure it runs on, the release process and the product decisions behind them. This page keeps the product unnamed because it operates in a regulated sector on sensitive personal data; everything else on it is real and checked against the codebase.
- 131
- endpoints
- 38
- tables
- 43
- migrations
- 1,522
- tests
- 85%
- min coverage
- 5
- releases
Tenancy as a database invariant
Every tenant-owned table carries an organization id, and no query is trusted to remember it: a tenant-scoped repository base injects the filter into every read, assigns the tenant on every write and rejects cross-tenant writes outright. A meta-test fails CI if any tenant-owned model gets a repository that skips that base. One level deeper, foreign keys are composite (id plus organization id), so even a buggy write cannot reference rows from another tenant. Isolation holds because the schema enforces it, not because every developer remembers to.
Sensitive data and GDPR as features
Clinical notes are encrypted per column with AES-256-GCM, and each column derives its own key via HKDF, so a blob decrypted in one context is useless in another. GDPR is implemented with the articles attached: export under 15 and 20 (a ZIP with PDF and JSON), and erasure under 17 behind a two-step gate where the purge runs in dry-run until an audited approval by the data protection officer, with an expiry, switches it to enforce. Every action lands in an audit log partitioned by month.
The database as the concurrency layer
Double-booking is impossible at the schema level: appointments are TSTZRANGE values under two EXCLUDE USING GIST constraints, one per professional and one per room, so the race that application-level checks always leave open simply cannot commit. Invoice numbers are correlative per organization, series and fiscal year, issued under a FOR UPDATE counter. And all background work (domain events, recurring jobs, notifications) runs on Postgres-native workers: a transactional outbox, LISTEN/NOTIFY, SKIP LOCKED and leases.
What production taught me
The changelog keeps the scars. The outbox worker dispatched twice during a rolling deploy because row locks released on commit inside the loop: it now claims exactly one event per transaction. The job scheduler raced against itself with two workers until claims carried a lease. Free slots computed at "09:00" drifted an hour across the DST change because wall-clock time was being treated as UTC. And the EXCLUDE constraint could not see busy blocks synced from an external calendar, so an explicit conflict check backs it up.
Delivery discipline
Releases are tagged SemVer with a curated changelog, and more than fifty architecture decision records document why things are the way they are. CI runs six gates on every change: lint and format, strict typing, import contracts that enforce the layering, a dependency CVE audit, container lint, and tests at three levels (unit, functional against the ASGI app, integration against a real Postgres). Minimum coverage is 85 percent, and the test suite is larger than the application it tests.
Operations
It ships as a multi-stage Docker image and deploys over SSH after a green CI run. Observability is self-hosted: Prometheus, Grafana, Loki and Tempo, with OpenTelemetry tracing through FastAPI, SQLAlchemy and the HTTP clients, load tests in k6, and a PII filter that masks personal data in logs and traces alike. Integrations sit behind swappable drivers (a fake in tests, the real service in production): transactional email, EU-jurisdiction object storage, and a two-way calendar sync with incremental sync tokens and webhook channel renewal.