Flutter makes it easy to start: a widget, a setState, an HTTP call in initState, and you have a
working screen. Two years and sixty screens later, our app had the predictable problems: widgets that
fetched data, parsed JSON, cached results and handled navigation; business rules duplicated across
screens; tests that needed a running backend. Every feature change touched files across the codebase.
We restructured the app over a quarter, screen by screen, without stopping feature work. This post describes the structure we arrived at, which is close to what Flutter's own architecture guidance recommends, and the practical choices behind it.
Two axes: by feature and by layer
Organising only by technical type (widgets/, models/, services/) scatters a feature across folders.
Organising only by feature mixes UI and data code. We use both: features at the top, layers inside.
lib/
app/ # app shell: routing, theme, dependency setup
features/
checkout/
ui/ # widgets and screens
state/ # view models / notifiers
data/ # repositories for this feature
domain/ # feature-specific models and rules (optional)
orders/
ui/
state/
data/
core/
services/ # HTTP client, storage, analytics: no business logic
models/ # shared models (Money, Address)
ui/ # shared widgets and design system
A new developer looking for the checkout logic opens features/checkout/ and finds everything there.
The layers and their rules
| Layer | Responsibility | May depend on | Must not |
|---|---|---|---|
| UI | Render state, forward user intent | State of its own feature, core UI | Call repositories or services directly |
| State | Hold screen state, call repositories, map results to UI state | Repositories, domain | Import Flutter widgets or BuildContext |
| Repository | Single source of truth for a type of data; combine API and cache | Services, models | Know about screens |
| Service | Talk to the outside world: HTTP, database, platform channels | Third-party packages | Contain business rules |
The direction of dependencies is the important part: UI depends on state, state on repositories, repositories on services. Never the other way round.
A walk through one feature
Service: thin and generic
class ApiClient {
ApiClient(this._http, this._baseUrl);
final http.Client _http;
final Uri _baseUrl;
Future<Map<String, dynamic>> getJson(String path) async {
final res = await _http.get(_baseUrl.resolve(path));
if (res.statusCode >= 400) throw ApiException(res.statusCode, res.body);
return jsonDecode(res.body) as Map<String, dynamic>;
}
}
Repository: owns the data
class OrderRepository {
OrderRepository(this._api, this._cache);
final ApiClient _api;
final OrderCache _cache;
Future<List<Order>> recentOrders({bool refresh = false}) async {
if (!refresh) {
final cached = await _cache.read();
if (cached != null) return cached;
}
final json = await _api.getJson('/v1/orders?limit=20');
final orders = (json['items'] as List).map((e) => Order.fromJson(e)).toList();
await _cache.write(orders);
return orders;
}
}
The repository decides about caching, retries and mapping JSON to models. Screens never see JSON.
State: a view model per screen
We use ChangeNotifier with provider for most screens; the pattern is the same with Riverpod or Bloc.
sealed class OrdersState { const OrdersState(); }
class OrdersLoading extends OrdersState { const OrdersLoading(); }
class OrdersLoaded extends OrdersState { const OrdersLoaded(this.orders); final List<Order> orders; }
class OrdersError extends OrdersState { const OrdersError(this.message); final String message; }
class OrdersViewModel extends ChangeNotifier {
OrdersViewModel(this._repo);
final OrderRepository _repo;
OrdersState state = const OrdersLoading();
Future<void> load({bool refresh = false}) async {
state = const OrdersLoading();
notifyListeners();
try {
state = OrdersLoaded(await _repo.recentOrders(refresh: refresh));
} on ApiException catch (e) {
state = OrdersError(e.statusCode == 401 ? 'Please sign in again' : 'Could not load orders');
}
notifyListeners();
}
}
Sealed classes make the UI handle every state explicitly, and the compiler complains if a case is missing.
UI: render and forward
class OrdersScreen extends StatelessWidget {
const OrdersScreen({super.key});
@override
Widget build(BuildContext context) {
final vm = context.watch<OrdersViewModel>();
return switch (vm.state) {
OrdersLoading() => const Center(child: CircularProgressIndicator()),
OrdersError(:final message) => ErrorView(message: message, onRetry: () => vm.load(refresh: true)),
OrdersLoaded(:final orders) => RefreshIndicator(
onRefresh: () => vm.load(refresh: true),
child: OrderList(orders: orders),
),
};
}
}
No HTTP, no JSON, no caching decisions in the widget.
Wiring dependencies
All objects are created in one place at startup and provided to the widget tree:
void main() {
final http = http.Client();
final api = ApiClient(http, Uri.parse(const String.fromEnvironment('API_URL')));
final orderRepo = OrderRepository(api, OrderCache());
runApp(MultiProvider(
providers: [Provider.value(value: orderRepo)],
child: const App(),
));
}
View models are created per screen with access to repositories through the provider, which keeps their lifetime tied to the screen.
Testing each layer
| Layer | Test type | Fakes |
|---|---|---|
| Service | A few integration tests against a test server | None |
| Repository | Unit tests | Fake ApiClient, in-memory cache |
| State | Unit tests, no Flutter binding needed | Fake repository |
| UI | Widget tests | View model with fixed state |
The state layer is where most logic lives, and its tests are plain Dart tests that run in milliseconds, because it does not import Flutter widgets.
Migrating an existing app
We did not rewrite. For each screen we touched for feature work, we extracted a repository for its data, a
view model for its state, and left the widget rendering only. A lint rule (using import_lint-style
checks in CI) flagged imports that broke the layer direction, for example a ui/ file importing from
core/services/. After two quarters, the remaining old screens were few enough to finish in a sprint.
Navigation and cross-feature communication
Features should not import each other's internals. When the orders feature needs to open the checkout
screen, it goes through the router with a route name and arguments defined in app/, not by importing a
checkout widget. When features need shared data, such as the signed-in user, that data lives in a
repository in core/ that both depend on. This keeps the dependency graph between features flat: features
depend on core/ and app/ routing, never on each other.
Error handling across layers
Each layer translates errors into its own vocabulary. Services throw transport errors
(ApiException, timeouts). Repositories turn them into domain results or rethrow meaningful exceptions
(OrderNotFound). View models turn those into user-facing states with messages. Widgets never see an HTTP
status code. That separation made it possible to change the backend error format once without touching a
single screen.
Common mistakes
| Mistake | Why it hurts | Do this instead |
|---|---|---|
HTTP calls in initState |
Untestable widgets, duplicated logic | Repository plus view model |
| One giant global state object | Every change rebuilds everything | State per screen or feature |
| Repositories returning JSON | Every caller parses | Return typed models |
| View models importing widgets | Tests need Flutter, layers leak | Keep state pure Dart |
| Architecture by folder names only | Layers exist on paper, not in imports | Enforce import rules in CI |
When not to use all the layers
For a settings screen that reads and writes three preferences, a view model talking directly to a small storage service is enough; adding a repository adds files without adding value. We apply the full set of layers where data comes from the network, is cached or is shared between screens, and stay pragmatic elsewhere. The rule that never bends is the dependency direction.
Key takeaways
- Organise by feature at the top and by layer inside each feature.
- UI renders state, state calls repositories, repositories call services; dependencies point one way.
- Repositories own data access, caching and mapping to models.
- Keep the state layer free of Flutter widgets so it is fast to test.
- Migrate screen by screen and enforce layer imports in CI.
References
- Flutter, Guide to app architecture: https://docs.flutter.dev/app-architecture
- Flutter, Architecture recommendations: https://docs.flutter.dev/app-architecture/recommendations
- Flutter, Simple app state management with provider: https://docs.flutter.dev/data-and-backend/state-mgmt/simple
- Dart, Patterns and sealed classes: https://dart.dev/language/class-modifiers#sealed
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.