Flutter clean architecture — what actually works in production (not just tutorials)
I have shipped over fifty Flutter apps. Not fifty tutorials. Not fifty counter-app demos with a neat folder tree screenshot for LinkedIn…
Flutter clean architecture — what actually works in production (not just tutorials)
I have shipped over fifty Flutter apps. Not fifty tutorials. Not fifty counter-app demos with a neat folder tree screenshot for LinkedIn. Fifty production apps with real users, real deadlines, and real bugs that showed up at 2 AM because a repository was silently swallowing exceptions.
Somewhere around app number fifteen, I stopped copying architecture templates from Medium articles. I started building a system based on what actually survived contact with a growing codebase. That system is what I want to walk through here.
What clean architecture actually solves
The pitch is always the same: separation of concerns, testability, independence from frameworks. Uncle Bob’s concentric circles get drawn on a whiteboard and everyone nods. But the reason I use layered architecture has nothing to do with diagrams. It has to do with what happens six months after launch when a client wants to swap their REST API for GraphQL, or when your junior developer needs to add a feature without understanding the entire app.
Good architecture makes those changes local. You touch one layer, maybe two. The rest of the app doesn’t care. That is the entire value proposition. Everything else is ceremony.
And ceremony is the problem. I have reviewed codebases where developers created an abstract repository, a repository implementation, a use case class, a use case provider, a data source, a data source implementation, a model, a separate entity, and a mapper between the two. For a feature that calls one endpoint and shows a list. That is not architecture. That is a jobs program for boilerplate.
The three layers I actually use
After years of iteration across apps like LifeLink (a mental health app integrating Gemini AI) and Nmo AI (a fitness and health platform), I settled on three layers: Core, View, and Meta. Not the standard presentation-domain-data split you see everywhere. Those three work in textbooks, but in a real Flutter project you need a place for all the stuff that doesn’t fit neatly into any layer. That place is Meta.
Here is what the top-level lib/ looks like in most of my projects:
lib/
core/
features/
auth/
models/
repos/
usecases/
profile/
models/
repos/
usecases/
health_tracking/
models/
repos/
usecases/
errors/
failures.dart
network/
api_client.dart
interceptors.dart
utils/
either_extensions.dart
view/
features/
auth/
bloc/
pages/
widgets/
profile/
bloc/
pages/
widgets/
health_tracking/
bloc/
pages/
widgets/
meta/
di/
injection.dart
modules/
routing/
app_router.dart
theme/
app_theme.dart
colors.dart
platform/
native_bridge.dart
Notice it is feature-first inside each layer. Every feature gets its own folder in both Core and View. The feature folder in Core holds models, abstract repositories, and use cases. The matching folder in View holds the BLoC or Cubit, pages, and widgets. Meta holds everything that wires the app together: dependency injection, routing, theming, and platform-specific code.
Why feature-first is non-negotiable above five screens
Layer-first organization looks clean when you have three features. You get a tidy data/, domain/, presentation/ and everything fits on one screen in your IDE. Then the app grows to twenty screens and you end up scrolling through a repositories/ folder with thirty files trying to find the one related to notifications.
Feature-first fixes this. When I open the auth folder, everything related to authentication is right there. Models, repository interfaces, use cases, BLoC, screens, widgets. A new developer joining the team can understand the auth feature without navigating half the project. This is not theoretical. I have onboarded juniors onto both structures. Feature-first cuts their ramp-up time roughly in half.
Flutter’s official architecture guide now recommends a similar approach, combining MVVM with feature-first organization. They arrived at the same conclusion most production teams did years ago.
Core layer: where the rules live
The Core layer knows nothing about Flutter. No BuildContext, no widgets, no package:flutter imports. It contains models, abstract repository definitions, and use cases.
I use dartz for error handling. Every repository method returns Either<String, T> instead of throwing exceptions. Some people use custom Failure classes on the left side. I used to do that. Then I realized that in ninety percent of cases, I just need an error message to show the user. A String is enough. Save the Failure class hierarchy for apps where you genuinely need to distinguish between network failures, cache failures, and validation failures at the call site.
Here is a real repository interface and use case from a profile feature:
// core/features/profile/repos/profile_repo.dart
import 'package:dartz/dartz.dart';
import '../models/user_profile.dart';
abstract class ProfileRepo {
Future<Either<String, UserProfile>> fetchProfile(String userId);
Future<Either<String, UserProfile>> updateProfile(UserProfile profile);
}
// core/features/profile/usecases/fetch_profile.dart
import 'package:dartz/dartz.dart';
import '../models/user_profile.dart';
import '../repos/profile_repo.dart';
class FetchProfile {
final ProfileRepo _repo;
FetchProfile(this._repo);
Future<Either<String, UserProfile>> call(String userId) {
return _repo.fetchProfile(userId);
}
}
The use case looks like a pointless wrapper. One method that calls another method. I hear this complaint constantly. But the use case is where business logic accumulates over time. Right now FetchProfile just delegates. Six months later it might check a local cache first, validate the userId format, or combine data from two repositories. Without the use case, that logic ends up in the BLoC, and then you have a god Cubit that does networking, caching, validation, and state management all at once.
View layer: BLoC stays thin
The View layer is where Flutter lives. BLoCs, Cubits, pages, widgets. The rule is simple: a BLoC should do exactly two things. Accept events, emit states. All the actual work happens in use cases that the BLoC calls.
// view/features/profile/bloc/profile_cubit.dart
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../core/features/profile/models/user_profile.dart';
import '../../../../core/features/profile/usecases/fetch_profile.dart';
class ProfileCubit extends HydratedCubit<ProfileState> {
final FetchProfile _fetchProfile;
ProfileCubit(this._fetchProfile) : super(ProfileInitial());
Future<void> loadProfile(String userId) async {
emit(ProfileLoading());
final result = await _fetchProfile(userId);
result.fold(
(error) => emit(ProfileError(error)),
(profile) => emit(ProfileLoaded(profile)),
);
}
@override
ProfileState? fromJson(Map<String, dynamic> json) {
try {
return ProfileLoaded(UserProfile.fromJson(json));
} catch (_) {
return null;
}
}
@override
Map<String, dynamic>? toJson(ProfileState state) {
if (state is ProfileLoaded) return state.profile.toJson();
return null;
}
}
I use HydratedCubit instead of regular Cubit for any feature where the user expects to see data immediately on app launch. Profile screens, settings, cached feed content. The state gets serialized to local storage automatically. When the app restarts, the last known state is already there before any network call fires. Users see content instantly instead of a loading spinner. This matters more than any architectural purity debate.
The fromJson and toJson overrides are the only cost. For features where offline persistence doesn't matter, I use a plain Cubit instead.
Meta layer: the glue
Meta is the layer nobody talks about because it doesn’t map to any clean architecture diagram. But every real app has one, whether it is explicit or scattered across random files in lib/.
Dependency injection lives here. I use GetIt. Not because it is the most powerful option, but because it stays out of the widget tree. Riverpod is excellent for state management, but when I need a repository instance inside a use case that has nothing to do with any widget, GetIt just gives it to me. No ref, no ProviderScope, no widget tree dependency.
// meta/di/injection.dart
import 'package:get_it/get_it.dart';
import '../../core/features/profile/repos/profile_repo.dart';
import '../../core/features/profile/usecases/fetch_profile.dart';
import '../../view/features/profile/bloc/profile_cubit.dart';
import 'modules/profile_module.dart';
final sl = GetIt.instance;
void initDependencies() {
// Repositories
sl.registerLazySingleton<ProfileRepo>(() => ProfileRepoImpl(sl()));
// Use cases
sl.registerLazySingleton(() => FetchProfile(sl()));
// Cubits
sl.registerFactory(() => ProfileCubit(sl()));
}
Registration order doesn’t matter because everything is lazy. The ProfileRepoImpl lives in the Meta layer too, since it is the concrete implementation that depends on external packages like http or dio. The Core layer only has the abstract ProfileRepo. This is the one inversion-of-dependency rule I never skip.
Routing and theming also live in Meta. They are app-level concerns, not feature concerns. When I see routing logic scattered inside feature folders, it usually means someone will accidentally break navigation in feature B while editing feature A.
Where to cut corners
Full clean architecture is overkill for a lot of work. I have built freelance MVPs with six screens and no architecture beyond “put related files in the same folder.” They shipped, the client was happy, nobody needed to swap out the data layer.
My rule: if the app will have fewer than ten screens and one developer is maintaining it, skip use cases. Just call the repository from the BLoC. If it grows, you can extract use cases later. The cost of adding them retroactively is low. The cost of writing them upfront for an app that might not survive its first month of user feedback is high.
Prototypes get no architecture. Hackathon code gets no architecture. If you are exploring whether an idea works, put everything in one file if you want. Architecture is for code that has to live long enough to hurt you when it is wrong.
I also skip the abstract repository for features that will never have a second implementation. If there is zero chance you will swap out the data source, the interface is just an extra file to maintain. I know this violates the dependency inversion principle. I don’t care. Principles serve the codebase. The codebase does not serve principles.
The testing payoff
The whole architecture pays for itself when you write tests. With abstract repositories, you can mock the data layer completely. BLoC tests become trivial: inject a mock repository, call a method, assert the emitted states.
// test/view/features/profile/bloc/profile_cubit_test.dart
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockFetchProfile extends Mock implements FetchProfile {}
void main() {
late ProfileCubit cubit;
late MockFetchProfile mockFetchProfile;
setUp(() {
mockFetchProfile = MockFetchProfile();
cubit = ProfileCubit(mockFetchProfile);
});
test('emits [Loading, Loaded] on success', () {
final profile = UserProfile(id: '1', name: 'Test');
when(mockFetchProfile(any))
.thenAnswer((_) async => Right(profile));
expectLater(
cubit.stream,
emitsInOrder([
isA<ProfileLoading>(),
isA<ProfileLoaded>(),
]),
);
cubit.loadProfile('1');
});
test('emits [Loading, Error] on failure', () {
when(mockFetchProfile(any))
.thenAnswer((_) async => const Left('Network error'));
expectLater(
cubit.stream,
emitsInOrder([
isA<ProfileLoading>(),
isA<ProfileError>(),
]),
);
cubit.loadProfile('1');
});
}
These tests run in milliseconds. No HTTP client to stub, no database to spin up. The use case is mocked, so you are testing exactly one thing: does the Cubit emit the right states for the right inputs? Without the layered structure, you end up mocking HTTP responses directly in your BLoC tests, and those tests break every time you change a URL or add a header.
Mistakes I keep seeing
After reviewing dozens of codebases from clients and open-source projects, the same patterns show up repeatedly.
God Cubits. A single Cubit managing authentication state, user preferences, notification settings, and theme mode. I saw one that had forty-three methods. If your Cubit needs a table of contents, split it.
Repositories doing business logic. The repository should fetch data and return it. If your repository is checking whether the user has permission to see certain data, that logic belongs in a use case. Repositories transform raw data into domain models. That is their entire job.
Skipping the abstract repository and then regretting it. The shortcut feels great until you need to write tests and realize your Cubit is tightly coupled to a concrete class that makes real HTTP calls. Even when I said earlier that I sometimes skip the interface, I only do it for features I am confident will stay simple. Anything touching a network or database gets an interface.
Putting DI setup inside feature folders. I have seen auth/di.dart, profile/di.dart, settings/di.dart scattered across the project, each one registering things in GetIt. When something fails to resolve at runtime, you have to search six different files to figure out which one forgot to register a dependency. Centralize it.
Copying entity-model-mapper patterns from Android. In Android, you often have separate entity and model classes with mappers between them because the ORM layer requires it. Dart doesn’t have that constraint. A single model class with fromJson and toJson works for both the API layer and the domain layer in most cases. Don't create a mapper class for every model just because a tutorial said to.
Honesty about inconsistency
I don’t follow every layer in every project. The architecture I described is what I reach for in production apps that will have multiple developers and a long maintenance window. But my pub.dev packages have almost no architecture. Some of my personal tools are single-file scripts. A freelance project I delivered last month has use cases for the core features and direct repository calls for the settings screen.
Architecture is a tool for managing complexity. When the complexity isn’t there, the tool isn’t needed. The worst outcome is not writing unstructured code. It is writing over-structured code for a simple problem and then spending more time maintaining the structure than the feature.
Find the level of structure that matches your project’s actual complexity. Not its imagined future complexity. If the project grows, add layers. If it doesn’t, you saved yourself weeks of boilerplate that nobody would have thanked you for.
I build Flutter and Dart packages. More at pub.dev and GitHub.
메타데이터
- post_id
- 627bfaadddae
- slug
- flutter-clean-architecture-what-actually-works-in-production-not-just-tutorials-627bfaadddae
- url
- https://medium.com/@ottomancoder/flutter-clean-architecture-what-actually-works-in-production-not-just-tutorials-627bfaadddae
- canonical_url
- https://medium.com/@ottomancoder/flutter-clean-architecture-what-actually-works-in-production-not-just-tutorials-627bfaadddae
- author_url
- https://medium.com/@ottomancoder
- status
- ok
- fetched_at
- 2026-06-12 07:40:50