Flutter App Architecture in 2026: A Practical Feature-First Guide

Coding Liquids blog cover featuring Sagnik Bhattacharya for Flutter app architecture, with feature-first app structure visuals.
Coding Liquids blog cover featuring Sagnik Bhattacharya for Flutter app architecture, with feature-first app structure visuals.

The best Flutter architecture is not the one with the most folders. It is the one that helps your team move quickly, test confidently, and keep features understandable six months later.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

In 2026, a practical feature-first structure is often the safest default because it keeps screens, state, data rules, and tests close to the feature they belong to.

Follow me on Instagram@sagnikteaches

Quick answer

Start with a feature-first structure and only add more abstraction when the codebase earns it. That usually means clear feature boundaries, a small shared core, and state, data, and UI decisions that match the app’s complexity instead of copying enterprise patterns too early.

Connect on LinkedInSagnik Bhattacharya
  • You are starting a medium-size Flutter app.
  • Your existing codebase is becoming hard to navigate.
  • Several developers need a shared structure that stays readable.

Why feature-first usually wins

Feature-first structure keeps related code together. The argument is easiest to see by comparing what a single change costs under each layout.

Under a layer-first structure (lib/screens/, lib/models/, lib/services/, lib/widgets/), adding a field to the booking flow means editing models/booking.dart, services/booking_service.dart, screens/booking_screen.dart and widgets/booking_card.dart — four directories, none of which tell you what else lives in the booking flow. After two years, lib/widgets/ holds 180 files and nobody knows which are still used.

Under feature-first, the same change happens inside lib/features/booking/. The diff is one directory. Deleting the feature is rm -rf on one directory plus one route. That deletability test is the practical measure of whether your boundaries are real.

Subscribe on YouTube@codingliquids

The folder structure, concretely

Here is the layout for a booking app with five features. This is the whole pattern — everything after this section is detail about what goes in each directory and why.

lib/
  main.dart
  app.dart                        # MaterialApp, theme wiring, router hookup
  core/                           # shared, feature-agnostic
    network/
      dio_client.dart             # single configured HTTP client
      api_exception.dart          # one error type the whole app understands
    router/
      app_router.dart             # go_router config, imports feature routes
    theme/
      app_theme.dart
    result.dart                   # Result<T> / sealed error type
  features/
    booking/
      data/
        booking_api.dart          # raw HTTP, returns DTOs
        booking_repository.dart   # the boundary the rest of the app talks to
        dto/booking_dto.dart      # wire format, fromJson/toJson
      domain/
        booking.dart              # the model your UI and logic use
        cancel_booking.dart       # non-trivial business rule, if any
      presentation/
        booking_list_screen.dart
        booking_controller.dart   # AsyncNotifier — the view model
        widgets/booking_card.dart
    search/
    account/
    payments/
    home/
test/
  features/
    booking/
      booking_repository_test.dart
      booking_controller_test.dart

Three rules make this hold up over time, and they matter more than the folder names:

  1. A feature may not import another feature's data/ or presentation/. Only domain/ is importable across features, and even that should be rare.
  2. core/ may not import from features/. Dependencies point inward. The moment core/ imports a feature, you have a cycle and the structure is decorative.
  3. If two features need to talk, they go through the router or a shared repository in core/ — never by importing each other directly.

You can enforce rule 2 mechanically rather than by review discipline. Add import_lint or a custom analysis_options.yaml rule to your CI, and the build fails when someone crosses a boundary under deadline pressure — which is exactly when it happens.

The three layers inside a feature

The split that earns its keep is data / domain / presentation. The distinction that matters most — and the one most often skipped — is DTO versus domain model.

// data/dto/booking_dto.dart — mirrors the wire format exactly
class BookingDto {
  BookingDto({required this.id, required this.startsAtIso, required this.statusCode});

  final String id;
  final String startsAtIso;   // API sends a string
  final int statusCode;       // API sends 0/1/2

  factory BookingDto.fromJson(Map<String, dynamic> json) => BookingDto(
        id: json['id'] as String,
        startsAtIso: json['starts_at'] as String,
        statusCode: json['status'] as int,
      );
}
// domain/booking.dart — what the rest of the app actually uses
enum BookingStatus { pending, confirmed, cancelled }

class Booking {
  const Booking({required this.id, required this.startsAt, required this.status});

  final String id;
  final DateTime startsAt;      // a real DateTime
  final BookingStatus status;   // a real enum

  bool get isCancellable =>
      status != BookingStatus.cancelled &&
      startsAt.difference(DateTime.now()) > const Duration(hours: 24);
}

The payoff is concrete: when the backend renames starts_at to start_time, you change one line in BookingDto.fromJson. No widget changes. No test changes outside the data layer. Without the split, that rename touches every widget that read the field.

The isCancellable getter shows where business rules belong. That 24-hour rule is not a UI concern and not an API concern — it lives on the domain model, where a plain Dart unit test can cover it with no widgets and no mocks.

The repository is the boundary that makes testing cheap

