Essay
Why clean architecture still matters for product MVPs
An architecture decision record on keeping domain boundaries in early product work, even when the team is small and the deadline is real.
Status: Accepted Date: 2026-08-12 Deciders: Engineering
Context
When a product is still an MVP, the usual advice is to move fast and skip structure. The argument is familiar: fewer files, fewer layers, fewer abstractions. Ship the feature. Talk to users. Refactor if the thing survives.
That advice is half right. Premature abstraction is expensive. A hexagonal cathedral around a form that will be deleted next week is waste. The opposite failure mode is quieter and just as common. Teams collapse domain rules into route handlers, mix persistence with presentation, and encode product decisions in whatever file was open at the time. Six weeks later the MVP is still an MVP, except every change now has side effects nobody can name.
This record captures a decision we keep making on early products: keep a thin clean architecture from day one, and refuse the layers that do not yet earn their keep.
Decision
We will structure MVP code around three things: a domain model that does not import the web framework, application use-cases that orchestrate that model, and adapters at the edges for HTTP, storage, and third-party APIs.
We will not introduce a full CQRS split, a repository per entity by default, a dependency-injection container, or a shared “core” package that exists only to feel enterprise.
The domain lives in plain language objects and functions. A use-case is a function with an explicit input and output. Adapters implement ports only when there is a real seam: a database, a payment provider, a queue, a UI. If a port has one implementation and no test double is needed yet, we still keep the call at the boundary so the seam is obvious.
Frameworks stay at the edge. Page handlers, ORMs, and SDK clients do not own business rules. They translate.
export async function rescheduleBooking(
input: RescheduleInput,
deps: { bookings: BookingStore; clock: Clock },
): Promise<RescheduleResult> {
const booking = await deps.bookings.get(input.id);
return booking.reschedule(input.at, deps.clock.now());
}
The handler maps HTTP to input. The domain decides whether the change is legal. Persistence is a dependency, not a superclass.
Why this is not ceremony
Clean architecture is often sold as folders. Folders are not the point. The point is the direction of dependencies.
Product MVPs change in two ways. The first is cosmetic: copy, layout, which field is required. The second is structural: who is allowed to do what, what “paid” means, when a job is retryable, which events are facts. The second kind of change is where speed actually dies.
If “a user may reschedule only before the provider accepts” lives in a click handler and also in a cron job, the product has two policies. They will drift. An ADR-style boundary puts that rule in one place, named, and tested without standing up a browser.
This is cheaper than it sounds. A forty-line use-case with a table-driven test is less work than reproducing a bug that only happens after a webhook and a UI race.
Consequences
Positive
- Product rules can be tested without HTTP.
- Replacing SQLite with Postgres, or a mock billing adapter with Stripe, does not rewrite the domain.
- New contributors can find the decision instead of reconstructing it from a long handler.
Negative
- More files than a single
index.ts. - Junior engineers may over-port: every function becomes an interface. That is a review problem, not an architecture problem. We reject ports that do not have a reason.
Risks we accept
- Some duplication at the adapter layer.
- Occasional “this could be a one-liner in the route.” When the one-liner encodes a rule, we still extract it.
Risks we do not accept
- Business invariants that exist only as comments.
- Shared mutable state between request handling and background work.
Notes for MVPs specifically
An MVP is a bet about learning, not a bet about traffic. Clean architecture here is not for millions of users. It is for the tenth pivot, when the team must change the bet without burning the board.
Keep the domain small. Name it after the product language: Booking, Invoice, Eligibility — not Manager, Helper, Util. Delete a use-case when the experiment dies. The architecture should make deletion easy, which is the real MVP skill.
If a layer cannot be justified in one sentence in an ADR, it does not ship. Feature folders are packaging, not a substitute for a dependency rule. The moment two features must agree on what a booking is, the model has to live above both of them. Brochures and throwaway spikes can stay flat. Everything we intend to learn from should keep the boundary.