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: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowIn 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.
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.
- 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.
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.dartThree rules make this hold up over time, and they matter more than the folder names:
- A feature may not import another feature's
data/orpresentation/. Onlydomain/is importable across features, and even that should be rare. core/may not import fromfeatures/. Dependencies point inward. The momentcore/imports a feature, you have a cycle and the structure is decorative.- 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);
});
}| Layer | Test type | What it needs |
|---|---|---|
domain/ | Plain unit test | Nothing — no Flutter, no mocks. Fastest tests you will have |
data/ | Unit test with a mock HTTP client | http_mock_adapter or a stubbed Dio; asserts DTO→domain mapping and error translation |
presentation/ controller | Unit test with ProviderContainer | A fake repository via overrideWithValue; no widget tree |
presentation/ widgets | Widget test | ProviderScope(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:
- Create
lib/features/alongside the existing folders. Both structures coexist while you migrate. - Pick the feature you are already about to change, not the messiest one. Migration rides along with work you were doing anyway.
- Move its files in, add the DTO/domain split, extract a repository. Fix imports; the analyser lists them all.
- Add tests at the new boundary — this is the step that makes the migration worth something rather than shuffling files.
- 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 fromfeatures/, which creates a cycle and quietly makes every feature depend on every other. - Handling loading and data but not error and empty.
AsyncValue.whenmakes 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
- Flutter Layout Widgets: The Complete Guide With Code Examples
- go_router in Flutter: Deep Linking, Nested Navigation, and Web URLs
- Flutter Testing Strategy in 2026: Unit, Widget, Integration, and Goldens
- Flutter Performance in 2026: Impeller, DevTools, and Rebuild Reduction
- Flutter State Management in 2026: Provider vs Riverpod vs BLoC
- Dart Language for Flutter: The Complete Beginner-to-Pro Guide
- Flutter Interview Questions and Answers (2026): 300+ MCQs and Short Answers
Official references
- Flutter docs: App architecture guide — the team's own recommended layering
- Flutter docs: Architecture case study — the same ideas applied to a full sample app
- Riverpod: Essentials —
AsyncNotifier,AsyncValueand provider overrides - Dart: Class modifiers — what
abstract interface classguarantees - go_router package — nested routes and redirect guards