Architectural Minimalism: Why My Backend Is Boring on Purpose

Most backend architectures grow by accumulation. A service here, a cache there, a message broker because “we might need it.” Before you know it, your system diagram looks like a conspiracy board — everything connected to everything, half the arrows labeled “sometimes.”
I went the other direction. When I designed the backend for my app, I started with one question: what’s the fewest moving parts that still give me the properties I care about? The answer turned out to be surprisingly small.
Direct writes
Request goes straight to DynamoDB. No queues, no event bus. TransactWriteItems lands two rows atomically — both land or neither lands.
Pre-computed reads
Sort keys encode status. Every read is a single begins_with query. Zero filtering at read time — the work happened at write time.
Deferred complexity
SQS queue provisioned but idle. Infrastructure is ready for sharing — not running until the problem actually arrives.
The entire write path
A user creates a task. Here’s what happens: the request hits a Lambda, which writes to DynamoDB. That’s it.
No write queue. No event bus. No streaming pipeline. No “let’s publish an event and see who picks it up.” The Lambda receives the request, validates it, and performs a TransactWriteItemscall that lands two rows atomically — the primary record and a pre-computed read projection. Both land or neither lands. The response goes back to the client with the data already consistent.
Client → API Gateway → Lambda → DynamoDBThree hops. One network call that matters. Read-your-writes consistency by default, not by accident.
The trick: sort keys as pre-computed read paths
This is where the minimalism pays for itself. Every task lives in a single DynamoDB table. When I write a task, I don’t just store the record — I store it multiple ways:
TASK#<id>— the canonical recordTASK_PENDING#<createdAt>#<id>— a projection for “show me pending tasks”TASK_DONE#<createdAt>#<id>— a projection for “show me completed tasks”
When someone marks a task as done, a three-item transaction fires: update the primary record, delete the TASK_PENDING# row, insert a TASK_DONE# row. One atomic operation, and now every possible read pattern is already prepared.
This means reads are almost trivially simple:
List all tasks: begins_with("TASK#")
List pending tasks: begins_with("TASK_PENDING#")
List done tasks: begins_with("TASK_DONE#")
Get one task: exact key lookupNo FilterExpression. No scan. No secondary query to filter by status. The status is the sort key. The filtering happened at write time.
Sort order comes free too. Because the sort key includes createdAt, DynamoDB’s lexicographic ordering gives you chronological order out of the box. Newest first? Just flip ScanIndexForward to false. No ORDER BY, no sort step, zero work at read time.
Why this works: you already know your read patterns
The insight that makes this architecture possible is that for most applications, read patterns are not a mystery. You know what screens your app has. You know what data each screen needs. If you can enumerate your reads at design time, you can pre-compute them at write time.
This is the opposite of how most people think about databases. The conventional approach is to normalize your data, store it once, and figure out how to query it later. That works fine when you have a relational database doing the heavy lifting. But it also means every read is a question your database answers on the fly — joins, filters, aggregations, all at query time, all scaling with your data.
Pre-computing read paths inverts this. The write is slightly more expensive (two or three rows instead of one), but every read is a single-partition query that returns exactly the rows you need. No filtering. No post-processing. The cost is paid once on write, amortized across every read that follows.
For an app where reads vastly outnumber writes — which is most apps — this is a good trade.
When a request needs more than storage
Not everything can be synchronous. When I eventually add sharing (user A’s task needs to appear in user B’s view), writing to every recipient’s partition in the same request would be untenable. You can’t hold a Lambda open while you fan out to hundreds of users.
The design for this is already in place: a FIFO SQS queue sits provisioned and waiting. The pattern will be simple — write to the source partition synchronously (the user always sees their own change immediately), then enqueue a message. A consumer Lambda reads from SQS and writes copies to each recipient’s partition under their own optimistic lock.
But here’s the key: I’m not running that consumer yet. In the current single-user version, the synchronous write is the entire path. The queue exists as infrastructure, not as active plumbing. I’ll turn it on when the problem actually arrives, not before.
This is architectural minimalism in practice. Anticipate the future shape of the system, provision the infrastructure so you’re not redesigning under pressure, but don’t run code that has no work to do.
One table, one schema, every entity type
The entire data model lives in a single DynamoDB table I call the Vault. Every entity type — tasks today, habits and notes and budgets tomorrow — shares the same table with different sort key prefixes. A new entity type requires zero schema changes, zero migrations, zero new tables. Just a new prefix.
The primary key model is simple: USER#<userId> as the partition key, TYPE#<id>as the sort key. Every user’s data lives in their own partition. Cross-user queries don’t exist by design — there’s nothing to leak, nothing to accidentally expose, no joins that could go wrong.
A single GSI keyed on updatedAthandles delta sync: “give me everything that changed since the last time I asked.” Because only primary records (not projection rows) write to this index, it’s sparse. Every row the GSI returns is authoritative. No filtering, no deduplication.
What I don’t have
No Redis. No Elasticsearch. No separate read replicas. No API gateway caching layer. No event sourcing. No CQRS framework. No service mesh. No Kubernetes.
Each of those tools solves a real problem. But solving problems you don’t have is how systems become complex. Complexity isn’t a sign of sophistication — it’s a sign that you’re paying maintenance costs on problems you may never encounter.
The backend is a SAM template, a handful of Lambda functions, and one DynamoDB table. It deploys in minutes. There’s no infrastructure to keep warm, no clusters to right-size, no connection pools to tune. When nothing is running, the cost is effectively zero.
The discipline of not adding things
Architectural minimalism isn’t about being clever. It’s about resisting the pull of “what if we need it later.” Every component you add is a component you maintain, monitor, debug, and pay for — not just in money but in cognitive overhead. Every architectural decision you defer is one you don’t have to be right about yet.
The hardest part isn’t choosing the right tools. It’s having the discipline to not choose tools you don’t need. To sit with a system that feels too simple and ask: is it actually too simple, or am I just uncomfortable with how few things can go wrong?
So far, the answer has been the latter.