Why I chose DynamoDB for Tourismo — and what I'd do differently

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.

Why DynamoDB at all

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.

Where it stopped being clean

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 things that wanted a join

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.

What I'd actually change

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.