Flutter Clean Architecture: Structuring Code for the Restless Developer Mind 🧠

MJ

June 9, 2025

Published by Manu Junjanna

Bright HR SVG

If you've ever opened an old Flutter project and found yourself wading through a jungle of widgets, services, and mysterious global variables, you're not alone. It starts innocently: just a screen here, a button there, but before you know it, everything's tangled together, and you are lost. That’s where Clean Architecture comes into the picture. It's not a buzzword, but a practical approach to organize your codebase in a way that's scalable, testable, and easy to maintain over time.

šŸ’ Why Clean Architecture is neededā“

In yogic philosophy, the mind is often described as "Markata", a Sanskrit word for monkey. Like a monkey, the mind is restless, constantly jumping from one thought to another. It's curious, impulsive, and always seeking something new.

As developers, we experience this same restlessness when we write code. We finish a feature, and moments later, thoughts creep in:

  • "Maybe I should refactor this…"
  • "What if we used a different approach?"
  • "This could be cleaner, faster, better…"

This is the Markata within us, the ever-wandering mind that seeks improvement. But without structure, this drive can quickly spiral into chaos. That’s why we need an architecture that embraces change instead of resisting it.

In reality, software is never truly finished. Requirements evolve. Technologies shift. That's why our code should be decoupled and modular, allowing us to adapt with minimal disruption. For example:

  • Today you might be using a REST API, but tomorrow you may need to move to gRPC. With clean architecture, you only need to update the data layer, not the entire app.

  • You might start with Stacked State managment for its simplicity, but later realize you need the scalability and modularity of Bloc. A well-structured architecture lets you swap this out cleanly.

  • Maybe you're using Material Design now, but later want to adopt platform-specific UI for a more native feel. With proper separation, you only need to adjust the presentation layer.

Clean architecture gives your code the flexibility to evolve without fighting against itself.

Understanding the Structure

Clean Architecture Flow

Clean Architecture is typically divided into three main layers: Domain, Data, and Presentation.

1. Domain Layer (The Core of the App)

This is the most important and independent part of your app. It doesn't depend on anything else.

  • Entities: These are the core business models or objects (like User, Product, etc.).
  • Repository Interfaces: Repository interfaces define what operations are available such as getUserById(), without specifying how those operations are implemented.
  • Use Cases: These define the business logic, the actions your app can perform (like LoginUser, PlaceOrder, etc.).

Think of this layer as the brain of your app. it defines the rules, logic, and core behaviors, independent of any external systems.

2. Data Layer (How Data is Handled)

This layer is responsible for getting and saving data, whether from a database, API, or local storage.

  • Models (or Data Entities): These are data structures that match the format of the data source (like JSON from an API).
  • Repository Implementations: These are the actual implementations of the interfaces from the Domain layer. They define how to fetch or save data.

The Data layer relies on the Domain layer to define contracts and logic, but the Domain layer remains completely independent. This one-way dependency ensures that core business rules are unaffected by changes in data sources

3. Presentation Layer (What the User Sees)

This is the UI layer — everything the user interacts with.

  • Widgets / Screens / Views: These are the visual components.
  • State Management: This handles how the UI reacts to changes (like loading, success, or error states). It uses the Use Cases from the Domain layer to perform actions.

Understanding with example

Before starting, it's important to understand how to structure your folders. Below are a few common approaches to organizing your project using clean architecture principles:

Clean Architecture Folder Strcture

The folder structure can vary depending on the nature of your project and what makes development easier and more maintainable for your team.

For this blog, we'll follow the Feature-First Approach. We'll implement a simple login flow where the user taps the login button, enters their credentials, and receives an authentication token upon successful login.

Domain

Always start with the independant layer i,e domain layer, lets start with defining entities followed by repositories interface and use cases

lib/feature/login/domain/entities/user.dart

class Auth {
  final String? accessToken;
  final String? refreshToken;

  Auth({this.accessToken, this.refreshToken});
}

lib/feature/login/domain/repositories/auth_repository.dart

abstract class AuthRepository {
  Future<Auth> login(String email, String password);
}

lib/feature/login/domain/usecase/login_usecase.dart

class LoginUseCase {
  final AuthRepository repository;

  LoginUseCase(this.repository);

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

As you can see, this code is completely decoupled and independent of any external layers. It defines the core business rules and contracts that the rest of the application must adhere to.

Data

With the domain layer defining the core logic and contracts, the data layer comes next, responsible for fetching and transforming data. It depends on the domain to ensure consistent business rules, regardless of how or where the data is sourced

lib/feature/login/data/model/auth_model.dart

import '../../domain/entities/auth.dart';

class AuthModel extends Auth {
// AuthModel can include serialization logic or other data-specific methods
// that are only relevant within the data layer, while still conforming to the Auth entity.
  AuthModel({
    super.accessToken,
    super.refreshToken,
  });

