Skip to main content

Command Palette

Search for a command to run...

What Happens When Your Flutter App Stops Being Small

Updated
7 min readView as Markdown
What Happens When Your Flutter App Stops Being Small
G
Hello, I am a senior Flutter developer with vast experience in crafting mobile applications. I am a seasoned community organizer with vast experience in launching and building Google Developer communities under GDG Bugiri Uganda and Flutter Kampala.

Flutter is easy to start with. You build a few screens, connect an API, wire up some state management, and everything works. It keeps working for a surprisingly long time — right up until it doesn't.

At some point, without a single dramatic failure, your app stops being small. Builds slow down. A bug that should take ten minutes to trace takes an afternoon. A feature that used to be a quick add now touches six files you didn't expect. Nothing broke. The codebase just got heavy.

The illusion of simplicity

What makes Flutter easy early on — you can put a screen, its widgets, and its logic anywhere and it'll compile and run — is the same thing that makes it fragile later, because "anywhere" quietly turns into "everywhere." A structure that works fine for five screens and one or two developers doesn't fail loudly at fifty screens; it fails slowly, in ways that look like the framework getting worse when it's actually the organization getting worse underneath it.

The signs are consistent enough to name: you find yourself asking "where is this logic even coming from?" more often than you used to. State starts behaving in ways you can't fully explain from reading the screen in front of you. A change to one feature quietly breaks another one that had no obvious reason to be affected. All of that points at the same root cause, and it isn't Flutter — it's structure.

Why layer-based folders stop working

Almost every Flutter tutorial teaches you to organize by type of thing:

lib/
  screens/
    login_screen.dart
    dashboard_screen.dart
    profile_screen.dart
  widgets/
    login_form.dart
    dashboard_card.dart
  services/
    auth_service.dart
    dashboard_service.dart
  models/
    user.dart
    dashboard_data.dart

This is layer-based structure, and it's genuinely fine for a small app — there just isn't enough code yet for the downside to show up. The downside is this: the logic for a single feature, say authentication, is now split across four unrelated folders. Touching login means opening screens/login_screen.dart, widgets/login_form.dart, services/auth_service.dart, and models/user.dart — four files, in four different places, that all belong to one idea. Multiply that by fifteen features and "where does this logic live" stops being a rhetorical question.

Feature-based structure inverts the organizing principle: instead of grouping by type, you group by what the code is for.

lib/
  features/
    auth/
      login_screen.dart
      login_form.dart
      auth_repository.dart
      user.dart
    dashboard/
      dashboard_screen.dart
      dashboard_card.dart
      dashboard_repository.dart
      dashboard_data.dart

Now everything auth needs to change lives in one folder. Deleting the auth feature, understanding its full surface area, or onboarding someone onto just that part of the app all become one-folder problems instead of whole-codebase scavenger hunts. This is the single highest-leverage structural change most growing Flutter apps can make, and it costs nothing but discipline going forward — it's not a rewrite, it's a decision about where new files go.

State: local first, global only when earned

The most common state mistake at scale isn't choosing the wrong state management library — it's putting too much state at the top of the app "just in case." A globalUserState, a globalCartState, and a globalUiState sitting at the root all seem convenient until every screen in the app is implicitly coupled to all three, whether it uses them or not.

The better default: state lives inside the feature that owns it, and only gets promoted to something app-wide when at least two unrelated features genuinely need to share it. A dashboard's sort order or expanded/collapsed card state has no business being global. A logged-in user's identity, which auth, profile, and checkout all legitimately need, does. The test isn't "might this be useful elsewhere someday" — it's "does something outside this feature need it right now."

Decoupling: depend on a contract, not an implementation

Layer-based folders group things poorly. The deeper problem underneath is when a widget depends directly on a concrete class instead of an abstraction — because now every part of the app that touches auth is glued to exactly one implementation of it, including in tests.

Here's the coupled version, which is the default if you never think about it:

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

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () async {
        await AuthService().login(email, password); // concrete class, created inline
      },
      child: const Text('Log in'),
    );
  }
}

LoginScreen now only works with a real AuthService that hits a real network. Testing it means either hitting a real backend or reaching for a mocking framework that reaches inside the widget to swap out a hardcoded dependency — exactly the problem dependency injection exists to solve.

The decoupled version depends on an interface instead:

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

class ApiAuthRepository implements AuthRepository {
  ApiAuthRepository(this._client);
  final http.Client _client;

  @override
  Future<User> login(String email, String password) async {
    final response = await _client.post(
      Uri.parse('https://api.example.com/login'),
      body: {'email': email, 'password': password},
    );
    return User.fromJson(jsonDecode(response.body));
  }
}

class FakeAuthRepository implements AuthRepository {
  @override
  Future<User> login(String email, String password) async {
    return User(id: 'test-user', name: 'Ada');
  }
}

LoginScreen now takes an AuthRepository, not an AuthService:

class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key, required this.authRepository});
  final AuthRepository authRepository;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () async => authRepository.login(email, password),
      child: const Text('Log in'),
    );
  }
}

And the test that this unlocks is direct, fast, and requires no network:

testWidgets('logging in shows the dashboard', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: LoginScreen(authRepository: FakeAuthRepository()),
  ));
  await tester.tap(find.text('Log in'));
  await tester.pumpAndSettle();
  expect(find.byType(DashboardScreen), findsOneWidget);
});

Nothing about LoginScreen changed in terms of what it does. What changed is that it no longer knows or cares whether it's talking to a real API or a fake one — which is exactly the property that keeps a change in one feature from being able to break an unrelated one, because features now talk to each other through contracts (AuthRepository) instead of directly through concrete classes.

Tightly coupled                     Decoupled

UI ──→ AuthService ──→ Network      UI ──→ AuthRepository (interface)
  └───→ DashboardService                        │
         └──→ AuthService (again)               ├──→ ApiAuthRepository ──→ Network
                                                 └──→ FakeAuthRepository ──→ (tests)

Changing AuthService risks          Changing the implementation behind
breaking Dashboard too.             the interface risks nothing above it.

Testing and tooling aren't an afterthought at scale

In a five-screen app, you can hold the whole thing in your head and manually verify a change didn't break anything. Past a certain size, that stops being true, and the things that replace "holding it in your head" are concrete, not aspirational: unit tests around repositories (exactly the kind the FakeAuthRepository example above makes trivial to write), integration tests around the flows that would be expensive to break in production, structured logging that tells you which feature a crash came from instead of just that one happened, and error tracking that surfaces problems before a user has to report them. None of these are optional "nice to have later" — they're what makes changing a fifty-screen app feel as safe as changing a five-screen one felt on day one.

What this actually adds up to

Flutter doesn't get slower or more fragile as your app grows — the framework's performance characteristics don't change. What changes is whether your structure was designed to absorb growth or just happened to survive the first few screens by accident. Feature folders keep related code together instead of scattered. Local-first state keeps screens from being invisibly coupled to state they don't use. Depending on interfaces instead of concrete classes keeps a change in one feature from being able to reach into another one. None of these are Flutter-specific ideas — they're general software architecture — but Flutter's low ceremony for "just make it work" makes it unusually easy to skip all three and not notice until the app is big enough that fixing it later is expensive.

Small apps are easy by default. Scalable apps are that way because someone decided, early, to structure them like they were going to grow — not because they got lucky.