The problem architecture should solve

A small application can mix API access, validation, and UI updates in one widget. In a real product, that creates a fragile dependency chain. Architecture establishes a direction:

presentation -> domain <- data
                    ^
                 external

The domain stays in the center and does not import Flutter, Dio, Firebase, or a database. External layers implement internal contracts and remain replaceable.

Feature-first, with layers inside

Group code that changes together:

lib/
  core/
    error/
    network/
    dependency_injection/
  features/
    products/
      domain/
        entities/
        repositories/
        use_cases/
      data/
        data_sources/
        dtos/
        mappers/
        repositories/
      presentation/
        pages/
        controllers/
        widgets/

Avoid a global collection of hundreds of pages, models, and services. A feature should be recognizable as a business capability.

Protect the domain

final class Product {
  const Product({required this.id, required this.name, required this.price});
  final String id;
  final String name;
  final double price;
}

abstract interface class ProductRepository {
  Future<List<Product>> getProducts();
}

final class GetProducts {
  const GetProducts(this._repository);
  final ProductRepository _repository;
  Future<List<Product>> call() => _repository.getProducts();
}

The entity speaks the language of the business. The repository contract describes what the use case needs without deciding whether data comes from REST, cache, or Firestore.

Translate at the boundary

DTOs represent external contracts; entities represent the domain.

final class ProductDto {
  const ProductDto({required this.id, required this.title, required this.price});

  factory ProductDto.fromJson(Map<String, Object?> json) => ProductDto(
    id: json['id'] as String,
    title: json['title'] as String,
    price: (json['price'] as num).toDouble(),
  );

  final String id;
  final String title;
  final double price;
}

extension ProductDtoMapper on ProductDto {
  Product toEntity() => Product(id: id, name: title, price: price);
}

The concrete repository coordinates data sources and returns domain values. Presentation never sees transport responses.

Presentation owns UI state, not infrastructure

A controller or notifier invokes use cases and exposes loading, data, empty, and failure states. Widgets render those states and send intentions such as load, retry, and save. They do not assemble URLs or interpret HTTP status codes.

Compose dependencies at the edge

final dio = Dio(BaseOptions(baseUrl: environment.apiUrl));
final remote = DioProductRemoteDataSource(dio);
final repository = ApiProductRepository(remote);
final getProducts = GetProducts(repository);

A container can reduce repetition, but registrations should remain explicit and fail fast. A service locator inside the domain hides dependencies and weakens tests.

Failure strategy

Define a small taxonomy such as NetworkFailure, UnauthorizedFailure, ValidationFailure, CacheFailure, and UnexpectedFailure. Data translates exceptions, the domain decides what can be recovered, and presentation chooses a useful message and action.

Guided practice: build the first Sales Management feature

Start in a new project so the Flutter application stays distinct from the Dart package built in Module 2:

flutter create sales_management
cd sales_management

Open the folder. Do not create the entire tree yet; each checkpoint adds only the layer it needs.

Checkpoint 1 — a domain that compiles without Flutter

Create these directories:

lib/features/products/domain/entities/
lib/features/products/domain/repositories/
lib/features/products/domain/use_cases/

Create domain/entities/product.dart:

final class Product {
  const Product({required this.id, required this.name, required this.price});

  final String id;
  final String name;
  final double price;
}

Create domain/repositories/product_repository.dart:

import '../entities/product.dart';

abstract interface class ProductRepository {
  Future<List<Product>> getProducts();
}

Create domain/use_cases/get_products.dart:

import '../entities/product.dart';
import '../repositories/product_repository.dart';

final class GetProducts {
  const GetProducts(this._repository);
  final ProductRepository _repository;

  Future<List<Product>> call() => _repository.getProducts();
}

None imports Flutter, Dio, or Firebase. Create test/features/products/domain/get_products_test.dart:

import 'package:flutter_test/flutter_test.dart';
import 'package:sales_management/features/products/domain/entities/product.dart';
import 'package:sales_management/features/products/domain/repositories/product_repository.dart';
import 'package:sales_management/features/products/domain/use_cases/get_products.dart';

final class FakeProductRepository implements ProductRepository {
  var calls = 0;

  @override
  Future<List<Product>> getProducts() async {
    calls++;
    return const [Product(id: 'p1', name: 'Mouse', price: 49)];
  }
}

void main() {
  test('delegates to the repository and returns products', () async {
    final repository = FakeProductRepository();
    final result = await GetProducts(repository)();

    expect(result.single.name, 'Mouse');
    expect(repository.calls, 1);
  });
}

Run flutter test test/features/products/domain/get_products_test.dart.

Checkpoint 1: the use case is tested without network, database, or widget.

Checkpoint 2 — translate the external contract in data

Create data/dtos/product_dto.dart:

final class ProductDto {
  const ProductDto({required this.id, required this.title, required this.price});

  factory ProductDto.fromJson(Map<String, Object?> json) {
    final id = json['id'];
    final title = json['title'];
    final price = json['price'];
    if (id is! String || title is! String || price is! num) {
      throw const FormatException('Invalid product contract');
    }
    return ProductDto(id: id, title: title, price: price.toDouble());
  }

