
Flutter Architecture
- 1.6k installs
- 105 repo stars
- Updated May 1, 2026
- madteacher/mad-agents-skills
flutter-architecture is a skill that >-
About
The flutter-architecture skill helps with >-. Key workflows include Flutter Architecture; Core Contract; Clarification Rules; Resource Routing. Documented capabilities cover Use feature-first for medium/large apps, team work, frequent feature; Use layer-first for small apps, solo work, or simple CRUD flows.; Use a Domain layer only for complex, reusable, or multi-repository; product boundary of a new feature;; expected scale of team or app when structure choice is ambiguous;. Agents should invoke it when users ask about flutter architecture or mention triggers defined in the skill frontmatter. Follow the SKILL.md steps, reference files, and output formats rather than improvising outside documented scope. Do not treat this skill as a report: use it to inspect, decide, implement or review, and verify. ## Core Contract 1. Confirm the target is a Flutter or Dart package by inspecting `pubspec.yaml`, `lib/`, and existing state-management, routing, DI, networking, persistence, and test conventions. 2. Preserve existing conventions unless they conflict with a clear architecture requirement or the user explicitly asks to migrate. 3. Choose the smallest architecture that
- Flutter Architecture
- Core Contract
- Use feature-first for medium/large apps, team work, frequent feature
- Use layer-first for small apps, solo work, or simple CRUD flows.
- Use a Domain layer only for complex, reusable, or multi-repository
Flutter Architecture by the numbers
- 1,615 all-time installs (skills.sh)
- +10 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #149 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 29, 2026 (Skillselion catalog sync)
flutter-architecture capabilities & compatibility
- Capabilities
- flutter architecture · core contract · use feature first for medium/large apps, team wo · use layer first for small apps, solo work, or si · use a domain layer only for complex, reusable, o
- Use cases
- planning · research
What flutter-architecture says it does
>-
npx skills add https://github.com/madteacher/mad-agents-skills --skill flutter-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.6k |
|---|---|
| repo stars | ★ 105 |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 1, 2026 |
| Repository | madteacher/mad-agents-skills ↗ |
How do I apply flutter-architecture for flutter architecture tasks?
>-
Who is it for?
Teams using flutter-architecture as documented in the skill repository.
Skip if: Tasks outside the flutter-architecture scope in SKILL.md.
When should I use this skill?
User mentions flutter-architecture, flutter-architecture, or related skill triggers.
What you get
Structured guidance and deliverables from the flutter-architecture SKILL.md workflow.
- Command0.dart
- Command1.dart
- ViewModel command wiring
By the numbers
- Defines Command0 for zero-argument actions and Command1 for single-argument actions
Files
Flutter Architecture
You are an architecture agent for Flutter apps. Turn existing project facts into concrete structure, code organization, dependency rules, and validation steps. Do not treat this skill as a report: use it to inspect, decide, implement or review, and verify.
Core Contract
1. Confirm the target is a Flutter or Dart package by inspecting pubspec.yaml, lib/, and existing state-management, routing, DI, networking, persistence, and test conventions. 2. Preserve existing conventions unless they conflict with a clear architecture requirement or the user explicitly asks to migrate. 3. Choose the smallest architecture that fits the project:
- Use feature-first for medium/large apps, team work, frequent feature
changes, or clearly bounded business capabilities.
- Use layer-first for small apps, solo work, or simple CRUD flows.
- Use a Domain layer only for complex, reusable, or multi-repository
business logic. ViewModels may call Repositories directly for simple flows. 4. Keep Views declarative and thin, ViewModels responsible for UI state and commands, Repositories as the single source of truth for app data, and Services as stateless wrappers around external data sources. 5. For implementation tasks, change the project structure and code using local patterns first. Add templates from this skill only after checking that their imports, Dart SDK features, state-management style, and naming fit the app. 6. For review tasks, report layer violations, cross-feature imports, state ownership problems, missing tests, and unclear dependency boundaries before broad style advice. 7. Validate with the repo's normal commands. Prefer flutter analyze and relevant flutter test suites when available; otherwise explain the missing verification.
Clarification Rules
Ask the user only when a high-impact decision cannot be inferred from the project:
- product boundary of a new feature;
- expected scale of team or app when structure choice is ambiguous;
- offline-first, sync, or conflict-resolution requirements;
- whether a migration should be incremental or all-at-once.
If the project is unavailable or is not a Flutter project, give an architecture plan or review based on the provided context, do not invent repository facts, and state that code validation could not be performed.
Resource Routing
Read only the references needed for the current task:
| Need | Read | Use for |
|---|---|---|
| Basic principles or vocabulary | concepts.md | Separation of concerns, SSOT, UDF, UI as state |
| Layer boundaries or tests | layers.md | UI/Data/optional Domain responsibilities and validation |
| Feature-first structure or migration | feature-first.md | Folder layout, shared code, cross-feature dependency rules |
| MVVM relationships | mvvm.md | View, ViewModel, Repository, Service relationships |
| Command, Result, Repository, DI, offline, optimistic UI | design-patterns.md | Pattern selection and code examples |
| Command template | command.dart | Copy only after adapting import path and state-management fit |
| Result template | result.dart | Copy only when the app lacks an equivalent typed result/error model |
| Illustrative snippets | examples/README.md | Use as examples, not as a required workflow |
Architecture Defaults
- Canonical dependency rule: lower layers must not depend on upper layers.
ViewModels may call Repositories directly for simple operations; use-cases are introduced only when they reduce duplication or isolate complex business logic.
- Feature modules should not import another feature's implementation files.
Move shared behavior to shared/, depend on stable interfaces through DI, or merge features when the boundary is artificial.
- Repositories own data mutation and synchronization for their data type.
Services should stay stateless and should not own business state.
- Do not add folders just to satisfy a diagram. Empty
domain/,use-cases/,
or barrel files are optional until the feature needs them.
Validation
Before finishing an implementation or review:
1. Check that new imports respect the chosen feature/layer boundary. 2. Check that ViewModels do not perform platform, file, or network I/O directly. 3. Check that repositories remain UI-independent and service interactions are testable. 4. Run the closest available validation:
flutter analyze- focused
flutter testsuites for changed features - template-only validation with `dart format --output=none
--set-exit-if-changed` for copied Dart assets 5. Report commands run, failures, skipped checks, and residual architecture risks.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'result.dart';
// Template asset: copy this beside the project's Result type or update this
// import path to match the app's package structure.
typedef CommandAction0<T> = Future<Result<T>> Function();
typedef CommandAction1<T, A> = Future<Result<T>> Function(A);
/// Facilitates interaction with a ViewModel.
///
/// Encapsulates an action,
/// exposes its running and error states,
/// and ensures that it can't be launched again until it finishes.
///
/// Use [Command0] for actions without arguments.
/// Use [Command1] for actions with one argument.
///
/// Actions must return a [Result].
///
/// Consume the action result by listening to changes,
/// then call to [clearResult] when the state is consumed.
abstract class Command<T> extends ChangeNotifier {
Command();
bool _running = false;
/// True when the action is running.
bool get running => _running;
Result<T>? _result;
/// true if action completed with error
bool get error => _result is Error;
/// true if action completed successfully
bool get completed => _result is Ok;
/// Get last action result
Result<T>? get result => _result;
/// Clear last action result
void clearResult() {
_result = null;
notifyListeners();
}
/// Internal execute implementation
Future<void> _execute(CommandAction0<T> action) async {
// Ensure the action can't launch multiple times.
// e.g. avoid multiple taps on button
if (_running) return;
// Notify listeners.
// e.g. button shows loading state
_running = true;
_result = null;
notifyListeners();
try {
_result = await action();
} finally {
_running = false;
notifyListeners();
}
}
}
/// [Command] without arguments.
/// Takes a [CommandAction0] as action.
class Command0<T> extends Command<T> {
Command0(this._action);
final CommandAction0<T> _action;
/// Executes the action.
Future<void> execute() async {
await _execute(_action);
}
}
/// [Command] with one argument.
/// Takes a [CommandAction1] as action.
class Command1<T, A> extends Command<T> {
Command1(this._action);
final CommandAction1<T, A> _action;
/// Executes the action with the argument.
Future<void> execute(A argument) async {
await _execute(() => _action(argument));
}
}
Flutter Architecture Examples
This directory contains example code demonstrating the Flutter architecture patterns.
Using Command Pattern
The Command pattern encapsulates actions with state management and Result handling.
Basic Usage
After copying command.dart and result.dart into your project, update these imports to match their final package path.
import 'package:flutter/foundation.dart';
import 'command.dart';
import 'result.dart';
class TodoViewModel extends ChangeNotifier {
Command0<void> get loadTodos => _loadTodosCommand;
late final _loadTodosCommand = Command0<void>(_loadTodos);
Future<Result<void>> _loadTodos() async {
// Load todos from repository
return Result.ok(null);
}
}In Your Widget
ListenableBuilder(
listenable: viewModel.loadTodos,
builder: (context, child) {
if (viewModel.loadTodos.running) {
return CircularProgressIndicator();
}
return ElevatedButton(
onPressed: viewModel.loadTodos.execute,
child: Text('Load Todos'),
);
},
);Using Result Type
The Result type provides type-safe error handling.
Future<Result<User>> fetchUser(String id) async {
try {
final user = await repository.getUser(id);
return Result.ok(user);
} catch (e) {
return Result.error(Exception('Failed: $e'));
}
}
// Handle result
final result = await fetchUser('123');
switch (result) {
case Ok():
print('User: ${result.value.name}');
case Error():
print('Error: ${result.error}');
}Repository Pattern Example
class TodoRepository {
final ApiService _api;
final DatabaseService _database;
Stream<List<Todo>> get todos => _database.todosStream;
TodoRepository(this._api, this._database);
Future<Result<Todo>> addTodo(Todo todo) async {
try {
final savedTodo = await _api.createTodo(todo);
await _database.saveTodo(savedTodo);
return Result.ok(savedTodo);
} catch (e) {
return Result.error(e is Exception ? e : Exception(e.toString()));
}
}
}Optimistic UI Example
Update UI immediately, then sync. The repository returns the server-confirmed Todo so the temporary item can be replaced safely:
Future<Result<Todo>> addTodo(Todo todo) async {
// Optimistic update
_todos = [..._todos, todo];
notifyListeners();
// Actual API call
final result = await repository.addTodo(todo);
if (result case Error()) {
// Rollback on error
_todos = _todos.where((t) => t.id != todo.id).toList();
notifyListeners();
return result;
}
final serverTodo = result.asOk.value;
_todos = _todos.map((t) => t.id == todo.id ? serverTodo : t).toList();
notifyListeners();
return Result.ok(serverTodo);
}Dependency Injection
Provide dependencies via constructor:
class TodoViewModel extends ChangeNotifier {
final TodoRepository _repository;
TodoViewModel(this._repository);
}
// In main.dart
final apiService = ApiService();
final databaseService = DatabaseService();
final repository = TodoRepository(apiService, databaseService);
final viewModel = TodoViewModel(repository);// Template asset: copy or adapt this only when the project does not already
// have an equivalent typed result/error model.
/// Utility class to wrap result data
///
/// Evaluate the result using a switch statement:
/// ```dart
/// switch (result) {
/// case Ok():
/// print(result.value);
/// case Error():
/// print(result.error);
/// }
/// ```
sealed class Result<T> {
const Result();
/// Creates an instance of Result containing a value
factory Result.ok(T value) => Ok(value);
/// Create an instance of Result containing an error
factory Result.error(Exception error) => Error(error);
/// Convenience method to cast to Ok
Ok<T> get asOk => this as Ok<T>;
/// Convenience method to cast to Error
Error<T> get asError => this as Error<T>;
}
/// Subclass of Result for values
final class Ok<T> extends Result<T> {
const Ok(this.value);
/// Returned value in result
final T value;
@override
String toString() => 'Result<$T>.ok($value)';
}
/// Subclass of Result for errors
final class Error<T> extends Result<T> {
const Error(this.error);
/// Returned error in result
final Exception error;
@override
String toString() => 'Result<$T>.error($error)';
}
Architecture Concepts
Core architectural principles for Flutter applications.
Separation of Concerns
Divide application functionality into distinct, self-contained units. Separate UI logic from business logic.
In Flutter:
- Separate widgets by functionality (authentication vs search logic)
- Write reusable, lean widgets with minimal logic
- Keep widgets focused on presentation only
Layered Architecture
Organize app into 2-3 distinct layers:
UI Layer
Displays data to users and handles user interaction. Also called the presentation layer.
Logic Layer (Domain Layer - Optional)
Implements core business logic, facilitates interaction between data and UI layers. Only needed for apps with complex client-side business logic.
Data Layer
Manages interactions with data sources (databases, APIs, platform plugins). Exposes data and methods to business logic layer.
Lower layers should not depend on upper layers. ViewModels in the UI layer may call Repositories directly for simple flows, or go through optional Domain Use-cases when business logic is complex, reused, or spans multiple Repositories. Views should not call Services directly.
Single Source of Truth (SSOT)
Every data type has one authoritative source responsible for representing local or remote state. The SSOT class should be the only class that can modify the data.
Benefits:
- Reduces bugs
- Simplifies code (one copy of data)
- Prevents sync issues
In Flutter, SSOT is typically held in a Repository class in the data layer. One repository per data type.
Apply SSOT across layers and within classes:
- Use getters to derive values from SSOT field
- Use records to group related values instead of parallel lists
Unidirectional Data Flow (UDF)
Decouple state from UI that displays it. State flows from data → logic → UI. User events flow from UI → logic → data.
Update cycle: 1. UI: User interaction triggers event handler 2. Logic: Logic class calls repository methods 3. Data: Repository updates data and provides new data 4. Logic: Logic class saves state, sends to UI 5. UI: UI displays new state
Data can also originate from data layer (e.g., repository polling HTTP server). Key principle: data changes always happen in SSOT (data layer).
UI is a Function of State
Flutter is declarative - UI reflects current app state. When state changes, trigger UI rebuild.
Key principles:
- Data drives UI, not the other way
- Data should be immutable and persistent
- Views contain minimal logic
- Minimizes data loss during app closure
- Improves testability and bug resistance
Extensibility
Each architecture piece has well-defined inputs and outputs. Example: ViewModel takes data sources (repositories) as inputs, exposes commands and formatted data for views.
Benefits:
- Swap implementations without changing consuming code
- Test components in isolation
- Easy to add new features
Testability
Principles making software extensible also make it testable:
- Test self-contained logic (ViewModel) by mocking Repository
- Test UI logic separate from Flutter widgets
- Add new logic/UI with low risk and high flexibility
Design Patterns
Common design patterns for building robust Flutter applications.
Command Pattern
Encapsulates actions as objects with state management and Result handling.
Purpose
- Encapsulate complex logic
- Expose running and error states
- Prevent multiple simultaneous executions
- Type-safe error handling with Result type
Implementation
See command.dart asset for template implementation. Adapt imports and naming to the target project before copying it into app code.
Usage:
class MyViewModel extends ChangeNotifier {
// Command without arguments
Command0<void> get loadData => _loadDataCommand;
late final _loadDataCommand = Command0<void>(_loadData);
// Command with one argument
Command1<void, String> get saveItem => _saveCommand;
late final _saveCommand = Command1<void, String>(_saveItem);
Future<Result<void>> _loadData() async {
// Logic here
return Result.ok(null);
}
Future<Result<void>> _saveItem(String item) async {
// Logic here
return Result.ok(null);
}
}In View:
ListenableBuilder(
listenable: viewModel.loadData,
builder: (context, child) {
if (viewModel.loadData.running) {
return CircularProgressIndicator();
}
if (viewModel.loadData.error) {
return Text('Error: ${viewModel.loadData.result?.asError.error}');
}
return ElevatedButton(
onPressed: () => viewModel.loadData.execute(),
child: Text('Load Data'),
);
},
);Benefits
- Prevents double-taps
- Exposes loading, error, success states
- Type-safe Result handling
- Encapsulates complex logic
Result Type
Type-safe error handling without exceptions.
Purpose
- Represent success or failure states
- Type-safe error handling
- Avoid try-catch blocks
- Clear error propagation
Implementation
See result.dart asset for template implementation. Use it only when the app does not already have an equivalent typed error/result abstraction.
Usage:
Future<Result<User>> fetchUser(String id) async {
try {
final response = await http.get('/users/$id');
final user = User.fromJson(response.body);
return Result.ok(user);
} catch (e) {
return Result.error(Exception('Failed to fetch user: $e'));
}
}
// Consume result
final result = await fetchUser('123');
switch (result) {
case Ok():
print('User: ${result.value.name}');
case Error():
print('Error: ${result.error}');
}Benefits
- Explicit error handling
- Type-safe
- No uncaught exceptions
- Clear state representation
Repository Pattern
Abstraction over data sources providing single source of truth.
Purpose
- Hide data source complexity
- Provide single source of truth
- Enable caching and offline support
- Facilitate testing
Implementation
class TodoRepository {
final ApiService _apiService;
final DatabaseService _databaseService;
final BehaviorSubject<List<Todo>> _todos = BehaviorSubject.seeded([]);
Stream<List<Todo>> get todos => _todos.stream;
TodoRepository(this._apiService, this._databaseService) {
_loadTodos();
}
Future<void> _loadTodos() async {
try {
final todos = await _databaseService.getTodos();
_todos.add(todos);
_refreshFromServer();
} catch (e) {
// Handle error
}
}
Future<void> _refreshFromServer() async {
try {
final todos = await _apiService.fetchTodos();
await _databaseService.saveTodos(todos);
_todos.add(todos);
} catch (e) {
// Keep cached data on network error
}
}
Future<Result<Todo>> addTodo(Todo todo) async {
try {
final savedTodo = await _apiService.createTodo(todo);
await _databaseService.saveTodo(savedTodo);
final updated = [..._todos.value, savedTodo];
_todos.add(updated);
return Result.ok(savedTodo);
} catch (e) {
return Result.error(e is Exception ? e : Exception(e.toString()));
}
}
}Benefits
- Unified data access interface
- Caching layer
- Error handling centralized
- Easy to mock for testing
Optimistic UI
Update UI immediately, then sync with server.
Purpose
- Improve perceived performance
- Better user experience
- Handle network delays gracefully
Implementation
class TodoViewModel extends ChangeNotifier {
final TodoRepository _repository;
List<Todo> _todos = [];
TodoViewModel(this._repository);
List<Todo> get todos => _todos;
Command0<Todo> get addTodo => _addTodoCommand;
late final _addTodoCommand = Command0<Todo>(_executeAddTodo);
Future<Result<Todo>> _executeAddTodo() async {
// Optimistic update
final tempTodo = Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: 'New Todo',
isComplete: false,
);
_todos = [..._todos, tempTodo];
notifyListeners();
// Actual API call
final result = await _repository.addTodo(tempTodo);
if (result case Error()) {
// Rollback on error
_todos = _todos.where((t) => t.id != tempTodo.id).toList();
notifyListeners();
return result;
}
// Update with server response
final serverTodo = result.asOk.value;
_todos = _todos.map((t) => t.id == tempTodo.id ? serverTodo : t).toList();
notifyListeners();
return Result.ok(serverTodo);
}
}Benefits
- Instant UI response
- Better perceived performance
- Graceful error handling with rollback
Offline-First
Cache data locally, sync when online.
Purpose
- Support offline usage
- Reduce network calls
- Improve performance
- Handle poor connectivity
Key Concepts
- Local cache as primary data source
- Background sync when online
- Optimistic updates with conflict resolution
- Network status monitoring
Implementation Pattern
class TodoRepository {
final ApiService _api;
final LocalStorage _local;
Stream<List<Todo>> get todos {
// Stream from local cache (always available)
return _local.todosStream;
}
Future<void> sync() async {
if (!await _hasConnection()) return;
final remote = await _api.getTodos();
// Conflict resolution: remote wins
await _local.saveTodos(remote);
}
Future<Result<Todo>> addTodo(Todo todo) async {
// Save locally first (offline support)
await _local.saveTodo(todo);
// Sync when online
if (await _hasConnection()) {
final result = await _api.createTodo(todo);
switch (result) {
case Ok<Todo>():
await _local.saveTodo(result.value);
return result;
case Error<Todo>():
return result;
}
}
return Result.ok(todo);
}
}Dependency Injection
Provide dependencies externally rather than creating internally.
Purpose
- Testability (inject mocks)
- Loose coupling
- Flexibility (swap implementations)
- Clear dependencies
Implementation Patterns
Constructor Injection:
class TodoViewModel extends ChangeNotifier {
final TodoRepository _repository;
TodoViewModel(this._repository);
}Provider Setup:
void main() {
final apiService = ApiService();
final databaseService = DatabaseService();
final repository = TodoRepository(apiService, databaseService);
final viewModel = TodoViewModel(repository);
runApp(
MultiProvider(
providers: [
Provider<TodoRepository>.value(value: repository),
ChangeNotifierProvider<TodoViewModel>.value(value: viewModel),
],
child: MyApp(),
),
);
}Usage:
class TodoScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final viewModel = context.watch<TodoViewModel>();
// Use viewModel
}
}Benefits
- Easy testing with mocks
- Clear dependency graph
- Flexible implementation swapping
- Follows inversion of control principle
Key-Value Storage
Simple data persistence for app settings and small data.
Purpose
- Store app settings
- Persist user preferences
- Cache small data
Implementation (SharedPreferences)
class SettingsRepository {
final SharedPreferences _prefs;
SettingsRepository(this._prefs);
static const _themeKey = 'theme_mode';
static const _languageKey = 'language';
ThemeMode get themeMode {
final value = _prefs.getString(_themeKey);
return value == 'dark' ? ThemeMode.dark : ThemeMode.light;
}
Future<void> setThemeMode(ThemeMode mode) async {
await _prefs.setString(_themeKey, mode == ThemeMode.dark ? 'dark' : 'light');
}
String get language => _prefs.getString(_languageKey) ?? 'en';
Future<void> setLanguage(String lang) async {
await _prefs.setString(_languageKey, lang);
}
}SQL Database
Structured data storage for complex data models.
Purpose
- Store complex relational data
- Query and filter efficiently
- Transaction support
- Offline-first data layer
Implementation (sqflite)
class TodoDatabase {
static const _tableName = 'todos';
static const _id = 'id';
static const _title = 'title';
static const _isComplete = 'is_complete';
final Database _database;
TodoDatabase(this._database);
Future<void> init() async {
await _database.execute('''
CREATE TABLE $_tableName (
$_id TEXT PRIMARY KEY,
$_title TEXT NOT NULL,
$_isComplete INTEGER NOT NULL
)
''');
}
Future<List<Todo>> getTodos() async {
final results = await _database.query(_tableName);
return results.map((map) => Todo.fromJson(map)).toList();
}
Future<void> saveTodo(Todo todo) async {
await _database.insert(
_tableName,
todo.toJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> deleteTodo(String id) async {
await _database.delete(
_tableName,
where: '$_id = ?',
whereArgs: [id],
);
}
}When to Use Each Pattern
| Pattern | Use When |
|---|---|
| Command | Encapsulating user actions, need loading/error states |
| Result | Type-safe error handling, avoiding exceptions |
| Repository | Abstracting data sources, need caching |
| Optimistic UI | Instant UI response, network latency issues |
| Offline-First | Poor connectivity, need offline support |
| Dependency Injection | Testing, loose coupling |
| Key-Value Storage | App settings, small data, preferences |
| SQL Database | Complex data, queries, relationships |
Feature-First Architecture
Organize code by features instead of layers for better maintainability and team scalability.
Overview
Feature-first organizes code by business features, with each feature containing all layers needed for that feature.
lib/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ └── services/
│ │ ├── domain/
│ │ │ ├── models/
│ │ │ └── use-cases/
│ │ └── presentation/
│ │ ├── views/
│ │ └── viewmodels/
│ ├── todos/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── settings/
│ ├── data/
│ ├── domain/
│ └── presentation/
├── shared/
│ ├── core/
│ │ ├── constants/
│ │ ├── theme/
│ │ └── utils/
│ ├── data/
│ │ ├── models/
│ │ └── services/
│ └── ui/
│ ├── widgets/
│ └── components/
└── main.dartFeature-First vs Layer-First
Layer-First (Traditional)
lib/
├── data/
│ ├── repositories/
│ ├── services/
│ └── models/
├── domain/
│ ├── use-cases/
│ └── entities/
├── presentation/
│ ├── views/
│ └── viewmodels/
└── shared/When to use:
- Small to medium apps
- Few features (<10)
- Solo developers or small teams
- Simple business logic
Pros:
- Clear separation by layer
- Easy to find components by type
- Less nesting
Cons:
- Harder to delete features
- Changes often span multiple folders
- Tangled dependencies between layers
Feature-First (Recommended for teams)
lib/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── todos/
│ ├── data/
│ ├── domain/
│ └── presentation/
└── shared/When to use:
- Medium to large apps
- Many features (10+)
- Team development (2+ developers)
- Complex business logic
- Frequently adding/removing features
Pros:
- Features are self-contained units
- Easy to add/remove entire features
- Clear feature boundaries
- Reduced merge conflicts
- Teams work independently on features
Cons:
- More nesting in folder structure
- Shared code requires careful organization
- Can duplicate similar code across features
Feature Structure
Each feature contains all layers for that feature:
features/
└── todos/
├── data/
│ ├── repositories/
│ │ └── todo_repository.dart
│ └── services/
│ └── todo_api_service.dart
├── domain/
│ ├── models/
│ │ └── todo.dart
│ └── use-cases/
│ └── fetch_todos.dart
│ └── create_todo.dart
│ └── delete_todo.dart
└── presentation/
├── views/
│ └── todo_list_view.dart
└── viewmodels/
└── todo_list_viewmodel.dartNaming Conventions
Feature folders: Plural, lowercase, underscore-separated
auth/,todos/,user_profile/
Component folders: Plural, lowercase
data/,domain/,presentation/repositories/,services/,models/views/,viewmodels/
Files: snake_case for regular files, camelCase for classes
todo_repository.dart→class TodoRepositorytodo_list_view.dart→class TodoListViewfetch_todos.dart→class FetchTodos
Shared Code
Code used across multiple features goes in shared/:
shared/
├── core/
│ ├── constants/
│ │ └── app_constants.dart
│ ├── theme/
│ │ ├── app_colors.dart
│ │ └── app_text_styles.dart
│ └── utils/
│ ├── extensions.dart
│ ├── validators.dart
│ └── formatters.dart
├── data/
│ ├── models/
│ │ └── common_models.dart
│ └── services/
│ ├── network_service.dart
│ ├── local_storage_service.dart
│ └── analytics_service.dart
└── ui/
├── widgets/
│ ├── app_scaffold.dart
│ ├── loading_indicator.dart
│ └── error_widget.dart
└── components/
├── app_button.dart
├── app_text_field.dart
└── app_card.dartWhat goes in shared/core/
- Constants: App-wide constants (API URLs, timeouts)
- Theme: Colors, text styles, themes
- Utils: Extensions, validators, formatters used across features
What goes in shared/data/
- Models: Domain models used by multiple features (User, etc.)
- Services: Platform services (network, storage, analytics)
What goes in shared/ui/
- Widgets: Reusable UI components (scaffolds, indicators)
- Components: Reusable UI elements (buttons, cards, inputs)
Dependency Rules
Within a Feature
presentation/ -> domain/ -> data/
presentation/ -> data/ // simple flows can skip domain
presentation/ -> shared/ui/
data/ -> shared/data/- Presentation ViewModels can use Domain, Data for simple flows, and
Shared/UI. Views should still talk to ViewModels, not Services.
- Domain layer can use feature data abstractions and shared pure utilities.
- Data layer can use Shared/Data services or models.
- Domain layer is optional. Add use-cases only for complex, reused, or
multi-repository business logic.
Between Features
Features should NOT import each other's implementation files.
If Feature A needs Feature B's functionality: 1. Move common logic to shared/ 2. Use dependency injection with a stable interface 3. Consider if features should be merged
// ❌ BAD: Feature depending on another feature
import 'package:my_app/features/auth/data/repositories/auth_repository.dart';
// ✅ GOOD: Using shared service
import 'package:my_app/shared/data/services/auth_service.dart';
// ✅ GOOD: Depending on a stable shared interface provided through DI
import 'package:my_app/shared/data/auth_session.dart';When to Add a New Feature
1. Create feature folder:
features/new_feature/
├── data/
└── presentation/2. Add subfolders based on complexity:
- Simple: Skip
domain/, use onlydata/andpresentation/ - Complex: Add
domain/for use-cases
3. Implement bottom-up:
- Add models in
data/ordomain/models/ - Add services in
data/services/ - Add repositories in
data/repositories/ - Add viewmodels in
presentation/viewmodels/ - Add views in
presentation/views/
4. Add exports:
// features/new_feature/new_feature.dart
export 'data/repositories/new_feature_repository.dart';
export 'presentation/views/new_feature_view.dart';
export 'presentation/viewmodels/new_feature_viewmodel.dart';5. Register in DI container (main.dart)
Best Practices
1. Barrel Files
Export feature contents from barrel file:
// features/todos/todos.dart
library todos;
export 'data/repositories/todo_repository.dart';
export 'domain/models/todo.dart';
export 'domain/use-cases/fetch_todos.dart';
export 'presentation/views/todo_list_view.dart';
export 'presentation/viewmodels/todo_list_viewmodel.dart';Usage:
import 'package:my_app/features/todos/todos.dart';
// Now have access to Todo, TodoRepository, etc.2. Lazy Loading
For large apps, use lazy loading:
// In router
GoRoute(
path: '/todos',
builder: (context, state) => const LazyLoad(
builder: () => const TodosView(),
),
);3. Feature Scoping
Keep feature dependencies minimal:
- Use
shared/for common code - Avoid imports from another feature's
data/orpresentation/ - When needed, use dependency inversion through an interface
4. Testing
Each feature is testable independently:
features/
└── todos/
├── data/
│ └── repositories/
│ └── todo_repository_test.dart
├── domain/
│ └── use-cases/
│ └── fetch_todos_test.dart
└── presentation/
└── viewmodels/
└── todo_list_viewmodel_test.dartMigration from Layer-First
Gradual migration strategy:
1. Start with new features:
- Add new features using feature-first
- Keep old code in layer-first
2. Migrate one feature at a time:
// Before (layer-first)
data/
├── todo_repository.dart
└── user_repository.dart
presentation/
├── todo_view.dart
└── user_view.dart
// After (feature-first)
features/
├── todos/
│ ├── data/repositories/todo_repository.dart
│ └── presentation/views/todo_view.dart
└── users/
├── data/repositories/user_repository.dart
└── presentation/views/user_view.dart3. Move shared code to `shared/`:
- Identify cross-feature code
- Move to appropriate
shared/folder - Update imports
4. Update barrel files for clean imports
Example: Complete Feature
features/
└── todos/
├── todos.dart // Barrel file
├── data/
│ ├── models/
│ │ └── todo_model.dart // DTOs from API
│ ├── repositories/
│ │ └── todo_repository.dart // SSOT for todos
│ └── services/
│ └── todo_api_service.dart // API calls
├── domain/
│ ├── models/
│ │ └── todo.dart // Domain entity
│ └── use-cases/
│ ├── fetch_todos.dart
│ ├── create_todo.dart
│ └── update_todo.dart
│ └── delete_todo.dart
└── presentation/
├── viewmodels/
│ └── todo_list_viewmodel.dart
└── views/
└── todo_list_view.dartThis structure provides:
- Complete feature encapsulation
- Clear layer separation within feature
- Easy to add/remove entire feature
- Minimal dependencies on other features
Architecture Layers
Detailed guide to layer responsibilities and interactions in Flutter apps.
Layer Overview
┌─────────────────────────────────────┐
│ UI Layer │
│ ┌────────────┐ ┌─────────────┐ │
│ │ Views │ │ ViewModels │ │
│ └────────────┘ └─────────────┘ │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Domain Layer (Optional) │
│ ┌─────────────────────┐ │
│ │ Use-cases │ │
│ └─────────────────────┘ │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Data Layer │
│ ┌────────────┐ ┌─────────────┐ │
│ │Repositories │ │ Services │ │
│ └────────────┘ └─────────────┘ │
└─────────────────────────────────────┘UI Layer
Responsibility: Interact with user, display data, receive input.
Views
What they do:
- Compose widgets to present data
- Handle user interactions (taps, form inputs)
- Pass events to ViewModel
- Re-render when ViewModel state changes
What they don't do:
- Business logic
- Data transformation
- State persistence
- API calls
Logic allowed in Views:
- Simple if-statements for conditional rendering
- Animation logic
- Layout logic (responsive design, orientation)
- Simple routing
ViewModels
What they do:
- Transform repository data into UI state
- Maintain UI state (survives configuration changes)
- Expose commands for user actions
- Aggregate data from multiple repositories
What they don't do:
- Direct UI manipulation
- Platform API calls
- File I/O
- Network requests directly (use repositories)
State management:
- Use ChangeNotifier for simple apps
- Consider Provider, Riverpod, or Bloc for complex apps
- Expose state as Streams or ChangeNotifiers
- Call Repositories directly for simple flows; introduce Use-cases only when
business logic is complex, reused, or spans multiple Repositories.
Domain Layer (Optional)
Purpose: Abstract complex business logic from ViewModels.
When to add Domain Layer
Add when ViewModels have logic that: 1. Merges data from multiple repositories 2. Is exceedingly complex 3. Will be reused by different ViewModels
Use-cases
What they do:
- Take data from repositories
- Transform for UI layer consumption
- Encapsulate reusable business logic
Relationships:
- Use-cases depend on Repositories
- Use-cases and Repositories: many-to-many
- ViewModels may depend on Use-cases for complex operations and on Repositories
directly for simple operations.
Pros:
- Avoid code duplication in ViewModels
- Improve testability by separating complex logic
- Improve code readability in ViewModels
Cons:
- Increases complexity
- Additional mocks for testing
- Adds boilerplate code
Recommendation: Add use-cases only when needed. Don't force all data access through use-cases for simple operations.
Data Layer
Responsibility: Handle business data and logic.
Repositories
What they do:
- Single source of truth for data types
- Poll data from Services
- Transform raw data into domain models
- Handle business logic:
- Caching
- Error handling
- Retry logic
- Data refresh (polling, user-triggered)
What they don't do:
- Direct UI rendering
- Business logic better suited for Domain layer
- Direct knowledge of other repositories
Output:
- Domain models (data classes tailored for app needs)
- Exposed as Streams (real-time) or Futures (one-time)
Relationships:
- Many-to-many with ViewModels
- Many-to-many with Services
- Should not be aware of each other
Services
What they do:
- Wrap external data sources
- Expose async response objects (Future, Stream)
- Isolate data loading
- One service per data source
What they don't do:
- Hold state (stateless)
- Business logic
- Data transformation
- Caching
Examples:
- Platform APIs (iOS/Android native)
- REST/GraphQL API clients
- Local file access
- Database access
- Platform plugins (geolocation, camera, etc.)
Relationships:
- Many-to-many with Repositories
- Can be shared across the app
Layer Communication Rules
1. Lower layers stay independent: Data doesn't depend on Domain or UI; Domain doesn't depend on UI. 2. Unidirectional data flow: Data -> ViewModel -> View. 3. Events flow opposite: View -> ViewModel -> Repository -> Service, or View -> ViewModel -> Use-case -> Repository -> Service when a Use-case is justified. 4. No direct service calls from UI: Views and ViewModels don't call Services directly. 5. No forced Domain layer: Skipping Use-cases is valid for simple logic.
When to Use Which Layer
Small CRUD apps:
- UI Layer (Views + ViewModels)
- Data Layer (Repositories + Services)
- Skip Domain Layer
Apps with complex business logic:
- UI Layer
- Domain Layer (for complex, reusable logic)
- Data Layer
- ViewModels may use Repositories directly for simple operations, Use-cases for complex
Enterprise apps with complex rules:
- All three layers
- Consider enforcing Use-cases for all data access
Testing Strategy
UI Layer Tests:
- Widget tests for Views
- Unit tests for ViewModels (mock Repositories)
Domain Layer Tests:
- Unit tests for Use-cases (mock Repositories)
Data Layer Tests:
- Unit tests for Repositories (mock Services)
- Integration tests for Services (actual APIs/databases)
MVVM Pattern
Model-View-ViewModel architectural pattern for Flutter applications.
Overview
MVVM separates application features into three parts:
- Model: Data and business logic (Repositories, Services)
- View: UI presentation (Widgets)
- ViewModel: UI logic and state management
Component Relationships
Every screen or user flow usually contains:
- One View (UI)
- One ViewModel (UI logic)
- One or more Repositories (data sources)
- Zero or more Services (external API access)
- Zero or more Use-cases for complex or reused business logic
Views and ViewModels normally have a one-to-one relationship at the screen or flow level. A View may be composed of many smaller widgets.
View Layer
Views (Widgets)
- Compose widgets to display UI
- Pass events to ViewModel via commands
- Receive data from ViewModel
- Contain minimal logic:
- Simple if-statements for conditional rendering
- Animation logic
- Layout logic based on device info
- Simple routing logic
Important: A View is not a single widget. Views are collections of widgets. One view may contain many widgets. ViewModels have one-to-one relationship with views, not individual widgets.
ViewModels
- Transform repository data into UI state
- Maintain current UI state for rebuilds
- Expose commands (callback functions) for user actions
- Hold state that survives configuration changes
Responsibilities:
- Retrieve application data from repositories
- Filter, sort, aggregate data for presentation
- Track UI state (flags, carousel positions, etc.)
- Expose commands for button presses, form submissions, etc.
- Call repositories directly for simple operations, or call use-cases when the
logic is complex, reused, or spans multiple repositories.
Model Layer
Repositories
Single source of truth for model data. Each data type has one repository class.
Responsibilities:
- Poll data from services
- Transform raw data into domain models
- Handle business logic:
- Caching
- Error handling
- Retry logic
- Refreshing data (polling, user actions)
Output: Domain models as Streams or Futures
Relationships:
- Many-to-many with ViewModels
- One ViewModel can use multiple Repositories
- One Repository can be used by multiple ViewModels
- Repositories should never be aware of each other
Use-cases (Optional)
Use-cases sit between ViewModels and Repositories only when they reduce duplication or isolate complex business rules. Do not add them to simple CRUD flows just to fill a layer.
Services
Lowest layer, wrap API endpoints and expose async response objects.
Responsibilities:
- Isolate data-loading
- Stateless (no state held)
- One service per data source
Examples:
- Platform APIs (iOS, Android)
- REST endpoints
- Local files
- Databases
Relationships:
- Many-to-many with Repositories
- One Repository can use multiple Services
- One Service can be used by multiple Repositories
Data Flow
User Interaction Flow
1. View: User interaction triggers event 2. View: Event handler calls ViewModel command 3. ViewModel: Command calls Repository directly, or a Use-case when justified 4. Repository: Updates data and returns new data 5. ViewModel: Saves new state 6. View: UI rebuilds with new state
Data-Originated Flow
1. Repository: Polls service for new data 2. Repository: Updates data 3. ViewModel: Receives new data from Repository 4. View: UI rebuilds with new state
Benefits
- Testability: Test ViewModel logic by mocking Repositories
- Maintainability: Clear separation of concerns
- Scalability: Easy to add features without breaking existing code
- Reusability: Components have well-defined interfaces
Related skills
How it compares
Pick flutter-architecture over generic state-management guides when you need copy-paste Command templates tied to a Result type rather than framework-specific provider patterns.
FAQ
What does flutter-architecture do?
>-
When should I use flutter-architecture?
When you need flutter architecture help per SKILL.md.
Is flutter-architecture safe to install?
Review the Security Audits panel on this page before installing in production.