Architecture · Mar 2026 · 8 min read

Riverpod architecture for MVPs that scale

A clean structure so your v2 isn't a costly rewrite — the exact layering, error handling and folder conventions I use on every founder project.

Every MVP I take on gets the same speech in the first call: the expensive version of this app is not the one we build now — it's the rewrite you'll be quoted in eighteen months if we build this one carelessly.

MVPs don't stay MVPs. The ones that work grow a v2, a second platform, a feature the founder swears is small. So the architecture question isn't "what's fastest today" — it's what structure lets us move fast today without making v2 a demolition job. After shipping this stack across multiple production apps — an AI planner, a quiz platform, a travel app, an image-generation product — this is the setup I now start every project with.

The stack in one paragraph

Riverpod for state and dependency injection, fpdart for typed error handling, go_router for navigation, Dio for HTTP, feature-first folders with a strict three-layer split inside each feature. Nothing exotic. The value isn't in the package list — it's in the rules about who is allowed to talk to whom.

Feature-first folders, three layers inside

The top-level split is by feature, not by kind. A features/quiz/ folder contains everything quiz-related; you never hunt across a global models/, screens/, services/ triad to assemble a mental picture of one feature.

lib/
  core/            // theme, router, dio client, shared widgets, utils
  features/
    auth/
      data/        // dto models, api + local data sources, repository impl
      domain/      // entities, repository contract, failures
      presentation/ // screens, widgets, controllers (riverpod)
    quiz/
      data/ ...
      domain/ ...
      presentation/ ...

Inside each feature, three layers with a one-way dependency rule: presentation → domain ← data. Presentation knows nothing about HTTP. Data knows nothing about widgets. Domain knows nothing about either — it's pure Dart: entities, the repository interface, and the failure types.

This is the part that saves the v2. When a client decided their backend should move — and one did — the change was confined to data/. Screens, controllers, and tests above the repository contract didn't change, because they never knew where the data came from.

Errors are values, not surprises

The most underrated decision in the whole setup: repositories never throw. They return Either<Failure, T> from fpdart:

abstract class QuizRepository {
  Future<Either<Failure, List<Topic>>> getTopics();
  Future<Either<Failure, QuizSession>> startQuiz(String topicId);
}

Failure is a small sealed family — NetworkFailure, ServerFailure, AuthFailure, and so on. Two things happen when errors become part of the return type:

  1. The compiler makes you handle them. You cannot get the topics out of an Either without saying what happens on failure. No forgotten try/catch, no red screens from an unhandled DioException three layers up.
  2. UI error states become data. A controller folds the Either into its state, and the screen renders the failure case the same way it renders the data case. Error UX stops being an afterthought because it structurally can't be skipped.

Riverpod as the wiring, not just "state"

Riverpod plays two roles. The obvious one is state — AsyncNotifiers backing screens. The quietly more important one is dependency injection. Every repository, every data source, every service is a provider:

final quizRepositoryProvider = Provider<QuizRepository>(
  (ref) => QuizRepositoryImpl(
    api: ref.watch(quizApiProvider),
    cache: ref.watch(quizCacheProvider),
  ),
);

Because construction happens in one graph, tests override any node of it — hand a fake repository to a controller test with one line, no mocking framework gymnastics. And because AsyncValue bakes loading/error/data into one type, screens follow one uniform pattern instead of every developer inventing their own boolean flags.

Rules I hold the line on:

  • Controllers stay thin. They orchestrate repository calls and map results to state. Business logic lives in domain; formatting lives in the widgets. A controller pushing 200 lines is a smell.
  • No ref.read business logic in widgets. Widgets render state and dispatch intents. The moment a widget contains an if about business rules, that rule has escaped testability.
  • Providers are named for what they provide, not where they're used. topicsProvider, not homeScreenDataProvider — the second name guarantees accidental coupling.

go_router, declared once

All navigation is declared in core/router/ — typed routes, auth redirect logic in one guard, deep links for free. On a quiz app this mattered on day 30, not day 1: adding paywalled routes meant one redirect rule, not a sweep through push calls scattered across screens.

What this costs, honestly

This structure is more files. A feature is born as eight files instead of two, and on a pure throwaway prototype that overhead isn't worth it — I cut these corners knowingly on spikes.

But for a funded MVP the maths flips fast. The structure's cost is front-loaded and small; its payoff compounds: new features copy an existing feature's shape, new developers navigate by convention, backend swaps stay inside data/, and the test suite doesn't fight the architecture. Every "that was a quick change" moment in month six is the interest payment.

The rewrite quote you never receive — that's the metric this architecture optimises for.

Tasaddaq Hussain
Flutter developer & AI app engineer
Work with me
Next article
Testing Flutter apps that talk to LLMs→