arrow_backBack to Blog homeHome
Flutter Mobile Dart Architecture

Flutter Architecture Patterns for Production Apps

Building Flutter apps that survive a growing team and a growing codebase.

Building a Flutter screen is easy. Building a Flutter app that a team can maintain, a codebase that can scale, and a user experience that stays reliable — that requires deliberate architecture.

calendar_todayDecember 2025 schedule14 min read personSaptarshi Sadhu

Flutter's reactive widget system makes it easy to build beautiful UIs fast. That speed is a double-edged sword: beginners ship features quickly by putting everything in a single widget — API calls, business logic, ephemeral UI state, and layout — in one 400-line file. It works, right until the app needs a second screen that shares data with the first, or right until someone else has to read your code.

Production-grade Flutter is about separating the right concerns at the right boundaries. This article is about finding those boundaries, understanding why they matter, and building patterns that hold up under real pressure.

"The best Flutter codebase is one where you can read a screen file and understand what it displays, without needing to know how it gets its data."

crisis_alertThe Beginner Architecture (And Why It Breaks)

The most common beginner Flutter architecture is what I call StatefulWidget Soup: every screen is a StatefulWidget, API calls live in initState(), setState() is called everywhere, and business logic is woven directly into build() methods.

This pattern has one virtue: it requires no upfront design decisions. It has three terminal vices:

  • State doesn't survive navigation. When you push a new route, the old widget's state is destroyed. When you pop, it rebuilds from scratch. Any data the two screens share must be passed through constructor arguments — which cascades down your widget tree.
  • Logic can't be tested. Business logic inside widget methods can only be tested through widget tests — which are slow, fragile, and tied to the UI. Pure Dart logic tests run in milliseconds.
  • Rebuilds are uncontrolled. A single setState() rebuilds the entire widget subtree. As the widget grows, every minor state change triggers expensive re-renders of the whole screen.

layersThe Clean Architecture Mindset for Flutter

Clean Architecture maps naturally onto Flutter. The principle: dependencies always point inward. The inner layers (domain, data models) know nothing about the outer layers (UI, frameworks). The outer layers depend on the inner ones, never the reverse.

phone_android
Presentation Layer
Widgets · Screens · Providers (Riverpod) · ViewModels — only knows about domain models
depends on ↓  (not the reverse)
hub
Domain Layer
Use cases · Business rules · Repository interfaces — pure Dart, zero Flutter imports
depends on ↓
storage
Data Layer
Repository implementations · API clients · Local DB (Hive/Isar) · DTOs

The domain layer is the heart of your app. It contains your business entities and rules. It imports nothing from Flutter, nothing from Dio, nothing from any external package. This makes it trivially testable — it's just Dart classes.

folder_openFolder Structure That Scales

There are two approaches to Flutter folder structure: layer-first (group by presentation/domain/data) and feature-first (group by feature, with layers inside). For small apps, layer-first is simpler. For multi-team apps with 20+ features, feature-first wins because each feature is self-contained and teams don't step on each other.

Flutter · Feature-first Structure
lib/
├── core/
│   ├── error/          # Failure classes, exceptions
│   ├── network/        # Dio client, interceptors, base URLs
│   ├── router/         # GoRouter config + route constants
│   └── theme/          # AppTheme, text styles, colors
│
├── features/
│   ├── auth/
│   │   ├── data/       # AuthRemoteDataSource, AuthRepository impl
│   │   ├── domain/     # User entity, AuthRepository interface, LoginUseCase
│   │   └── presentation/
│   │       ├── providers/  # authProvider, sessionProvider (Riverpod)
│   │       └── screens/    # LoginScreen, RegisterScreen
│   │
│   └── posts/
│       ├── data/
│       ├── domain/
│       └── presentation/
│
└── main.dart           # ProviderScope + runApp
info
The self-containment rule A feature folder should be deletable without breaking other features. If removing features/posts/ causes compile errors in features/profile/, your feature boundaries are leaking. Shared entities go in core/ — not in a feature folder that another feature imports.

sync_altState Management: Why Riverpod

State management is the most debated topic in Flutter — and the debate is mostly irrelevant. Any of the major options (Riverpod, Bloc, Provider) work at scale when used consistently. What matters is your mental model of state ownership.

Riverpod is architecturally sound because it solves two problems that Provider doesn't: providers are global but lazily initialized (no need to wrap the widget tree), and provider dependencies are explicit and compile-time safe. You declare what a provider depends on, and Riverpod handles invalidation when dependencies change.

The Three Kinds of State

Ephemeral
Focus state, animation progress, text field values. Lives in StatefulWidget or StateProvider.
Server State
API responses. Use FutureProvider / AsyncNotifier. Never manually cache — Riverpod handles it.
App State
Auth session, theme, cart. NotifierProvider with explicit state transitions only.
Dart · Riverpod AsyncNotifier for posts list
// domain/repositories/post_repository.dart
abstract class PostRepository {
  Future<List<Post>> fetchPosts({int page = 1});
}

// presentation/providers/posts_provider.dart
final postsProvider =
    AsyncNotifierProvider<PostsNotifier, List<Post>>(PostsNotifier.new);

class PostsNotifier extends AsyncNotifier<List<Post>> {
  @override
  Future<List<Post>> build() async {
    // read the repository via ref — not a direct import
    final repo = ref.read(postRepositoryProvider);
    return repo.fetchPosts();
  }

