Advanced · Lesson 1 · 14 min read
App Architecture That Survives Growth
Layer a Flutter codebase so features can be added by several people at once without the whole thing turning into spaghetti.
Updated July 30, 2026
What you will learn
- Separate presentation, domain and data layers with clear rules
- Structure folders by feature rather than by type
- Inject dependencies so every layer is testable in isolation
- Know when a layer is over-engineering rather than insurance
Architecture is not about diagrams. It is about answering one question quickly: where does this code go? When every developer answers that the same way, a codebase stays navigable at 200 files. When they do not, business logic ends up inside build methods and nothing can be tested or reused.
Three layers and one rule
| Layer | Contains | May depend on |
|---|---|---|
| Presentation | Widgets, controllers/notifiers, view state | Domain |
| Domain | Entities, repository interfaces, use cases | Nothing (pure Dart) |
| Data | API clients, DB access, DTOs, repository implementations | Domain |
The rule: dependencies point inward. Domain is pure Dart with no Flutter import at all — no BuildContext, no widgets, no http. That is what lets you unit-test your business rules in milliseconds and swap the whole data layer without touching them.
Folder structure
lib/
main.dart
app/
app.dart # MaterialApp, theme, router
di.dart # dependency wiring
core/
error/failures.dart
network/api_client.dart
utils/result.dart
features/
lessons/
domain/
lesson.dart # entity — pure Dart
lesson_repository.dart # abstract interface
get_lessons.dart # use case
data/
lesson_dto.dart # wire format + mapping
lesson_api.dart
lesson_local_store.dart
lesson_repository_impl.dart
presentation/
lessons_controller.dart
lessons_screen.dart
widgets/lesson_tile.dart
profile/
domain/ data/ presentation/The domain layer
// Pure Dart. No Flutter, no JSON, no database.
class Lesson {
const Lesson({
required this.id,
required this.title,
required this.level,
required this.minutes,
});
final String id;
final String title;
final Level level;
final int minutes;
// Business rules live with the entity that owns them
bool get isQuickRead => minutes <= 10;
@override
bool operator ==(Object other) =>
other is Lesson && other.id == id;
@override
int get hashCode => id.hashCode;
}// The interface lives in domain; the implementation lives in data.
// Domain defines what it needs; data figures out how.
abstract interface class LessonRepository {
Future<List<Lesson>> getLessons({Level? level});
Future<Lesson?> getLesson(String id);
Stream<List<Lesson>> watchBookmarked();
}This inversion is the whole point. Presentation depends on LessonRepository, not on http or sqflite. In a test you hand it a fake and the entire feature runs with no network, no database and no plugin registration.
Results instead of thrown exceptions
Exceptions are invisible in a function signature. Returning an explicit result type makes failure part of the contract, so the compiler reminds callers to handle it.
sealed class Result<T> {
const Result();
}
final class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}
final class Err<T> extends Result<T> {
const Err(this.failure);
final Failure failure;
}
sealed class Failure {
const Failure(this.message);
final String message;
}
final class NetworkFailure extends Failure {
const NetworkFailure() : super('No internet connection');
}
final class ServerFailure extends Failure {
const ServerFailure(super.message, {this.statusCode});
final int? statusCode;
}
final class CacheFailure extends Failure {
const CacheFailure() : super('Could not read local data');
}// In the controller — exhaustive, compiler-checked handling
final result = await getLessons(level: Level.beginner);
state = switch (result) {
Ok(value: final lessons) => LessonsState.loaded(lessons),
Err(failure: NetworkFailure()) => const LessonsState.offline(),
Err(failure: final f) => LessonsState.error(f.message),
};Keeping DTOs out of the domain
// Mirrors the wire format exactly, including its warts
class LessonDto {
const LessonDto({
required this.id,
required this.title,
required this.levelName,
required this.readingTimeSeconds,
});
final String id;
final String title;
final String levelName;
final int readingTimeSeconds;
factory LessonDto.fromJson(Map<String, dynamic> json) => LessonDto(
id: json['lesson_id'] as String,
title: json['title'] as String,
levelName: json['difficulty'] as String,
readingTimeSeconds: json['reading_time_s'] as int? ?? 0,
);
// Mapping to the domain shape happens here, once
Lesson toDomain() => Lesson(
id: id,
title: title,
level: Level.values.byName(levelName.toLowerCase()),
minutes: (readingTimeSeconds / 60).ceil(),
);
}When the backend renames reading_time_s, exactly one file changes. Without a DTO boundary, that rename ripples into your widgets.
Wiring dependencies
// With Riverpod, wiring is just providers — overridable in tests
final apiClientProvider = Provider<ApiClient>((ref) {
final client = ApiClient(baseUrl: const String.fromEnvironment('API_URL'));
ref.onDispose(client.close);
return client;
});
final lessonRepositoryProvider = Provider<LessonRepository>((ref) {
return LessonRepositoryImpl(
api: LessonApi(ref.watch(apiClientProvider)),
local: ref.watch(lessonLocalStoreProvider),
);
});
// In a widget test, swap the real repository for a fake:
ProviderScope(
overrides: [
lessonRepositoryProvider.overrideWithValue(FakeLessonRepository()),
],
child: const MyApp(),
);When layers are too much
This structure has a real cost: more files, more indirection, more ceremony for a screen that just shows a list. Be honest about which parts you need.
- Always worth it: keeping business logic out of widgets, and a repository interface between UI and data.
- Usually worth it: DTOs separate from entities, and an explicit
Result/Failuretype. - Often over-engineering: a use-case class per method that only forwards to the repository, or a separate mapper class per DTO. Add them when a use case gains real logic — combining sources, caching policy, permission checks — not before.
Key takeaways
- Dependencies point inward; the domain layer imports nothing from Flutter.
- Group folders by feature so features can be added, changed and deleted independently.
- DTOs isolate wire-format churn from your entities.
- Every dependency must be injectable, or your tests will need a real network and database.
Practice
Refactor one feature into layers
Take a screen that currently fetches and parses JSON inside its State class. Extract a domain entity, a repository interface, a DTO with mapping, and an implementation. Then write a unit test for the controller using a fake repository — with no HTTP and no widget pumping.
Show hints
- Do it feature-by-feature; a big-bang rewrite of a whole app almost never lands.
- If the test needs
TestWidgetsFlutterBinding, your logic is still inside the widget layer.