Structure a Flutter app in watertight layers with the repository pattern and typed sealed results.
Open this lesson in KodokonA Flutter app built to last separates three responsibilities: presentation (widgets, BuildContext), logic (state, use cases) and data (API, local database). The cardinal rule is the direction of dependencies: presentation depends on logic, logic depends on data abstractions - never the other way around. Concretely, no BuildContext and no material.dart import should appear below the presentation layer: a controller that pops its own SnackBar is an untestable controller. Another point that's often overlooked: errors are part of the contract between layers. Rather than letting untyped exceptions leak, model the outcome with a sealed class - the compiler will force you to handle every case.
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error);
final Object error;
}
class Course {
const Course({required this.id, required this.title});
final String id;
final String title;
}The repository is the boundary between the domain and the outside world. Its exact role: expose domain entities (never the raw API DTOs), hide the access strategy (network, cache, local database) and centralize the data-freshness policy. In Dart 3, declare the contract with abstract interface class: this modifier only allows implements from outside the library - you can't accidentally inherit a piece of implementation, so the contract stays pure. The JSON-to-entity mapping lives in the implementation: if the API renames a field tomorrow, only the data layer changes.
abstract interface class CourseApi {
Future<List<Map<String, Object?>>> fetchRaw();
}
abstract interface class CourseRepository {
Future<Result<List<Course>>> getAll();
void invalidate();
}
class ApiCourseRepository implements CourseRepository {
ApiCourseRepository(this._api);
final CourseApi _api;
List<Course>? _cache;
@override
void invalidate() => _cache = null;
@override
Future<Result<List<Course>>> getAll() async {
final cached = _cache;
if (cached != null) {
return Success(cached);
}
try {
final rows = await _api.fetchRaw();
final courses = [
for (final row in rows)
Course(
id: row['id'] as String,
title: row['title'] as String,
),
];
_cache = courses;
return Success(courses);
} on Exception catch (error) {
return Failure(error);
}
}
}On the presentation side, a ChangeNotifier injected through the constructor is very often enough - no need to reach for a framework. Avoid global singletons: they freeze the dependency graph and make substitution in tests painful. ListenableBuilder only rebuilds its builder, and the exhaustive switch on the Result locks the UI down: add a third state to the sealed type and compilation fails everywhere it isn't handled. That's the whole value of the pattern - an oversight becomes a compile error, not a production bug.
import 'package:flutter/material.dart';
class CourseListController extends ChangeNotifier {
CourseListController(this._repository);
final CourseRepository _repository;
Result<List<Course>>? state;
Future<void> load() async {
state = null;
notifyListeners();
state = await _repository.getAll();
notifyListeners();
}
}
class CourseListScreen extends StatelessWidget {
const CourseListScreen({
super.key,
required this.controller,
});
final CourseListController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return switch (controller.state) {
null => const Center(
child: CircularProgressIndicator(),
),
Failure() => const Center(
child: Text('Loading error'),
),
Success(value: final courses) =>
ListView.builder(
itemCount: courses.length,
itemBuilder: (context, i) => ListTile(
title: Text(courses[i].title),
),
),
};
},
);
}
}