Skip to main content
FlutterArchitectureTutorial

Implementing Clean Architecture in Flutter: A Practical Guide

January 10, 2024
12 min read
Share:

Stop overcomplicating it. Here's what Clean Architecture actually means in Flutter, explained like a friend would over coffee.


So you've been building Flutter apps for a while. Maybe you've shipped a few. Things are going well — until they're not. Your codebase starts looking like a plate of hoppers someone dropped on the floor. Business logic mixed with UI code, API calls hiding inside widgets, and every time you want to change one thing, three other things break.

Sound familiar? Yeah, me too.

That's exactly the problem Clean Architecture solves. And no, it's not as scary as those 47-layer diagrams on Medium make it look.

What Even Is Clean Architecture?

Forget the fancy diagrams for a second.

Clean Architecture is basically organizing your code so that each piece has one job, and changing one piece doesn't blow up everything else.

That's it. That's the core idea.

Uncle Bob (Robert C. Martin) came up with this concept, and while he was thinking about software in general, the Flutter community adopted it beautifully. The idea is simple: separate your app into layers, and make sure those layers only talk to each other in a specific direction.

Think of it like a restaurant:

  • The customer (UI) doesn't need to know how the kitchen works
  • The waiter (Use Cases) takes orders and delivers food — they connect the two worlds
  • The kitchen (Data layer) does the actual cooking and gets ingredients from suppliers

If the restaurant changes its supplier, the customer doesn't care. If they redecorate the dining room, the kitchen doesn't care. Everyone minds their own business.

The Three Layers (Yes, Just Three)

Here's where people overcomplicate things. At its heart, Flutter Clean Architecture has three main layers:

1. Presentation Layer (The Face)

This is everything the user sees and touches. Your widgets, your screens, your state management (BLoC, Riverpod, Provider — whatever you prefer).

This layer's only job is to display things and react to user actions. It should NOT be making API calls, parsing JSON, or deciding business rules.

lib/
  features/
    authentication/
      presentation/
        pages/
          login_page.dart
          register_page.dart
        widgets/
          login_form.dart
        bloc/
          auth_bloc.dart
          auth_event.dart
          auth_state.dart

Your LoginPage shows a form. When the user taps "Login," it tells the BLoC. The BLoC talks to a use case. The page doesn't know or care where the data comes from. Could be an API. Could be a local database. Could be a hamster running on a wheel generating tokens. The page doesn't care.

2. Domain Layer (The Brain)

This is the heart of your app. It contains:

  • Entities — pure Dart objects that represent your core business concepts (User, Product, Order)
  • Use Cases — single actions your app can perform (LoginUser, GetProducts, PlaceOrder)
  • Repository Interfaces — contracts that say "I need data, but I don't care how you get it"

Here's the beautiful part: this layer has ZERO dependencies on Flutter, any packages, or any external library. It's pure Dart. You could theoretically take this folder and drop it into a command-line app, and it would still work.

// entities/user.dart
class User {
  final String id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});
}
// repositories/auth_repository.dart (just an interface!)
abstract class AuthRepository {
  Future<User> login(String email, String password);
  Future<void> logout();
}
// usecases/login_user.dart
class LoginUser {
  final AuthRepository repository;

  LoginUser(this.repository);

  Future<User> call(String email, String password) {
    return repository.login(email, password);
  }
}

See how LoginUser doesn't know anything about HTTP, SharedPreferences, or Firebase? It just knows there's a repository that can log people in. How that happens is someone else's problem.

3. Data Layer (The Worker)

This is where the real work happens. API calls, database queries, caching logic, JSON parsing — all of it lives here.

This layer contains:

  • Models — data classes that know how to convert to/from JSON, database rows, etc.
  • Data Sources — the classes that actually talk to APIs, databases, and local storage
  • Repository Implementations — the concrete implementations of those interfaces from the domain layer
// models/user_model.dart
class UserModel extends User {
  UserModel({required String id, required String name, required String email})
      : super(id: id, name: name, email: email);

  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'],
      name: json['name'],
      email: json['email'],
    );
  }

  Map<String, dynamic> toJson() {
    return {'id': id, 'name': name, 'email': email};
  }
}
// datasources/auth_remote_datasource.dart
class AuthRemoteDataSource {
  final http.Client client;

  AuthRemoteDataSource(this.client);

  Future<UserModel> login(String email, String password) async {
    final response = await client.post(
      Uri.parse('https://api.myapp.com/login'),
      body: {'email': email, 'password': password},
    );

    if (response.statusCode == 200) {
      return UserModel.fromJson(json.decode(response.body));
    } else {
      throw ServerException();
    }
  }
}
// repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  final AuthLocalDataSource localDataSource;

  AuthRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
  });

  @override
  Future<User> login(String email, String password) async {
    final user = await remoteDataSource.login(email, password);
    await localDataSource.cacheUser(user);
    return user;
  }
}

The Dependency Rule (The One Rule to Remember)

If you forget everything else, remember this:

Dependencies point inward.

The Presentation layer depends on the Domain layer. The Data layer depends on the Domain layer. But the Domain layer depends on nothing. It's the center of your universe.

Presentation → Domain ← Data

That's why the Domain layer defines repository interfaces (abstract classes) instead of concrete implementations. The Data layer provides the actual implementations, and we wire them together using dependency injection.

Your Folder Structure

Here's what a real project might look like:

