Skip to content
FlutterLearn

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

LayerContainsMay depend on
PresentationWidgets, controllers/notifiers, view stateDomain
DomainEntities, repository interfaces, use casesNothing (pure Dart)
DataAPI clients, DB access, DTOs, repository implementationsDomain

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/
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

lib/features/lessons/domain/lesson.dart
// 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;
}
lib/features/lessons/domain/lesson_repository.dart
// 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.

lib/core/utils/result.dart
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');
}
Dart
// 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

lib/features/lessons/data/lesson_dto.dart
// 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

lib/app/di.dart
// 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/Failure type.
  • 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.