Four ways to split UI from logic. They differ in who calls whom; Flutter's reactive rebuilds make MVVM and MVI the natural fits.
MVC: a controller sits between model and view; the vaguest of the four, and most modern "MVC" is really MVP or MVVM
MVP: the presenter holds the logic and calls methods on a dumb view (showLoading()); an Android-Java habit, rare in Flutter
MVVM: the view model exposes observable state and the view rebuilds from it; that is ChangeNotifier, Riverpod's Notifier, Cubit
MVI: one immutable state object per screen, flowing one way: intent → reducer → state → view; that is BLoC's event/state model
Mapping to Flutter: the widget tree is the view in all four; a widget never mutates state itself, it sends an intent (a method call or an event) and rebuilds from the new state
FlutterMiddle
Architecture Patterns
MVC, MVP, MVVM, Clean Architecture, Repository pattern
Questions closed
0 / 22
- 0Review
- 7Asked often
Questions
Clean Architecture splits an app into three layers with one rule: dependencies point inward only. The inner layers never know about the outer ones.
Domain (inside): entities, use cases and repository interfaces, in pure Dart with no Flutter import
Data: implements those interfaces: API and database sources, JSON models, mappers to entities
Presentation (outside): Bloc/Cubit/Notifier and widgets, reaching data only through use cases
Why the rule pays: the business rules compile without Flutter, so swapping the HTTP client or the UI touches one layer
Common mistake: fromJson or a Color on a domain entity: the arrow now points outward
The guide on docs.flutter.dev recommends MVVM in two layers: a UI layer of views and view models, and a data layer of repositories and services. A domain layer of use cases is optional, added only when logic is complex, reused, or merges several repositories.
View: widgets with almost no logic; one view model per screen
View model: a ChangeNotifier that turns repository data into UI state and exposes user actions as commands, small objects that track running, error and result
Repository: the single source of truth for one kind of data: caching, retries, mapping into domain models; repositories never depend on each other
Service: a stateless wrapper around one data source: a REST client, a platform plugin, local files
Also recommended: one-way data flow, immutable models, DI through provider, abstract repositories so dev and prod builds swap implementations; its case study also uses a Result type instead of throwing
Against Clean Architecture: the domain layer is optional rather than mandatory, and a view model may call a repository directly. Dependencies run top-down, view model -> (use case) -> repository -> service, so the UI depends on the data layer; Clean inverts that by keeping repository interfaces in the domain so data depends on it. The guide asks for fewer classes and does not demand the inversion.
SOLID is five rules for keeping classes small and swappable. In Flutter, Single Responsibility and Dependency Inversion pay off most; the other three mostly bite at package boundaries.
S (one job per class): a widget renders, a repository fetches; if describing it needs the word "and", split it
O (open for extension): add a new implementation of an interface (a new PaymentMethod) instead of editing the checkout code that switches on a type; sealed families are the opposite trade, closed on purpose so the compiler finds every switch
L and I (honest, narrow interfaces): a fake must work anywhere the real one does, and callers shouldn't depend on methods they never call
D (depend on abstractions): a Bloc takes AuthRepository, not DioAuthRepository, handed in through the constructor
Don't overdo it: an interface with one implementation and no test double is extra code, not design
In a small or CRUD-style app: when you cannot name the change a layer would isolate, that layer is pure cost, and there it is most of the code.
One CRUD screen under full layering becomes a page, a state holder, a use case, a repository interface, its implementation, a data source, a DTO, an entity and a mapper. Adding one field touches nearly all of them.
Skip it for: an MVP, an internal tool, thin screens over an API, one developer with one backend
Keep anyway: a repository boundary per feature: that is the seam which is expensive to add later
Keep anyway: immutable state, one state holder per screen, and no BuildContext below the widget layer
Add layers when: the same data starts arriving from two sources, or one rule is duplicated on two screens
Keep two classes: a UserDto in the data layer that knows JSON, and a plain User entity in the domain that doesn't. A mapper (an extension method or a small function) converts between them.
Putting fromJson on the entity ties your business rules to the backend's wire format, so every JSON change ripples into the domain and the UI.
What it buys: the API can rename a field and only the DTO and the mapper change
Freedom of shape: drop fields the business ignores, add computed ones, build one entity from two DTOs
Never subclass: a DTO extending the entity to save typing leaks the data layer into the domain; use a mapper
When to skip: a prototype or a one-screen app where the API already matches the domain exactly
Not pedantic: that one import reverses the dependency arrow. The layer everything else depends on now compiles only where Flutter exists, not on a server, not in a CLI, not under plain dart test.
**A Color is a rendering decision**: frozen into a business object; next comes an IconData, then a translated string, and the boundary is gone
Model the meaning: enum OrderStatus { pending, paid, shipped, cancelled } in the domain; colour, icon and wording are chosen in the UI, usually by an extension on that enum
Same for text: AppLocalizations inside a use case is the same mistake in a different hat
Make a tool enforce it: put the domain in its own package with no flutter dependency, and the analyzer flags the import (depend_on_referenced_packages) and dart test fails
Quick check: if your domain tests need flutter test rather than dart test, the arrow already points the wrong way
That is an HTTP client wearing a repository's name. A repository is shaped by what its callers need (domain types in, domain types out), not by what the backend happens to expose.
Domain types only: a Response or a Map<String, dynamic> crossing the boundary means every caller parses it again
Name methods after intents: byId, watch, changeAvatar, not getUserV2 or postAvatarUpload
One repository per aggregate: User, Order, Cart; not one per endpoint, and not one per screen
Leave yourself room: a cache hit, two parallel calls or a different backend must not show up in the signature
Quick check: if renaming a backend route forces an edit in a domain or UI file, the boundary isn't there yet
That is an anemic domain model: the entity holds data only, so the rules that define what an order is get copied into every state holder that touches one.
Duplicates drift apart: the details screen allows cancelling within 24 hours, the list screen within 48, and nobody notices
Rules get expensive to test: checking one boolean now needs a Cubit, a repository mock and an emitted state
Invalid objects can exist: anyone can build an Order that is paid with a null paidAt, so every reader adds defensive checks
Where each rule goes: invariants in the constructor; rules about one entity as methods on it (order.canCancelAt(now)); rules needing another aggregate or any I/O in a use case
Don't overcorrect: an entity that fetches and saves itself is worse; and pass now in rather than reading the clock inside the rule
Three independent fields (isLoading, error, items) can hold eight combinations while the screen has four legal ones. The widget is faithfully rendering a state that should never have existed.
Use one sealed family per screen instead: Loading, Loaded(items), Empty, Failed(error). A switch over a sealed type is exhaustive, so an unhandled case is a compile error, not a QA screenshot.
Data lives in the variant that has it: no more state.items! and no "can this be null here?"
Real combinations are modelled on purpose: "loaded and refreshing" is a field on Loaded, so list and spinner coexist only because you allowed it
Library-agnostic: Cubit, Notifier and ValueNotifier all hold sealed state; Riverpod's AsyncValue is this pattern prebuilt
One-shot things are not state: a snackbar left in the state object replays on the next rebuild
It does three layers' jobs at once: it builds its own Dio, parses JSON, and keeps a BuildContext to show UI. Inject a repository, emit every outcome as state, and let the widget turn a state into the snackbar.
**BuildContext in a Cubit**: the Cubit can outlive the screen, so the context may be deactivated by the time load() resumes; it also makes the Cubit untestable without a widget tree
**Dio() built inline: a test cannot pass a fake, and the shared client's base URL, auth interceptor and connection pool are bypassed
JSON in presentation: the wire format leaks into the state holder; parsing belongs to a DTO and mapper in the data layer
No error path**: a failed request throws out of load(), the state stays ProfileLoading and the spinner runs forever
A snackbar is a one-shot effect, not a Cubit's job: expose the fact (needsEmail) in state and react in a BlocListener, which fires on state changes and never during build
A ViewModel lives in whatever owns it in the widget tree: a State or a provider element above the screens that use it, created once and disposed with that owner. Created inside build, it is a new object on every rebuild: load() runs again and the old one leaks.
Position in the tree is the scope: ChangeNotifierProvider(create: ...) builds the notifier once for its Element and disposes it when that element is removed
A rebuild doesn't recreate it, a new element does: hoist the provider above the TabBarView or the route to extend its life; at the app root it is a singleton
**.value never disposes: use it for an object owned elsewhere; constructing one inline leaks
Riverpod inverts this**: providers are global and lifetime comes from listeners: autoDispose ends it with the last one, ref.keepAlive() pins it
Tabs specifically: TabBarView disposes off-screen pages; use AutomaticKeepAliveClientMixin or an IndexedStack, or hoist the holder above the tabs
Fix the repository, not the screens: each screen fetched its own copy, so there is no single source of truth. Let the repository own the cached user and expose a stream that every screen listens to.
One writer, many readers: a write calls the API, puts the result in the store, and the store emits; no screen assigns the response to its own state
A local database gives you this free: Drift's watch(), Isar listeners and Firestore snapshots all fire on write; without one, a BehaviorSubject (rxdart) or a broadcast StreamController that replays the last value does the job
Seed late subscribers: a plain broadcast stream gives a screen opened after the last emit nothing, and it renders empty forever
Optimistic updates need a rollback: show the new name at once, keep the old one, restore it if the request fails
Common mistake: caching in the state holder instead: every screen keeps a copy, and correctness rests on nobody forgetting to invalidate
The repository turns every failure into an empty list, so the cubit cannot tell "no orders" from "no network": it shows the empty state, the Retry button never appears, and a 500 or a broken mapping looks exactly the same.
Why it happens: [] is a legal result, so the error is erased at the layer boundary and everything above sees a successful call; the log line makes it look handled, but only someone reading device logs ever learns about it
It hides more than the network: the bare catch also eats a 401, a 500 and the TypeError from a broken toEntity(), so a backend contract change ships to every user as "no orders" instead of a crash report
The fix: catch only the transport exception and rethrow it as an AppError; the cubit catches AppError and emits OrdersFailed, whose view has the Retry button. A mapping bug keeps propagating to PlatformDispatcher.instance.onError and crash reporting, where it belongs; the screen still needs a generic OrdersFailed(UnknownError()) fallback (e.g. from onError), otherwise load() throws and the spinner never stops
When a fallback is right: serving cached orders offline is fine, but return them as cached data with an "offline" marker, never as an invented empty list
Catch it next time: a repository test with a fake API that throws must expect throwsA(isA<NetworkError>()); the avoid_catches_without_on_clauses lint flags the bare catch
Group files by feature, not by layer: lib/features/cart/ holds the cart's models, repository, state holder and screens together, instead of scattering them across lib/models/, lib/blocs/ and lib/screens/.
With layer-first folders, adding one feature means editing four folders and deleting one means hunting its files everywhere. A feature folder is one place to read, to hand to a team, or to delete whole.
Inside a feature: keep the same layers (data, domain, presentation), just scoped to that feature
Shared code: lib/core/ for theming, the HTTP client, logging; nothing feature-specific
No cross-feature imports: if cart needs auth, go through a shared interface or the composition root
Room to grow: each folder can later become a local package whose pubspec.yaml lists exactly what it may use; add an import-boundary lint to CI
Ownership: one CODEOWNERS entry per feature folder
Common mistake: a lib/core/ that slowly becomes a second app: if one feature uses it, it belongs to that feature
Move each feature into its own local package under packages/ and let a thin app/ package depend on them. A package sees only what it declares in pubspec.yaml, so the boundary becomes an explicit dependency list instead of a convention.
It is worth doing once accidental cross-feature imports and long rebuilds actually hurt: several teams, not several screens.
Tooling: pub workspaces (workspace: in the root pubspec.yaml, Dart 3.6+) give one shared resolution and one pub get; Melos adds scripts and versioning on top
Enforcement: make the depend_on_referenced_packages lint an error in CI, because inside a workspace an undeclared sibling import still resolves
Pure-Dart packages: domain and core have no Flutter dependency, so they run under dart test in a second
Plugin packages: anything shipping its own Kotlin or Swift, even if only your app uses it
What not to split: a small app, or two features that are tightly coupled: untangle the coupling first
Expect a refactor: packages force you to declare a public API; that is the point and also the work
Build the whole object graph in one place (the composition root, usually main.dart) and pass dependencies down through constructors. Business code never looks a dependency up for itself.
At scale the hard part is not the wiring but the lifetime: decide what lives as long as the app, as long as the signed-in user, and as long as one screen.
Three scopes: app (Dio, logger), user (everything that must die at sign-out), screen (disposed with the route)
Bind interfaces: the Bloc sees AuthRepository, never DioAuthRepository, so tests and flavors can swap it
Environments are just overrides: dev, staging, prod and the test suite are one graph with different bindings
Common mistake: MyService.instance.method() inside business code: a global with extra steps, and untestable
Catch transport exceptions once, in the repository, and rethrow them as a sealed class AppError; the view model keeps it in state and the UI switches over it. An expected business outcome ("username taken", a declined card) is a returned Result, not an exception.
The tree: only cases the UI renders differently: NetworkError, Unauthorized, ServerError(statusCode), UnknownError; add ValidationError(fields) once a form needs it
Map at the edge: the repository catches DioException or SocketException and throws AppError (the mapping itself is in the networking topic); nothing above it sees a transport type
Render in the UI: the view model puts the error into state (Failed(error)) and the widget switches; a switch over a sealed type is exhaustive, so a new subclass breaks compilation until every screen handles it
The UI decides per case: Unauthorized goes to login, NetworkError shows a retry banner
Result vs throw: throw for the unexpected (no network, a 500); return a Result or a sealed outcome for what callers must handle, because the type forces them to
A feature flag separates deploying code from releasing it: the code ships turned off, and you flip it on later for some users or for everyone.
Where the truth lives: a remote config service, with defaults bundled in the app so the first offline launch still behaves
One thin client: wrap it in your own FeatureFlags class returning plain booleans, cache the last values, refresh on foreground
Where you read it: best at the composition root, choosing which implementation to build; if (flag) scattered through the UI ages badly
Kill switch: a flag read at runtime turns a broken feature off in minutes without a store review; default to the safe state when config cannot be fetched
A/B tests: the same FeatureFlags class returns the user's variant; log the variant with every analytics event, or you cannot tell what moved the metric
Every flag has an expiry: experiment, ramp, then delete the flag and the dead branch; file the removal ticket the day you add it
Don't let features import each other; that is the cycle nobody untangles later. Either a coordinator in the composition root calls a narrow interface each feature exposes, or auth publishes an event and interested features subscribe.
Coordinator: a SessionCoordinator.signOut() runs cart.onSignedOut(), chat.onSignedOut() and images.onSignedOut() in a deliberate order, then clears the token; the wiring is one greppable file
Event stream: AuthRepository exposes Stream<AuthEvent> and each feature subscribes on its own; nobody knows who listens, which is the benefit and the price
Which one: the coordinator when the effects are few, ordered and mandatory; the stream when they are many and optional
Order matters: tear down local state first and clear the token last, or the socket reconnects and retries into 401s
Common mistake: one handler throwing kills the rest; wrap and log each, so a failing image cache can't leave the cart full
A use case owns the sequence: it spans three repositories, which is exactly what the domain layer is for. There is no transaction across three services, so a failed charge is handled by compensation and idempotency, not by a rollback.
Not the repository: OrderRepository would have to import payments and cart, recreating the coupling you split them to avoid
Not the Cubit: the rule would live and die with one screen; close it mid-flight and the order is half-placed
One idempotency key per attempt: sent with every step and reused on retry, so the server deduplicates instead of charging twice
Compensate in reverse: failed at step N: release the reservation and return a typed outcome; clear the cart only after the money is taken
Prefer one server call: if the backend can, expose one idempotent placeOrder and let the server run reserve, charge and compensate; this sequence is the fallback
A declined card is not an exception: it is a normal business outcome and belongs in the result type; keep exceptions for what you did not plan for
Strangle it, don't rewrite it: freeze the old shape, build every new feature in the target shape, and migrate old code only when you already have a reason to open that file.
Pin the behaviour first: widget and golden tests over the area you are about to touch, ugly parts included; refactoring without them is guessing
Add a composition root: keep the old service locator, but have it delegate to the root so both worlds share one instance
One feature at a time: a repository interface in front of its data access, then the logic moves out of the StatefulWidgets into a state holder
Lock each step in: move files in their own commit, and add lints for import boundaries and for no new global singletons
Common mistake: a parallel rewrite branch: it drifts away from main and dies there