lib/
├── core/
│   ├── error/
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── network/
│   │   └── network_info.dart
│   └── usecases/
│       └── usecase.dart
│
├── features/
│   ├── authentication/
│   │   ├── data/
│   │   │   ├── datasources/
│   │   │   ├── models/
│   │   │   └── repositories/
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   ├── repositories/
│   │   │   └── usecases/
│   │   └── presentation/
│   │       ├── bloc/
│   │       ├── pages/
│   │       └── widgets/
│   │
│   └── products/
│       ├── data/
│       ├── domain/
│       └── presentation/
│
├── injection_container.dart
└── main.dart

Each feature is self-contained. Want to delete the products feature? Remove the folder. Done. Nothing else breaks.

"Isn't This Too Much Code for a Simple App?"

Honestly? Yes, sometimes.

If you're building a simple to-do app or a weekend project, Clean Architecture is overkill. It's like wearing a three-piece suit to go buy groceries. Technically fine, but unnecessary.

Clean Architecture shines when:

  • Your app has complex business logic (not just CRUD)
  • Multiple developers are working on the same codebase
  • You need to write tests (and you should)
  • The app will be maintained and grow over time
  • You might swap out your backend, database, or state management solution

If your app is going to live for more than six months and has more than a handful of screens, the upfront investment pays for itself many times over.

Error Handling — The Part Nobody Talks About

Here's something that separates a tutorial project from a production app: proper error handling.

In Clean Architecture, we typically use the Either type from the dartz package (or fpdart if you prefer). Instead of throwing exceptions everywhere, your use cases return either a Failure or a Success.

// In your repository interface
abstract class AuthRepository {
  Future<Either<Failure, User>> login(String email, String password);
}
// In your use case
class LoginUser {
  final AuthRepository repository;

  LoginUser(this.repository);

  Future<Either<Failure, User>> call(String email, String password) {
    return repository.login(email, password);
  }
}
// In your BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUser loginUser;

  AuthBloc({required this.loginUser}) : super(AuthInitial()) {
    on<LoginRequested>((event, emit) async {
      emit(AuthLoading());

      final result = await loginUser(event.email, event.password);

      result.fold(
        (failure) => emit(AuthError(failure.message)),
        (user) => emit(AuthSuccess(user)),
      );
    });
  }
}

No try-catch spaghetti. No uncaught exceptions crashing your app at 2 AM. Clean, predictable error flow.

Dependency Injection — Wiring It All Together

All these layers need to be connected somehow. That's where dependency injection comes in. You can use get_it, injectable, or even Riverpod's providers.

// injection_container.dart
final sl = GetIt.instance;

void init() {
  // BLoC
  sl.registerFactory(() => AuthBloc(loginUser: sl()));

  // Use Cases
  sl.registerLazySingleton(() => LoginUser(sl()));

  // Repositories
  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(
      remoteDataSource: sl(),
      localDataSource: sl(),
    ),
  );

  // Data Sources
  sl.registerLazySingleton(() => AuthRemoteDataSource(client: sl()));
  sl.registerLazySingleton(() => AuthLocalDataSource(sharedPreferences: sl()));

  // External
  sl.registerLazySingleton(() => http.Client());
}

Everything is wired up in one place. Want to swap your real API for a mock during testing? Change one line.

Testing — The Real Payoff

Here's where Clean Architecture really proves its worth. Because everything is loosely coupled with clear interfaces, testing becomes almost enjoyable (I said almost).

// Testing your use case — no API needed, no database, no Flutter
class MockAuthRepository extends Mock implements AuthRepository {}

void main() {
  late LoginUser loginUser;
  late MockAuthRepository mockRepository;

  setUp(() {
    mockRepository = MockAuthRepository();
    loginUser = LoginUser(mockRepository);
  });

  test('should return User when login is successful', () async {
    final testUser = User(id: '1', name: 'Anjana', email: 'test@test.com');

    when(() => mockRepository.login('test@test.com', 'password123'))
        .thenAnswer((_) async => Right(testUser));

    final result = await loginUser('test@test.com', 'password123');

    expect(result, Right(testUser));
  });
}

You can test your business logic without spinning up a server, without a database, without even importing Flutter. That's powerful.

Common Mistakes I See Developers Make

After working with Clean Architecture for a while, here are the pitfalls I keep seeing:

Putting business logic in the BLoC. Your BLoC should be thin. It receives events, calls use cases, and emits states. That's it. If you have if-else chains and calculations in your BLoC, that logic probably belongs in a use case.

Making use cases too granular or too broad. A use case called GetUserById makes sense. A use case called GetUserByIdAndCheckIfTheyHaveASubscriptionAndFormatTheirName doesn't. But also, a use case called DoEverything is equally bad. Find the balance.

Skipping the domain layer. "I'll just call the repository directly from the BLoC." Don't. The domain layer is what makes this whole thing work. Skip it, and you've just created fancy folders with no real separation.

Over-engineering from day one. You don't need to implement every pattern for every feature on day one. Start with the structure, add complexity as you need it.

Final Thoughts

Clean Architecture isn't about following rules blindly. It's about organizing your code so that future-you (or your teammates) can actually work with it without wanting to throw their laptop out the window.

Start simple. Get the three layers right. Make sure dependencies point inward. Write tests for your use cases. Everything else is refinement.

The best architecture is the one your team understands and can maintain. Clean Architecture gives you a solid foundation, but don't be afraid to adapt it to your project's needs. There's no architecture police coming to arrest you for having a slightly different folder structure.

Now go build something great — and keep it clean.


Have questions about implementing Clean Architecture in your Flutter project? Connect with me on Twitter or LinkedIn!

Comments

Comments section coming soon! For now, feel free to reach out on Twitter or LinkedIn.