Arquitetura de App Flutter Que Escala Além do Tutorial
Padrões de arquitetura Flutter para produção — desde gerenciamento de estado até navegação, injeção de dependência e estrutura de projeto feature-first.

Apps Flutter de tutorial funcionam muito bem até atingirem cerca de vinte telas. Então o arquivo único main.dart com todas as rotas se torna ingerenciável, as chamadas setState criam condições de corrida, a abordagem "apenas passe pelo construtor" produz árvores de widgets com dez parâmetros, e cada nova feature toca cinco arquivos existentes. Já vi isso acontecer em múltiplos projetos, incluindo os meus, e o padrão é sempre o mesmo — o time bate em uma parede por volta do terceiro mês e precisa reestruturar ou aceitar a perda crescente de velocidade de desenvolvimento.
A arquitetura que descrevo aqui é o que uso para apps em produção. Não é a abordagem mais simples possível, e esse é o ponto. A abordagem mais simples é o que os tutoriais ensinam, e ela para de funcionar no momento em que seu app tem requisitos reais.
Por Que a Arquitetura de Tutorial Falha
A maioria dos tutoriais e cursos de Flutter ensinam uma estrutura de projeto plana:
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
Isso funciona para um app de tarefas. Falha para um app real por razões previsíveis:
Arquivos crescem sem limites naturais. Quando home_screen.dart tem o layout da tela, o gerenciamento de estado, as chamadas de API e a lógica de negócio, ele atinge 500+ linhas rapidamente. Não há orientação estrutural sobre o que deve ser extraído e para onde deve ir.
Dependências se tornam implícitas. Quando home_screen.dart instancia diretamente ApiService(), não há como substituí-lo por um mock nos testes, não há como configurá-lo diferentemente por ambiente, e não há como compartilhar uma única instância entre telas que precisam dos mesmos dados.
A navegação se torna uma teia de rotas hardcoded. Deep links, guardas de autenticação e fluxos condicionais são todos aparafusados em uma lista plana de rotas. Quando você tem trinta rotas com guardas e redirecionamentos, o código de navegação é a parte mais frágil do app.
Os testes se tornam impraticáveis. Sem injeção de dependência, widget tests exigem mocking no nível HTTP em vez do nível de serviço. Sem separação clara de lógica de negócio da UI, você acaba testando detalhes de implementação em vez de comportamento.
Estrutura de Projeto Feature-First
A decisão arquitetural de maior impacto é a estrutura de pastas. Eu uso uma abordagem feature-first onde cada feature possui suas telas, widgets, estado e modelos:
lib/
├── app/
│ ├── app.dart # MaterialApp configuration
│ ├── router.dart # GoRouter configuration
│ └── theme.dart # Theme data
├── core/
│ ├── constants/
│ │ ├── api_constants.dart
│ │ └── app_constants.dart
│ ├── errors/
│ │ ├── app_exception.dart
│ │ └── error_handler.dart
│ ├── network/
│ │ ├── api_client.dart
│ │ ├── interceptors/
│ │ └── network_info.dart
│ └── utils/
│ ├── date_utils.dart
│ └── string_utils.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── auth_remote_source.dart
│ │ ├── domain/
│ │ │ ├── user_model.dart
│ │ │ └── auth_state.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── login_screen.dart
│ │ │ │ └── register_screen.dart
│ │ │ ├── widgets/
│ │ │ │ ├── login_form.dart
│ │ │ │ └── social_login_buttons.dart
│ │ │ └── providers/
│ │ │ └── auth_provider.dart
│ │ └── auth.dart # Barrel file for public API
│ ├── home/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── home.dart
│ └── settings/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ └── settings.dart
└── shared/
├── widgets/
│ ├── app_button.dart
│ ├── app_text_field.dart
│ ├── loading_overlay.dart
│ └── error_view.dart
├── models/
│ └── paginated_response.dart
└── providers/
└── connectivity_provider.dart
As Regras
Features nunca importam de outras features. Se a feature A precisa de dados da feature B, esses dados ficam em um modelo compartilhado ou um serviço compartilhado, não na pasta da feature B. Isso previne dependências circulares e mantém features implantáveis independentemente (útil se você eventualmente migrar para um monorepo modular).
O diretório core/ contém infraestrutura. Rede, tratamento de erros, constantes, utilitários — coisas que toda feature pode precisar mas que não pertencem a nenhuma feature específica.
O diretório shared/ contém UI reutilizável e preocupações transversais. Widgets compartilhados, modelos compartilhados e providers dos quais múltiplas features dependem.
Barrel files expõem a API pública de cada feature. Outras partes do app importam de features/auth/auth.dart, nunca de arquivos internos. Isso permite refatorar a estrutura interna de uma feature sem quebrar consumidores externos.
// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// Don't export internal implementation details
Gerenciamento de Estado: Riverpod
Já usei Provider, Bloc e Riverpod em projetos de produção. Cada um tem trade-offs, e a escolha depende mais da familiaridade do time do que de superioridade técnica. Dito isso, eu opto por Riverpod em novos projetos por razões específicas.
Por Que Riverpod ao Invés de Bloc
Bloc é excelente para times vindos de um background enterprise. O padrão event/state é familiar para desenvolvedores que trabalharam com Redux ou MVI. Ele impõe um fluxo de dados unidirecional estrito e gera state machines previsíveis e testáveis. A desvantagem é a verbosidade — uma feature simples requer uma classe de evento, uma classe de estado e uma classe bloc, frequentemente em três arquivos. Para apps com lógica de negócio complexa e múltiplos desenvolvedores, essa cerimônia compensa. Para times menores se movendo rápido, pode parecer overhead.
Provider (o pacote original) funciona bem para injeção de dependência simples mas tem dificuldade com estado complexo. Ele depende da árvore de widgets para escopo, o que significa que o ciclo de vida do seu estado está vinculado à estrutura da sua UI. Refatorar a árvore de widgets pode quebrar o gerenciamento de estado de formas sutis.
Riverpod resolve as limitações fundamentais do Provider. Providers são globais (não dependentes da árvore de widgets), seguros em tempo de compilação e suportam padrões avançados como auto-disposal, family providers e estado assíncrono. Também gera menos boilerplate que Bloc mantendo a testabilidade.
Riverpod na Prática
Aqui está como eu estruturo o gerenciamento de estado para uma feature típica:
// features/auth/domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_repository.dart';
import '../../domain/auth_state.dart';
part 'auth_provider.g.dart';
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
_checkAuthStatus();
return const AuthState.initial();
}
Future<void> _checkAuthStatus() async {
final repository = ref.read(authRepositoryProvider);
final user = await repository.getCurrentUser();
state = user != null
? AuthState.authenticated(user)
: const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
final repository = ref.read(authRepositoryProvider);
try {
final user = await repository.signIn(email, password);
state = AuthState.authenticated(user);
} on AuthException catch (e) {
state = AuthState.error(e.message);
}
}
Future<void> signOut() async {
final repository = ref.read(authRepositoryProvider);
await repository.signOut();
state = const AuthState.unauthenticated();
}
}
Usar a anotação @riverpod com geração de código elimina o boilerplate manual de declaração de provider. Execute dart run build_runner build para gerar o código do provider.
Quando Bloc É Melhor
Apesar da minha preferência por Riverpod, existem cenários onde Bloc é a escolha mais forte:
- Times grandes onde padrões impostos previnem inconsistência.
- State machines complexas com muitas transições — o sistema de eventos do Bloc torna essas explícitas e testáveis.
- Quando você precisa de funcionalidade de replay/undo — o log de eventos do Bloc torna isso direto.
- Quando o time já conhece Bloc. Consistência na base de código importa mais do que qualquer diferença técnica entre os dois.
Navegação com GoRouter
O GoRouter se tornou o padrão para navegação Flutter, e com razão. Ele suporta roteamento declarativo, deep links, redirecionamentos e navegação aninhada prontos para uso.
// app/router.dart
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';
@riverpod
GoRouter router(RouterRef ref) {
final authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState is Authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) return '/auth/login';
if (isAuthenticated && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ItemDetailScreen(itemId: id);
},
),
],
),
],
);
}
Guardas de Navegação
O callback redirect lida com guardas de autenticação globalmente. Mas você pode precisar de lógica mais complexa — acesso baseado em funções, fluxos de onboarding, feature flags. Eu lido com esses usando uma cadeia de redirecionamentos:
redirect: (context, state) {
// Authentication check
final isAuthenticated = authState is Authenticated;
if (!isAuthenticated && !state.matchedLocation.startsWith('/auth')) {
return '/auth/login';
}
// Onboarding check
if (isAuthenticated && !hasCompletedOnboarding && state.matchedLocation != '/onboarding') {
return '/onboarding';
}
// Already authenticated, redirect away from auth screens
if (isAuthenticated && state.matchedLocation.startsWith('/auth')) {
return '/';
}
return null; // No redirect
},
O Padrão Repository
Repositories ficam entre sua lógica de negócio e fontes de dados. Eles abstraem se os dados vêm de uma API REST, um banco de dados local, um cache ou uma combinação de todos os três.
// features/auth/data/auth_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user_model.dart';
part 'auth_repository.g.dart';
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepository(
remoteSource: ref.read(authRemoteSourceProvider),
localSource: ref.read(authLocalSourceProvider),
);
}
class AuthRepository {
final AuthRemoteSource _remoteSource;
final AuthLocalSource _localSource;
AuthRepository({
required AuthRemoteSource remoteSource,
required AuthLocalSource localSource,
}) : _remoteSource = remoteSource,
_localSource = localSource;
Future<User?> getCurrentUser() async {
// Try local cache first
final cachedUser = await _localSource.getCachedUser();
if (cachedUser != null) return cachedUser;
// Fall back to remote
try {
final user = await _remoteSource.fetchCurrentUser();
if (user != null) await _localSource.cacheUser(user);
return user;
} catch (_) {
return null;
}
}
Future<User> signIn(String email, String password) async {
final user = await _remoteSource.signIn(email, password);
await _localSource.cacheUser(user);
await _localSource.saveToken(user.token);
return user;
}
Future<void> signOut() async {
await _remoteSource.signOut();
await _localSource.clearCache();
}
}
Por Que Não Chamar a API Diretamente
Chamadas diretas de API a partir do código de gerenciamento de estado criam vários problemas:
- A lógica de cache vaza para a lógica de negócio. Você verifica o cache antes da chamada de API? Quanto tempo o cache é válido? Isso é preocupação da camada de dados, não do gerenciamento de estado.
- O suporte offline se torna uma reescrita. Se seu Bloc chama diretamente
http.get, adicionar suporte offline significa modificar cada Bloc. Com um repository, você adiciona cache e lógica de fallback em um só lugar. - Testar requer mocking HTTP. Mockar um repository é um mock por teste. Mockar HTTP requer configurar bodies de resposta, status codes e headers para cada caso de teste.
Camada de Serviço
Para operações que abrangem múltiplos repositories ou envolvem lógica de negócio complexa, eu uso uma camada de serviço:
class OrderService {
final OrderRepository _orderRepo;
final PaymentRepository _paymentRepo;
final NotificationRepository _notificationRepo;
OrderService({
required OrderRepository orderRepo,
required PaymentRepository paymentRepo,
required NotificationRepository notificationRepo,
}) : _orderRepo = orderRepo,
_paymentRepo = paymentRepo,
_notificationRepo = notificationRepo;
Future<Order> placeOrder(Cart cart, PaymentMethod method) async {
// Validate stock
await _orderRepo.validateStock(cart.items);
// Process payment
final payment = await _paymentRepo.charge(method, cart.total);
// Create order
final order = await _orderRepo.create(
items: cart.items,
paymentId: payment.id,
);
// Send confirmation (fire and forget)
_notificationRepo.sendOrderConfirmation(order).ignore();
return order;
}
}
Serviços contêm lógica de negócio que não pertence à UI ou a nenhum repository específico. A camada de gerenciamento de estado (Riverpod notifier, Bloc) chama o serviço, e o serviço coordena entre repositories.
Tratamento de Erros
O tratamento consistente de erros é um dos aspectos mais negligenciados da arquitetura Flutter. Eu uso uma combinação de exceções tipadas e um tipo de resultado.
// core/errors/app_exception.dart
sealed class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
}
class NetworkException extends AppException {
const NetworkException([String message = 'Network error']) : super(message);
}
class ServerException extends AppException {
final int statusCode;
const ServerException(this.statusCode, String message) : super(message);
}
class AuthException extends AppException {
const AuthException([String message = 'Authentication failed']) : super(message);
}
class ValidationException extends AppException {
final Map<String, String> fieldErrors;
const ValidationException(this.fieldErrors) : super('Validation failed');
}
O cliente de API captura erros HTTP e os converte em exceções tipadas:
// core/network/api_client.dart
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<T> get<T>(String path, {T Function(dynamic)? fromJson}) async {
try {
final response = await _dio.get(path);
return fromJson != null ? fromJson(response.data) : response.data as T;
} on DioException catch (e) {
throw _mapError(e);
}
}
AppException _mapError(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return const NetworkException('Connection timed out');
}
final statusCode = error.response?.statusCode;
if (statusCode == null) return const NetworkException();
return switch (statusCode) {
401 => const AuthException('Session expired'),
403 => const AuthException('Access denied'),
422 => ValidationException(
_parseValidationErrors(error.response?.data),
),
_ => ServerException(statusCode, 'Server error'),
};
}
}
Arquitetura de Testes
Uma boa arquitetura torna os testes diretos. Cada camada tem sua própria estratégia de teste:
Testes de Repository
void main() {
late AuthRepository repository;
late MockAuthRemoteSource mockRemote;
late MockAuthLocalSource mockLocal;
setUp(() {
mockRemote = MockAuthRemoteSource();
mockLocal = MockAuthLocalSource();
repository = AuthRepository(
remoteSource: mockRemote,
localSource: mockLocal,
);
});
group('getCurrentUser', () {
test('returns cached user when available', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => user);
final result = await repository.getCurrentUser();
expect(result, equals(user));
verifyNever(() => mockRemote.fetchCurrentUser());
});
test('fetches from remote when cache is empty', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => null);
when(() => mockRemote.fetchCurrentUser()).thenAnswer((_) async => user);
when(() => mockLocal.cacheUser(any())).thenAnswer((_) async {});
final result = await repository.getCurrentUser();
expect(result, equals(user));
verify(() => mockLocal.cacheUser(user)).called(1);
});
});
}
Testes de Provider com Riverpod
void main() {
test('signIn updates state to authenticated', () async {
final user = User(id: '1', name: 'Test');
final mockRepository = MockAuthRepository();
when(() => mockRepository.signIn(any(), any()))
.thenAnswer((_) async => user);
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.signIn('test@test.com', 'password');
final state = container.read(authNotifierProvider);
expect(state, AuthState.authenticated(user));
});
}
O ProviderContainer do Riverpod e o mecanismo de override tornam os testes limpos — você sobrescreve dependências no nível do container em vez do nível da árvore de widgets.
Trade-offs Práticos
Essa arquitetura não é gratuita. Existem custos reais:
Mais arquivos. Uma feature que seria um arquivo em um app de tutorial pode ser cinco a oito arquivos nesta estrutura. Para apps pequenos (menos de dez telas), isso pode parecer exagero.
Curva de aprendizado. Um desenvolvedor novo no projeto precisa entender os limites das camadas e onde colocar as coisas. Documentação ajuda, mas ainda há tempo de adaptação.
Geração de código. O sistema de anotações do Riverpod, Freezed para modelos imutáveis e serialização JSON todos requerem build_runner. A geração de build adiciona um passo ao desenvolvimento e pode ser lenta em bases de código grandes.
Tentação de sobre-abstrair. Quando você tem uma arquitetura limpa, é fácil adicionar abstrações "por precaução". Uma interface de repository para uma fonte de dados que só terá uma implementação adiciona complexidade sem valor. Eu crio abstrações quando tenho uma segunda implementação concreta, não antes.
A arquitetura vale a pena quando seu app tem mais de cerca de dez telas, mais de um desenvolvedor, ou uma vida útil além de alguns meses. Para um projeto de hackathon ou uma prova de conceito, use a coisa mais simples que funcione. Mas quando você sabe que o app precisa crescer, investir em estrutura cedo economiza múltiplos do custo depois.