Kodokon kodokon.com

Architecting an app: UI, logic, data

Structure a Flutter app in watertight layers with the repository pattern and typed sealed results.

11 min · 3 questions

Open this lesson in Kodokon

A 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.

DART
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;
}
A sealed type makes failure explicit in the signature.

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.

DART
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);
    }
  }
}
The repository maps, caches and converts errors into a Result.

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.

DART
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),
              ),
            ),
        };
      },
    );
  }
}
null = loading, and the switch covers every case of the sealed type.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Why does the UI depend on the CourseRepository interface rather than on ApiCourseRepository?
    • To swap in a fake implementation in tests and change the data source without touching the widgets
    • Because abstract classes are faster to instantiate
    • Because Dart forbids instantiating a concrete class from a widget
  2. What advantage does a sealed type like Result provide over an exception?
    • The compiler checks the switch is exhaustive: you can't forget the error case
    • It makes asynchronous code faster
    • It avoids any object allocation on the heap
    • It also catches errors from isolates
  3. Where should the JSON-to-Course-entity mapping live?
    • In the data layer, so the domain stays unaware of the API format
    • In the widget, as close as possible to the display
    • In main(), before runApp