Flutter Architecture Patterns
A comprehensive guide to building scalable Flutter applications using MVC, MVVM, Clean Architecture, and DDD.
๐ฏ Goal
This project implements the same set of features (Counter, Notes, Theme Toggle) across four different architecture patterns using two state management solutions (BLoC & GetX). This allows for a direct, apples-to-apples comparison.
MVC (Model-View-Controller)
SimpleOverview
The Model-View-Controller pattern separates the application into three main components: Model, View, and Controller.
Interaction Flow
Core Concepts
๐ก Core Concept
The Controller acts as the "brain". It receives user input from the View, processes it (often updating the Model), and then tells the View to update. The View is passive and only displays what it's told.
๐ Real World Analogy: The Restaurant
View (Customer): Sees the menu and asks for food.
Controller (Waiter): Takes the order to the kitchen and brings the food back.
Model (Kitchen): Prepares the food (data) and handles the ingredients (logic).
Analysis
Structure
- Model: Data and business logic
- View: UI components
- Controller: Mediates between Model and View
Best For
- โ Small to medium apps
- โ Rapid prototyping
- โ Learning Flutter basics
โ Pros
- Simplicity: Easy to understand and implement.
- Separation: Clear distinction between data (Model) and UI (View).
- Development Speed: Great for getting an MVP out quickly.
โ Cons
- Tight Coupling: Views often depend directly on Models.
- Massive Controllers: Controllers can become "God Objects" handling too much logic.
- Harder Testing: UI logic mixed with business logic can be hard to test.
Implementation
class CounterController {
int count = 0;
// The Controller handles the logic
void increment() {
count++;
// And manually tells the View to update
update();
}
}
lib/
โโโ main.dart # Entry point
โโโ config/ # Routes, Themes
โโโ models/ # Data Models
โ โโโ user_model.dart
โโโ views/ # UI Screens & Widgets
โ โโโ home_view.dart
โ โโโ widgets/
โโโ controllers/ # Business Logic
โโโ home_controller.dart
MVVM (Model-View-ViewModel)
ModerateOverview
MVVM facilitates a separation of development of the graphical user interface from the development of the business logic or back-end logic.
Data Binding Flow
Core Concepts
๐ก Core Concept
The ViewModel exposes streams of data (State) that the View listens to. When the Model changes, the ViewModel updates the stream, and the View automatically rebuilds. This removes the need for the ViewModel to manually update the View.
๐บ Real World Analogy: The TV Setup
View (TV Screen): Displays whatever signal it receives.
ViewModel (Cable Box): Processes the raw signal into something the TV can show.
Model (Broadcast Station): The source of the raw signal/data.
Note: The TV doesn't ask the Station for data; it just reacts to the Cable Box.
Analysis
Structure
- Model: Data entities
- View: UI components
- ViewModel: Presentation logic with observables
Best For
- โ Medium to large apps
- โ Complex UI state
- โ Reactive programming
โ Pros
- Decoupling: View doesn't know about the Model, only the ViewModel.
- Testability: ViewModels are easy to unit test (no UI dependency).
- Reusability: ViewModels can be reused across different Views.
โ Cons
- Complexity: Requires understanding reactive programming (Streams/Observables).
- Overhead: Can be overkill for very simple UI screens.
- Debugging: Tracing data flows in reactive code can be tricky.
Implementation
class CounterViewModel {
// Expose a stream of data (State)
final _countController = StreamController<int>();
Stream<int> get countStream => _countController.stream;
int _count = 0;
void increment() {
_count++;
// Add new value to the stream - View updates automatically
_countController.add(_count);
}
}
lib/
โโโ main.dart
โโโ core/ # Constants, Utils
โโโ models/ # Data Models
โโโ views/ # UI Layer
โ โโโ login_view.dart
โ โโโ widgets/
โโโ viewmodels/ # State & Logic
โ โโโ login_vm.dart
โโโ services/ # API Calls, Local Storage
Clean Architecture
ComplexOverview
Clean Architecture separates the software into layers. The inner layers contain business rules, while the outer layers contain implementation details.
(BLoC / Controllers)"] Presentation --> Domain["Domain Layer
(Use Cases / Entities)"] Data["Data Layer
(Repositories / Data Sources)"] --> Domain
The Repository Pattern
How data flows between layers without violating dependency rules.
(Domain)"] RepoImpl["Repository Impl
(Data)"] -- Implements --> RepoInterface RepoImpl -- Uses --> DataSource["Data Source
(API/DB)"]
Control Flow (Request/Response)
Core Concepts
๐ก The Dependency Rule
Source code dependencies can only point inwards. Nothing in an inner circle (like Domain) can know anything at all about something in an outer circle (like Presentation or Data). This makes the core logic immune to UI or Database changes.
๐ฐ Real World Analogy: The Castle
Domain (The King): Lives in the center, makes the rules, knows nothing about the outside world.
Presentation/Data (The Guards): Protect the King, handle messengers (API) and visitors (UI), and translate their requests into something the King understands.
Analysis
Structure
- Data Layer: Repositories, data sources, models
- Domain Layer: Use cases, entities, interfaces
- Presentation Layer: Controllers, views, bindings
Best For
- โ Large scalable apps
- โ Multiple teams
- โ High testability requirements
โ Pros
- Independence: UI, Database, and Frameworks can change without affecting business rules.
- Testability: Business logic (Use Cases) can be tested in isolation.
- Maintainability: Clear boundaries make it easier to navigate and fix bugs.
โ Cons
- Boilerplate: Requires writing many files (DTOs, Mappers, Interfaces).
- Learning Curve: Concepts like Dependency Inversion can be hard to grasp.
- Over-engineering: Too complex for simple CRUD apps.
Implementation
// Domain Layer: Pure Dart, no Flutter dependencies
class GetUserUseCase {
final UserRepository repository;
GetUserUseCase(this.repository);
// Executes a specific business rule
Future<User> execute(String userId) async {
// Can add validation or other logic here
if (userId.isEmpty) throw InvalidIdException();
return await repository.getUser(userId);
}
}
lib/
โโโ main.dart
โโโ core/ # Errors, Network, Utils
โโโ config/ # Routes, Theme
โโโ features/ # Feature-based
โโโ auth/
โโโ data/ # Repo Impl, Data Sources, Models
โ โโโ datasources/
โ โโโ models/
โ โโโ repositories/
โโโ domain/ # Entities, Repo Interfaces, UseCases
โ โโโ entities/
โ โโโ repositories/
โ โโโ usecases/
โโโ presentation/# BLoC/Cubit, Pages, Widgets
โโโ bloc/
โโโ pages/
โโโ widgets/
DDD (Domain-Driven Design)
ExpertOverview
DDD focuses on the core domain logic and domain logic interactions. It involves a collaboration between technical and domain experts.
Aggregate Root Example
Core Concepts
๐ก Strategic vs Tactical
Strategic Design defines large-scale boundaries (Bounded Contexts) and how teams collaborate. Tactical Design provides the patterns (Entities, Value Objects, Aggregates) to build the internal logic of those contexts.
๐ข Real World Analogy: A Large Corporation
Bounded Contexts (Departments): Sales, HR, and Engineering are separate departments.
Ubiquitous Language (Jargon): "Lead" means something different in Sales (a potential customer) vs. Engineering (a senior dev).
Context Mapping (Communication): How these departments talk to each other formally.
Analysis
Structure
- Domain Layer: Entities, value objects (Pure Dart)
- Application Layer: Use cases orchestrating logic
- Infrastructure Layer: Data sources, implementations
- Presentation Layer: UI and Controllers
Best For
- โ Enterprise applications
- โ Complex business logic
- โ Evolving requirements
โ Pros
- Business Alignment: Code speaks the same language as business experts (Ubiquitous Language).
- Flexibility: Bounded Contexts allow different parts of the system to evolve independently.
- Rich Models: Encapsulates logic within domain objects, preventing "Anemic Models".
โ Cons
- Complexity: Extremely high learning curve.
- Time Consuming: Requires deep analysis and modeling before coding.
- Expertise: Needs developers who understand both tech and domain.
Implementation
// Domain Entity with self-contained validation logic
class EmailAddress extends ValueObject<String> {
@override
final Either<ValueFailure<String>, String> value;
// Private constructor
const EmailAddress._(this.value);
// Factory constructor that validates on creation
factory EmailAddress(String input) {
return EmailAddress._(
validateEmailAddress(input),
);
}
}
lib/
โโโ main.dart
โโโ domain/ # Enterprise Business Rules
โ โโโ auth/
โ โ โโโ value_objects.dart
โ โ โโโ i_auth_facade.dart
โ โโโ core/
โโโ infrastructure/ # Interface Adapters
โ โโโ auth/
โ โ โโโ auth_facade_impl.dart
โ โ โโโ user_dtos.dart
โ โโโ core/
โโโ application/ # Application Business Rules
โ โโโ auth/
โ โโโ sign_in_form_bloc.dart
โ โโโ auth_bloc.dart
โโโ presentation/ # Frameworks & Drivers
โโโ sign_in/
โโโ app_widget.dart
State Management: BLoC vs GetX
This repository provides two complete implementations for each architecture pattern.
BLoC Pattern
GetX Pattern
| Feature | BLoC (Business Logic Component) | GetX |
|---|---|---|
| Philosophy | Stream-based, predictable, explicit | Observer pattern, simple, productive |
| Learning Curve | Steep | Easy |
| Boilerplate | High (Events, States) | Low (Minimal code) |
| Performance | Excellent โกโกโกโกโก | Excellent โกโกโกโกโก |
| Testability | Excellent (blocTest) | Good |
| Best For | Large teams, strict architecture | Rapid development, MVPs |
Performance Analysis
A detailed comparison of memory usage, build times, and frame rates.
Memory Usage (Idle)
- GetX: ~45MB
- BLoC: ~48MB
- Provider: ~46MB
GetX is slightly lighter due to lack of streams.
Cold Start Time
- GetX: ~400ms
- BLoC: ~420ms
- Provider: ~410ms
Differences are negligible for most apps.
Developer Experience
BLoC Experience
- โ Predictable: Unidirectional data flow makes debugging easy.
- โ Tooling: Excellent VS Code extensions and DevTools integration.
- โ Boilerplate: Requires writing Events, States, and BLoCs.
GetX Experience
- โ Speed: Very fast to write features. Less code.
- โ All-in-One: Includes Navigation, Dialogs, Snackbars, etc.
- โ Magic: Can be harder to debug "magic" behavior.
Best Practices
General Flutter Tips
- Break down complex widgets into smaller, reusable components.
- Use
constconstructors wherever possible to improve performance. - Handle errors gracefully and show user-friendly messages.
- Keep dependencies up to date.
Clean Architecture
- Keep layers independent.
- Domain layer should have NO Flutter dependencies.
- Use repositories to abstract data sources.
State Management
- Use immutable state classes (Equatable).
- Keep logic out of the UI (Widgets).
- Use
buildWhen/listenWhento optimize rebuilds.
Learning Resources
Official Docs
Architecture Articles
Video Channels
Troubleshooting
Ensure you have wrapped your widget tree with BlocProvider. If navigating to a new route, pass the existing BLoC using BlocProvider.value.
Make sure you are emitting a new instance of the state. If using Equatable, ensure props are correctly set. Do not mutate state directly.
Call HydratedStorage.build in your main() function before runApp(). Ensure WidgetsFlutterBinding.ensureInitialized() is called first.