
One Table, One DAL: Designing InvocaCare's Data Layer for a Team of One
Single-table DynamoDB and a generic `createDAL()` factory turned out to be the same decision made twice: push key-structure discipline out of individual developer diligence and into a structure that enforces it by construction
Part 4 of 7 β InvocaCare Architecture series
When you're the only engineer, the database decision that matters most isn't "which database is fastest" β it's "which database design means I'm not rewriting the same plumbing every time I add a domain." InvocaCare started with tenants, subscriptions, and users. It's going to grow into call triage, on-call scheduling, secure messaging, and whatever comes after that. Every one of those is a new domain that needs to read and write data, tenant-scoped, safely, without me reinventing data access from scratch each time.
That constraint β one person, a growing number of domains, no time to spare β is what actually drove two decisions that look, from the outside, like separate technology choices: DynamoDB single-table design, and a single generic createDAL() factory that every domain uses instead of writing its own database code.
Why single-table, not relational
The obvious default for a "clinics, users, subscriptions" data model is a relational database with a table per entity and foreign keys tying them together. I didn't go that way, for reasons that are more about how the system runs than how the schema reads on a whiteboard:
- Serverless fit. InvocaCare runs on Lambda. Relational databases want long-lived connections and connection pooling; a fleet of Lambdas scaling from zero to many concurrent invocations is exactly the traffic shape that makes connection pooling painful. DynamoDB's request-per-call model has no connection state to manage.
- On-demand billing matches an early-stage product.
PAY_PER_REQUESTbilling means the database costs scale with actual usage, not a provisioned instance sitting idle most of the day β the right shape for a product with a handful of clinics today and an unknown number next quarter. - Access-pattern-first design forces the right question early. With DynamoDB you can't get away with "model the entities, add indexes later" β you have to enumerate your access patterns before you touch the schema, because the key design is the query capability. That's more upfront work, and it's work that pays for itself: every access pattern InvocaCare's core and platform services need today is answered by a
GetItemor a singleQuery, with effectively no joins.
The schema is deliberately boring: one table, partition key TENANT#{tenantId}, sort key identifying the record type β for the tenant record, for billing state, for a user, for a tenant-scoped business object. Tenant metadata and subscription state share a partition for low-latency reads; listing all users or all items in a tenant is a single partition query with a sort-key prefix, not a join. A single overloaded GSI handles the two secondary access patterns that need one (admin tenant listing, optional list-items-by-user) β kept deliberately sparse, because every GSI attribute you write is write amplification you're paying for on every relevant mutation.