DEV Community

ALISAM SRIVARDHAN
ALISAM SRIVARDHAN

Posted on

Building TravelMate: Part 2 — Architecture Decisions (And the Ones I Almost Got Wrong)

This is Part 2 of a 4-part series on building TravelMate. Part 1 covered the idea and the problem. Part 3 dives into the hardest engineering problems — a greedy settlement algorithm, real-time chat security, and an AI integration with actual guardrails. Part 4 comes after the project ships.

The rule I set before writing any code

After LexAssist, I made myself sit with the architecture before touching a controller. That felt slow at the time — genuinely, embarrassingly slow, watching classmates already pushing feature commits while I was still drawing entity relationships on paper. But almost every phase since has built cleanly on top of the last one, and I think that's a direct result of the time spent up front, not in spite of skipping it.

Here's what that architecture actually looks like, and — more usefully — where I almost got it wrong.

Modular monolith, not microservices

The instinct, especially after reading enough "how big tech does it" blog posts, is to reach for microservices. I didn't. TravelMate is a modular monolith — one deployable application, but organized internally into clean, independent modules (auth, trips, matching, chat, expenses, and so on), each with its own routes, controllers, services, and models.

The honest reason: microservices solve problems I don't have. I don't have multiple teams stepping on each other's code. I don't have one module that needs to scale independently at 100x the traffic of another. What microservices would have given me, at this stage, is network calls between services, more infrastructure to deploy and monitor, and distributed-systems bugs I'd have zero practice debugging. A modular monolith gives almost all the organizational benefit — clear boundaries, one module's mess doesn't leak into another's — without any of that cost. If a module like matching or chat ever needs to scale independently, the boundaries are already clean enough to extract it later. That's the actual argument for "modular" in modular monolith: it's monolith now, extractable later, by design.

The decision I made over and over: embed or reference?

If there's one architectural question I answered more times than any other, it's this one. MongoDB lets you either embed related data directly inside a document, or store it separately and reference it by ID. Get this wrong in enough places and your database becomes either a tangle of oversized documents or a maze of unnecessary joins.

The rule I settled on, and reused constantly:** embed when the data is small, bounded, and always fetched together with its parent. Reference when it's unbounded, needs independent querying, or shared across multiple parents.
**
A user's profile and travel preferences? Embedded — small, fixed set of fields, always needed alongside the user. Chat messages? Referenced — a trip could accumulate thousands of them, and you need to paginate and query them independently of the trip itself. Expense split participants, on the other hand, look like they should be referenced (money data feels like it should live in its own collection), but they're actually a good embedding case — bounded by trip size, always created and read together with their parent expense. Noticing that distinction — that the right answer follows from the actual access pattern, not from "this is financial data so it must be relational-feeling" — was one of those small realizations that made a lot of later decisions faster.

The pattern I didn't expect to reuse eight times

Early on, I needed to stop users from sending duplicate join requests to the same trip. The naive fix — check if a request already exists, then create one if it doesn't — has a real bug hiding in it: two nearly-simultaneous requests can both pass the "does this exist" check before either one finishes writing. Both succeed. Now there are two.

The fix was a compound unique index at the database level: trip and user together must be unique. Not a check in my code — a guarantee enforced by MongoDB itself, regardless of timing.

I expected to use that pattern once. I ended up using it for join requests, trip membership, poll votes, reviews, budgets, blocks, community memberships, and trusted-contact shares. Eight different features, the same underlying shape: "at most one of this relationship between these two things, guaranteed even under concurrent requests." By the sixth or seventh time, I stopped re-deriving the solution and just recognized the shape immediately. That's what a good architectural decision actually buys you — not that you solve a problem once, but that you stop having to re-solve it.

Where I almost got it wrong: trip capacity

The join-request duplicate problem has a sibling that's actually more dangerous: what happens when a trip has one seat left, and two people request to join at almost the same instant? A naive "check seats, then increment" has the exact same race condition, except here the consequence is a genuinely overbooked trip, not just a duplicate row.

The fix looks almost too simple for how much it matters: the seat-availability check and the increment happen as a single atomic database operation — findOneAndUpdate with the "is there room" condition built directly into the query, not checked separately beforehand. MongoDB guarantees no other operation can slip in between the check and the write. If two requests land at the same moment, the database still processes them one at a time internally, and the second one correctly sees the already-updated count and fails.

I don't think I would have caught this if I'd been moving fast. It's the kind of bug that works perfectly in every manual test you run — you're one person, clicking one button at a time — and only breaks under conditions you didn't simulate. Slowing down to actually think through "what happens if two people do this at once" before writing the code, rather than after a bug report, is the single habit from this project I'm most likely to carry into everything I build afterward.

What's next

Part 3 is where the more interesting problems live: a greedy algorithm for settling group expenses in the minimum number of transactions (with an actual proof for why it's optimal, not just "it seemed to work"), a Min-Heap reused for two completely different purposes, and the real security work behind making sure a Socket.IO chat room can't be joined by someone who was never invited. I'll also walk through integrating an AI trip planner and being deliberate about the difference between a feature that sounds smart and one that's actually guarded against making things up.


Next: Part 3 — The hardest engineering problems: a provably-optimal settlement algorithm, real-time security, and AI with guardrails.

Top comments (0)