A repository turns DTOs into domain models and network failures into typed errors. It is the only thing presentation/ is allowed to call.

// data/booking_repository.dart
abstract interface class BookingRepository {
  Future<List<Booking>> fetchUpcoming();
  Future<void> cancel(String bookingId);
}

class HttpBookingRepository implements BookingRepository {
  HttpBookingRepository(this._api);
  final BookingApi _api;

  @override
  Future<List<Booking>> fetchUpcoming() async {
    try {
      final dtos = await _api.getUpcoming();
      return dtos.map(_toDomain).toList();
    } on DioException catch (e) {
      throw ApiException.fromDio(e);   // one error type, defined in core/
    }
  }

  @override
  Future<void> cancel(String bookingId) => _api.cancel(bookingId);

  Booking _toDomain(BookingDto dto) => Booking(
        id: dto.id,
        startsAt: DateTime.parse(dto.startsAtIso),
        status: BookingStatus.values[dto.statusCode],
      );
}

Declaring the interface with abstract interface class (Dart 3) means nobody can accidentally extend the implementation. Swapping HttpBookingRepository for a FakeBookingRepository in tests, or adding a caching decorator later, requires no change above this line.

One thing worth being honest about: if your repository only forwards calls and does no mapping or error translation, it is not earning its place. A repository that is one line per method wrapping an API client is the over-abstraction people rightly complain about. Add it when there is something to translate.

State: the controller is a view model, not a place for business rules

Riverpod's AsyncNotifier handles loading, data and error states without you writing a state enum by hand.

// presentation/booking_controller.dart
final bookingRepositoryProvider = Provider<BookingRepository>((ref) {
  return HttpBookingRepository(BookingApi(ref.watch(dioClientProvider)));
});

class BookingController extends AsyncNotifier<List<Booking>> {
  @override
  Future<List<Booking>> build() =>
      ref.watch(bookingRepositoryProvider).fetchUpcoming();

  Future<void> cancel(String id) async {
    final repo = ref.read(bookingRepositoryProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      await repo.cancel(id);
      return repo.fetchUpcoming();
    });
  }
}

final bookingControllerProvider =
    AsyncNotifierProvider<BookingController, List<Booking>>(BookingController.new);

AsyncValue.guard is the detail worth copying. It catches whatever the repository throws and moves the state to AsyncError without a try/catch in every method — so an unhandled exception cannot leave the UI stuck on a spinner forever.

The widget then has no logic left to get wrong:

class BookingListScreen extends ConsumerWidget {
  const BookingListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final bookings = ref.watch(bookingControllerProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Your bookings')),
      body: bookings.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => ErrorView(
          message: e is ApiException ? e.userMessage : 'Something went wrong',
          onRetry: () => ref.invalidate(bookingControllerProvider),
        ),
        data: (items) => items.isEmpty
            ? const EmptyView(message: 'No upcoming bookings')
            : ListView.builder(
                itemCount: items.length,
                itemBuilder: (_, i) => BookingCard(booking: items[i]),
              ),
      ),
    );
  }
}

Note that all four states — loading, error, empty and data — are handled. The empty state is the one teams forget, and it is the one users hit on day one of a new account.

Testing: what each layer costs to test

The point of the structure is that each layer is testable without booting the app. Overriding a provider is the whole trick:

// test/features/booking/booking_controller_test.dart
class FakeBookingRepository implements BookingRepository {
  FakeBookingRepository(this._items);
  final List<Booking> _items;

  @override
  Future<List<Booking>> fetchUpcoming() async => _items;

  @override
  Future<void> cancel(String id) async => _items.removeWhere((b) => b.id == id);
}

void main() {
  test('cancel removes the booking and re-emits data', () async {
    final container = ProviderContainer(
      overrides: [
        bookingRepositoryProvider.overrideWithValue(
          FakeBookingRepository([sampleBooking]),
        ),
      ],
    );
    addTearDown(container.dispose);

    await container.read(bookingControllerProvider.future);
    await container.read(bookingControllerProvider.notifier).cancel('b1');

    expect(container.read(bookingControllerProvider).value, isEmpty);
  });
}
LayerTest typeWhat it needs
domain/Plain unit testNothing — no Flutter, no mocks. Fastest tests you will have
data/Unit test with a mock HTTP clienthttp_mock_adapter or a stubbed Dio; asserts DTO→domain mapping and error translation
presentation/ controllerUnit test with ProviderContainerA fake repository via overrideWithValue; no widget tree
presentation/ widgetsWidget testProviderScope(overrides: [...]) around the widget under test

If testing your controller requires a real HTTP call or a running emulator, the boundary is in the wrong place. That is the signal to fix, not the folder names.

Routing: keep route definitions with their feature

A single 400-line router file becomes its own merge-conflict hotspot. Let each feature export its routes and have core/router/ assemble them.

