skills/flutter-architecture/references/design-patterns.md

10 KiB

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