ADVANCEDarchitecture35 min read

Architectural Patterns for Reactive Applications

Design patterns and architectural approaches for building scalable, maintainable reactive applications

Reactiv TeamUpdated 1/15/2025

📚 Prerequisites

  • Strong understanding of reactive programming
  • Experience building Flutter applications
  • Familiarity with software design patterns
#architecture#design-patterns#mvvm#clean-architecture#state-management#scalability

Architectural Patterns for Reactive Applications

Building scalable reactive applications requires thoughtful architecture. This guide covers proven patterns and approaches for structuring reactive applications.

Table of Contents

Architectural Principles

Core Principles for Reactive Apps

Separation of Concerns

  • UI layer handles presentation only
  • Business logic stays independent of UI
  • Data layer manages persistence and API calls

Unidirectional Data Flow

  • Data flows from repositories → ViewModels → Views
  • User actions flow from Views → ViewModels → Repositories
  • Prevents circular dependencies and race conditions

Reactive by Default

  • State changes propagate automatically
  • No manual update calls needed
  • Streams as the primary communication mechanism

Testability

  • Each layer independently testable
  • Mock dependencies easily
  • Business logic fully unit-testable

MVVM Pattern

Model-View-ViewModel Architecture

// Model: Data structures class User { final String id; final String name; final String email; User({required this.id, required this.name, required this.email}); factory User.fromJson(Map<String, dynamic> json) => User( id: json['id'], name: json['name'], email: json['email'], ); } // ViewModel: Business logic + reactive state class UserViewModel { final UserRepository _repository; // Reactive state final user = ReactiveValue<User?>(null); final isLoading = ReactiveValue<bool>(false); final error = ReactiveValue<String?>(null); // Computed properties late final displayName = ReactiveComputed( () => user.value?.name ?? 'Unknown User', dependencies: [user], ); UserViewModel(this._repository); // Actions Future<void> loadUser(String userId) async { isLoading.value = true; error.value = null; try { final userData = await _repository.getUser(userId); user.value = userData; } catch (e) { error.value = e.toString(); } finally { isLoading.value = false; } } Future<void> updateUserName(String newName) async { if (user.value == null) return; isLoading.value = true; try { final updated = await _repository.updateUser( user.value!.id, name: newName, ); user.value = updated; } catch (e) { error.value = e.toString(); } finally { isLoading.value = false; } } void dispose() { user.dispose(); isLoading.dispose(); error.dispose(); displayName.dispose(); } } // View: UI presentation class UserProfileView extends StatefulWidget { final String userId; const UserProfileView({required this.userId}); State<UserProfileView> createState() => _UserProfileViewState(); } class _UserProfileViewState extends State<UserProfileView> { late final UserViewModel _viewModel; void initState() { super.initState(); _viewModel = UserViewModel( context.read<UserRepository>(), ); _viewModel.loadUser(widget.userId); } void dispose() { _viewModel.dispose(); super.dispose(); } Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: ReactiveBuilder( reactive: _viewModel.displayName, builder: (context, name) => Text(name), ), ), body: ReactiveBuilder( reactive: _viewModel.isLoading, builder: (context, isLoading) { if (isLoading) { return Center(child: CircularProgressIndicator()); } return ReactiveBuilder( reactive: _viewModel.user, builder: (context, user) { if (user == null) { return Center(child: Text('No user data')); } return Column( children: [ Text(user.name), Text(user.email), ElevatedButton( onPressed: () => _showEditDialog(), child: Text('Edit Name'), ), ], ); }, ); }, ), ); } void _showEditDialog() { // Show edit dialog } }

Clean Architecture

Layered Architecture

┌─────────────────────────────────────────┐ │ Presentation Layer │ │ (Widgets, ViewModels, UI State) │ └─────────────────────────────────────────┘ ↓ ↑ ┌─────────────────────────────────────────┐ │ Domain Layer │ │ (Use Cases, Entities, Interfaces) │ └─────────────────────────────────────────┘ ↓ ↑ ┌─────────────────────────────────────────┐ │ Data Layer │ │ (Repositories, Data Sources, Models) │ └─────────────────────────────────────────┘

Implementation

