Tourismo is a PWA for car enthusiasts — you log drives with GPS tracks, build a garage, organise meets, and discover good roads. The whole backend is Lambda, and the database is DynamoDB. I want to be honest about that choice: parts of it were exactly right, and parts of it I'd undo if I started again.
The deciding factor wasn't scale. It was operational surface area. I'm one person. A Lambda backend with DynamoDB means there is no database server to patch, no connection pool to exhaust, no instance to right-size, and the bill is genuinely zero when nobody's driving. Every table is PAY_PER_REQUEST, so a closed beta costs me cents. With RDS I'd be paying for an instance to sit idle 23 hours a day.
So the access pattern that matters most — "show me my drives, newest first" — maps onto DynamoDB beautifully. Trips are keyed by tripId, with a GSI that does the real work:
global_secondary_index {
name = "byUser_startedAt"
hash_key = "userId"
range_key = "startedAt"
projection_type = "ALL"
}
One query, partition by userId, sort descending on startedAt, paginate. No scan, no filter, single-digit milliseconds. The garage is the same shape — cars keyed by carId, a byOwner GSI on ownerUserId sorted by createdAt. These are the patterns DynamoDB was built for, and they've never given me a moment's trouble.
Here's my first confession: I did not build a single-table design. The orthodoxy says one table, overloaded keys, every entity sharing a partition. I have eleven-plus tables — tourismo_trips, tourismo_cars, tourismo_events, tourismo_follows, tourismo_reactions, and so on. I tried the single-table version on paper and found that for a solo dev it optimises for the wrong thing. It makes the schema unreadable to save a few RCUs I will never spend. Separate tables per entity, each with its own purpose-built GSIs, is the version I can still understand at 11pm six months later. I regret nothing here.
What I do wish I'd seen earlier is how quickly the GSIs multiply. Trips alone carry three:
byUser_startedAt # my drives
byCar_startedAt # this car's history
public_startedAt # the public feed
Every new way the product wanted to read trips became a new index. That public_startedAt GSI partitions on visibility — which means every public trip lands in the same partition (visibility = "public"). That's a textbook hot-partition risk. In a closed beta it's invisible. At scale it's a redesign waiting to happen, and I knew it when I wrote it. I'd shard that key now.
The real friction is social. "Show me the public drives from people I follow, newest first" is one SQL join and a WHERE clause. In DynamoDB it's: query the follows table by followerId, collect the followee IDs, then fan out N queries against the trips GSI and merge-sort the results in the Lambda. The database can't do the join, so my code is the join engine. That's fine at a few hundred follows. It is not a feed architecture, and I always knew the home feed would eventually need a fan-out-on-write model or a separate read store.
The byHandleLower index taught me a smaller, sharper lesson. The original handle GSI was keyed on the case-sensitive handle, so searching for "Dan" couldn't find a user stored as "dan". The fix was to denormalise a handleLower attribute and key the index on that. In Postgres that's WHERE lower(handle) = lower($1). In DynamoDB, case-insensitivity is something you design into your keys up front or pay to migrate later.
I'd keep DynamoDB for trips, cars, and anything keyed cleanly by owner-and-time — that's most of the app, and it's perfect. But I'd reach for Postgres the moment a feature smelled like a graph or an ad-hoc query: the social feed, "who's coming to this meet and who do they follow", any future analytics. The mistake isn't choosing DynamoDB. It's pretending every access pattern is a key-value lookup when some of them are obviously relational, and then writing the join in application code because you've already committed.
The honest summary: DynamoDB gave me a backend with zero idle cost and zero servers to mind, which for a solo-built product is worth an enormous amount. I just paid for it in the social layer, one hand-rolled join at a time.