I Rebuilt One App's Architecture Three Times — Here's How I Choose Now
Software Architecture · Software Engineering

I Rebuilt One App's Architecture Three Times — Here's How I Choose Now

23 June 2026

I rewrote the architecture of a single logistics app three times, each time chasing the "best" architecture instead of the one the project actually needed — and that mistake taught me more than any book.

Let me tell you about the project that cured me of architecture astronautics.

It was a logistics app — shipments, routes, carriers, ETAs, a firehose of GPS pings, and the kind of business rules that look simple until you meet partial deliveries and SLA penalties. Over its life I rebuilt its architecture three times. Not because the business changed three times, but because *I* kept reaching for whatever pattern I'd just decided was "correct." I wasn't solving the problem. I was collecting architectures.

Here's the whole embarrassing arc — and how each round quietly taught me the lesson I'll get to at the end.

Round 1: microservices on day one

I started with microservices. Not after hitting a wall — on day one, because "scalable from the start" sounded responsible and every conference talk I admired ran on them. With a team of basically me and a couple of others, I split the thing into orders, routing, inventory, and tracking before there was a single real user.

It went exactly how you'd expect. A single "dispatch this shipment" became a distributed transaction across three services, and I was debugging message ordering at 2 a.m. instead of moving shipments. This is the Amazon Prime Video story everyone cites — a team that folded microservices back into a monolith and cut cost ~90% — except I paid the tuition first. The honest caveat is that their case was one team's pipeline, not a law of nature. But for a tiny team with zero scale, microservices-first was pure self-harm. I hadn't earned a single one of those boundaries; I'd just drawn them because they looked grown-up.

Round 2: collapse into a monolith — and over-correct into ceremony

Burned, I pulled everything back into one process and swore to "do it properly this time." Which I read as: aggregates for everything, ports and adapters around every corner, the admin CRUD screens treated with the exact same ceremony as the dispatch engine. Adding a phone number field to a carrier profile now meant touching five files and two mappers.

This is what DHH calls *test-induced design damage* — indirection added to satisfy a principle, not a need. And here's the thing: the dispatch core genuinely deserved that rigor. The "edit a phone number" screen absolutely did not. I'd bought one expensive suit and was trying to wear it to the gym. It was the same mistake as Round 1, just pointing the other way — taking a single pattern and spreading it uniformly across the whole app, instead of putting the weight only where the weight was needed.

Round 3: the boring answer that finally worked

Version three wasn't a new trophy architecture. It was a *modular monolith* where each part got exactly the treatment it deserved — and that's the whole point. The breakthrough wasn't a pattern; it was admitting that **different parts of the same app deserve different answers.**

Concretely, that meant mixing approaches on purpose:

The **dispatch, routing, and pricing core** got real **DDD**, because that's where the rules *are* the product — a shipment can't dispatch without a confirmed route, pricing depends on weight, zone, and SLA tier, a partial delivery has to keep its remainder consistent. So it became a proper domain model: `shipment.confirmDispatch()` instead of `setStatus(4)`, value objects like `Weight`, `Money`, and `GeoPoint`, and an aggregate that guards what must change together. That's where Dan North's "write code that fits in your head" finally paid off — a sharp model makes a gnarly core legible.

I wrapped that core in a thin slice of **Clean Architecture** — not the whole app, just a boundary around the part that was both complex *and* likely to swap infrastructure. Routing sat behind a `RoutingProvider` port, persistence behind a `ShipmentRepository`. The payoff was concrete: when we changed maps/ETA vendors, not one business rule moved.

Everything around that core — carrier onboarding, the admin panel, reports — got **vertical slices** in Jimmy Bogard's sense: organize by feature, not by layer, so each one is a small thing you read top to bottom and change without spelunking. No aggregates, no ceremony, just the feature.

All of it stayed a **monolith**, because we were one team — one deploy, clear module seams instead of network calls, DHH's "majestic monolith." And exactly one piece broke out into a **microservice**: the real-time GPS/telemetry ingestion, with its enormous write volume and totally separate failure profile. The same microservices I'd started with in Round 1 — except now it was a single seam proven by load, not a guess.

What three rewrites actually taught me

Looking back, rounds 1 and 2 were the same mistake wearing different clothes: I picked one pattern and spread it across the entire app — microservices everywhere, then DDD and Clean Architecture everywhere — when the only question that helps is *"what does this part of this system actually need right now?"* The best architects I've worked with aren't reaching for grander patterns; they're matching each decision to its real forces, and they're comfortable running a rich domain model in one module and a dumb CRUD slice right next to it.

Strip away the tribes and even the people who argue about this agree on the boring core: complexity has to be *earned*. Layers, aggregates, and services are a cost you pre-pay for change you actually expect — no expected change, no pre-payment — and the real enemy is cargo-culting a FAANG-scale pattern onto a problem that doesn't have FAANG-scale forces.

So now, before I restructure anything, I stop and ask five quick questions about that *part* of the system — not the whole app:

  1. How real are the rules here? Genuine invariants point to DDD; plain create-read-update doesn't.
  2. How often, and how, will this change? Constant feature churn wants vertical slices; a stable core with many entry points wants a clean boundary.
  3. How big is the team, and how independent must this really be? One team almost always means a modular monolith; genuinely independent deploys or scaling earn a service — later, not first.
  4. What do the non-functionals force? Compliance, data residency, or real scale can justify boundaries nothing else would.
  5. How long will it live, and who maintains it? Short life rewards simplicity; a decade-long platform rewards structure.

None of that is exotic. It's just the discipline I didn't have when I treated architecture like a trophy cabinet.

If you take one line from three rewrites I'd rather you skip: don't choose an architecture — choose a constraint, and let it choose the architecture. The logistics app never needed the best one. It needed me to stop looking for it.


Sources

  1. Jimmy Bogard — Vertical Slice Architecture
  2. DHH — Test-induced design damage
  3. Dan North — CUPID, the back story
  4. Amazon Prime Video — scaling to a monolith (analysis)