MVP architecture done right: split the disposable shell (UI, fake features) from the permanent spine (data model, auth, domain rules) and instrument every event.
The fastest MVP I ever shipped took three weeks. The rebuild took four months. That ratio is the whole story, and it's the one nobody warns you about when they tell you to "just ship something."
That first version worked. It got users, it validated the idea, everyone was happy. Then we opened the hood: a single 4,000-line file, database credentials hardcoded in the client, and not one event logged, so we couldn't tell which of the twelve features anyone actually used. We didn't extend it. We couldn't. We rebuilt from zero and threw all of it away, and that rebuild cost more than doing it properly the first time would have. I've since watched a dozen founders make the exact same trade — including me, more than once, because knowing the failure mode and avoiding it under deadline are two different skills.
Here's the thing nobody tells you: an MVP being fast and an MVP being throwaway are two different decisions, and most people accidentally couple them. They think "we're moving fast, so of course the whole thing is disposable." Wrong. Some of it should be disposable. Most of it, even. But a thin, load-bearing spine underneath has to survive to v2, v3, and the Series A rewrite — and if you don't decide which parts those are on purpose, you pay for the MVP twice: once to build it, once to escape it. That double payment is the most common hidden cost in early-stage product engineering, and it's entirely avoidable.
The default mental model for early-stage software is a slider. On the left: quick and dirty, ship this week, technical debt everywhere. On the right: slow and solid, proper architecture, ships in three months. Founders think their only choice is where to put the slider, and most "move fast" advice just tells them to slam it left.
That model is wrong because "the MVP" is not one thing with one quality level. It's a stack of layers, and each layer has a wildly different lifespan. Treating it as a single dial is how you end up with a minimum viable product that's uniformly mediocre — too rough to keep, too entangled to salvage.
Once you see the MVP as layers with different lifespans, the question stops being "how good should this be?" and becomes "which parts am I keeping?" That's a much better question. It's answerable. And it lets you go genuinely fast on 80% of the surface because you were deliberate about the 20% underneath. Speed and durability stop being a tradeoff and become a partition.
I draw one line before writing any code. On one side is the shell: everything a user sees and touches. On the other is the spine: the data model, the domain rules, and the boundaries between systems. The shell is allowed to be embarrassing. The spine is not.
The trick is that the seam between them has to be explicit. If your UI reaches directly into Firestore, there is no seam — the shell and the spine are fused, and when you throw away the shell you rip out the spine with it. So even in a three-week build, I keep one boring layer of indirection. This is the repository pattern, and in an MVP context it's the single highest-leverage architectural decision you make.
In a Flutter app that looks like a repository the UI talks to, not raw queries scattered across widgets:
// Spine: the contract. This survives to v2.abstract class OrderRepository { Future<Order> create(OrderDraft draft); Stream<List<Order>> watchForUser(String userId);}// Shell: the throwaway implementation. Firestore today,// Postgres tomorrow, a mock in tests. Nobody upstream cares.class FirestoreOrderRepository implements OrderRepository { FirestoreOrderRepository(this._db); final FirebaseFirestore _db; @override Future<Order> create(OrderDraft draft) async { final doc = await _db.collection('orders').add(draft.toMap()); final snap = await doc.get(); return Order.fromSnapshot(snap); } @override Stream<List<Order>> watchForUser(String userId) => _db .collection('orders') .where('userId', isEqualTo: userId) .snapshots() .map((q) => q.docs.map(Order.fromSnapshot).toList());}Order, OrderDraft, and the OrderRepository interface are the spine. The Firestore class is the shell. That one abstraction costs maybe twenty extra minutes on day one. It has saved me weeks every single time I've had to swap a backend, and I always end up swapping a backend.
There's a second payoff people underrate: the same seam that lets you swap Firestore for Postgres also lets you inject a FakeOrderRepository in tests. Your domain rules become testable without a network, an emulator, or a live database. In practice, the interface that protects you from a migration is the same interface that gives you fast unit tests — you don't have to choose.
The seam isn't only in code. It's in your head. Before the build, write two lists: things I am deliberately doing badly and things I refuse to do badly. If you can't name what's on each list, you haven't drawn the seam yet — you've just decided to be sloppy everywhere and call it speed.
Let me be specific about the shell, because "move fast" is meaningless without examples of what you actually allow yourself to skip. Here's what I happily do badly in an MVP:
The discipline is that all of this embarrassing code lives on the shell side of the seam. It touches the spine only through the clean contract. When I rip it out, nothing bleeds.
The failure mode is embarrassing code that leaks into the spine — a UI hack that quietly changes the shape of your data, a fake feature that writes a malformed record you'll be migrating around for years. Embarrassing is fine. Load-bearing embarrassing is a time bomb. The test I apply constantly: if I deleted this line tomorrow, would any stored data be wrong? If yes, it's not shell — it snuck onto the spine and needs to be treated with real care.
The spine is short. That's the point. If your list of permanent things is long, you're not building an MVP, you're building v1 and lying about it. Here's what I refuse to compromise, in order.
How you name and shape your core entities is the single most expensive thing to change later, because every migration touches production data that real users depend on. Renaming a field with ten users is a five-minute script. With ten thousand, it's a planned maintenance window, a backfill, and a rollback plan you actually rehearse.
The concrete mistake I see most is stuffing three concepts into one document because it's faster today. A payment gets crammed onto the order. The delivery address lives as loose fields on the user. Six months later you need order history to keep the address it shipped to at the time — but you only ever stored the current one, and the old orders now lie about where they went. That's not a bug you fix; it's data you never captured. So model the relationships you know are true even if you don't build features for them yet:
// A payment is not a property of an order — it's its own thing// with its own lifecycle. Separating them costs nothing now and// saves a brutal migration later.class Order { final String id; final String userId; final List<LineItem> items; final Address shipTo; // snapshotted at order time, never a live ref final OrderStatus status;}class Payment { final String id; final String orderId; // relationship, not embedding final int amountMinor; // integer minor units, never a double final String currency; // 'AED', 'USD' — money is always tagged final PaymentStatus status;}Two rules I never break here: money is an integer in minor units with an explicit currency, never a floating-point number (a double will eventually round 0.1 + 0.2 into a cent that doesn't exist, and reconciliation with a payment processor becomes a nightmare); and anything you'll want the historical value of gets snapshotted, not referenced. Get the nouns right and the boundaries between them right. Everything downstream inherits those decisions.
A quick heuristic for the modeling pass: for every field, ask "does this describe a fact at a point in time, or a live pointer that should track changes?" A shipping address on an order is a point-in-time fact. A user's current address is a live pointer. Conflating the two is the root cause of a huge share of "our historical data is wrong" incidents.
Who a user is, and what they're allowed to touch, has to be right from record one. I've never regretted a clean auth boundary. I have deeply regretted bolting real permissions onto a system that assumed everyone was an admin. Retrofitting a security model onto live data is one of the worst jobs in software — you're writing access rules while simultaneously discovering which existing records violate them.
Even in an MVP with a single role, encode the boundary explicitly: every query is scoped by owner, every write checks who's asking. Firestore security rules or a where('userId', isEqualTo: currentUser) on every read is cheap to add on day one and impossible to add cleanly on day three hundred, once ten thousand documents already exist with fuzzy ownership.
The logic that makes your product your product — how a price is computed, when a subscription lapses, what makes an order valid. Keep it in one place, out of the UI, tested. Everything around it can be a mess; this can't be, because a bug here corrupts data you can't un-corrupt.
The anti-pattern is scattering that logic across button handlers: one screen computes tax, another recomputes it slightly differently, and now you have two truths. Put it behind a service or a set of pure functions, cover it with a handful of tests, and let the ugly UI call into it. The rules are the part of the codebase most likely to still be running, essentially unchanged, three years from now.
Notice what's not on the list: the framework, the styling, the specific database, the API shape, the deployment pipeline. All swappable. The spine is data, identity, and rules. Guard those three like they're production — because in six months, they will be.
Here's the part founders skip and I never do. The entire point of an MVP is to learn. An MVP with no instrumentation is not an experiment — it's just a small, unfinished product. You shipped, you got vibes, you learned nothing you can defend.
Analytics events are the most permanent artifact in the whole build. Long after the UI is gone, the event history is what tells you the feature was worth building. So I wire logging in from the first commit, and I treat event names with the same care as the data model — because renaming events breaks your historical funnels the same way renaming a column breaks your queries. Your event schema is a schema; give it the same respect.
// Instrumentation lives on the spine. Name events like you'll// read them in a dashboard for the next two years — because you will.void logExportRequested({required String userId, required String format}) { analytics.logEvent( name: 'export_requested', // stable, past-tense, snake_case parameters: { 'user_id': userId, 'format': format, // 'pdf' | 'csv' 'is_manual_fallback': true, // the feature is faked, log it anyway }, );}Three rules I hold to even in a rushed MVP:
export_requested, not clickedExportBtnV2. You'll be reading these names in a funnel long after you've forgotten what "V2" meant.On a recent build we shipped a "paid upgrade" flow that was entirely fake — the button logged an event and showed a "coming soon" toast. A meaningful chunk of users tapped it in the first week. That number, not any conversation, is why we built the real thing. Without the event, we'd have argued about it for a month. Instrumentation is how you convert an argument into a decision.
I've killed a lot of MVPs. That's the job. The interesting question is whether killing them was cheap or expensive — and that came down entirely to whether the spine survived. Here are three, spanning the full range from a clean death to an expensive one.
A booking tool for a client's service business. Ugly, hardcoded, faked half its features. When we validated it and rebuilt for real, the rebuild took two weeks instead of two months — because the data model for bookings, customers, and availability was clean from day one. We threw away 90% of the code and kept the 10% that mattered. Textbook. This is the one that wasn't really throwaway: the shell was disposable, the spine walked straight into v2.
A dashboard I built fast for our own team, wiring charts directly to Firestore queries embedded in the widgets. No repository, no seam. It worked. Then requirements changed, and every screen was welded to a query shape that no longer fit. There was no spine to keep — the whole thing was shell. We didn't rebuild it; we abandoned it and started over with nothing carried forward. The lesson wasn't "it died," it's that nothing survived, because I never drew the seam. The twenty minutes I "saved" by skipping the repository cost weeks.
We shipped a community feed inside an existing app. The code was fine — the seam was clean, the spine was solid. But the instrumentation told a brutal story: engagement cratered after day two, every cohort. We killed it in three weeks. That's a success. The MVP did its only job: it told us the truth cheaply, before we'd sunk a quarter into it. A throwaway MVP that saves you from a bad bet is the best money you'll spend.
The pattern across all three: the death is fine, expected, healthy. What varies is the cost of the death. Clean seam plus real instrumentation makes killing an MVP cheap and educational. Fused code plus no instrumentation makes it expensive and silent. You don't get to choose whether an MVP dies — most of them should. You only get to choose whether it dies teaching you something.
Before I start any MVP, I run this. It takes ten minutes and it's the highest-leverage ten minutes in the whole project.
Before you write code:
While you build:
Before you call it done:
If you can check that last box, you built an MVP that can die well. And dying well is the whole skill.