// features/booking/presentation/booking_routes.dart
final bookingRoutes = [
  GoRoute(
    path: '/bookings',
    builder: (_, __) => const BookingListScreen(),
    routes: [
      GoRoute(
        path: ':id',
        builder: (_, state) =>
            BookingDetailScreen(id: state.pathParameters['id']!),
      ),
    ],
  ),
];

// core/router/app_router.dart
final appRouter = GoRouter(
  initialLocation: '/home',
  redirect: (context, state) => authGuard(context, state),
  routes: [...homeRoutes, ...bookingRoutes, ...searchRoutes, ...accountRoutes],
);

This is also how two features communicate without importing each other. Search pushes /bookings/123; it never imports anything from the booking feature.

When to split features into packages

Directories are enough for most apps. Move to separate packages in a monorepo (with Melos) only when you hit one of these, and not before:

  • Build times hurt. Separate packages let you run tests and analysis for one package only, which matters once a full test run passes a few minutes.
  • You need enforced boundaries. A package literally cannot import something not in its pubspec.yaml. Compiler-enforced beats convention.
  • Separate teams ship on separate cadences, or you have a second app sharing features.

The cost is real: every cross-package change becomes a version bump, and tooling gets more complex. For a single team on one app, directories plus a lint rule give you most of the benefit at a fraction of the overhead.

Migrating an existing layer-first app

You do not need a rewrite, and you should not attempt one. Strangle it feature by feature:

  1. Create lib/features/ alongside the existing folders. Both structures coexist while you migrate.
  2. Pick the feature you are already about to change, not the messiest one. Migration rides along with work you were doing anyway.
  3. Move its files in, add the DTO/domain split, extract a repository. Fix imports; the analyser lists them all.
  4. Add tests at the new boundary — this is the step that makes the migration worth something rather than shuffling files.
  5. Repeat. When lib/screens/ is empty, delete it.

A half-migrated codebase is genuinely fine for months, as long as the direction is one-way: new code goes in features/, and nothing new is added to the old folders.

Common mistakes

  • Copying a large-company pattern into a small app — four layers and a use-case class per endpoint for a five-screen CRUD app is cost with no protection.
  • Treating architecture as folder design only, with no rule about which direction imports may point. Folders without dependency rules drift back into a ball of mud within a release or two.
  • Choosing state management before clarifying feature boundaries. The boundaries determine what state needs to be shared; deciding in the other order forces awkward global state.
  • Passing DTOs into widgets. The moment a widget reads dto.statusCode == 1, every backend change is a UI change.
  • Repositories that only forward calls. If there is no mapping and no error translation, it is indirection, not architecture.
  • A core/ folder that imports from features/, which creates a cycle and quietly makes every feature depend on every other.
  • Handling loading and data but not error and empty. AsyncValue.when makes all four cheap; skipping two of them is how spinners get stuck forever.

When to use something else

If your bottleneck is route structure, read go_router. If the problem is testability, the better next read is Flutter testing strategy. If you are still deciding between Provider, Riverpod and BLoC, start with Flutter state management — that decision shapes the presentation layer above.

Frequently asked questions

What does a feature-first structure actually mean?

Organising code by feature — home, search, payments — where each feature owns its data/, domain/ and presentation/ plus its tests, with a small core/ for networking, theme, routing and error types. The practical test is deletability: removing a feature should mean deleting one directory and one route entry.

Is feature-first always the right choice?

No. A prototype or a five-screen app is faster to build layer-first, and that is a legitimate choice. Feature-first pays off once more than one person works on the app, or once it will be extended for more than a few months. Adopt it when you feel the cost of the alternative, not pre-emptively.

Do I need both a DTO and a domain model?

Whenever the wire format differs from what your UI wants — dates as strings, statuses as ints, nested payloads. Then a backend rename is a one-line change in fromJson instead of a change in every widget. If your API returns exactly your domain shape and you control both ends, one class is fine.

Should I choose a state management solution first?

Clarify feature boundaries first, because they determine what state genuinely needs to be shared. Then pick the solution the team understands. The architecture above works with Riverpod, BLoC or plain ChangeNotifier — only the controller layer changes.

How do two features communicate without importing each other?

Through the router for navigation (push /bookings/123 rather than importing BookingDetailScreen), and through a shared repository in core/ for data both need — session or the current user, typically. Direct feature-to-feature imports are the first step back to a tangled codebase.

When should features become separate packages?

When build and test times hurt, when you need compiler-enforced boundaries rather than reviewer-enforced ones, or when separate teams ship on separate cadences. Until then directories plus an import lint rule give most of the benefit without the version-bump overhead.

How much architecture is too much?

If a simple feature takes noticeably longer to build and nothing in the structure protects against a real risk, it is overbuilt. A repository that only forwards calls, or a use-case class per endpoint that adds no logic, are the usual symptoms.

How do I migrate an existing app without stopping feature work?

Create lib/features/ beside the old folders and migrate one feature at a time, starting with whichever you were already about to change. Keep the direction one-way — new code only goes in features/. A half-migrated app is fine for months; a big-bang rewrite usually is not.

Related guides on this site

Official references