Flutter mobile boilerplate using layered clean architecture with MobX state management, Retrofit API layer (packages/api), shared design system (packages/design_system), and GetIt + Injectable dependency injection. Melos-managed monorepo.
Rules below are summaries. Full context, alternatives considered, and rationale are in docs/adr/. Read the ADR when you need why.
- Layered architecture, adjacent-only access — UI → State → Store → DioService → API Provider; UI never reaches Dio or stores directly. ADR-0001
- State vs Store separation —
*_state.dartis per-screen with no API access;*_store.dartowns API and may be feature-scoped or@singleton. ADR-0002 - No use-case → use-case dependencies — UCs depend on services/stores/navigators only; shared logic becomes a service, not a peer UC. ADR-0003
- Use-case taxonomy + colocation — 4 UC types (API / Navigation / Service Coordination / State Delegation); placement follows the consumer layer; promote on second consumer. ADR-0004
- Flat feature tree — features are peers under a domain grouping; no nested
features/inside a feature. ADR-0005 - Feature-owned singletons — every store lives under its owning feature's
mobx/, regardless of@singletonvs@injectable;lib/shared/is deprecated. ADR-0006 - DI scopes + constructor injection —
@injectable,@singleton,@lazySingleton; flavor bindings via@dev/@prod; nevergetIt<>()inside classes (one allowed seam:Provider(create:)). ADR-0007 AppNavigatorrouting abstraction — pages never usecontext.router; states injectAppNavigator. ADR-0008- Provider-based state access — page roots create
Provider<MyPageState>; descendants read viacontext.read<T>(); never pass state as widget params. ADR-0009 HookWidgetdefault —HookWidgetfor any widget needing controllers/effects/local state;StatelessWidgetfor pure presentation;StatefulWidgetonly for hook-incompatible APIs. ADR-0010- Retrofit + freezed typed API layer —
@RestApi()providers inpackages/api;@freezed sealedDTOs;ListResponseDto<T>for paginated lists; access only viaDioService. ADR-0011 - Mandatory localization — no inline UI strings;
LocaleKeys.x.tr()only; add toassets/translations/en-US.jsonthenmelos run translations. ADR-0012 - DS tokens only in
lib/— no raw colors, text styles, or shadow stacks; tokens live inpackages/design_systemwith both light + dark values. ADR-0013 - Melos workspace, two packages —
packages/api+packages/design_systemas Dart workspace siblings; melos scripts drive codegen / lint / test. ADR-0014
# Development (dev flavor)
flutter run -t lib/main_dev.dart
# Production
flutter run -t lib/main_prod.dart
# Bootstrap workspace (deps + codegen, all packages)
melos run bootstrap # runs `melos run deps && melos run generate`
# Note: bare `melos bootstrap` (Melos built-in) only fetches deps.
# Code generation (after changing MobX, Retrofit, Freezed, Injectable annotations)
melos run build
# Single package codegen
melos exec --scope api -- "dart run build_runner build -d"
melos exec --scope design_system -- "dart run build_runner build -d"
# Analyze / test / format / auto-fix lints
melos run analyze
melos run test
melos run format
melos run lint # dart fix --apply
# Single test file
flutter test test/path/to_file_test.dart
# Translations (after editing assets/translations/en-US.json)
melos run translations
# Deploy dev build (iOS)
melos run deploy-devRationale + alternatives: ADR-0001.
| Layer | Files | Can Access | Cannot Access |
|---|---|---|---|
| UI | *_page.dart, *_widget.dart |
State classes via context.read<T>() |
DioService, Stores directly |
| State | *_state.dart |
Stores, AppNavigator, Use Cases | DioService directly |
| Store | *_store.dart |
DioService, other Stores, Use Cases | — |
| Use Case | *_use_case.dart |
DioService, Services, AppNavigator | Other Use Cases |
| Data | packages/api providers |
— | — |
UI → State → Store → DioService → API Provider
↘ Use Case ↗
main_dev.dart / main_prod.dart → main.dart:run(FlavorType) → WidgetsFlutterBinding + portrait lock + EasyLocalization.ensureInitialized() + registerGetIt(env) → Sentry init in release, skipped in debug → MyApp renders MaterialApp.router with AppNavigator.config from GetIt, wrapped in ConnectionWrapperPage + OverlaySupport.global.
flutter_boilerplate/
├── lib/
│ ├── core/
│ │ ├── configs/ # App configuration
│ │ ├── constants/ # FlavorType, SupportedLocals, etc.
│ │ ├── extensions/ # Dart extensions
│ │ ├── guards/ # Route guards
│ │ ├── navigation/ # AppNavigator, router
│ │ ├── services/ # FlavorService, DioService, interceptors, social auth
│ │ ├── ui/ # Reusable widgets (cross-feature)
│ │ ├── use_cases/ # App-wide use cases (cross-feature)
│ │ └── utils/ # Utilities
│ ├── features/{feature}/
│ │ ├── view/ # Pages, states, widgets
│ │ │ └── use_cases/ # Use cases consumed only by view/state layer
│ │ ├── mobx/ # Feature stores
│ │ │ └── use_cases/ # Use cases consumed only by store layer
│ │ ├── core/use_cases/ # Use cases shared across layers within the feature
│ │ ├── models/
│ │ ├── modals/
│ │ │ └── use_cases/ # Use cases consumed only by modal states
│ │ └── components/
│ ├── gen/ # Generated (locale_keys.g.dart, assets) — NEVER edit
│ ├── app.dart # MyApp root widget
│ ├── main.dart # run() entry
│ ├── main_dev.dart # dev flavor entrypoint
│ ├── main_prod.dart # prod flavor entrypoint
│ ├── injectable.dart # configureDependencies, resetDependencies
│ └── injectable.config.dart # Generated DI wiring — NEVER edit
├── packages/
│ ├── api/lib/src/
│ │ ├── providers/ # Retrofit API providers (@RestApi)
│ │ ├── models/ # DTOs (*_dto.dart) with freezed + json_serializable
│ │ └── constants/
│ └── design_system/lib/
│ ├── src/ # Themes, components, colors, typography
│ └── gen/ # Generated asset bindings — NEVER edit
├── assets/translations/ # en-US.json etc.
└── test/
lib/shared/ — DEPRECATED — see ADR-0006
lib/shared/ currently contains stores/ (auth_store, connectivity, notifications_store), features/connection_wrapper, widgets/, modals/, state/, constants/. Do not add new files there. New code belongs under the owning feature, regardless of singleton scope.
- Stores (even singletons like
AuthStore) →lib/features/{owning_feature}/mobx/ - Reusable UI components →
lib/core/ui/ - Reusable models →
lib/core/models/or the owning feature'smodels/
Existing lib/shared/ files may be migrated opportunistically when touched.
No nested features/ — features are peers — see ADR-0005
A feature must never contain a features/ subdirectory. Sub-modules with their own store/state/view get promoted to siblings under the domain grouping.
# ❌ WRONG — nested features
lib/features/meetings/meeting_details/features/ai_chat/
# ✅ CORRECT — peer features under domain
lib/features/meetings/ai_chat/
lib/features/meetings/meeting_details/
Pure view-layer sub-pages (no store) can remain under view/ (e.g., meeting_details/view/share_meeting/).
- Safe to edit:
lib/,packages/api/lib/src/,packages/design_system/lib/src/,test/ - Never manually edit:
*.g.dart,*.gr.dart,*.freezed.dart,*.gen.dart,lib/gen/,packages/*/lib/gen/,injectable.config.dart
| Type | Pattern | Example |
|---|---|---|
| Pages | *_page.dart |
login_page.dart |
| Page States | *_page_state.dart |
login_page_state.dart |
| Stores | *_store.dart |
auth_store.dart |
| Use Cases | *_use_case.dart |
create_tag_use_case.dart |
| Widgets | *_widget.dart |
avatar_widget.dart |
| DTOs | *_dto.dart |
user_dto.dart |
| Services | *_service.dart |
flavor_service.dart |
| API Providers | *_api_provider.dart |
auth_api_provider.dart |
Rationale + alternatives: ADR-0008.
Always inject AppNavigator into state classes. Pages call state methods, never navigate directly.
// ❌ FORBIDDEN
context.router.push(const SomeRoute());
// ✅ CORRECT — in state class
abstract class _MyPageStateBase with Store {
final AppNavigator _appNavigator;
_MyPageStateBase(this._appNavigator);
@action
void navigateToSettings() {
_appNavigator.push(const SettingsRoute());
}
}Rationale + alternatives: ADR-0009.
Create Provider at the page root. Child widgets access via context.read<T>().
// ❌ WRONG
class _MySection extends StatelessWidget {
final MyPageState state;
}
// ✅ CORRECT
class MyPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Provider(
create: (_) => getIt<MyPageState>()..init(),
dispose: (_, value) => value.dispose(),
child: const _Content(),
);
}
}
class _Content extends StatelessWidget {
@override
Widget build(BuildContext context) {
final state = context.read<MyPageState>();
return Scaffold(/* ... */);
}
}@readonly auto-generates a public getter. Never add @computed for it.
// ✅ CORRECT
@readonly
bool _hasActiveSubscription = false;
// Access as: store.hasActiveSubscription (auto-generated)
// ❌ FORBIDDEN — redundant getter
@readonly
bool _hasActiveSubscription = false;
@computed
bool get hasActiveSubscription => _hasActiveSubscription; // DELETE
// ✅ @computed only for derived state
@computed
bool get hasItems => _items.isNotEmpty;Rationale + alternatives: ADR-0007.
@injectable— feature-scoped (State classes, feature Stores, Use Cases)@singleton— app-wide Stores (AuthStore, etc.) andAppNavigator— still live under their owning feature'smobx/@lazySingleton— app-wide services constructed on first resolution (e.g.DioService)- Flavor-scope bindings with
@dev/@prod—configureDependencies(FlavorType)passes the flavor name as theenvironmentto injectable - Always inject via constructor. Never call
getIt<>()inside classes (except at Widget→State boundary insideProvider(create:)) resetDependencies()tears down GetIt and re-registers with current flavor — use for env switching
Rationale + alternatives: ADR-0010.
Use HookWidget for local state/lifecycle. Use StatelessWidget for pure presentation.
class _Content extends HookWidget {
@override
Widget build(BuildContext context) {
final counter = useState(0);
useEffect(() { /* side effect */ return () { /* cleanup */ }; }, []);
return Text('${counter.value}');
}
}Use cases (*_use_case.dart) encapsulate a single business operation. @injectable classes with a call() method. Rationale + alternatives: ADR-0003, ADR-0004.
- No UC → UC — UC depends on services / stores /
AppNavigator, never another UC. Shared logic becomes a service. - 4 types — API / Navigation / Service Coordination / State Delegation.
- Place next to consumer — see Location Rules below; promote on second consumer.
@injectable
class CreateTagUseCase {
final DioService _dioService;
final IssueTrackingService _issueTrackingService;
CreateTagUseCase(this._dioService, this._issueTrackingService);
Future<String?> call(String name, String color) async {
try {
final response = await _dioService.tagsProvider.createTag(
tagCreateDto: TagCreateUpdateRequestDto(name: name, color: color),
);
return response.data.id;
} on DioException catch (e) {
_issueTrackingService.trackIssue(e);
return null;
}
}
}
// Usage — direct call syntax (no .call() needed)
final id = await _createTagUseCase(name, color);| Consumer | Path |
|---|---|
| Store only | lib/features/{feature}/mobx/use_cases/ |
| State/View only | lib/features/{feature}/view/use_cases/ |
| Modal only | lib/features/{feature}/modals/{modal}/use_cases/ |
| Multiple layers within one feature | lib/features/{feature}/core/use_cases/ |
| Multiple features | lib/core/use_cases/ |
Rule: place next to consumer. Gains second consumer in different layer → promote one level. Gains consumer in different feature → promote to lib/core/use_cases/.
- Extract: reused in 2+ places, coordinates 2+ services, complex enough for its own test
- Keep inline: trivial one-liner delegation
Rationale + alternatives: ADR-0011.
@RestApi()
abstract class TodosApiProvider {
factory TodosApiProvider(Dio dio) = _TodosApiProvider;
@GET(_Paths.getTodos)
Future<List<TodoDto>> getTodos();
}- Single-resource endpoints return the DTO directly; paginated lists return
ListResponseDto<T>(seepackages/api/lib/src/models/list_response_entity/) - DTOs are
@freezed sealed classwithfromJsonfactory (json_serializableexplicit_to_json: true,any_map: true— seebuild.yaml) - Providers accessed only through
DioServicein Stores/Use Cases - Interceptors live in
lib/core/services/interceptors/(app concerns: auth, logging, mocking)
Rationale + alternatives: ADR-0002.
| Question | → State (*_state.dart) |
→ Store (*_store.dart) |
|---|---|---|
| Needs API access? | No — uses Stores | Yes — injects DioService |
| DI annotation? | @injectable |
@injectable (feature) or @singleton (app-wide) |
| Location? | features/*/view/ |
features/*/mobx/ (always under owning feature) |
Rationale + alternatives: ADR-0012.
Never hardcode text in UI. Always use LocaleKeys.keyName.tr().
// ❌ Text('Continue with email')
// ✅ Text(LocaleKeys.loginPage_continueWithEmail.tr())Add strings to assets/translations/en-US.json, then run melos run translations. Supported locales enumerated in lib/core/constants/supported_locals.dart — add new locales there.
- Import from
package:design_system/design_system.dart - Use named constructors:
PrimaryButton.largeFilled() - Use generated
AppColorsandAssets— never hardcode colors or asset paths - Themes (
lightTheme,darkTheme) exposed from package root; both wired inlib/app.dart - Asset codegen lives in
design_system/lib/gen; root app asset codegen inlib/genviaflutter_gen
No hardcoded colors or styles in lib/ — see ADR-0013
The lib/ layer (app) must never contain raw Color(0x…) / hex literals, raw TextStyle(…) composites, or one-off shadow stacks. Every visual token lives in the design system package.
- Colors →
context.geist.<token>(Geist palette) or legacycontext.<token>(TailorCustomTheme). Add new colors to the appropriate extension inpackages/design_system/lib/src/theme/src/with both light and dark variants. - Typography →
GeistTextStyles.<role>orcontext.<textStyle>. Add new text styles toGeistTextStyles(geist) orTextStyles(legacy) with.copyWith(color: …)at the use site for color swaps only. - Radii →
GeistRadius.<scale>constants. - Durations →
GeistDuration.<speed>constants. - Shadows / elevation →
context.geist.cardShadow,shadowBorder,shadowFab, etc. Never assemble ad-hocBoxShadowstacks inlib/. - Spacing →
kSpacingNpxconstants fromdesign_system.
When a new design token is needed:
- Add the field to
GeistTheme(or the relevantThemeExtension) withlightanddarkvalues. - Update
copyWithandlerpmethods. - Consume in
lib/viacontext.geist.newToken— never inline the hex value.
The only allowed "bare" colors in lib/ are Colors.transparent and Color.lerp results applied to tokens already sourced from the DS.
Both lightTheme and darkTheme include the GeistTheme extension. Follow the DESIGN.md dark-mode guidance: desaturated tonal variants, not pure inversion. Always design new tokens in pairs — add a dark value for every new light value. Every foreground/background pair must meet WCAG AA (4.5:1 for body, 3:1 for large/UI glyphs) in both modes.
- Always use
group()— name after class under test - Name tests with "should":
test('should create user with valid data', () {}) - Ask: "Can this test fail if real code is broken?" — avoid testing only mocked behavior
- Prefer real objects > Fake > Mock
- Arrange-Act-Assert pattern
- Make changes
melos run buildif codegen annotations changedmelos run lint(dart fix --apply)melos run analyzemelos run format
analysis_options.yaml promotes these to errors (not warnings): prefer_relative_imports, prefer_single_quotes, require_trailing_commas, cascade_invocations, avoid_print, cancel_subscriptions. Use relative imports inside lib/, single quotes, trailing commas on multiline args.
Extensive guidance in .cursor/rules/*.mdc covering architecture, testing, mocktail/mockito, MobX, navigation, design system, Flutter error handling — consult relevant rule for substantive work.
- Pages:
lib/features/*/view/ - Page States:
lib/features/*/view/*_page_state.dart - Stores (all, including singletons):
lib/features/*/mobx/ - Use Cases (by consumer):
lib/features/*/mobx/use_cases/,lib/features/*/view/use_cases/,lib/features/*/core/use_cases/,lib/core/use_cases/ - Reusable UI:
lib/core/ui/ - Core Services:
lib/core/services/(FlavorService,DioService, interceptors, social auth) - Navigation:
lib/core/navigation/ - API Providers:
packages/api/lib/src/providers/ - API Models (DTOs):
packages/api/lib/src/models/ - Design System Components:
packages/design_system/lib/src/ - DI bootstrap:
lib/injectable.dart,lib/core/services/get_it.dart - Entrypoints:
lib/main.dart,lib/main_dev.dart,lib/main_prod.dart