A Flutter project can start out beautifully simple.
You have:
lib/
main.dart
screens/
widgets/
services/You build the first few screens.
Everything is easy to find.
Then the application grows.
Authentication gets added.
Then notifications.
Then payments.
Then orders.
Then profile management.
Then offline support.
Then analytics.
Then a second developer joins the project.
Six months later, opening one feature means jumping through twelve folders, three BLoCs, two services, a repository, some helpers, and a file called utils.dart that nobody is quite sure should still exist.
The problem usually isn't Flutter.
The problem is that the project outgrew the structure it started with.
Flutter's current architecture guidance emphasizes separation of concerns, clear boundaries between UI and data, repositories as sources of truth, unidirectional data flow, dependency injection, and testable components. It also treats use-cases as something to introduce when complexity requires them, not as mandatory boilerplate for every feature.
So how do you structure a Flutter application as it grows without turning the project into an architecture experiment?
Start with responsibilities.
The First Mistake: Organizing Everything by File Type
A common Flutter project starts like this:
lib/
screens/
widgets/
models/
services/
repositories/
blocs/
utils/It looks organized.
At first.
But imagine you now have:
screens/
login_screen.dart
register_screen.dart
home_screen.dart
profile_screen.dart
product_screen.dart
cart_screen.dart
checkout_screen.dart
orders_screen.dart
settings_screen.dartThen:
blocs/
auth_bloc.dart
profile_bloc.dart
product_bloc.dart
cart_bloc.dart
checkout_bloc.dart
orders_bloc.dartThen:
repositories/
auth_repository.dart
user_repository.dart
product_repository.dart
cart_repository.dart
order_repository.dartThen:
services/
auth_service.dart
api_service.dart
payment_service.dart
storage_service.dartThe project is technically organized.
But the feature itself is scattered everywhere.
If you are working on checkout, you have to know where the checkout screen, checkout BLoC, checkout models, checkout repository, payment service, validators, and widgets all live.
This is where feature-oriented organization becomes useful.
Think in Features, Not Just File Types
Instead of asking:
"Where should all my BLoCs go?"
Ask:
"What belongs to the checkout feature?"
For example:
lib/
features/
auth/
home/
products/
cart/
checkout/
orders/
profile/Now a developer working on checkout immediately has a starting point.
A feature might look like:
features/
checkout/
presentation/
data/
domain/Or, if your project doesn't need that many layers:
features/
checkout/
bloc/
pages/
widgets/
models/
repository/There isn't one mandatory folder structure.
Flutter's own architecture case study actually discusses organizing UI code by feature while organizing reusable data-layer components by type. It also notes that feature-based organization and type-based organization are both valid depending on what you're building.
The important thing is that the structure should make responsibilities obvious.
Separate the UI From the Data
One of the most important architectural boundaries is this:
UI
↓
Logic
↓
DataThe UI displays state.
The data layer retrieves and changes application data.
The logic layer, when needed, coordinates the two.
Flutter's current architecture guidance describes this as a UI layer and a data layer, with an optional domain/logic layer for applications with more complex client-side business logic.
That distinction is more useful than memorizing the name of an architecture pattern.
What Should a Screen Actually Do?
A screen should primarily describe the interface.
For example:
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: LoginForm(
onSubmit: (email, password) {
context.read<AuthBloc>().add(
LoginSubmitted(
email: email,
password: password,
),
);
},
),
);
}
}The screen doesn't need to know:
how authentication works
which API endpoint is called
how tokens are stored
how the response is parsed
whether the user is cached
how an expired session is handled
Those are different responsibilities.
The UI should communicate the user's intent.
The appropriate logic layer should handle what that intent means.
Don't Turn Your BLoC Into an API Client
This is a mistake I've seen repeatedly in growing Flutter projects.
You start with something simple:
class LoginBloc extends Bloc<LoginEvent, LoginState> {
LoginBloc() : super(const LoginState.initial()) {
on<LoginSubmitted>((event, emit) async {
final response = await http.post(
Uri.parse('https://api.example.com/login'),
body: {
'email': event.email,
'password': event.password,
},
);
// ...
});
}
}It works.
Then more requirements arrive.
Now the BLoC is responsible for:
HTTP requests
JSON parsing
token storage
error mapping
retry logic
caching
session handling
state management
Eventually it becomes difficult to test and difficult to change.
A better boundary is:
LoginPage
↓
LoginBloc
↓
AuthRepository
↓
AuthApiServiceThe names can differ.
The principle matters more than the names.
What Belongs in a Service?
A service is close to the external system.
For example:
class AuthApiService {
final ApiClient client;
AuthApiService(this.client);
Future<LoginResponse> login({
required String email,
required String password,
}) {
return client.post(
'/auth/login',
body: {
'email': email,
'password': password,
},
);
}
}Its job is to communicate with the external source.
That might be:
REST API
GraphQL API
local database
platform API
file system
native SDK
Flutter's architecture guidance describes services as the lowest data-layer component and recommends using them to isolate external data sources. Services should generally not own application state.
What Belongs in a Repository?
The repository sits above those external services.
This is where application-level data decisions start happening.
For example:
class AuthRepository {
final AuthApiService api;
final SessionStorage storage;
AuthRepository({
required this.api,
required this.storage,
});
Future<User> login({
required String email,
required String password,
}) async {
final response = await api.login(
email: email,
password: password,
);
await storage.saveToken(response.token);
return response.user;
}
}Now the BLoC doesn't need to know:
where the token is stored
how the API response looks
which endpoint was called
It only needs the repository.
Repositories are particularly useful because they can handle concerns such as caching, retry logic, refreshing data, synchronization, and error handling. Flutter's current architecture guide explicitly places these responsibilities in the repository layer.
The Repository Should Be the Source of Truth
This is an important idea.
Imagine three different parts of your application all maintain their own copy of the current user:
AuthBloc.currentUser
ProfileBloc.currentUser
SettingsBloc.currentUserNow the user changes their name.
Which one is correct?
If all three are independently mutable, you have created a synchronization problem.
Flutter's current architecture guidance recommends a single source of truth for application data, generally represented by a repository in the data layer.
That doesn't mean every screen needs to read directly from one enormous global object.
It means there should be a clear authority for the data.
For example:
UserRepository
│
├── Profile feature
├── Settings feature
└── Account featureMultiple consumers can use the same repository without creating competing sources of truth.
Where Does BLoC Fit?
If you use flutter_bloc, you don't have to abandon it just because Flutter's current documentation demonstrates MVVM.
The architectural principles are more important than the particular state-management package.
Flutter's own architecture case study explicitly notes that the same principles can be implemented using streams and packages such as flutter_bloc, Riverpod, and Signals.
A BLoC can occupy the role of the feature's presentation/logic layer.
For example:
┌───────────────┐
│ Screen │
└───────┬───────┘
│
▼
┌───────────────┐
│ BLoC │
└───────┬───────┘
│
▼
┌───────────────┐
│ Repository │
└───────┬───────┘
│
▼
┌───────────────┐
│ Service │
└───────────────┘That gives each component a clear job.
Don't Create a Use Case for Everything
This is where architecture discussions often become unnecessarily complicated.
You will sometimes see projects like:
LoginUseCase
RegisterUseCase
GetUserUseCase
GetProductsUseCase
GetProductByIdUseCase
UpdateProfileUseCase
DeleteAccountUseCaseEven when each class contains one line.
That isn't automatically better architecture.
Flutter's current recommendations are surprisingly pragmatic here: use-cases can be useful in very large applications or when client-side business logic becomes complex, but introducing them everywhere can add unnecessary overhead.
For example, this may be unnecessary:
class GetProductUseCase {
final ProductRepository repository;
GetProductUseCase(this.repository);
Future<Product> call(String id) {
return repository.getProduct(id);
}
}If all it does is forward one method call and there is no meaningful business rule around it, you may not have gained much.
But this could justify a use-case:
Get checkout total
↓
product prices
+
discount rules
+
customer membership
+
shipping destination
+
tax rules
↓
final totalNow you have meaningful business logic that may deserve its own boundary.
Architecture should solve complexity.
It shouldn't manufacture it.
Keep State Boundaries Deliberate
A growing application often ends up with another problem:
too much global state.
Not everything needs to live at application level.
Consider:
Authentication session
Theme preference
Cart
Current userThese may reasonably need broader access.
But:
Is the password field visible?
Which tab is selected?
Is this dialog open?
What is currently typed into this search field?usually doesn't need to become global application state.
Flutter distinguishes between ephemeral state and application state for exactly this reason.
A useful question is:
Who actually needs to know this state?
If the answer is "one widget," don't build an application-wide state-management system around it.
Keep State Immutable
Suppose your state looks like:
class ProductState {
List<Product> products;
bool loading;
}and different parts of the application can mutate that list directly.
You have made it harder to understand where changes are coming from.
Prefer immutable state:
class ProductState {
final List<Product> products;
final bool loading;
const ProductState({
required this.products,
required this.loading,
});
}Then produce a new state when something changes.
Flutter's current recommendations strongly favor immutable data models and unidirectional data flow because they make state changes more predictable and reduce accidental mutation.
This also works particularly well with BLoC-style state management.
Make Dependencies Explicit
Another sign that a project is becoming difficult to maintain is code like this:
final api = ApiService.instance;
final storage = StorageService.instance;
final analytics = Analytics.instance;Now your class secretly depends on three global objects.
Testing becomes harder.
Replacing an implementation becomes harder.
Understanding the class becomes harder.
Instead:
class ProductRepository {
final ProductApiService api;
final ProductCache cache;
ProductRepository({
required this.api,
required this.cache,
});
}Now its dependencies are obvious.
Flutter's current architecture recommendations strongly recommend dependency injection and explicitly connect it to reducing globally accessible objects and improving testability.
The mechanism you use can vary.
The principle is:
A class should not have to secretly discover the things it depends on.
Architecture Should Make Testing Easier
This is one of the best tests of whether your architecture is actually helping.
Suppose you have:
class ProductBloc extends Bloc<ProductEvent, ProductState> {
// 900 lines
}and testing it requires:
Flutter bindings
a real API
a real database
authentication
several global singletons
Something is wrong.
If the BLoC depends on:
ProductRepositoryyou can provide a fake repository:
class FakeProductRepository implements ProductRepository {
@override
Future<List<Product>> getProducts() async {
return [
Product(id: '1', name: 'Test Product'),
];
}
}Now the BLoC can be tested without the network.
Flutter's architecture testing guidance specifically recommends designing components with clear inputs and outputs so repositories can be faked and view-model/UI logic can be tested independently.
That's not just a testing trick.
It's evidence that your boundaries are doing their job.
A Feature Should Be Understandable in Isolation
Consider a developer joining your project.
You tell them:
"Add wishlist support."
Ideally they should be able to find something like:
features/
wishlist/
presentation/
bloc/
pages/
widgets/
data/
wishlist_repository.dart
wishlist_api_service.dart
domain/
models/They shouldn't need to search the entire project for every file containing "wishlist."
This becomes increasingly important as teams grow.
Flutter's own architecture case study notes that good organization reduces code conflicts and makes it easier for new engineers to navigate the project.
But Don't Create a Folder for Everything
There is another extreme.
I've seen projects with:
core/
constants/
enums/
extensions/
helpers/
utilities/
managers/
handlers/
providers/
factories/
builders/
adapters/
mappers/At some point, the architecture becomes harder to understand than the application.
A folder should exist because there is a meaningful responsibility behind it.
Not because an architecture diagram has another box.
A Structure I Would Start With
For a feature-rich Flutter application, something along these lines is a reasonable starting point:
lib/
app/
router/
theme/
config/
core/
network/
storage/
errors/
widgets/
utils/
features/
auth/
presentation/
bloc/
pages/
widgets/
data/
repositories/
services/
domain/
models/
products/
presentation/
bloc/
pages/
widgets/
data/
repositories/
services/
domain/
models/
cart/
presentation/
data/
domain/
checkout/
presentation/
data/
domain/
main.dartYou don't need every folder on day one.
Start with the structure your application actually needs.
Then introduce another boundary when the complexity justifies it.
One Important Rule: Don't Let Features Depend on Each Other Randomly
Suppose:
Checkout → Cart
Cart → Products
Products → Checkout
Checkout → Auth
Auth → CheckoutNow you have a dependency graph that is difficult to reason about.
Before long, changing one feature breaks three others.
Prefer clear ownership and communication boundaries.
For example:
UI
↓
Feature logic
↓
Repositories
↓
Servicesand shared application state flows through deliberately chosen sources of truth.
Flutter describes this as unidirectional data flow: state moves toward the UI, while user interactions move back through the appropriate logic/data boundaries.
The goal isn't to prevent features from interacting.
The goal is to make how they interact predictable.
What About Clean Architecture?
Clean Architecture can be useful.
So can MVVM.
So can BLoC.
So can a feature-first structure.
These aren't necessarily competing choices.
You can build:
Feature-first
+
BLoC
+
Repository pattern
+
optional domain/use-case layerwithout creating a contradiction.
The mistake is treating the architecture name as the goal.
Your actual goals are:
clear responsibilities
predictable data flow
testable components
replaceable dependencies
understandable features
fewer accidental dependencies
easier changes
Flutter's own documentation makes a similar point: its recommended architecture is guidance rather than an absolute rule, and state-management libraries can vary while the underlying architectural principles remain useful.
The Architecture Test I Use
When I'm unsure whether a Flutter project needs restructuring, I ask a few simple questions.
Can I find a feature quickly?
If not, the structure may be working against the team.
Can I test business logic without rendering widgets?
If not, responsibilities may be mixed together.
Can I replace the API without rewriting the UI?
If not, the data boundary may be too weak.
Can two screens use the same data without maintaining separate copies?
If not, you may have a source-of-truth problem.
Can I understand a class's dependencies from its constructor?
If not, hidden dependencies may be accumulating.
Can I explain where a piece of logic belongs?
If developers repeatedly debate whether something belongs in a screen, BLoC, repository, service, or utility, the boundaries may not be clear enough.
These questions are often more useful than asking:
"Are we using Clean Architecture correctly?"
Don't Architect for the App You Might Build
This is probably the most important point.
If your application currently has:
Login
Home
Profile
Settingsyou probably don't need:
20 repositories
14 use-cases
6 abstraction layers
3 dependency containersbecause you read somewhere that large applications need them.
Build for the complexity you actually have.
When the application grows:
more features
↓
more responsibilities
↓
clearer boundaries
↓
new architectural layers where justifiedArchitecture should evolve with the product.
Not become a project of its own.
A Practical Refactoring Order
If you already have a large Flutter application and the architecture is messy, don't rewrite the entire project.
That is rarely necessary.
Pick one feature.
For example:
checkout/Then:
Step 1
Move the UI into a feature boundary.
Step 2
Separate state management from widgets.
Step 3
Move API communication into a service.
Step 4
Introduce a repository as the data boundary.
Step 5
Move meaningful business rules out of the UI/state layer.
Step 6
Add tests around the new boundaries.
Step 7
Use the same approach for the next feature.
After several iterations, the architecture starts becoming consistent without requiring a risky rewrite.
The Goal Isn't More Layers
A well-structured Flutter project isn't the one with the most folders.
It is the one where you can answer simple questions quickly:
Where does this data come from?
Who owns this state?
Where does this business rule belong?
What happens when this API fails?
How do I test this without hitting the real server?
If we replace this API, what needs to change?
If we add another screen using this data, where does it connect?
If your architecture makes those questions easy to answer, it is doing its job.
If it requires an architecture diagram just to understand a login button, it may be time to simplify.
Good architecture isn't about making a Flutter project look sophisticated.
It is about making the next change easier than the previous one.
And that's the real test of whether the structure is working.