Muhammad Uzair
Architecture

Flutter Clean Architecture: A Pragmatic Folder Structure

Published: 2026-10-14 · 8 min read

A pragmatic flutter clean architecture folder structure: feature-first domain, data, and presentation layers, real Dart examples, and what to skip.

Every Flutter project starts the same way: a lib/main.dart, a couple of screens, and API calls living directly inside widgets. It works for a week. Then the second feature arrives, the third screen needs the same data, and you discover that your "quick" HTTP call is now copy-pasted in four places with three different error-handling strategies. Flutter clean architecture is the way out — but most tutorials teach it as a ceremony of empty folders. This guide is the pragmatic version I actually ship: a feature-first folder structure with just enough layers to keep large apps maintainable, and nothing you don't need.

What Clean Architecture Means in Flutter

The idea comes from Robert Martin's Clean Architecture, and the core rule fits on one line: dependencies point inward. Your app has three layers:

  • Presentation — widgets, pages, and state management. Knows about domain, never about data.
  • Domain — entities (your business objects), repository interfaces, and use cases. Knows about nothing else. This is the stable core.
  • Data — models (JSON shapes), API clients, local databases, and repository implementations. Knows about domain, never about presentation.

The payoff: you can swap Dio for http, or a REST API for Firebase, and the UI never notices — because the UI only ever talks to the domain layer. The cost, if you're not careful, is a maze of one-line classes. The structure below is calibrated to pay the cost only where it buys something.

The Folder Structure (Feature-First)

Forget the old "folders by type" layout (lib/screens, lib/widgets, lib/services) that turns into a junk drawer. Organize by feature, with the three layers inside each feature:

lib/
├── core/
│   ├── error/          # Failure and Exception classes
│   ├── usecase/        # Base UseCase contract
│   ├── network/        # Dio client, interceptors
│   ├── di/             # Service locator setup
│   └── theme/          # App-wide theming
├── 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 (interface)
│       │   └── usecases/         # sign_in.dart
│       └── presentation/
│           ├── bloc/             # auth_cubit.dart, auth_state.dart
│           ├── pages/            # login_page.dart
│           └── widgets/          # login_form.dart
└── main.dart

core/ holds what every feature shares. features/ holds vertical slices — each feature is a mini-app you can understand, test, and eventually delete without touching the others. New feature? Copy a feature folder, rename, done.

Domain Layer: Entities, Interfaces, Use Cases

The domain layer is pure Dart — no Flutter imports, no JSON, no HTTP. That purity is what makes it testable in milliseconds.

Entity — the business object, framework-free:

class User {
  final String id;
  final String name;
  final String email;

  const User({required this.id, required this.name, required this.email});
}

Repository interface — what the feature needs, not how it's done:

abstract class AuthRepository {
  Future<Either<Failure, User>> signIn(String email, String password);
  Future<Either<Failure, void>> signOut();
  Stream<User?> authStateChanges();
}

Returning Either<Failure, User> (from dartz) instead of throwing makes failure a value the compiler forces you to handle. No more "it crashed because nobody caught the exception."

Use case — one class, one job, one reason to change:

class SignIn implements UseCase<User, SignInParams> {
  final AuthRepository repository;

  SignIn(this.repository);

  @override
  Future<Either<Failure, User>> call(SignInParams params) {
    return repository.signIn(params.email, params.password);
  }
}

class SignInParams {
  final String email;
  final String password;

  const SignInParams(this.email, this.password);
}

A shared base contract in core/usecase/ keeps them uniform:

abstract class UseCase<Type, Params> {
  Future<Either<Failure, Type>> call(Params params);
}

Be pragmatic here: write a use case class when it encapsulates real logic — validation, combining two repositories, caching policy. If a use case would be a one-line pass-through to the repository forever, call the repository directly from your cubit. Ceremony without logic is just indirection.

Data Layer: Models, Datasources, Implementations

The data layer speaks the outside world's language — JSON keys, HTTP status codes, database rows — and translates it into domain entities.

Model — the JSON shape, extending or converting to the entity:

class UserModel extends User {
  const UserModel({
    required super.id,
    required super.name,
    required super.email,
  });

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

  Map<String, dynamic> toJson() => {
        'id': id,
        'name': name,
        'email': email,
      };
}

Datasource — raw I/O, throws exceptions, knows nothing about Failure:

class AuthRemoteDataSource {
  final Dio client;

  AuthRemoteDataSource(this.client);

  Future<UserModel> signIn(String email, String password) async {
    final response = await client.post(
      '/auth/login',
      data: {'email': email, 'password': password},
    );
    if (response.statusCode == 200) {
      return UserModel.fromJson(response.data);
    }
    throw ServerException('Login failed: ${response.statusCode}');
  }
}

Repository implementation — the translator. Catches exceptions, returns Either:

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;

  AuthRepositoryImpl(this.remoteDataSource);

  @override
  Future<Either<Failure, User>> signIn(String email, String password) async {
    try {
      final user = await remoteDataSource.signIn(email, password);
      return Right(user);
    } on ServerException catch (e) {
      return Left(ServerFailure(e.message));
    } on SocketException {
      return const Left(NetworkFailure('No internet connection'));
    }
  }
}

This is the single most valuable file in the pattern. Every API quirk — status codes, timeouts, offline handling — is quarantined here, and the presentation layer only ever sees Failure values it can render.

Presentation Layer: Pages, Widgets, State Management

