PFA emphasizes pragmatism over dogma, focusing on deliverable apps with easy exploration, testability, scalability, and minimal boilerplate while keeping Flutter code recognizable.
"Flutter code should still look like Flutter code" - avoid over-engineering and maintain Flutter's natural patterns.
Purpose: Encapsulate external dependencies (APIs, databases, OS services, hardware)
Rules:
- Convert data formats between external sources and app domain
- NEVER independently modify app state
- Pure data transformation and I/O operations
- Examples:
ApiService,HiveStorageService,FirebaseService
Example:
class HiveStorageService {
Future<List<TodoDTO>> getAllTodos() async { ... }
Future<void> saveTodo(TodoDTO todo) async { ... }
Future<void> deleteTodo(String id) async { ... }
}Purpose: Semantically-grouped business logic
Rules:
- Implement CRUD operations and business rules
- Use Services or other Managers
- Expose Commands (using command_it) for state-modifying operations
- Expose ValueListenables/Streams for reactive data
- Accessed via service locator (get_it)
- Examples:
TodoManager,AuthManager,SyncManager
Example:
class TodoManager {
final HiveStorageService _storage;
// Reactive data
final ValueNotifier<List<Todo>> todos = ValueNotifier([]);
// Commands for operations
late Command<Todo, void> addTodoCommand;
late Command<String, void> deleteTodoCommand;
late Command<Todo, void> updateTodoCommand;
TodoManager(this._storage) {
addTodoCommand = Command.createAsync(_addTodo);
deleteTodoCommand = Command.createAsync(_deleteTodo);
updateTodoCommand = Command.createAsync(_updateTodo);
}
Future<void> _addTodo(Todo todo) async {
await _storage.saveTodo(todo.toDTO());
await _loadTodos();
}
}Purpose: Full-page widgets that are "self-responsible"
Rules:
- Know what data they need
- Read data from Managers/Services
- Modify state ONLY through Manager-provided Commands
- Use
WatchingWidgetfrom watch_it for reactive rebuilds - Avoid unrelated state changes
- Examples:
TodoListView,TodoDetailView,SettingsView
Example:
class TodoListView extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Watch reactive data
final todos = watchPropertyValue((TodoManager m) => m.todos.value);
final isLoading = watchValue((TodoManager m) => m.loadTodosCommand.isExecuting);
// Access command for operations
final deleteCommand = di<TodoManager>().deleteTodoCommand;
return Scaffold(
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo.title),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => deleteCommand(todo.id),
),
);
},
),
);
}
}- Built on top of get_it service locator
- Enables reactive rebuilds in StatelessWidgets
- Eliminates verbose builder patterns
- Observes Listenable, ValueListenable, Stream, and Future
| Method | Use Case |
|---|---|
watch() |
Observe any Listenable directly |
watchIt() |
Observe Listenable registered in get_it |
watchValue() |
Target ValueListenable properties in get_it objects |
watchPropertyValue() |
Watch specific properties (most efficient) |
watchStream() |
Observe Stream emissions |
watchFuture() |
Observe Future completion |
- ONLY call watch methods within
build() - Call in the SAME ORDER every build (no conditionals around watch calls)
- NEVER use inside nested builders
- Use
WatchingWidgetbase class for all reactive views
registerHandler(
select: (TodoManager m) => m.addTodoCommand.errors,
handler: (context, error, cancel) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${error.error}')),
);
}
);Wrap functions with automatic state tracking (execution state, errors, results)
- isExecuting: Tracks if function is running (for loading indicators)
- canExecute: Enables/disables based on conditions
- results: Complete execution results including parameters, output, errors
- errors: Exception handling
// Sync with no parameters
Command.createSyncNoParam(() { ... }, initialValue);
// Sync with parameter
Command.createSync((param) { ... }, initialValue);
// Async with no parameters
Command.createAsyncNoParam(() async { ... }, initialValue);
// Async with parameter
Command.createAsync((param) async { ... }, initialValue);CommandBuilder<String, List<Todo>>(
command: todoManager.loadTodosCommand,
whileExecuting: (context, _) => CircularProgressIndicator(),
onData: (context, todos, _) => TodoList(todos: todos),
onError: (context, error, _) => ErrorWidget(error: error),
)Represent real-world concepts with business logic:
class Todo {
final String id;
final String title;
final String description;
final bool isCompleted;
final DateTime createdAt;
Todo copyWith({...}) { ... }
TodoDTO toDTO() { ... }
}Generated from storage schemas (Hive, Firebase):
@HiveType(typeId: 0)
class TodoDTO {
@HiveField(0)
final String id;
@HiveField(1)
final String title;
Todo toDomain() { ... }
}import 'package:get_it/get_it.dart';
import 'package:watch_it/watch_it.dart';
// watch_it provides 'di' as global GetIt instance
// final di = GetIt.instance; // Already provided by watch_it
void setupLocator() {
// Services (Singletons)
di.registerLazySingleton<HiveStorageService>(() => HiveStorageService());
// Managers (Singletons with dependencies)
di.registerLazySingleton<TodoManager>(
() => TodoManager(di<HiveStorageService>()),
);
// For testing, use di.pushNewScope() and register fakes
}// In code
final todoManager = di<TodoManager>();
// In widgets with watch_it
final todos = watchPropertyValue((TodoManager m) => m.todos.value);lib/
├── main.dart
├── locator.dart # Dependency injection setup
├── features/
│ ├── todos/
│ │ ├── views/
│ │ │ ├── todo_list_view.dart
│ │ │ ├── todo_detail_view.dart
│ │ │ └── widgets/
│ │ │ ├── todo_item.dart
│ │ │ └── todo_form.dart
│ │ ├── managers/
│ │ │ └── todo_manager.dart
│ │ └── models/
│ │ ├── todo.dart
│ │ └── todo_dto.dart
│ ├── settings/
│ │ ├── views/
│ │ │ └── settings_view.dart
│ │ └── managers/
│ │ └── settings_manager.dart
├── services/
│ ├── storage/
│ │ ├── hive_storage_service.dart
│ │ └── firebase_storage_service.dart
│ └── sync/
│ └── sync_service.dart
└── shared/
├── widgets/
│ └── loading_indicator.dart
└── utils/
└── extensions.dart
- ALWAYS modify state through Commands
- NEVER directly modify state from widgets
- Use
canExecuteto prevent invalid operations
// In Manager
addTodoCommand = Command.createAsync(
_addTodo,
catchAlways: true, // Catch all exceptions
);
// In View
registerHandler(
select: (TodoManager m) => m.addTodoCommand.errors,
handler: (context, error, cancel) {
// Show error to user without rebuilding widget
showErrorDialog(context, error.error);
},
);// Watch command execution state
final isLoading = watchValue((TodoManager m) => m.loadTodosCommand.isExecuting);
if (isLoading) {
return CircularProgressIndicator();
}void main() {
setUp(() {
// Create test scope
di.pushNewScope();
// Register mocks
di.registerSingleton<HiveStorageService>(MockHiveService());
di.registerSingleton<TodoManager>(TodoManager(di()));
});
tearDown(() {
di.popScope();
});
test('Adding todo should update list', () async {
final manager = di<TodoManager>();
await manager.addTodoCommand(Todo(...));
expect(manager.todos.value.length, 1);
});
}void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive
await Hive.initFlutter();
await Hive.openBox<TodoDTO>('todos');
// Setup dependency injection
setupLocator();
// Wait for async dependencies
await di.allReady();
runApp(MyApp());
}class HiveStorageService {
final Box<TodoDTO> _box = Hive.box('todos');
Future<List<TodoDTO>> getAllTodos() async {
return _box.values.toList();
}
}abstract class StorageService {
Future<List<TodoDTO>> getAllTodos();
Future<void> saveTodo(TodoDTO todo);
Future<void> deleteTodo(String id);
}
class HiveStorageService implements StorageService { ... }
class FirebaseStorageService implements StorageService { ... }// In locator.dart
void setupLocator() {
// Switch between implementations
di.registerLazySingleton<StorageService>(
() => FirebaseStorageService(), // Was: HiveStorageService()
);
}- Never commit sensitive data
- Use environment variables for API keys
- Validate all user inputs
- Sanitize data before storage
- Implement proper authentication with Firebase Auth
- Use
watchPropertyValue()for fine-grained reactivity (most efficient) - Lazy load with
registerLazySingleton() - Dispose commands and notifiers properly
- Use
constconstructors where possible - Implement pagination for large lists
- ❌ Calling watch methods outside
build() - ❌ Conditional watch calls (breaks order requirement)
- ❌ Directly modifying state from widgets
- ❌ Forgetting to call
notifyListeners()in managers - ❌ Mixing state management approaches
- ❌ Creating commands inside
build()method - ❌ Not handling command errors
- ❌ Forgetting to initialize async dependencies before app starts