| name | achieve-flutter-architecture |
| description | Use when building Flutter apps following the Achieve architecture โ repository pattern with caching mixins, service locator DI, event bus communication, and DataPage lifecycle for screens |
Achieve Flutter Architecture
Overview
Every feature follows the same shape: Model โ Repository โ Screen โ Route. A screen doesn't catch errors, manage loading spinners, or know auth tokens exist. It calls a repository, assigns the result to a field, and returns widgets. Everything else โ loading states, error handling, caching, auth refresh, analytics โ is the architecture's problem.
When to Use
- Building a Flutter app that will grow beyond a few screens
- Adding a feature (screen, repository, model) following this pattern
- Setting up DI, routing, or network layers
Don't use for: simple prototypes, projects with established different architecture (BLoC, Riverpod), package/plugin development.
Project Structure
features/<feature>/
โโโ <feature>_repository.dart # Abstract interface + implementation
core/models/
โโโ <model>.dart # Data model
ui/pages/user/<feature>/
โโโ <feature>_list_page.dart # List screen (extends DataPage)
โโโ <feature>_detail_page.dart # Detail screen (extends DataPage)
โโโ widgets/ # Child widgets (may listen to PageReloaded)
services/
โโโ injection_container.dart # DI setup (GetIt)
โโโ event_bus/ # Cross-feature events
โโโ core/ # Network clients, logging, config
router/
โโโ user_router.dart # Authenticated routes
โโโ guest_router.dart # Unauthenticated routes
Data Layer
Repository + Caching Mixin
Each feature: abstract interface + implementation mixing in RepositoryMixin. Four strategies:
| Strategy | Method | Use for |
|---|
| Persistent | runPersistedQuery | Data that doesn't change every second (goals, profiles). Returns cached version immediately; pass remoteOnly: true for fresh fetch |
| Secure | runSecureQuery | Same pattern, encrypted storage. Auth tokens, balances, transactions |
| Ephemeral | runEphemeralQuery | In-memory only, session-scoped. Search results, filtered lists |
| None | runOperation | Mutations. You don't cache writes |
Cache is a performance optimization, not correctness. If cached data fails to deserialize (schema change after app update), it silently falls through to network fetch.
GetIt โ Service Locator
Repositories registered as lazy singletons against abstract interfaces. Accessed via getIt.get<GoalRepository>(). We chose GetIt over Riverpod/Provider because repositories need to be accessible outside the widget tree โ in interceptors, in other repositories, in utility classes. GetIt doesn't care about BuildContext.
Screen Layer
DataPage โ The Screen Lifecycle
Every screen loading async data MUST extend DataPage. No initState, no setState, no try/catch.
class _GoalListPageState extends DataPage<GoalListPage> {
List<Goal> goals = [];
@override
Future<void> onLoad() async {
goals = await getIt.get<GoalRepository>().fetchGoals();
}
@override
Future<void> onRefresh() => onLoad();
@override
Widget buildPage(BuildContext context) {
return ListView.builder(
itemCount: goals.length,
itemBuilder: (_, i) => GoalTile(goal: goals[i]),
);
}
}
DataPage provides: loading state until onLoad completes, error widget with retry if it throws, only calls buildPage when data is ready, manages setState internally.
onLoad is called on first focus. onRefresh on every subsequent focus โ navigate back, tab switch, app resume. DataPage wraps content in FocusDetector which fires automatically. This eliminates "I created a goal but it didn't show up in the list" โ every DataPage refreshes on focus.
Two-layer loading: The page shows a plain white Container during initial load (invisible against white backgrounds โ no flash). The actual spinner is OverlayManager โ a global overlay on top of the app. Why app-level? Because screens can dispose during async operations. User taps Submit then navigates back โ per-screen overlay crashes or leaks. App-level OverlayManager outlives every screen.
OperationRunnerState โ User Actions
DataPage extends OperationRunnerState, so every screen gets both reads and writes:
await runOperation('redeem_reward', () async {
await rewardRepository.redeemReward(id);
await onRefresh();
});
runOperation handles: loading overlay, hiding it when done, catching errors, showing error dialog, logging analytics. The errorHandler callback suppresses the default dialog for business-specific errors (return true = handled, false = show default dialog).
Child Widgets and PageReloaded
DataPage handles page-level data. For composed screens (balance card + goals list + transactions + promo banner), child widgets load their own data and listen for PageReloaded:
class _BalanceCardState extends State<BalanceCard> implements EventBusListener {
@override
void initState() {
super.initState();
getIt.get<EventBus>().registerListener<PageReloaded>(this);
_loadBalance();
}
@override
void onEvent(event) {
if (event is PageReloaded) _loadBalance();
}
}
The page doesn't know what child widgets it contains. It emits "I refreshed" and each child independently reloads. Add, remove, or rearrange widgets without touching onLoad.
Navigation
Dual routers: Guest router (login, registration) and user router (everything else). Auth state change swaps the entire router โ clears the nav stack naturally. No redirect guards scattered across screens.
Named routes only: context.pushNamed(UserRoutes.goalDetails, pathParameters: {'id': goalId}). Never raw paths.
pushNamed โ user should be able to go back
goNamed โ top-level transition, no back
extra for objects: Pass the full object when drilling down from a list (instant navigation, no re-fetch). Fall back to fetching by ID when extra is null (deep links, app restarts):
@override
Future<void> onLoad() async {
payslip = widget.payslip ??
await getIt.get<PayrollRepository>().getPayslip(widget.payslipId);
}
Routes are for navigation identity (which screen, which entity), not transient state (current step, active filters). That lives in a state manager or repository via GetIt.
Error Flow
No screen handles infrastructure errors. Three layers do it:
Layer 1 โ Network Interceptors: Auth expiry โ pause all requests, refresh token, retry. 503 โ emit maintenance event, app transitions. Screens never see these.
Layer 2 โ Repository Mixin: runOperation wraps raw exceptions into typed OperationFailure with human-readable messages. UI never sees stack traces.
Layer 3 โ OperationRunnerState: Catches OperationFailure, hides overlay, logs analytics (operation name + failure reason), shows error dialog. Optional errorHandler callback for business-specific errors.
What screens don't handle: Auth errors, 503s, raw exceptions, error UI, loading state for operations. A screen provides the happy path and optionally one or two business-specific error handlers. Everything else is the architecture's problem.
Building a New Feature
- Model โ
@JsonSerializable() + Equatable in core/models/
- Repository โ abstract interface + implementation with
RepositoryMixin in features/<feature>/
- Register โ one line in
injection_container.dart (lazySingleton for most)
- Screen โ extend
DataPage, implement onLoad/onRefresh/buildPage
- Route โ register in router
That screen gets loading states, error handling, caching, auto-refresh on focus, loading overlay during mutations, error dialogs, and analytics โ without writing any of it.
Testing Pattern
setUpAll โ register mock logger
setUp โ register mock network client + shared prefs via registerMock<T>()
- Register fallback values for matchers
- Create repository instance directly (not from DI)
- Mock responses with
when().thenAnswer()
- Use
data_fixture_dart for realistic test data
Common Mistakes
| Mistake | Fix |
|---|
| Custom loading/error/refresh in pages | Extend DataPage โ it handles all of this |
| Manual operation handling in pages | Use runOperation() โ overlay, errors, analytics included |
| Catching errors in DataPage subclasses | Let DataPage handle it โ shows DataError with retry |
| Per-screen loading overlay | Use OverlayManager โ survives screen disposal |
| Skipping repository interface | Always define abstract class first |
| Wrong cache strategy | Persistent=stable, secure=sensitive, ephemeral=session, none=mutations |
| Fat pages with business logic | Keep logic in repository; pages call repos and render |
| Putting all child data in page onLoad | Use PageReloaded event โ child widgets load their own data |
| Direct cross-feature calls | Use event bus for loose coupling |
| Using raw paths for navigation | Named routes with constants only |
| Business state in route params | Routes = navigation identity. Transient state lives in GetIt |
If Starting a New Project
Same architecture, modern Dart 3 upgrades:
- Freezed for models โ one annotation replaces JsonSerializable + Equatable + manual copyWith
- Sealed classes + Result type for errors โ compiler enforces exhaustive handling instead of runtime hope
- go_router_builder for navigation โ generated type-safe route methods, compile-time parameter validation
These are implementation upgrades, not architectural changes. The five pieces โ repository with caching mixin, DataPage, OperationRunnerState with OverlayManager, EventBus, GetIt โ stay the same.
See achieve-flutter-reference.md for complete code examples.