Presentation depends on domain only. It never imports a model or a datasource — that import is the canary: if a widget file imports from data/, the architecture is broken.

A cubit keeps the example small (the same shape works with BLoC, Riverpod, or GetX):

class AuthCubit extends Cubit<AuthState> {
  final SignIn signIn;

  AuthCubit(this.signIn) : super(AuthInitial());

  Future<void> login(String email, String password) async {
    emit(AuthLoading());
    final result = await signIn(SignInParams(email, password));
    result.fold(
      (failure) => emit(AuthError(failure.message)),
      (user) => emit(AuthAuthenticated(user)),
    );
  }
}

The page wires it up with a BlocProvider at the route level so the cubit dies with the screen:

BlocProvider(
  create: (context) => sl<AuthCubit>(),
  child: const LoginPage(),
)

And LoginPage itself stays dumb: form fields, a button that calls context.read<AuthCubit>().login(...), and a BlocConsumer that renders states and shows a snackbar on AuthError. All branching logic lives in the cubit; all pixels live in the page and its widgets.

Wiring It Together: Dependency Injection

Clean architecture without DI is just well-organized pain — you'd hand-construct repositories five layers deep. get_it as a service locator keeps main.dart honest:

final sl = GetIt.instance;

Future<void> init() async {
  // Presentation
  sl.registerFactory(() => AuthCubit(sl()));

  // Domain
  sl.registerLazySingleton(() => SignIn(sl()));

  // Data
  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(sl()),
  );
  sl.registerLazySingleton(() => AuthRemoteDataSource(sl()));

  // Core
  sl.registerLazySingleton(() => Dio(BaseOptions(baseUrl: apiBaseUrl)));
}

Registration order follows the dependency rule: core first, then data, then domain, then presentation. When you swap the REST datasource for Firebase, you change one registration line and one class — the domain and presentation layers don't move.

What to Skip: The Pragmatism Rules

Architecture is a budget. Spend it where the app is complex, not where the tutorial says to:

  1. Skip use case classes for trivial features. A settings screen that reads one repository method doesn't need a GetSettings class. Call the repository from the cubit and move on.
  2. Skip separate entities when the model IS the entity. If your JSON shape and your business object are identical and stable, one class is fine — split them the day the API shape diverges.
  3. Don't create a feature folder for one screen. A static "About" page lives fine in core/ or a common feature. Folders earn their existence at two or three related screens.
  4. Don't abstract the datasource until the second implementation. One REST API means one datasource class. Add the interface when Firebase or a cache actually arrives.

The test is always the same: does this abstraction protect against a change I can name? If not, it's speculative, and speculative abstractions are just tech debt with good intentions.

Naive Structure vs Clean Architecture

Concern Naive (folders by type) Clean (features by layer)
Where API calls live Inside widgets or "services" Datasources, behind a repository interface
Changing API provider Touch every screen Change one datasource + one DI line
Unit-testing business logic Requires widget tests or mocks of everything Pure-Dart domain tests, no Flutter needed
Finding feature code Grep across screens/, widgets/, services/ One folder: features/auth/
Error handling Inconsistent, per-screen Centralized: exceptions become Failure in the repository
Onboarding a new dev "Good luck, it's all in lib/" "Open features/, pick a slice, read inward"

Pitfalls Checklist

  • Models leaking into the UI. The moment a widget imports from data/models/, the API shape is coupled to your pixels. Map to entities at the repository boundary — no exceptions.
  • Exceptions crossing layers. Datasources throw; repositories catch and return Failure. If a SocketException ever reaches a widget, the quarantine failed.
  • Anemic domain, fat cubits. If your cubit is 300 lines and your use cases are one-liners, the logic is in the wrong layer. Push decisions down into the domain.
  • Circular imports. data importing presentation (or domain importing data) means a layer boundary is drawn wrong. The compiler will tell you — listen to it.
  • Over-foldering tiny features. Three layers for a screen with one button is not architecture, it's origami. Match the structure to the feature's actual complexity.
  • DI registration order bugs. Registering AuthCubit before its SignIn dependency throws at startup, not at the call site. Follow the order: core → data → domain → presentation.

Start Small, Grow Into It

You don't need all of this on day one. Start with the folder skeleton and the dependency rule — presentation imports domain, domain imports nothing — and add models, use cases, and DI as the feature earns them. Flutter clean architecture done pragmatically isn't about the folders. It's about one habit: never let the outside world past the repository boundary. Everything else is bookkeeping.

Building something that needs to scale past an MVP? I architect production Flutter apps with exactly this structure — let's talk.

Frequently Asked Questions

Is clean architecture overkill for small Flutter apps?

The full ceremony can be, but the core habits scale down well: feature-first folders and the rule that the UI never talks directly to APIs. Start with the folder skeleton and the dependency rule, then add models, use cases, and DI as each feature earns them.

What is the difference between an entity and a model in Flutter clean architecture?

An entity is a framework-free business object in the domain layer; a model is the JSON or database shape in the data layer. Split them the day the API shape diverges from what your business logic needs - until then, one class is fine.

Where does state management live in clean architecture?

In the presentation layer. Cubits, blocs, or notifiers call use cases or repositories and emit UI states; they never import models or datasources. Wire them with a service locator like get_it so the UI stays dumb.

Do I need a UseCase class for every feature?

No. Write a use case class when it encapsulates real logic - validation, combining repositories, caching policy. If it would be a permanent one-line pass-through to the repository, call the repository directly from your state management.

Related Articles

← Back to all articles