Skip to main content

Command Palette

Search for a command to run...

Flutter Architecture Patterns: Clean Architecture vs Feature-First

Updated
β€’13 min read
Y

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:

  1. 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();
     }
   }
  1. 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()));
       }
     }
   }
  1. Shared code in core/

    • Network client

    • Common models (User, pagination)

    • Services used across features (auth, storage, analytics)

    • Reusable widgets

  2. 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

  3. 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.