Most Flutter tutorials teach you how to build an app. This one is about building an app that doesn't become a liability.
There's a real difference between the two. A screen that works is not the same as a system that a team can maintain, extend, and debug as requirements change — and in any serious product, requirements always change. The gap between those two things is architecture. Not just folder structure, not framework choice, not which state management library you installed. But the thinking that happens before any of that.
This playbook covers how we approach Flutter at the level where those decisions actually get made: domain modeling, layer separation, state management strategy, use cases, testing, performance, and deployment. Each topic has its own deep-dive article. This one exists to show how they fit together — and why the order matters.
Why Flutter in 2026
The hesitation is over. Flutter is no longer a bet — it's a proven platform used in production by BMW, Toyota, Google Pay, eBay, and hundreds of enterprise applications with millions of users. The arguments against it — immaturity, rendering concerns, limited ecosystem — have been addressed one by one.
The Impeller rendering engine, now the default, resolved the longstanding jank complaints on older Android devices. WebAssembly support means Flutter Web is now a serious option for high-performance dashboards. Hot reload and a single codebase across six platforms (iOS, Android, Web, macOS, Windows, Linux) still represent a development velocity advantage that native alternatives can't match on a team budget.
None of this means Flutter is the right tool for every project. Native still wins for apps that depend heavily on platform-specific UI conventions or cutting-edge OS features with no Flutter equivalent. But for the class of apps most agencies and product teams actually build — cross-platform mobile with a shared business logic core — Flutter is the mature, defensible choice in 2026.
The Foundation: Domain First, Code Second
The most expensive mistake in mobile development is writing code before understanding the domain.
Domain-Driven Design gives us the vocabulary and the patterns to avoid this. Before thinking about screens, before thinking about API calls, before thinking about state management — what are the concepts in this business? What are the rules? What can and can't happen, and in what order?
An order can't be confirmed without at least one item. A bank withdrawal can't exceed the overdraft limit. A prescription can't be modified after it's been dispensed. These rules exist independently of any framework or library. They should be expressible in plain code, with no Flutter, no HTTP, no database. If your domain logic requires importing package:flutter or package:dio, it's in the wrong place.
This is where aggregates come in — clusters of related objects that enforce rules together, controlled through a single root. It's where value objects live — immutable types that carry meaning, not just data. It's where bounded contexts draw lines between different parts of the system that shouldn't bleed into each other.
If these concepts are unfamiliar, the DDD series is the right starting point. The rest of this playbook assumes the domain has been thought through before architecture decisions are made.
Clean Architecture: The Layer Separation That Makes Testing Possible
Once the domain is clear, Clean Architecture gives us the structural principle for organising code around it.
Three layers, one rule:
- Domain — the core. Entities, value objects, aggregates, use cases, repository interfaces. No Flutter. No HTTP. No database. Pure Dart.
- Data — the infrastructure. Repository implementations, API clients, local storage, caches, data models, serialisation. Knows about external systems. Depends on domain interfaces, never the other way.
- Presentation — the UI. Widgets, screens, BLoC or Riverpod state management, navigation. Talks to use cases. Never talks to repositories directly.
The rule is the dependency direction: outer layers depend on inner layers. Domain depends on nothing. Data depends on domain. Presentation depends on domain. Nothing depends outward.
This single constraint is what makes the architecture work. The domain layer has no external dependencies, which means it can be tested with pure Dart unit tests — no Flutter test runner, no mock HTTP client, no widget pump. Just logic and assertions. In a mature codebase, this is where the highest-confidence, fastest-running test suite lives.
The Clean Architecture in Flutter covers this in full, with folder structure, concrete examples, and the mistakes that are easy to make and expensive to fix.
Use Cases: The Layer Everyone Skips
Use cases — sometimes called interactors — are single-purpose classes that represent one thing the application can do.
GetProductsUseCase. ConfirmOrderUseCase. WithdrawFundsUseCase. Each one takes input, talks to a repository, applies any domain logic that doesn't belong in the entity itself, and returns a result.
The temptation is to skip this layer. Why not just call the repository directly from the BLoC? It works. Until the same operation needs to happen from two different places in the app — and you copy the logic, or you reference the BLoC from another BLoC, or you put the logic in a utility function that grows without bounds.
Use cases enforce single responsibility at the application layer. They're the seam between state management and domain logic. When a feature gets complex — when a single user action touches two repositories, validates a rule, emits an event, and updates a cache — having that complexity isolated in a named, testable class is the difference between a debuggable codebase and an archaeological dig.
They're also the correct place for orchestration that doesn't belong in the domain. The domain enforces invariants. Use cases coordinate the steps to satisfy them.
class ConfirmOrderUseCase {
final OrderRepository _orderRepository;
final InventoryRepository _inventoryRepository;
final NotificationService _notificationService;
ConfirmOrderUseCase(
this._orderRepository,
this._inventoryRepository,
this._notificationService,
);
Future<Either<Failure, Order>> execute(String orderId) async {
final order = await _orderRepository.getById(orderId);
// Domain logic — the aggregate enforces its own rules
final confirmedOrder = order.confirm();
// Orchestration — use case coordinates across boundaries
await _inventoryRepository.decrementStock(confirmedOrder.items);
await _orderRepository.save(confirmedOrder);
await _notificationService.sendConfirmation(confirmedOrder);
return Right(confirmedOrder);
}
}One action. One class. Fully testable with mocked dependencies.
State Management: A Decision, Not a Default
The choice between BLoC and Riverpod isn't a preference — it's an architectural decision that follows from the domain complexity and team context.
BLoC is a state machine. It gives you named events, explicit state transitions, and a traceable history of why state changed. For complex local flows — multi-step checkout, authentication with token refresh, forms with branching validation — that explicitness is valuable. It also aligns naturally with domain events: if the backend emits OrderConfirmed, the frontend BLoC receives OrderConfirmedEvent. The architecture reads consistently.
Riverpod is a reactive dependency graph. It gives you compile-time safety, minimal ceremony for async data fetching, and providers that live outside the widget tree. For data-heavy screens with no complex local state machine, it's cleaner and faster to write.
The answer for most enterprise apps is neither "always BLoC" nor "always Riverpod." It's BLoC for flows with real state machines, Riverpod for data and simple state — with the domain and use case layers underneath both. State management is presentation-layer infrastructure. It should not own the business logic it dispatches to.
The full breakdown of when to use each is in Riverpod vs BLoC: Why "Which Is Better" Is the Wrong Question.
Testing: Confidence at Every Layer
The architecture above isn't just about organisation. It's about where tests live and what they can prove.
Domain tests — pure Dart, no Flutter runner. Test that order.confirm() throws when the order has no items. Test that Money.subtract() returns a Failure on negative result. Fast, deterministic, no setup.
Use case tests — mock the repositories and services, test the orchestration. Does ConfirmOrderUseCase call decrementStock only when the order confirms successfully? Does it return a Failure when the order is already confirmed? These tests cover the coordination logic that neither the domain nor the UI should own.
BLoC tests — emit events, assert state sequences. The bloc_test package makes this readable and explicit. Test the full event→state contract for every flow.
Widget tests — test screens with mocked use cases. The widget doesn't know whether the use case is real or fake. It responds to states.
Integration and E2E tests — the full stack, including real API calls, reserved for critical paths.
This isn't about coverage numbers. It's about having a test suite where a failing test tells you exactly which layer broke and why. When domain tests pass and use case tests fail, you know the problem is in orchestration. When use case tests pass and BLoC tests fail, you know the problem is in state transitions. The layers make the signal clear.
Performance as a Discipline
Flutter gives you 60fps out of the box on most things. It also gives you tools to see exactly where you're dropping frames when it doesn't.
Performance analysis isn't a phase at the end of a project. It's a practice that runs alongside development. Key areas:
Widget rebuilds — the most common source of unnecessary work. const constructors, proper key usage, and avoiding rebuilds of widget subtrees that haven't changed.
Heavy computation on the main thread — image processing, large list sorting, complex serialisation. These belong on an Isolate.
Memory leaks — streams not closed, controllers not disposed, large objects kept alive by closures. Flutter DevTools' memory profiler catches these early.
Impeller and render performance — shader compilation, complex animations, custom painters. Knowing how to profile with DevTools' performance overlay and timeline view is a basic skill for anyone shipping to production.
The Performance Profiling with DevTools covers this with concrete examples of finding and fixing each category of problem.
Security Fundamentals
Security in mobile apps is a surface, not a checkbox.
Sensitive data at rest — API keys, tokens, user credentials — belong in flutter_secure_storage, not SharedPreferences. The difference matters: SharedPreferences is plaintext on the device.
API key exposure — keys bundled in the app binary can be extracted. Sensitive operations that require secret keys should go through a backend proxy, not be called directly from the client.
Biometric authentication — for apps that handle sensitive data, local_auth adds a meaningful layer at minimal integration cost.
Certificate pinning — for apps in high-security contexts (fintech, health), pinning the server certificate prevents man-in-the-middle attacks on production traffic.
Obfuscation — Flutter's --obfuscate flag doesn't make your app unbreakable, but it raises the cost of reverse-engineering significantly.
The Securing Flutter Apps: Beyond the Basics article covers each of these with implementation detail.
How It Comes Together
The architecture described in this playbook isn't a collection of independent best practices. It's a system where each part supports the others.
Domain-first thinking gives you the rules. Clean Architecture gives you the structure to enforce them without contaminating layers. Use cases give you the orchestration layer that keeps both domain and presentation clean. State management connects the use cases to the UI without owning the logic. Testing validates each layer in isolation. Performance and security are disciplines that run throughout, not phases bolted on at the end.
The result is a codebase where a new developer can read the domain layer and understand the business without reading a single widget. Where a bug in production narrows down to a layer and a test that should have caught it. Where a requirement change touches the right files and only those files.
That's the standard we build to at Amgres. Not because it makes individual features faster to ship — it doesn't, at first. Because it makes the second year of development faster than the first, and the third year faster than the second. The cost is front-loaded. The payoff compounds.
Everything in This Series
Architecture & State
Native Interop & Performance
Business & Strategy
DDD Series (the conceptual foundation)