gotiation.
Step 1: Define Layer Boundaries
Structure the project around three explicit layers:
- Presentation: UI widgets, BLoC instances, routing
- Domain: Entities, use cases, repository interfaces
- Data: Repository implementations, data sources, DTOs, network clients
lib/
├── core/ # DI setup, error handling, utilities
├── features/
│ └── auth/
│ ├── presentation/
│ │ ├── bloc/
│ │ └── screens/
│ ├── domain/
│ │ ├── entities/
│ │ ├── repositories/
│ │ └── usecases/
│ └── data/
│ ├── models/
│ ├── repositories/
│ └── datasources/
└── main.dart
Step 2: Implement Domain Contracts
Domain layer defines what the app does, not how. Use abstract repositories and explicit use cases.
// domain/entities/user.dart
import 'package:equatable/equatable.dart';
class User extends Equatable {
final String id;
final String email;
final String? displayName;
const User({required this.id, required this.email, this.displayName});
@override
List<Object?> get props => [id, email, displayName];
}
// domain/repositories/auth_repository.dart
import 'package:dartz/dartz.dart';
import '../entities/user.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, void>> logout();
}
// domain/usecases/login_usecase.dart
import 'package:dartz/dartz.dart';
import '../entities/user.dart';
import '../repositories/auth_repository.dart';
class LoginUseCase {
final AuthRepository repository;
LoginUseCase(this.repository);
Future<Either<Failure, User>> call(String email, String password) {
return repository.login(email, password);
}
}
Step 3: Wire BLoC for State Transitions
BLoC enforces explicit events and immutable states. Use freezed for exhaustive state modeling.
// presentation/bloc/auth_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/usecases/login_usecase.dart';
import '../../domain/entities/user.dart';
part 'auth_event.freezed.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthEvent with _$AuthEvent {
const factory AuthEvent.loginRequested(String email, String password) = _LoginRequested;
const factory AuthEvent.logoutRequested() = _LogoutRequested;
}
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.error(String message) = _Error;
}
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUseCase loginUseCase;
AuthBloc({required this.loginUseCase}) : super(const AuthState.initial()) {
on<AuthEvent>((event, emit) async {
await event.map(
loginRequested: (e) async {
emit(const AuthState.loading());
final result = await loginUseCase(e.email, e.password);
result.fold(
(failure) => emit(AuthState.error(failure.message)),
(user) => emit(AuthState.authenticated(user)),
);
},
logoutRequested: (_) => emit(const AuthState.initial()),
);
});
}
}
Decouple instantiation from consumption. Use get_it with injectable for compile-time DI graph generation.
// core/di/injection.config.dart (generated)
// core/di/injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';
final getIt = GetIt.instance;
@InjectableInit(
initializerName: 'init',
preferRelativeImports: true,
asExtension: true,
)
void configureDependencies() => getIt.init();
// Register implementations
@module
abstract class AppModule {
@lazySingleton
AuthRepository get authRepository => AuthRepositoryImpl();
}
Step 5: Establish Error Boundaries & Loading States
Never let unhandled exceptions crash the presentation layer. Wrap BLoC consumers in BlocListener for side effects and BlocBuilder for UI updates.
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
state.maybeWhen(
error: (msg) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg)),
),
orElse: () {},
);
},
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return state.maybeWhen(
loading: () => const Center(child: CircularProgressIndicator()),
authenticated: (user) => HomeScreen(user: user),
orElse: () => const LoginScreen(),
);
},
),
)
Architecture Rationale:
- Unidirectional data flow prevents state mutations from scattering across widget lifecycles.
- Domain isolation ensures business rules remain framework-agnostic and testable without UI dependencies.
- DI enables mock injection in unit tests, eliminating flaky integration tests.
freezed + equatable guarantee state equality checks work correctly, preventing unnecessary rebuilds.
go_router (not shown) handles deep linking, nested routes, and declarative navigation without coupling screens to navigation logic.
Pitfall Guide
- Coupling BLoC directly to widgets: Embedding BLoC logic inside
StatefulWidget or calling context.read<AuthBloc>() inside build() creates tight coupling. Extract logic into use cases and keep widgets purely presentational.
- Over-nesting BLoCs: Creating a separate BLoC for every minor UI element inflates boilerplate and complicates state sharing. Group related events/states into feature-level BLoCs and use
BlocProvider.value to share instances across sibling widgets.
- Treating repositories as direct API clients: Skipping the domain layer and returning DTOs directly from data sources violates separation of concerns. Always map network responses to domain entities before crossing layer boundaries.
- Ignoring error boundaries in streams: BLoC relies on
StreamTransformer under the hood. Unhandled exceptions in mapEventToState or on<Event> crash the stream subscription. Wrap async operations in try/catch or use Either/Result types to propagate failures safely.
- Mixing imperative state with reactive streams: Calling
setState() alongside BLoC updates creates race conditions and unpredictable UI states. Commit to a single state source per feature. Remove StatefulWidget unless implementing platform-specific controllers or animations.
- Skipping DI container initialization in tests: Tests fail silently when dependencies are unresolved. Always call
getIt.init() in test setup and register mock implementations before running assertions.
- Using global BLoC for feature-local state: Application-wide BLoCs become garbage collectors for unrelated state. Scope BLoCs to feature trees using
BlocProvider at route boundaries. Use BlocObserver for cross-cutting concerns like analytics or crash reporting.
Production Best Practices:
- Enforce strict event/state typing with
freezed and equatable.
- Validate architecture boundaries with static analysis (e.g.,
flutter_lints, custom lint rules).
- Prefer composition over inheritance for shared UI components.
- Cache domain entities, not DTOs, to prevent data source changes from breaking presentation logic.
- Audit state transitions with
BlocObserver in debug mode to detect missing states or redundant emissions.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP / Solo developer | Provider + Riverpod | Low boilerplate, fast iteration, minimal setup | Low initial, moderate at scale |
| Enterprise / Multi-team | Clean Architecture + BLoC | Strict boundaries, predictable state, testable domains | High initial, low long-term |
| Complex real-time state (chat, trading) | BLoC + Streams + Equatable | Deterministic event ordering, prevents race conditions | Medium initial, low maintenance |
| Legacy Flutter app refactor | Incremental BLoC migration + DI | Allows feature-by-feature extraction without rewrite | Medium initial, high risk if rushed |
| Platform-specific UI heavy (games, editors) | Custom state manager + Controller pattern | BLoC adds overhead; imperative control is more performant | Low initial, high coupling risk |
Configuration Template
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
equatable: ^2.0.5
freezed_annotation: ^2.4.1
dartz: ^0.10.1
get_it: ^7.6.4
injectable: ^2.3.2
go_router: ^12.1.1
dio: ^5.4.0
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.7
freezed: ^2.4.5
injectable_generator: ^2.4.1
mocktail: ^1.0.1
// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'core/di/injection.dart';
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/domain/usecases/login_usecase.dart';
void main() {
configureDependencies();
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MultiRepositoryProvider(
providers: [
RepositoryProvider(
create: (_) => getIt<AuthRepository>(),
),
],
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (_) => AuthBloc(
loginUseCase: getIt<LoginUseCase>(),
),
),
],
child: MaterialApp.router(
routerConfig: AppRouter(),
theme: ThemeData(useMaterial3: true),
),
),
);
}
}
Quick Start Guide
- Scaffold a new Flutter project:
flutter create my_app && cd my_app
- Install dependencies:
flutter pub add flutter_bloc equatable freezed_annotation dartz get_it injectable go_router dio
- Add dev dependencies:
flutter pub add --dev build_runner freezed injectable_generator mocktail
- Generate DI and freezed files:
dart run build_runner build --delete-conflicting-outputs
- Run the app:
flutter run
The architecture is now structured for predictable state flow, testable business logic, and scalable feature expansion. Maintain layer boundaries, audit state transitions, and let the framework handle the UI while your code handles the logic.