  final String id;
  final String title;
  final double price;
}

The DTO uses title because that is the hypothetical API contract. Keep name in the domain. Create data/mappers/product_mapper.dart:

import '../../domain/entities/product.dart';
import '../dtos/product_dto.dart';

extension ProductMapper on ProductDto {
  Product toEntity() => Product(id: id, name: title, price: price);
}

Create data/data_sources/product_remote_data_source.dart:

import '../dtos/product_dto.dart';

abstract interface class ProductRemoteDataSource {
  Future<List<ProductDto>> getProducts();
}

final class FakeProductRemoteDataSource implements ProductRemoteDataSource {
  @override
  Future<List<ProductDto>> getProducts() async {
    await Future<void>.delayed(const Duration(milliseconds: 300));
    return const [
      ProductDto(id: 'p1', title: 'Mouse', price: 49),
      ProductDto(id: 'p2', title: 'Keyboard', price: 99),
    ];
  }
}

Checkpoint 2: the delay simulates I/O, but the domain never learns about it.

Checkpoint 3 — connect adapter and contract

Create data/repositories/product_repository_impl.dart:

import '../../domain/entities/product.dart';
import '../../domain/repositories/product_repository.dart';
import '../data_sources/product_remote_data_source.dart';
import '../mappers/product_mapper.dart';

final class ProductRepositoryImpl implements ProductRepository {
  const ProductRepositoryImpl(this._remote);
  final ProductRemoteDataSource _remote;

  @override
  Future<List<Product>> getProducts() async {
    final dtos = await _remote.getProducts();
    return dtos.map((dto) => dto.toEntity()).toList(growable: false);
  }
}

Write a test with a controlled data source and verify mapping. Test the concrete repository’s responsibility, not the fake with itself.

Checkpoint 4 — model states before the screen

Create presentation/controllers/products_controller.dart:

import 'package:flutter/foundation.dart';

import '../../domain/entities/product.dart';
import '../../domain/use_cases/get_products.dart';

sealed class ProductsState {
  const ProductsState();
}
final class ProductsInitial extends ProductsState { const ProductsInitial(); }
final class ProductsLoading extends ProductsState { const ProductsLoading(); }
final class ProductsLoaded extends ProductsState {
  const ProductsLoaded(this.items);
  final List<Product> items;
}
final class ProductsFailure extends ProductsState {
  const ProductsFailure(this.message);
  final String message;
}

final class ProductsController extends ChangeNotifier {
  ProductsController(this._getProducts);
  final GetProducts _getProducts;
  ProductsState state = const ProductsInitial();

  Future<void> load() async {
    state = const ProductsLoading();
    notifyListeners();
    try {
      state = ProductsLoaded(await _getProducts());
    } catch (_) {
      state = const ProductsFailure('Products could not be loaded');
    }
    notifyListeners();
  }
}

The controller imports Flutter because it belongs to presentation. Test Initial → Loading → Loaded and failure with different repositories.

Checkpoint 5 — compose dependencies in one place

Create lib/app_dependencies.dart:

import 'features/products/data/data_sources/product_remote_data_source.dart';
import 'features/products/data/repositories/product_repository_impl.dart';
import 'features/products/domain/use_cases/get_products.dart';
import 'features/products/presentation/controllers/products_controller.dart';

final class AppDependencies {
  AppDependencies() {
    final remote = FakeProductRemoteDataSource();
    final repository = ProductRepositoryImpl(remote);
    productsController = ProductsController(GetProducts(repository));
  }

  late final ProductsController productsController;
}

Module 4 will inject this controller into the UI. Run:

dart format lib test
flutter analyze
flutter test

Substitution test and common errors

Temporarily replace the fake data source with another implementation that returns an empty list. Change only composition. Domain, controller, and future widgets should remain unchanged.

SymptomBroken decision
entity imports Dio/Firestoreinfrastructure crossed the boundary
widget parses ProductDtopresentation learned about transport
use case constructs a repositorydependency is hidden and cannot be replaced
one models folder mixes DTO and entitytwo contracts lost their boundary
domain test initializes Flutter bindingsthe core depends on an external detail

Deeper understanding: dependency direction

“Putting code in folders” does not create architecture. The important property is who knows whom. Domain defines language, invariants, and contracts; external implementations depend on it. If an entity imports Dio, Firebase, or Flutter, infrastructure is controlling the core.

Apply the substitution test: replace API with memory and Flutter presentation with a Dart program. If the use case still compiles and its tests remain valid, the boundary has value. Otherwise, find the type that crossed layers and create a contract or mapper at the right edge.

Record an architecture decision with context, options, choice, consequences, and review condition. The record does not claim perfection; it prevents the reason from disappearing.

Independent practice: add GetProductById without duplicating data-source access. Write a short ADR explaining why DTO and entity remain separate types.

Terms to review: dependency inversion, composition root, DTO, mapper, repository, use case, adapter, and failure taxonomy. See the course reference.