O problema que a arquitetura resolve

Um aplicativo pequeno tolera acesso à API, validação e atualização visual no mesmo widget. Em um produto real, isso cria uma cadeia difícil de testar: interface conhece transporte, transporte conhece persistência e qualquer mudança atravessa o sistema.

Arquitetura não é uma coleção de pastas. É uma direção de dependência:

presentation -> domain <- data
                    ^
                 external

O domínio fica no centro e não importa Flutter, Dio, Firebase ou banco local. Camadas externas conhecem contratos internos e podem ser substituídas.

Feature-first com camadas internas

Uma estrutura equilibrada agrupa o que muda junto:

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

Evite uma raiz global com centenas de pages, models e services. A feature precisa ser reconhecível como uma unidade de negócio.

Domínio: a parte que merece proteção

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();
}

A entidade expressa o negócio. O contrato do repositório define o que o caso de uso precisa, sem dizer se os dados virão de REST, cache ou Firestore.

Data: tradução nas fronteiras

DTOs representam contratos externos. Entidades representam o domínio. Misturá-los faz o domínio herdar nomes, nulos e mudanças que pertencem à API.

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);
}

O repositório concreto coordena fontes e converte falhas técnicas:

final class ApiProductRepository implements ProductRepository {
  const ApiProductRepository(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);
  }
}

Presentation: estado e intenção

A interface conhece casos de uso e estados de apresentação. Ela não deve montar URLs, interpretar status HTTP ou abrir tabelas locais. Um controller/notifier expõe intenções como load, retry e save, enquanto widgets renderizam loading, success, empty e failure.

Injeção de dependências na borda

Instancie implementações no composition root:

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

Em produção, um container pode reduzir código repetitivo. Ainda assim, prefira registro explícito e falha rápida. Service locator dentro do domínio esconde dependências e dificulta testes.

Estratégia de erros

Defina uma pequena taxonomia: NetworkFailure, UnauthorizedFailure, ValidationFailure, CacheFailure e UnexpectedFailure. A camada data converte exceções; o domínio decide o que é recuperável; a apresentação escolhe a mensagem e a ação oferecida ao usuário.

Prática guiada: crie a primeira feature do Sales Management

Este módulo começa em um projeto novo para separar claramente a aplicação Flutter do package Dart criado no Módulo 2. Execute:

flutter create sales_management
cd sales_management

Abra a pasta no editor. Não crie toda a árvore de diretórios antecipadamente: cada checkpoint adiciona somente a camada necessária.

Checkpoint 1 — domínio que compila sem Flutter

Crie estas pastas:

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

Crie 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;
}

Crie domain/repositories/product_repository.dart:

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

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

Crie 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();
}

Nenhum desses arquivos importa package:flutter, Dio ou Firebase. Essa é a primeira fronteira verificável.

Crie test/features/products/domain/get_products_test.dart com um fake escrito à mão:

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: 199)];
  }
}

void main() {
  test('delega a busca ao repositório e devolve os produtos', () async {
    final repository = FakeProductRepository();
    final useCase = GetProducts(repository);

    final result = await useCase();

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

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

Checkpoint 1: o caso de uso está testado sem rede, banco ou widget.

Checkpoint 2 — traduza o contrato externo na camada data

Crie:

lib/features/products/data/data_sources/
lib/features/products/data/dtos/
lib/features/products/data/mappers/
lib/features/products/data/repositories/

Crie 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('Contrato de produto inválido');
    }

    return ProductDto(id: id, title: title, price: price.toDouble());
  }

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

O DTO usa title porque esse é o nome do contrato externo hipotético. Não renomeie o domínio para acomodar a API.

Crie 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);
}

Crie 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: 199),
      ProductDto(id: 'p2', title: 'Teclado', price: 299),
    ];
  }
}

Checkpoint 2: o atraso simula I/O, mas nenhum domínio conhece sua existência.

Checkpoint 3 — conecte adapter e contrato

Crie 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);
  }
}

Escreva um teste que injeta um data source controlado e confirma o mapeamento. Não teste o fake com o próprio fake; teste a responsabilidade do repositório concreto.

Checkpoint 4 — modele estados antes da tela

Crie 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 {
      final products = await _getProducts();
      state = ProductsLoaded(products);
    } catch (_) {
      state = const ProductsFailure('Não foi possível carregar os produtos');
    }
    notifyListeners();
  }
}

O controller ainda usa Flutter porque pertence à apresentação. O domínio continua puro. Teste a sequência Initial → Loading → Loaded e uma falha usando repositórios diferentes.

Checkpoint 5 — componha as dependências em um único lugar

Crie 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;
}

Esse é o composition root inicial. No Módulo 4, a interface receberá ProductsController; ela não criará data source nem repositório.

Execute:

dart format lib test
flutter analyze
flutter test

Teste de substituição

Crie temporariamente outro ProductRemoteDataSource que devolva uma lista vazia. Troque somente a linha de composição. Se domínio, controller e futuros widgets não precisarem mudar, a fronteira funciona. Depois restaure o fake original.

Erros comuns

SintomaDecisão arquitetural quebrada
entidade importa Dio/Firestoretipo de infraestrutura atravessou a fronteira
widget chama ProductDto.fromJsonapresentação passou a conhecer transporte
caso de uso instancia repositóriodependência ficou escondida e não pode ser substituída
pasta models mistura DTO e entidadedois contratos diferentes perderam seus limites
teste de domínio precisa inicializar Fluttero núcleo depende de detalhe externo

Aprofundamento: direção da dependência

“Separar em pastas” não cria arquitetura. A propriedade importante é quem conhece quem. O domínio define linguagem, invariantes e contratos; implementações externas dependem dele. Se uma entidade importa Dio, Firebase ou Flutter, uma decisão de infraestrutura passou a controlar o núcleo.

Faça o teste de substituição: troque API por memória e apresentação Flutter por um programa Dart. Se o caso de uso continuar compilando e seus testes permanecerem válidos, a fronteira tem valor. Caso contrário, identifique qual tipo atravessou a camada e crie um contrato ou mapper no limite correto.

Registre uma decisão de arquitetura com contexto, opções, escolha, consequências e condição de revisão. O documento não serve para declarar que a solução é perfeita; serve para impedir que a motivação desapareça.

Prática autônoma: adicione GetProductById sem duplicar acesso ao data source. Registre uma ADR curta explicando por que DTO e entidade são tipos distintos.

Termos para revisar: dependency inversion, composition root, DTO, mapper, repository, use case, adapter e failure taxonomy. Consulte a referência do curso.