// Domain Layer: Use Cases abstract class UseCase<Type, Params> { Future<Type> call(Params params); } class GetUserUseCase implements UseCase<User, String> { final UserRepository repository; GetUserUseCase(this.repository); Future<User> call(String userId) async { return await repository.getUser(userId); } } class UpdateUserNameUseCase implements UseCase<User, UpdateUserNameParams> { final UserRepository repository; UpdateUserNameUseCase(this.repository); Future<User> call(UpdateUserNameParams params) async { return await repository.updateUser( params.userId, name: params.newName, ); } } class UpdateUserNameParams { final String userId; final String newName; UpdateUserNameParams({required this.userId, required this.newName}); } // Domain Layer: Repository Interface abstract class UserRepository { Future<User> getUser(String id); Future<User> updateUser(String id, {String? name, String? email}); Stream<User> watchUser(String id); } // Data Layer: Repository Implementation class UserRepositoryImpl implements UserRepository { final RemoteDataSource _remoteDataSource; final LocalDataSource _localDataSource; UserRepositoryImpl(this._remoteDataSource, this._localDataSource); Future<User> getUser(String id) async { try { // Try remote first final user = await _remoteDataSource.getUser(id); // Cache locally await _localDataSource.cacheUser(user); return user; } catch (e) { // Fallback to cache return await _localDataSource.getUser(id); } } Future<User> updateUser(String id, {String? name, String? email}) async { final user = await _remoteDataSource.updateUser(id, name: name, email: email); await _localDataSource.cacheUser(user); return user; } Stream<User> watchUser(String id) { return _localDataSource.watchUser(id); } } // Presentation Layer: ViewModel using Use Cases class UserViewModel { final GetUserUseCase _getUserUseCase; final UpdateUserNameUseCase _updateUserNameUseCase; final user = ReactiveValue<User?>(null); final isLoading = ReactiveValue<bool>(false); final error = ReactiveValue<String?>(null); UserViewModel(this._getUserUseCase, this._updateUserNameUseCase); Future<void> loadUser(String userId) async { isLoading.value = true; error.value = null; try { final userData = await _getUserUseCase(userId); user.value = userData; } catch (e) { error.value = e.toString(); } finally { isLoading.value = false; } } Future<void> updateUserName(String userId, String newName) async { isLoading.value = true; try { final updated = await _updateUserNameUseCase( UpdateUserNameParams(userId: userId, newName: newName), ); user.value = updated; } catch (e) { error.value = e.toString(); } finally { isLoading.value = false; } } void dispose() { user.dispose(); isLoading.dispose(); error.dispose(); } }

Repository Pattern

Reactive Repository

