Flutter Architecture Patterns: Clean Architecture vs Feature-First
Google Developer Expert in Flutter & Dart π| Mobile App Development π±| Tech Advocate π| Women Who Code KL Director
I've been building Flutter apps for a team of around 5 developers for the past few years. When I started, everyone talked about Clean Architecture like it was the only "professional" way to build apps. So naturally, I tried it.
After months of writing mappers between entities and models, creating use cases for simple API calls, and watching new team members struggle to add basic features, I switched to Feature-First. I haven't looked back.
This isn't to say Clean Architecture is bad. It's just that for most apps, it's overkill. Here's what I learned.
What is Clean Architecture?
Clean Architecture organizes code into layers with strict dependency rules:
lib/
core/
error/
usecases/
network/
features/
auth/
data/
datasources/
auth_remote_datasource.dart
models/
user_model.dart
repositories/
auth_repository_impl.dart
domain/
entities/
user.dart
repositories/
auth_repository.dart
usecases/
login_usecase.dart
logout_usecase.dart
presentation/
bloc/
auth_bloc.dart
auth_event.dart
auth_state.dart
pages/
login_page.dart
The rules:
Domain layer (entities, repository interfaces, use cases) has no dependencies on other layers
Data layer (models, repository implementations, data sources) depends on domain
Presentation layer (UI, Bloc/Cubit) depends on domain
Dependencies point inward (presentation β domain β data)
The goal:
Business logic is independent of frameworks
Testable
Easy to swap implementations (change API, change database, etc.)
What is Feature-First?
Feature-First organizes code by features, not layers:
lib/
core/
network/
api_client.dart
auth/
auth_service.dart
models/
user.dart
features/
auth/
login_screen.dart
auth_bloc.dart
auth_repository.dart
profile/
profile_screen.dart
profile_bloc.dart
profile_repository.dart
settings/
settings_screen.dart
settings_cubit.dart
The rules:
Group by feature, not technical role
Shared code goes in
core/Each feature is self-contained
No strict layer rules
The goal:
Easy to find related code
Fast to add new features
Less ceremony
Notice the difference? Clean Architecture: 3 folders deep before you write code. Feature-First: 1 folder deep.
Auth Flow: Clean Architecture Style
Let me show you a login feature in Clean Architecture:
1. Domain Entity:
// domain/entities/user.dart
class User {
final String id;
final String email;
final String name;
const User({
required this.id,
required this.email,
required this.name,
});
}
2. Data Model:
// data/models/user_model.dart
class UserModel extends User {
const UserModel({
required String id,
required String email,
required String name,
}) : super(id: id, email: email, name: name);
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'],
email: json['email'],
name: json['name'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
};
}
}
3. Repository Interface:
// domain/repositories/auth_repository.dart
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, void>> logout();
Future<Either<Failure, User>> getCurrentUser();
}
4. Repository Implementation:
// data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
final NetworkInfo networkInfo;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, User>> login(String email, String password) async {
if (await networkInfo.isConnected) {
try {
final userModel = await remoteDataSource.login(email, password);
await localDataSource.cacheUser(userModel);
return Right(userModel);
} on ServerException {
return Left(ServerFailure());
}
} else {
return Left(NetworkFailure());
}
}
}
5. Use Case:
// domain/usecases/login_usecase.dart
class LoginUseCase {
final AuthRepository repository;
LoginUseCase(this.repository);
Future<Either<Failure, User>> call(LoginParams params) async {
return await repository.login(params.email, params.password);
}
}
class LoginParams {
final String email;
final String password;
LoginParams({required this.email, required this.password});
}
6. Bloc:
// presentation/bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUseCase loginUseCase;
final LogoutUseCase logoutUseCase;
final GetCurrentUserUseCase getCurrentUserUseCase;
AuthBloc({
required this.loginUseCase,
required this.logoutUseCase,
required this.getCurrentUserUseCase,
}) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
on<LogoutRequested>(_onLogoutRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
final result = await loginUseCase(
LoginParams(email: event.email, password: event.password),
);
result.fold(
(failure) => emit(AuthError(message: _mapFailureToMessage(failure))),
(user) => emit(AuthAuthenticated(user: user)),
);
}
}
7. UI:
// presentation/pages/login_page.dart
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AuthBloc(
loginUseCase: getIt<LoginUseCase>(),
logoutUseCase: getIt<LogoutUseCase>(),
getCurrentUserUseCase: getIt<GetCurrentUserUseCase>(),
),
child: LoginView(),
);
}
}
class LoginView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
if (state is AuthLoading) {
return Center(child: CircularProgressIndicator());
}
return LoginForm(
onSubmit: (email, password) {
context.read<AuthBloc>().add(
LoginRequested(email: email, password: password),
);
},
);
},
);
}
}
Count the files for a simple login: 11 files (Entity, Model, Repository Interface, Repository Impl, Data Source Interface, Data Source Impl, Use Case, Bloc, Event, State, Page).
Auth Flow: Feature-First Style
Now let me show you the same login in Feature-First:
1. Model (in core):
// core/models/user.dart
class User {
final String id;
final String email;
final String name;
const User({
required this.id,
required this.email,
required this.name,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
email: json['email'],
name: json['name'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
};
}
}
2. Repository:
// features/auth/auth_repository.dart
class AuthRepository {
final ApiClient _apiClient;
AuthRepository(this._apiClient);
Future<User> login(String email, String password) async {
try {
final response = await _apiClient.post(
'/auth/login',
body: {'email': email, 'password': password},
);
return User.fromJson(response);
} catch (e) {
throw Exception('Login failed: $e');
}
}
Future<void> logout() async {
await _apiClient.post('/auth/logout');
}
Future<User> getCurrentUser() async {
final response = await _apiClient.get('/auth/me');
return User.fromJson(response);
}
}
3. Bloc:
// features/auth/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repository;
AuthBloc(this._repository) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
on<LogoutRequested>(_onLogoutRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
final user = await _repository.login(event.email, event.password);
emit(AuthAuthenticated(user: user));
} catch (e) {
emit(AuthError(message: e.toString()));
}
}
Future<void> _onLogoutRequested(
LogoutRequested event,
Emitter<AuthState> emit,
) async {
await _repository.logout();
emit(AuthInitial());
}
}
// Events and States (same as Clean Architecture)
abstract class AuthEvent {}
class LoginRequested extends AuthEvent {
final String email;
final String password;
LoginRequested({required this.email, required this.password});
}
class LogoutRequested extends AuthEvent {}
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
AuthAuthenticated({required this.user});
}
class AuthError extends AuthState {
final String message;
AuthError({required this.message});
}
4. UI:
// features/auth/login_screen.dart
class LoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => AuthBloc(
context.read<AuthRepository>(),
),
child: BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
if (state is AuthLoading) {
return Center(child: CircularProgressIndicator());
}
return LoginForm(
onSubmit: (email, password) {
context.read<AuthBloc>().add(
LoginRequested(email: email, password: password),
);
},
);
},
),
);
}
}
Count the files: 3 files (Model in core, Repository, Bloc + Screen).
Same functionality. 8 fewer files. No mappers. No use cases. No interfaces.
Repository Pattern: The Differences
Clean Architecture says:
Repository interface in domain
Repository implementation in data
Abstracts data sources (remote, local, cache)
Returns
Either<Failure, T>for error handling
Feature-First says:
Repository is just a class
No interface needed (YAGNI - You Aren't Gonna Need It)
Throws exceptions for errors
Let the caller handle errors
My take: The interface adds indirection without benefit for most apps. You're not going to swap your Firebase implementation for a different backend. And if you do, search and replace works fine.
The Either<Failure, T> pattern from dartz or fpdart looks nice but adds cognitive overhead. Try/catch is simpler and everyone understands it.
The Entity vs Model Debate
This is where Clean Architecture frustrates me most.
Clean Architecture:
// Domain entity (no dependencies)
class User {
final String id;
final String name;
const User({required this.id, required this.name});
}
// Data model (extends entity, has JSON methods)
class UserModel extends User {
const UserModel({required String id, required String name})
: super(id: id, name: name);
factory UserModel.fromJson(Map<String, dynamic> json) => UserModel(
id: json['id'],
name: json['name'],
);
// Now map everywhere:
User toEntity() => User(id: id, name: name);
}
// In repository:
final userModel = await dataSource.getUser();
return Right(userModel.toEntity()); // Map model to entity
// In use case, it's already an entity
// In Bloc:
final result = await useCase.call();
// Use the entity
The problem:
You're mapping identical data structures back and forth
The entity has no behavior (it's just data)
The model extends the entity, so they're not really separate anyway
You write
.toEntity()everywhere
Feature-First:
// Just one class
class User {
final String id;
final String name;
const User({required this.id, required this.name});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
name: json['name'],
);
Map<String, dynamic> toJson() => {'id': id, 'name': name};
}
Done. No mapping. No confusion about which one to use where.
"But what if the API response is different from your domain model?"
Then make two classes:
class User {
final String id;
final String displayName;
const User({required this.id, required this.displayName});
}
class UserResponse {
final String userId;
final String firstName;
final String lastName;
UserResponse.fromJson(Map<String, dynamic> json)
: userId = json['user_id'],
firstName = json['first_name'],
lastName = json['last_name'];
User toUser() => User(
id: userId,
displayName: '$firstName $lastName',
);
}
Now you map only when the structures actually differ. Not as a ceremony.
Use Cases: Do You Need Them?
Clean Architecture creates a use case for every action:
class LoginUseCase {
final AuthRepository repository;
LoginUseCase(this.repository);
Future<Either<Failure, User>> call(LoginParams params) {
return repository.login(params.email, params.password);
}
}
class LoginParams {
final String email;
final String password;
LoginParams({required this.email, required this.password});
}
This use case just calls the repository. It adds no logic. It's a pass-through.
When use cases make sense:
class LoginUseCase {
final AuthRepository repository;
final AnalyticsService analytics;
final CacheService cache;
Future<User> call(String email, String password) async {
// Clear old cache
await cache.clear();
// Login
final user = await repository.login(email, password);
// Track analytics
await analytics.logLogin(user.id);
// Cache user
await cache.saveUser(user);
return user;
}
}
Now the use case coordinates multiple services. That's useful.
Feature-First approach: If your use case is just calling the repository, call the repository directly from Bloc:
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _repository;
Future<void> _onLoginRequested(LoginRequested event, Emitter emit) async {
emit(AuthLoading());
try {
final user = await _repository.login(event.email, event.password);
emit(AuthAuthenticated(user: user));
} catch (e) {
emit(AuthError(e.toString()));
}
}
}
If you need to coordinate multiple services, add a method to your repository:
class AuthRepository {
final ApiClient _api;
final AnalyticsService _analytics;
final CacheService _cache;
Future<User> login(String email, String password) async {
await _cache.clear();
final user = await _api.login(email, password);
await _analytics.logLogin(user.id);
await _cache.saveUser(user);
return user;
}
}
Or if it's complex, make a separate service:
class AuthService {
final AuthRepository _repository;
final AnalyticsService _analytics;
final CacheService _cache;
Future<User> login(String email, String password) async {
await _cache.clear();
final user = await _repository.login(email, password);
await _analytics.logLogin(user.id);
await _cache.saveUser(user);
return user;
}
}
Same functionality. No ceremony.
Testing Differences
Clean Architecture:
test('should return User when login is successful', () async {
// Arrange
final tUserModel = UserModel(id: '1', email: 'test@test.com', name: 'Test');
final tUser = User(id: '1', email: 'test@test.com', name: 'Test');
when(mockRemoteDataSource.login(any, any))
.thenAnswer((_) async => tUserModel);
when(mockLocalDataSource.cacheUser(any))
.thenAnswer((_) async => Future.value());
when(mockNetworkInfo.isConnected).thenAnswer((_) async => true);
// Act
final result = await repository.login('test@test.com', 'password');
// Assert
expect(result, Right(tUser));
verify(mockRemoteDataSource.login('test@test.com', 'password'));
verify(mockLocalDataSource.cacheUser(tUserModel));
});
Feature-First:
test('should return User when login is successful', () async {
// Arrange
final mockApi = MockApiClient();
final repository = AuthRepository(mockApi);
when(mockApi.post('/auth/login', body: any))
.thenAnswer((_) async => {'id': '1', 'email': 'test@test.com', 'name': 'Test'});
// Act
final user = await repository.login('test@test.com', 'password');
// Assert
expect(user.id, '1');
expect(user.email, 'test@test.com');
verify(mockApi.post('/auth/login', body: any)).called(1);
});
Same test coverage. Less setup. Less mocking.
For Bloc tests:
Both approaches use bloc_test the same way:
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthAuthenticated] when login succeeds',
build: () {
when(() => mockRepository.login(any(), any()))
.thenAnswer((_) async => testUser);
return AuthBloc(mockRepository);
},
act: (bloc) => bloc.add(LoginRequested(
email: 'test@test.com',
password: 'password',
)),
expect: () => [
AuthLoading(),
AuthAuthenticated(user: testUser),
],
);
The testing approach doesn't change much between architectures.
When to Use Clean Architecture
Clean Architecture makes sense when:
1. Your business logic is really complex
Not just CRUD operations
Many domain rules (e.g., financial calculations, workflow engines)
Multiple ways data can be manipulated
2. You need to swap implementations frequently
Actually switching between different backends
A/B testing different data sources
Migrating from one service to another incrementally
3. Your team is large (10+ developers)
Strict boundaries help prevent conflicts
Clear contracts between layers
Multiple teams working on different layers
4. You're building a long-term enterprise app
5+ year timeline
Formal requirements documentation
5. Your domain experts are not developers
Domain layer becomes a communication tool
Entities represent business concepts
Use cases map to business processes
- Example: A banking app with loan calculations, fraud detection, transaction rules, multiple payment gateways, and regulatory requirements.
When to Use Feature-First
Feature-First makes sense when:
1. You're building a typical mobile app
Fetch data from API β Show it on screen
Submit forms
Handle auth
2. Your team is small to medium (2-8 developers)
Everyone can see the whole codebase
Less overhead in coordination
Faster onboarding
3. You need to move fast
Startup environment
Frequent pivots
MVP development
4. Your business logic is simple
Backend does the heavy lifting
Client is mostly a view layer
5. You want pragmatic, not dogmatic architecture
Add structure when needed
Start simple, refactor when it hurts - YAGNI principle
Example: A social media app, e-commerce app, content app, fitness tracker, most CRUD apps. This covers 90% of mobile apps.
What I Actually Use
For my team of 5 developers, we use Feature-First with these additions:
Folder structure:
lib/
core/
network/
api_client.dart
api_exception.dart
models/
user.dart
pagination.dart
services/
auth_service.dart
storage_service.dart
widgets/
loading_indicator.dart
error_view.dart
features/
auth/
login_screen.dart
register_screen.dart
auth_bloc.dart
auth_repository.dart
home/
home_screen.dart
home_bloc.dart
home_repository.dart
profile/
profile_screen.dart
profile_bloc.dart
profile_repository.dart
main.dart
app.dart
Our patterns:
- Repositories handle API calls
class HomeRepository {
final ApiClient _api;
Future<List<Post>> getPosts() async {
final response = await _api.get('/posts');
return (response as List).map((e) => Post.fromJson(e)).toList();
}
}
- Blocs handle state and business logic
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final HomeRepository _repository;
HomeBloc(this._repository) : super(HomeInitial()) {
on<HomePostsRequested>(_onPostsRequested);
}
Future<void> _onPostsRequested(
HomePostsRequested event,
Emitter<HomeState> emit,
) async {
emit(HomeLoading());
try {
final posts = await _repository.getPosts();
emit(HomeLoaded(posts: posts));
} catch (e) {
emit(HomeError(message: e.toString()));
}
}
}
Shared code in core/
Network client
Common models (User, pagination)
Services used across features (auth, storage, analytics)
Reusable widgets
Features are self-contained
Feature-specific models stay in the feature
Only promote to core/ when shared by 2+ features
Each feature has its own repository and bloc
No interfaces unless needed
If we actually need to mock something, we add an interface
Most of the time, we just mock the class directly in tests
This works because:
Features are isolated, easy to find
New developers can contribute on day 1
Adding a feature is much faster
Refactoring is straightforward
Tests are simple to write
When We Add Structure
We don't have strict rules. We add structure when we feel pain:
"This repository is getting too big" β Split it into multiple repositories or services
"Multiple features use the same model" β Move the model to core/models/
"This business logic is complex and needs testing" β Extract to a separate service class
"We need to swap implementations for testing" β Add an interface for just that class
We don't add these things preemptively. We add them when they solve a real problem.
Common Objections to Feature-First
"But Feature-First doesn't scale!"
It scales fine for small to medium teams. We've built apps with 50+ features and it's still easy to navigate. If you're at Google scale, sure, use Clean Architecture. Most of us aren't.
"But you're tightly coupling your code!"
Sure, thatβs a tradeoff, but real decoupling comes from good boundaries (features don't depend on each other, shared code is in core/), not from layers.
"But you can't test your business logic!"
Yes you can. Your Bloc and Repository are testable. You mock the repository when testing Bloc. You mock the API client when testing repository. Same as Clean Architecture, just fewer files.
"But what about SOLID principles?"
SOLID is about good design, not about layers. You can follow SOLID with Feature-First:
Single Responsibility: Each class has one job
Open/Closed: Use composition, not inheritance
Liskov Substitution: Not really relevant (we're not doing deep inheritance)
Interface Segregation: Create interfaces when you need them
Dependency Inversion: Depend on abstractions (e.g. ApiClient interface, not Dio directly)
You don't need domain/data/presentation layers to follow SOLID.
Final Thoughts
Clean Architecture is a good pattern. It's well-documented, battle-tested, and has clear benefits in the right context.
But for most Flutter apps, it's overkill.
Your app probably doesn't need:
Three layers with strict dependency rules
Separate entities and models for identical data
Use cases that just call repositories
Interfaces for every repository
Your app probably does need:
Clear feature boundaries
Shared code in a common module
Testable business logic
Easy onboarding for new developers
Feature-First gives you these without the boilerplate.
Start simple. Add complexity when it solves a problem. Not before.