This guide covers best practices for working with different architecture patterns in Flutter, based on the implementations in this repository.
- General Flutter Best Practices
- MVC Best Practices
- MVVM Best Practices
- Clean Architecture Best Practices
- DDD Best Practices
- State Management with BLoC
- Code Organization
- Testing Best Practices
- Performance Optimization
- Security Best Practices
✅ DO: Break down complex widgets into smaller, reusable components
// Good - Composable and reusable
class FeatureCard extends StatelessWidget {
final String title;
final String description;
final IconData icon;
final VoidCallback onTap;
const FeatureCard({
required this.title,
required this.description,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Icon(icon, size: 64),
const SizedBox(height: 16),
Text(title, style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 8),
Text(description),
],
),
),
),
);
}
}❌ DON'T: Create monolithic widgets with deep nesting
// Bad - Too complex, hard to maintain
class HomeView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Card(
child: InkWell(
onTap: () => Get.toNamed('/counter'),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
// 50+ lines of nested widgets here...
],
),
),
),
),
// More complex nested widgets...
],
),
);
}
}✅ DO: Use const constructors wherever possible
// Good - Improves performance
const SizedBox(height: 16)
const Padding(padding: EdgeInsets.all(24.0))
const Text('Hello')❌ DON'T: Miss optimization opportunities
// Bad - Unnecessary widget rebuilds
SizedBox(height: 16)
Padding(padding: EdgeInsets.all(24.0))✅ DO: Use null-safe code
// Good
String? name;
int counter = 0;
List<Note> notes = [];
// Safe null checks
if (name != null) {
print(name.length);
}
// Null-aware operators
String displayName = name ?? 'Anonymous';❌ DON'T: Use late initialization without null checks
// Bad - Can cause runtime errors
late String name;
print(name.length); // Runtime error if not initialized✅ DO: Handle errors gracefully
// Good
Future<void> saveNote(Note note) async {
try {
await repository.save(note);
// Show success message using ScaffoldMessenger or a custom notification
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Note saved')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to save note: $e'),
backgroundColor: Colors.red,
),
);
}
}❌ DON'T: Ignore potential errors
// Bad
Future<void> saveNote(Note note) async {
await repository.save(note); // What if this fails?
Get.snackbar('Success', 'Note saved');
}✅ DO: Organize assets properly
# pubspec.yaml
flutter:
assets:
- assets/images/
- assets/icons/
fonts:
- family: Roboto
fonts:
- asset: fonts/Roboto-Regular.ttf✅ DO: Keep dependencies up to date
# pubspec.yaml
dependencies:
flutter_bloc: ^8.1.3 # Specify versions
hydrated_bloc: ^9.1.2
equatable: ^2.0.5❌ DON'T: Use outdated or unspecified versions
# Bad
dependencies:
flutter_bloc: any # Don't use 'any'✅ DO: Keep Model, View, and Controller separate
// Model - Data only
class CounterModel {
int value;
CounterModel({this.value = 0});
}
// Controller - Business logic (using Cubit)
class CounterCubit extends Cubit<CounterState> {
CounterCubit() : super(CounterInitial());
final CounterModel _model = CounterModel();
int get counter => _model.value;
void increment() {
_model.value++;
emit(CounterUpdated(_model.value));
}
}
// View - UI only
class CounterView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<CounterCubit, CounterState>(
builder: (context, state) {
if (state is CounterUpdated) {
return Text('${state.value}');
}
return Text('0');
},
);
}
}✅ DO: Clean up resources in close
class CounterCubit extends Cubit<CounterState> {
late Timer _timer;
CounterCubit() : super(CounterInitial()) {
_timer = Timer.periodic(Duration(seconds: 1), (_) {
// Update state
});
}
@override
Future<void> close() {
_timer.cancel();
return super.close();
}
}✅ Use MVC when:
- Building small to medium apps (< 10 screens)
- Rapid prototyping
- Learning Flutter
- Simple business logic
- Personal projects
❌ Avoid MVC when:
- Complex business rules
- Multiple teams working
- High testability requirements
- Enterprise applications
✅ DO: Keep ViewModels focused on presentation logic
// Good - ViewModel handles presentation logic (using Cubit)
class CounterViewModel extends Cubit<CounterViewState> {
CounterViewModel() : super(CounterViewState.initial());
Future<void> increment() async {
emit(state.copyWith(isLoading: true));
try {
final newValue = state.counter + 1;
await _saveToStorage(newValue);
emit(state.copyWith(counter: newValue, isLoading: false));
} catch (e) {
emit(state.copyWith(
errorMessage: e.toString(),
isLoading: false,
));
}
}
Future<void> _saveToStorage(int value) async {
// Save using shared_preferences or hive
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('counter', value);
}
}
// State class
class CounterViewState extends Equatable {
final int counter;
final bool isLoading;
final String errorMessage;
const CounterViewState({
required this.counter,
required this.isLoading,
required this.errorMessage,
});
factory CounterViewState.initial() => const CounterViewState(
counter: 0,
isLoading: false,
errorMessage: '',
);
CounterViewState copyWith({
int? counter,
bool? isLoading,
String? errorMessage,
}) {
return CounterViewState(
counter: counter ?? this.counter,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object> get props => [counter, isLoading, errorMessage];
}✅ DO: Use immutable states
// Good - Immutable state management
class NotesViewModel extends Cubit<NotesViewState> {
NotesViewModel() : super(NotesViewState.initial());
void addNote(Note note) {
final updatedNotes = List<Note>.from(state.notes)..add(note);
emit(state.copyWith(notes: updatedNotes));
}
// Computed properties in state
bool get hasNotes => state.notes.isNotEmpty;
int get notesCount => state.notes.length;
}
class NotesViewState extends Equatable {
final List<Note> notes;
final bool isLoading;
const NotesViewState({
required this.notes,
required this.isLoading,
});
factory NotesViewState.initial() => const NotesViewState(
notes: [],
isLoading: false,
);
NotesViewState copyWith({
List<Note>? notes,
bool? isLoading,
}) {
return NotesViewState(
notes: notes ?? this.notes,
isLoading: isLoading ?? this.isLoading,
);
}
@override
List<Object> get props => [notes, isLoading];
}❌ DON'T: Mutate state directly
// Bad - Mutable state
class NotesViewModel extends Cubit<NotesViewState> {
void addNote(Note note) {
state.notes.add(note); // DON'T mutate state directly!
}
}✅ DO: Use BlocProvider for dependency injection
// Good - Dependency injection with BlocProvider
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CounterViewModel(),
child: CounterView(),
);
}
}
// Or use MultiBlocProvider for multiple providers
MultiBlocProvider(
providers: [
BlocProvider(create: (context) => CounterViewModel()),
BlocProvider(create: (context) => ThemeCubit()),
],
child: MyApp(),
)✅ DO: Use BlocBuilder for automatic updates
// Good - Automatic UI updates
class CounterView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<CounterViewModel, CounterViewState>(
builder: (context, state) {
return Text('${state.counter}');
},
);
}
}
// Or use BlocConsumer for side effects
BlocConsumer<CounterViewModel, CounterViewState>(
listener: (context, state) {
if (state.errorMessage.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage)),
);
}
},
builder: (context, state) {
return Text('${state.counter}');
},
)✅ Use MVVM when:
- Medium-sized apps (10-20 screens)
- Complex UI state
- Reactive programming preferred
- Two-way data binding needed
- Testable presentation logic required
❌ Avoid MVVM when:
- Very simple apps (use MVC)
- Enterprise apps with complex domains (use DDD)
- Team unfamiliar with reactive programming
✅ DO: Keep layers independent
// Domain Layer - No Flutter dependencies
abstract class CounterRepository {
Future<int> getCounter();
Future<void> saveCounter(int value);
}
// Data Layer - Implements domain interface
class CounterRepositoryImpl implements CounterRepository {
final SharedPreferences _prefs;
CounterRepositoryImpl(this._prefs);
@override
Future<int> getCounter() async => _prefs.getInt('counter') ?? 0;
@override
Future<void> saveCounter(int value) async {
await _prefs.setInt('counter', value);
}
}
// Presentation Layer - Uses domain interface (BLoC)
class CounterBloc extends Bloc<CounterEvent, CounterState> {
final GetCounterUseCase getCounterUseCase;
final IncrementCounterUseCase incrementCounterUseCase;
CounterBloc({
required this.getCounterUseCase,
required this.incrementCounterUseCase,
}) : super(CounterInitial()) {
on<IncrementCounter>(_onIncrement);
on<LoadCounter>(_onLoad);
}
Future<void> _onIncrement(
IncrementCounter event,
Emitter<CounterState> emit,
) async {
final result = await incrementCounterUseCase.execute();
emit(CounterUpdated(result));
}
Future<void> _onLoad(
LoadCounter event,
Emitter<CounterState> emit,
) async {
final result = await getCounterUseCase.execute();
emit(CounterUpdated(result));
}
}✅ DO: Follow the dependency rule (inner layers don't depend on outer layers)
Presentation → Domain ← Data
↓ ↑ ↓
Views Use Cases Repos
❌ DON'T: Let inner layers depend on outer layers
// Bad - Domain layer depending on infrastructure
class CounterUseCase {
final SharedPreferences prefs; // Domain shouldn't know about SharedPreferences!
}✅ DO: Create focused use cases
// Good - Single responsibility
class IncrementCounterUseCase {
final CounterRepository repository;
IncrementCounterUseCase(this.repository);
int execute() {
final current = repository.getCounter();
return current + 1;
}
}
class DecrementCounterUseCase {
final CounterRepository repository;
DecrementCounterUseCase(this.repository);
int execute() {
final current = repository.getCounter();
return current - 1;
}
}❌ DON'T: Create god use cases
// Bad - Too many responsibilities
class CounterUseCase {
void increment() {}
void decrement() {}
void reset() {}
void saveToCloud() {}
void loadFromCloud() {}
void exportToFile() {}
void importFromFile() {}
}✅ DO: Keep entities pure (no Flutter dependencies)
// Good - Pure Dart entity
class Note {
final String id;
final String content;
final DateTime createdAt;
Note({
required this.id,
required this.content,
required this.createdAt,
});
Note copyWith({String? content}) {
return Note(
id: id,
content: content ?? this.content,
createdAt: createdAt,
);
}
}✅ Use Clean Architecture when:
- Large applications (20+ screens)
- Multiple developers/teams
- High testability required
- Long-term maintenance planned
- Framework independence desired
- Multiple platform targets
❌ Avoid Clean Architecture when:
- Small apps (< 10 screens)
- Tight deadlines
- Solo developer on simple project
- Team lacks experience with layered architecture
✅ DO: Define clear bounded contexts
// Good - Separate contexts
// Counter Context
lib/
domain/counter/
application/counter/
infrastructure/counter/
presentation/counter/
// Notes Context
lib/
domain/notes/
application/notes/
infrastructure/notes/
presentation/notes/✅ DO: Use value objects for domain concepts
// Good - Value object with validation
class NoteContent {
final String value;
NoteContent(String input) : value = _validate(input);
static String _validate(String input) {
if (input.isEmpty) {
throw ArgumentError('Note content cannot be empty');
}
if (input.length > 500) {
throw ArgumentError('Note content too long');
}
return input.trim();
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is NoteContent && value == other.value;
@override
int get hashCode => value.hashCode;
}❌ DON'T: Use primitive types for domain concepts
// Bad - No validation, no domain meaning
class Note {
String content; // Just a string, no validation
}✅ DO: Add behavior to entities
// Good - Entity with behavior
class Counter {
final int value;
Counter(this.value);
Counter increment() {
if (value >= 1000) {
throw CounterLimitExceeded('Counter cannot exceed 1000');
}
return Counter(value + 1);
}
Counter decrement() {
if (value <= -1000) {
throw CounterLimitExceeded('Counter cannot go below -1000');
}
return Counter(value - 1);
}
Counter reset() => Counter(0);
bool get isPositive => value > 0;
bool get isNegative => value < 0;
}❌ DON'T: Create anemic domain models
// Bad - Just data, no behavior (anemic model)
class Counter {
int value;
Counter(this.value);
}
// Business logic in service instead of entity
class CounterService {
void increment(Counter counter) {
counter.value++; // Logic should be in entity
}
}✅ DO: Use repositories for aggregate roots
// Good - Repository for aggregate root
abstract class NoteRepository {
Future<Note?> findById(String id);
Future<List<Note>> findAll();
Future<void> save(Note note);
Future<void> delete(String id);
}✅ DO: Orchestrate domain logic in application layer
// Good - Application service orchestrates use case
class AddNoteUseCase {
final NoteRepository repository;
AddNoteUseCase(this.repository);
Future<void> execute(String content) async {
// Validate with value object
final noteContent = NoteContent(content);
// Create domain entity
final note = Note.create(
content: noteContent,
createdAt: DateTime.now(),
);
// Persist through repository
await repository.save(note);
}
}✅ DO: Use domain events for side effects
// Good - Domain events for loose coupling
abstract class DomainEvent {}
class NoteCreated extends DomainEvent {
final String noteId;
final DateTime createdAt;
NoteCreated(this.noteId, this.createdAt);
}
class Note {
// ... entity code ...
List<DomainEvent> get domainEvents => _events;
final List<DomainEvent> _events = [];
Note create(String content) {
// ... creation logic ...
_events.add(NoteCreated(id, createdAt));
return this;
}
}✅ Use DDD when:
- Enterprise applications
- Complex business logic
- Domain experts involved
- Evolving requirements
- Multiple bounded contexts
- Long-term strategic project
❌ Avoid DDD when:
- Simple CRUD apps
- Small team/solo dev
- Tight budget/timeline
- No access to domain experts
- Simple business rules
✅ DO: Use Cubit for simple state management
// Cubit - Simpler, no events needed
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
// In widget
BlocBuilder<CounterCubit, int>(
builder: (context, count) => Text('$count'),
)✅ DO: Use BLoC for complex state with events
// BLoC - Better for complex logic with events
abstract class CounterEvent {}
class IncrementCounter extends CounterEvent {}
class DecrementCounter extends CounterEvent {}
class ResetCounter extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<IncrementCounter>((event, emit) => emit(state + 1));
on<DecrementCounter>((event, emit) => emit(state - 1));
on<ResetCounter>((event, emit) => emit(0));
}
}
// In widget
BlocBuilder<CounterBloc, int>(
builder: (context, count) => Text('$count'),
)
// Trigger events
context.read<CounterBloc>().add(IncrementCounter());✅ DO: Use immutable state classes with Equatable
// Good - Immutable state with Equatable
class CounterState extends Equatable {
final int value;
final bool isLoading;
final String? error;
const CounterState({
required this.value,
this.isLoading = false,
this.error,
});
CounterState copyWith({
int? value,
bool? isLoading,
String? error,
}) {
return CounterState(
value: value ?? this.value,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
@override
List<Object?> get props => [value, isLoading, error];
}✅ DO: Use BlocProvider for dependency injection
// Good - Single provider
BlocProvider(
create: (context) => CounterCubit(),
child: CounterView(),
)
// Good - Multiple providers
MultiBlocProvider(
providers: [
BlocProvider(create: (context) => CounterCubit()),
BlocProvider(create: (context) => ThemeCubit()),
],
child: MyApp(),
)
// Good - Lazy loading with dependencies
BlocProvider(
create: (context) => CounterBloc(
repository: context.read<CounterRepository>(),
),
child: CounterView(),
)✅ DO: Use context.read() for triggering actions
// Good - Read for one-time access (events/methods)
ElevatedButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: Text('Increment'),
)✅ DO: Use context.watch() or BlocBuilder for listening
// Good - Watch for reactive updates
@override
Widget build(BuildContext context) {
final count = context.watch<CounterCubit>().state;
return Text('$count');
}
// Or use BlocBuilder (preferred)
BlocBuilder<CounterCubit, int>(
builder: (context, count) => Text('$count'),
)✅ DO: Use BlocListener for side effects
// Good - Listener for side effects (navigation, snackbars)
BlocListener<CounterCubit, CounterState>(
listener: (context, state) {
if (state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!)),
);
}
},
child: CounterView(),
)
// Or use BlocConsumer for both listening and building
BlocConsumer<CounterCubit, CounterState>(
listener: (context, state) {
// Side effects
if (state.value == 10) {
Navigator.pushNamed(context, '/success');
}
},
builder: (context, state) {
// UI
return Text('${state.value}');
},
)✅ DO: Use buildWhen to optimize rebuilds
// Good - Only rebuild when value changes
BlocBuilder<CounterCubit, CounterState>(
buildWhen: (previous, current) => previous.value != current.value,
builder: (context, state) => Text('${state.value}'),
)
// Good - Only listen when error changes
BlocListener<CounterCubit, CounterState>(
listenWhen: (previous, current) => previous.error != current.error,
listener: (context, state) {
if (state.error != null) {
// Show error
}
},
child: CounterView(),
)✅ DO: Use blocTest for easy testing
// Good - Test Cubit with blocTest
blocTest<CounterCubit, int>(
'emits [1] when increment is called',
build: () => CounterCubit(),
act: (cubit) => cubit.increment(),
expect: () => [1],
);
// Test BLoC with events
blocTest<CounterBloc, int>(
'emits [1] when IncrementCounter is added',
build: () => CounterBloc(),
act: (bloc) => bloc.add(IncrementCounter()),
expect: () => [1],
);✅ DO: Use HydratedBloc for automatic persistence
// Good - Automatic state persistence
class CounterCubit extends HydratedCubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
@override
int? fromJson(Map<String, dynamic> json) => json['value'] as int?;
@override
Map<String, dynamic>? toJson(int state) => {'value': state};
}
// Initialize in main
void main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(MyApp());
}✅ DO: Group by feature
lib/
features/
counter/
data/
domain/
presentation/
notes/
data/
domain/
presentation/
❌ DON'T: Group by type
lib/
controllers/
counter_controller.dart
notes_controller.dart
views/
counter_view.dart
notes_view.dart
✅ DO: Use snake_case for files
counter_controller.dart
notes_view.dart
add_note_use_case.dart
❌ DON'T: Use other naming conventions
CounterController.dart // Wrong
counter-controller.dart // Wrong
counterController.dart // Wrong
✅ DO: Use PascalCase for classes
class CounterController extends GetxController {}
class NoteRepository {}
class AddNoteUseCase {}✅ DO: Centralize constants
// lib/core/constants/app_constants.dart
class AppConstants {
static const String appName = 'Counter Notes App';
static const int maxNoteLength = 500;
static const Duration animationDuration = Duration(milliseconds: 300);
}
// lib/core/theme/app_colors.dart
class AppColors {
static const Color primary = Colors.blue;
static const Color counterPrimary = Colors.blue;
static const Color notesPrimary = Colors.green;
}✅ DO: Test business logic
// test/controllers/counter_controller_test.dart
void main() {
test('Counter increments correctly', () {
final controller = CounterController();
controller.increment();
expect(controller.counter, 1);
});
test('Counter decrements correctly', () {
final controller = CounterController();
controller.increment();
controller.decrement();
expect(controller.counter, 0);
});
}✅ DO: Test UI behavior
void main() {
testWidgets('Counter increments on button tap', (tester) async {
await tester.pumpWidget(MyApp());
expect(find.text('0'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('1'), findsOneWidget);
});
}✅ DO: Use mocks for external dependencies
class MockCounterRepository extends Mock implements CounterRepository {}
void main() {
test('Use case calls repository', () {
final mockRepo = MockCounterRepository();
when(mockRepo.getCounter()).thenReturn(0);
final useCase = IncrementCounterUseCase(mockRepo);
useCase.execute();
verify(mockRepo.saveCounter(1));
});
}✅ DO: Keep build methods pure
// Good - Pure, no side effects
@override
Widget build(BuildContext context) {
return Text('${controller.counter}');
}❌ DON'T: Perform computations in build
// Bad - Computation on every build
@override
Widget build(BuildContext context) {
final result = expensiveCalculation(); // Don't do this!
return Text('$result');
}✅ DO: Load data only when needed
class NotesController extends GetxController {
final notes = <Note>[].obs;
@override
void onInit() {
super.onInit();
loadNotes(); // Load on initialization
}
Future<void> loadNotes() async {
notes.value = await repository.findAll();
}
}✅ DO: Use ListView.builder for long lists
// Good - Builds items on demand
ListView.builder(
itemCount: notes.length,
itemBuilder: (context, index) => NoteCard(notes[index]),
)❌ DON'T: Build all items at once
// Bad - Builds all items immediately
ListView(
children: notes.map((note) => NoteCard(note)).toList(),
)✅ DO: Never commit sensitive data
// Good - Use environment variables
final apiKey = dotenv.env['API_KEY'];❌ DON'T: Hardcode secrets
// Bad - Exposed in source code
const apiKey = 'sk-1234567890abcdef';✅ DO: Validate all user input
class NoteContent {
final String value;
NoteContent(String input) : value = _validate(input);
static String _validate(String input) {
if (input.isEmpty) throw ArgumentError('Cannot be empty');
if (input.length > 500) throw ArgumentError('Too long');
return input.trim();
}
}✅ DO: Use secure storage for sensitive data
// For sensitive data, use flutter_secure_storage
final secureStorage = FlutterSecureStorage();
await secureStorage.write(key: 'token', value: token);- Flutter Official Docs: https://flutter.dev/docs
- BLoC Library: https://bloclibrary.dev
- flutter_bloc Package: https://pub.dev/packages/flutter_bloc
- hydrated_bloc Package: https://pub.dev/packages/hydrated_bloc
- Clean Architecture: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- DDD Book: Domain-Driven Design by Eric Evans
- Flutter Best Practices: https://dart.dev/guides/language/effective-dart
Remember: These are guidelines, not strict rules. Adapt them to your project's needs while maintaining code quality and maintainability.
Happy Coding! 🚀