class TodoRepository { final _todos = ReactiveList<Todo>([]); final _isLoading = ReactiveValue<bool>(false); // Public reactive state ReactiveList<Todo> get todos => _todos; Stream<List<Todo>> get todosStream => _todos.stream; ReactiveValue<bool> get isLoading => _isLoading; // Data sources final ApiClient _api; final LocalDatabase _db; TodoRepository(this._api, this._db); Future<void> loadTodos() async { _isLoading.value = true; try { // Load from local DB first (instant UI update) final localTodos = await _db.getTodos(); _todos.replaceAll(localTodos); // Then fetch from API (background sync) final remoteTodos = await _api.getTodos(); _todos.replaceAll(remoteTodos); // Save to local DB await _db.saveTodos(remoteTodos); } catch (e) { print('Error loading todos: $e'); } finally { _isLoading.value = false; } } Future<void> addTodo(Todo todo) async { // Optimistic update _todos.add(todo); try { final created = await _api.createTodo(todo); final index = _todos.indexOf(todo); _todos[index] = created; await _db.saveTodo(created); } catch (e) { // Rollback on error _todos.remove(todo); rethrow; } } Future<void> updateTodo(Todo todo) async { final index = _todos.indexWhere((t) => t.id == todo.id); if (index == -1) return; final oldTodo = _todos[index]; _todos[index] = todo; // Optimistic update try { final updated = await _api.updateTodo(todo); _todos[index] = updated; await _db.saveTodo(updated); } catch (e) { _todos[index] = oldTodo; // Rollback rethrow; } } Future<void> deleteTodo(String id) async { final index = _todos.indexWhere((t) => t.id == id); if (index == -1) return; final todo = _todos.removeAt(index); // Optimistic delete try { await _api.deleteTodo(id); await _db.deleteTodo(id); } catch (e) { _todos.insert(index, todo); // Rollback rethrow; } } void dispose() { _todos.dispose(); _isLoading.dispose(); } }

State Management Layers

Application State Architecture

// Global App State class AppState { final auth = AuthState(); final settings = SettingsState(); final connectivity = ConnectivityState(); void dispose() { auth.dispose(); settings.dispose(); connectivity.dispose(); } } // Feature State (e.g., Auth) class AuthState { final currentUser = ReactiveValue<User?>(null); final isAuthenticated = ReactiveValue<bool>(false); late final StreamSubscription _authSubscription; AuthState() { _authSubscription = currentUser.stream .map((user) => user != null) .listen((authenticated) { isAuthenticated.value = authenticated; }); } void dispose() { _authSubscription.cancel(); currentUser.dispose(); isAuthenticated.dispose(); } } // Feature State (e.g., Settings) class SettingsState { final theme = ReactiveValue<ThemeMode>(ThemeMode.system); final notifications = ReactiveValue<bool>(true); void dispose() { theme.dispose(); notifications.dispose(); } } // Provide at app root class MyApp extends StatefulWidget { State<MyApp> createState() => _MyAppState(); } class _MyAppState extends State<MyApp> { final appState = AppState(); void dispose() { appState.dispose(); super.dispose(); } Widget build(BuildContext context) { return Provider.value( value: appState, child: MaterialApp( home: HomePage(), ), ); } }

Dependency Injection

Service Locator Pattern

class ServiceLocator { static final _instance = ServiceLocator._(); static ServiceLocator get instance => _instance; ServiceLocator._(); final _services = <Type, Object>{}; void register<T>(T service) { _services[T] = service; } T get<T>() { final service = _services[T]; if (service == null) { throw Exception('Service of type $T not registered'); } return service as T; } void dispose() { for (final service in _services.values) { if (service is Disposable) { service.dispose(); } } _services.clear(); } } // Setup void setupDependencies() { final sl = ServiceLocator.instance; // Data sources sl.register<ApiClient>(ApiClient()); sl.register<LocalDatabase>(LocalDatabase()); // Repositories sl.register<UserRepository>( UserRepositoryImpl( sl.get<ApiClient>(), sl.get<LocalDatabase>(), ), ); // Use cases sl.register<GetUserUseCase>( GetUserUseCase(sl.get<UserRepository>()), ); }

Feature-Based Structure

Project Organization

lib/ ├── core/ │ ├── di/ │ │ └── service_locator.dart │ ├── reactive/ │ │ └── reactive_extensions.dart │ └── utils/ │ └── error_handler.dart ├── features/ │ ├── auth/ │ │ ├── data/ │ │ │ ├── datasources/ │ │ │ ├── models/ │ │ │ └── repositories/ │ │ ├── domain/ │ │ │ ├── entities/ │ │ │ ├── repositories/ │ │ │ └── usecases/ │ │ └── presentation/ │ │ ├── viewmodels/ │ │ └── widgets/ │ └── todos/ │ ├── data/ │ ├── domain/ │ └── presentation/ └── main.dart

Communication Patterns

Event Bus Pattern

class AppEventBus { static final _instance = AppEventBus._(); static AppEventBus get instance => _instance; AppEventBus._(); final _controller = StreamController<AppEvent>.broadcast(); Stream<T> on<T extends AppEvent>() { return _controller.stream.where((event) => event is T).cast<T>(); } void fire(AppEvent event) { _controller.add(event); } void dispose() { _controller.close(); } } // Events abstract class AppEvent {} class UserLoggedIn implements AppEvent { final User user; UserLoggedIn(this.user); } class UserLoggedOut implements AppEvent {} // Usage class AuthViewModel { final _eventBus = AppEventBus.instance; Future<void> login(String email, String password) async { final user = await _authRepository.login(email, password); _eventBus.fire(UserLoggedIn(user)); } } class HomeViewModel { late final StreamSubscription _eventSubscription; HomeViewModel() { _eventSubscription = AppEventBus.instance .on<UserLoggedIn>() .listen((event) { print('User logged in: ${event.user.name}'); }); } void dispose() { _eventSubscription.cancel(); } }

Error Handling Architecture

Centralized Error Handler

class Result<T> { final T? data; final AppError? error; bool get isSuccess => error == null; bool get isError => error != null; Result.success(this.data) : error = null; Result.error(this.error) : data = null; } class AppError { final String message; final ErrorType type; AppError(this.message, this.type); } enum ErrorType { network, authentication, validation, unknown, } // Repository with Result class UserRepositoryImpl implements UserRepository { Future<Result<User>> getUser(String id) async { try { final user = await _api.getUser(id); return Result.success(user); } on NetworkException catch (e) { return Result.error( AppError(e.message, ErrorType.network), ); } on AuthException catch (e) { return Result.error( AppError(e.message, ErrorType.authentication), ); } catch (e) { return Result.error( AppError('Unknown error', ErrorType.unknown), ); } } } // ViewModel handling Result class UserViewModel { Future<void> loadUser(String userId) async { isLoading.value = true; final result = await _repository.getUser(userId); if (result.isSuccess) { user.value = result.data; error.value = null; } else { error.value = result.error!.message; _handleError(result.error!); } isLoading.value = false; } void _handleError(AppError error) { switch (error.type) { case ErrorType.network: // Show retry option break; case ErrorType.authentication: // Navigate to login break; default: // Show generic error } } }

Testing Strategy

Testing Each Layer

// Unit test: ViewModel void main() { group('UserViewModel Tests', () { late MockUserRepository mockRepo; late UserViewModel viewModel; setUp(() { mockRepo = MockUserRepository(); viewModel = UserViewModel(mockRepo); }); test('loads user successfully', () async { when(mockRepo.getUser('123')) .thenAnswer((_) async => User(id: '123', name: 'John')); await viewModel.loadUser('123'); expect(viewModel.user.value?.name, equals('John')); expect(viewModel.error.value, isNull); }); }); }

Summary

Effective reactive architecture requires:

  1. Clear separation: MVVM or Clean Architecture
  2. Reactive repositories: Stream-based data access
  3. Layered state: App, feature, and screen state
  4. Dependency injection: Loose coupling
  5. Feature organization: Scalable project structure
  6. Event communication: Decoupled components
  7. Error handling: Centralized, type-safe errors
  8. Testing strategy: Each layer independently testable

These patterns enable building large-scale reactive applications that remain maintainable, testable, and scalable.