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
| Sintoma | Decisão arquitetural quebrada |
|---|---|
| entidade importa Dio/Firestore | tipo de infraestrutura atravessou a fronteira |
widget chama ProductDto.fromJson | apresentação passou a conhecer transporte |
| caso de uso instancia repositório | dependência ficou escondida e não pode ser substituída |
pasta models mistura DTO e entidade | dois contratos diferentes perderam seus limites |
| teste de domínio precisa inicializar Flutter | o 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.