ADVANCEDarchitecture⏱ 35 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
- MVVM Pattern
- Clean Architecture
- Repository Pattern
- State Management Layers
- Dependency Injection
- Feature-Based Structure
- Communication Patterns
- Error Handling Architecture
- Testing Strategy
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:
- Clear separation: MVVM or Clean Architecture
- Reactive repositories: Stream-based data access
- Layered state: App, feature, and screen state
- Dependency injection: Loose coupling
- Feature organization: Scalable project structure
- Event communication: Decoupled components
- Error handling: Centralized, type-safe errors
- Testing strategy: Each layer independently testable
These patterns enable building large-scale reactive applications that remain maintainable, testable, and scalable.