  Future<void> refresh() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() =>
        ref.read(postRepositoryProvider).fetchPosts());
  }
}

Notice that PostsNotifier doesn't know about Dio, HTTP, or JSON. It interacts only with the PostRepository interface — which is a domain-layer abstraction. The actual HTTP implementation can be swapped for a mock in tests without changing a single line of the provider.

wifiAPI Handling: The Data Layer Contract

Every API call in a Flutter app should go through a defined path: the widget calls a provider, the provider calls a use case, the use case calls a repository interface, the repository implementation calls a remote data source, the data source makes the HTTP call and returns a DTO, which the repository maps to a domain entity and returns.

That's a lot of layers for a GET request. But each layer earns its existence: you can test the repository with a mock data source; you can test the use case with a mock repository; you can test the provider with a mock use case. Each unit is independently testable and replaceable.

Dart · Data layer with error mapping
// Data source: knows about Dio, DTOs, HTTP status codes
class PostRemoteDataSource {
  final Dio _dio;
  PostRemoteDataSource(this._dio);

  Future<List<PostDto>> fetchPosts() async {
    try {
      final response = await _dio.get('/posts');
      return (response.data as List)
          .map((json) => PostDto.fromJson(json))
          .toList();
    } on DioException catch (e) {
      throw ServerException(message: e.message ?? 'Unknown error');
    }
  }
}

// Repository impl: maps DTOs → domain entities, maps exceptions → Failures
class PostRepositoryImpl implements PostRepository {
  final PostRemoteDataSource _remote;
  PostRepositoryImpl(this._remote);

  @override
  Future<List<Post>> fetchPosts() async {
    final dtos = await _remote.fetchPosts();
    return dtos.map((dto) => dto.toDomain()).toList();
  }
}

Error Handling That Doesn't Crash

Every network call can fail. A production app handles this gracefully at every layer: the data source throws typed exceptions (ServerException, NetworkException), the repository converts them to Failure objects (or re-throws for async providers to catch), and the UI layer maps AsyncError states to user-facing error messages — never a blank white screen with a stack trace.

bug_reportCommon Mistakes and Their Fixes

closeCalling APIs directly in build()

Placing http.get() inside build() means a new API call fires every time the widget rebuilds — which happens constantly in Flutter.

checkFix: Use AsyncNotifier / FutureProvider

Riverpod's FutureProvider calls the async function once, caches the result, and only re-fetches when explicitly invalidated or when its dependencies change.

closesetState() on the root widget

Calling setState() high in the tree rebuilds the entire subtree — including expensive widgets that didn't change. This causes jank on mid-range devices.

checkFix: Push state down, use Consumer narrowly

Wrap only the widget that actually needs to rebuild in a Consumer (or ref.watch() in a ConsumerWidget). The rebuild scope is as small as the widget tree node you choose.

closeSingle global app state

A single Notifier that holds all app state becomes a god object — every screen watches it, every change rebuilds everything, and mutations are impossible to trace.

checkFix: One provider per responsibility

Split state by domain. authProvider owns authentication. cartProvider owns the shopping cart. themeProvider owns appearance. Each is independently watchable — screens only rebuild when their specific dependency changes.

warning
The navigation mistake Using Navigator.push() throughout the codebase tightly couples screens to each other. A screen shouldn't know what comes after it. Use a router abstraction — GoRouter or AutoRoute — where navigation is driven by named routes and state, not by one screen holding a reference to another.

labsTesting: The Architecture Pays Off Here

The reason to invest in clean architecture is not theoretical purity. It's that clean architecture makes testing fast and reliable — and fast tests make you confident to refactor, which is what keeps a codebase from rotting.

  • Domain layer tests — pure unit tests on use cases and entities. No Flutter, no mocks of UI, just plain Dart. Run in under 100ms total.
  • Repository tests — mock the data source, test the mapping and error-handling logic. Verify that a ServerException from the data source becomes the right Failure type.
  • Provider tests — use Riverpod's ProviderContainer in tests, override the repository with a mock, verify that the provider transitions through the right AsyncValue states.
  • Widget tests — test screen layout and user interaction, not API responses. Mock the provider instead of the network.
check_circle
The test pyramid holds Most tests should be unit tests (fast, cheap, many). Fewer should be widget tests (slower, UI-dependent). Fewer still should be integration tests (slow, fragile, expensive). Clean architecture enables this distribution naturally — because logic lives in pure Dart layers that don't require Flutter to test.

flagArchitecture Is a Team Decision

The patterns in this article — clean layers, feature-first folders, Riverpod for state, repositories for data — aren't the only way to build Flutter apps. They're a coherent system that has clear rules, which means it's teachable, reviewable, and enforceable across a team.

The worst Flutter codebase isn't one that uses the wrong state management library — it's one where each screen was built with a different approach, by a different person, with no shared conventions. Consistency is the real architectural goal. Pick a system. Document it. Enforce it in code review. The specific choices matter far less than the consistency with which they're applied.

Enjoyed this article? ☕
If this helped you or saved you some time, consider supporting my work.
Support my work

emoji_objects Key Takeaways

Saptarshi Sadhu
Saptarshi Sadhu
System-focused developer at the intersection of AI, backend engineering, and scalable infrastructure. Builds things that have to work in the real world.
← Previous Designing AI Systems That Scale