  factory AuthModel.fromJson(Map<String, dynamic> json) {
    return AuthModel(
      accessToken: json['access_token'] as String?,
      refreshToken: json['refresh_token'] as String?,
    );
  }
}


lib/feature/login/data/datasources/auth_remote_data_source.dart

abstract class AuthRemoteDataSource {
  Future<AuthModel> login(String email, String password);
}

class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
  final http.Client client;

  AuthRemoteDataSourceImpl(this.client);

  @override
  Future<AuthModel> login(String email, String password) async {
    final response = await client.post(
      Uri.parse('https://yourapi.com/login'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email, 'password': password}),
    );

    if (response.statusCode == 200) {
      return AuthModel.fromJson(jsonDecode(response.body));
    } else {
      throw Exception('Login failed');
    }
  }
}

lib/feature/login/repositories/auth_repository_impl.dart

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;

  AuthRepositoryImpl(this.remoteDataSource);

  @override
  Future<Auth> login(String email, String password) {
    return remoteDataSource.login(email, password);
  }
}

Presentation

Once the Domain and Data layers are in place, we move on to the Presentation layer. To keep things simple, we'll use setState instead of any state management libraries. Regardless of the approach, the core interaction remains the same: using UseCases to drive the UI.

First, let's initialize all the dependencies using dependency injection (DI) with the help of get_it.

lib/feature/login/presentation/di/dependency_injection.dart

void initDependencies() {
Ā  
Ā  Get.lazyPut(() => http.Client());

Ā  // Data sources
Ā  Get.lazyPut(() => AuthRemoteDataSourceImpl(Get()));
Ā  Get.lazyPut(() => AuthLocalDataSourceImpl());

Ā  // Repository
Ā  Get.lazyPut(() => AuthRepositoryImpl(
Ā Ā Ā Ā Ā Ā Ā  remoteDataSource: Get(),
Ā Ā Ā Ā Ā Ā Ā  localDataSource: Get(),
Ā Ā Ā Ā Ā  ));

Ā  // UseCase
Ā  Get.lazyPut(() => LoginUseCase(Get()));
}


class LoginButton extends StatelessWidget {
  const LoginButton({super.key});

 
  void _handleLogin() async {
    .....    
      final Auth auth = await Get.find<LoginUseCase>(
        _emailController.text.trim(),
        _passwordController.text.trim(),
      );
    ...
  
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ......
          ElevatedButton(
            onPressed: _handleLogin,
            child: const Text('Login'),
          ),
        
        ......
      ],
    );
  }
}

In summary, the LoginButton calls the LoginUseCase to perform the login operation. The LoginUseCase delegates the task to the AuthRepository, which then uses the AuthRemoteDataSource to make the actual API call. Once the API responds, the result is passed back through the layers and returned to the LoginButton as an Auth entity.

This clear separation of concerns ensures that each layer is responsible only for its specific tasks, making your codebase easier to maintain, test, and extend.



Just as the wandering "Markata" mind finds peace through discipline and structure, our codebases thrive when guided by clear architectural boundaries. Clean Architecture isn’t just a technical choice, it’s a way to future-proof our projects and welcome change without fear. By investing in well-defined layers and feature-first organization, you create software that’s not only easier to build and test today, but also far more adaptable for whatever tomorrow brings.

So, the next time your codebase starts to feel like a jungle, remember: structure brings clarity, and clarity brings calm. Embrace Clean Architecture and let your Flutter projects evolve with grace and confidence



While Clean Architecture lays the foundation but building a truly robust Flutter app involves many other best practices. Here are some key principles and habits to elevate your code quality, performance, and developer experience:

  • Use pure functions for predictable, testable logic.
  • Implement robust function-level error handling with try-catch or Result types.
  • Prefer immutable data structures to avoid unintended side effects.
  • Use dependency injection (DI) with tools like get_it or Riverpod for modularity.
  • Structure routing cleanly using go_router or auto_route with deep linking support.
  • Leverage Dart extensions to enhance readability and reuse logic.
  • Use isolates or compute() for heavy computations to keep UI responsive.
  • Adopt effective state management (e.g., Bloc, Riverpod, Cubit) based on app complexity.
  • Keep widgets small, focused, and composable for better maintainability.
  • Follow Dart style guide and enforce with flutter analyze and dart format.
  • Write meaningful tests for logic, UI, and integration layers.
  • Avoid magic numbers and hardcoded strings—use constants and localization.
  • Document complex logic and public APIs with clear comments.
  • Use late, final, and required smartly to enforce null safety and intent.

Remember, in Flutter, the performance of your app’s UI is inversely proportional to the number of times widgets are rebuilt. If you want a high-performance app, focus on minimizing unnecessary widget rebuilds.

Performance of app

Registered Office: Bright HR Limited, The Peninsula, Victoria Place, Manchester, M4 4FB. Registered in England and Wales No: 9283467. Tel: 0844 892 3928. I Copyright Ā© 2024 BrightHR