Kenresoft Technologies Ltd
Discuss a projectLog in
Back to Insights
Flutter ArchitectureSeptember 23, 202614 min readKenneth Amadi

When Your Flutter App Gets Bigger: How to Structure It Before the Codebase Becomes a Problem

Is your Flutter app outgrowing its original structure? Here is a practical guide to setting clear boundaries between UI, logic, and data, so your team can ship features faster without creating a tangled mess.

Scalable CodeDartMobile App DesignRefactoringClean CodeDeveloper Experience
When Your Flutter App Gets Bigger: How to Structure It Before the Codebase Becomes a Problem

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.dart

Then:

blocs/
  auth_bloc.dart
  profile_bloc.dart
  product_bloc.dart
  cart_bloc.dart
  checkout_bloc.dart
  orders_bloc.dart

Then:

repositories/
  auth_repository.dart
  user_repository.dart
  product_repository.dart
  cart_repository.dart
  order_repository.dart

Then:

services/
  auth_service.dart
  api_service.dart
  payment_service.dart
  storage_service.dart

The 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
↓
Data

The 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
    ↓
AuthApiService

The 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.currentUser

Now 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 feature

Multiple 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
DeleteAccountUseCase

Even 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 total

Now 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 user

These 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:

ProductRepository

you 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.dart

You 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 → Checkout

Now 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
 ↓
Services

and 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 layer

without 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
Settings

you probably don't need:

20 repositories
14 use-cases
6 abstraction layers
3 dependency containers

because 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 justified

Architecture 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.

About Kenresoft

Kenresoft is a software engineering company. We build web and mobile applications, backend systems, APIs and developer tools, including Kenresoft CMS.

About Kenresoft

Let's talk

Have a system to build or fix?

If you have a product idea or a technical problem without a good answer yet, tell us about it.