Why architecture matters in Flutter
After 34 days of building Flutter apps, you've probably noticed that throwing everything into widgets works fine for a weekend project—until it doesn't. A messy codebase becomes unmaintainable fast: business logic tangled with UI, state scattered across multiple BuildContexts, and tests that feel impossible to write. This article walks you through Flutter app architecture: a hands-on guide for engineers built on proven layers that separate concerns, keep your code testable, and let teams scale without constant refactoring.
The layered architecture we'll explore is battle-tested in production e-commerce, SaaS, and ride-hailing apps. It's not dogma—it's a practical framework that trades minimal boilerplate for massive gains in maintainability, testability, and team velocity.
Layers: the foundation of maintainable Flutter
A clean architecture in Flutter rests on four clear layers, each with a single responsibility. Think of them as concentric rings: the outermost ring is what users see, and each inner ring is less coupled to the UI framework.
| Layer | Responsibility | Can access |
|---|---|---|
| Views (Widgets) | Render UI and forward events | View Models only |
| View Models | UI state and commands | Repositories, Services |
| Repositories | Data orchestration and caching | Services only |
| Services | APIs, storage, platform channels | Nothing (bottom layer) |
The rule is simple: a layer can only talk to the layer directly below it. Views depend on View Models, View Models depend on Repositories, Repositories depend on Services. This unidirectional dependency graph keeps concerns isolated and makes testing straightforward.
Views: widgets stay dumb
A widget's job is narrow: receive state as input, render it, and forward user events upward. That's it. No business logic, no data fetching, no conditional navigation based on permission checks.
Here's what a clean view looks like:
class ProductListPage extends StatelessWidget {
final ProductViewModel viewModel;
const ProductListPage({required this.viewModel});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: StreamBuilder>(
stream: viewModel.products,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final product = snapshot.data![index];
return ListTile(
title: Text(product.name),
onTap: () => viewModel.selectProduct(product.id),
);
},
);
},
),
);
}
}This widget is dumb by design. It receives a ProductViewModel, listens to its products stream, and calls methods when the user taps. The widget knows nothing about how products are fetched, cached, or filtered. This simplicity makes the widget trivial to test—you mock the ViewModel and verify the widget renders correctly.
In larger apps, consider using Provider, Riverpod, or GetIt for dependency injection, so you're not threading ViewModels through constructor parameters. The principle remains: the widget is a thin rendering layer.
View Models: own your UI state and commands
The ViewModel is the bridge between widgets and data. It holds UI state (loading, error, data), exposes streams or reactive properties that widgets listen to, and implements commands that widgets trigger.
Here's a real-world example from an e-commerce app:
class ProductViewModel extends ChangeNotifier {
final ProductRepository _productRepository;
List _products = [];
bool _isLoading = false;
String? _error;
List get products => _products;
bool get isLoading => _isLoading;
String? get error => _error;
ProductViewModel({required ProductRepository productRepository})
: _productRepository = productRepository;
Future loadProducts() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_products = await _productRepository.getProducts();
} on RepositoryException catch (e) {
_error = e.message;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future selectProduct(String productId) async {
try {
final product = await _productRepository.getProductDetail(productId);
// Navigate, show dialog, etc.
} catch (e) {
_error = 'Failed to load product';
notifyListeners();
}
}
}The ViewModel is plain Dart—no Flutter imports except ChangeNotifier for reactivity. It orchestrates the Repository, manages UI state, and exposes commands. The widget never calls the Repository directly; it always goes through the ViewModel. This indirection means you can swap repositories, inject mocks, or change how data flows without touching your widget code.
ViewModels are your bread and butter for testability. Since they're not widgets, you test them with plain Dart unit tests:
void main() {
test('loadProducts updates state on success', () async {
final mockRepository = MockProductRepository();
when(mockRepository.getProducts()).thenAnswer(
(_) async => [Product(id: '1', name: 'Widget')],
);
final viewModel = ProductViewModel(productRepository: mockRepository);
await viewModel.loadProducts();
expect(viewModel.isLoading, false);
expect(viewModel.products.length, 1);
expect(viewModel.error, isNull);
});
test('loadProducts sets error on failure', () async {
final mockRepository = MockProductRepository();
when(mockRepository.getProducts()).thenThrow(
RepositoryException('Network error'),
);
final viewModel = ProductViewModel(productRepository: mockRepository);
await viewModel.loadProducts();
expect(viewModel.isLoading, false);
expect(viewModel.error, 'Network error');
});
}No mocking of Flutter, no WidgetTester, no complexity. Just pure logic testing.
Repositories: own your data
The Repository is your app's single source of truth for data. It decides where data comes from—API, local database, in-memory cache—and the outside world doesn't care. It also handles data transformation and combines multiple sources.
Consider a real e-commerce scenario: you want to show the user a list of products, but you cache them locally and only refresh from the API if the cache is stale. You might also combine the product list with the user's wishlist to show which items are favorited. The Repository handles all of that:
class ProductRepository {
final ApiClient _apiClient;
final LocalDatabase _database;
final CacheManager _cache;
ProductRepository({
required ApiClient apiClient,
required LocalDatabase database,
required CacheManager cache,
}) : _apiClient = apiClient,
_database = database,
_cache = cache;
Future> getProducts() async {
// Check in-memory cache first
if (_cache.has('products')) {
return _cache.get('products') as List;
}
// Check if database cache is fresh (e.g., < 5 minutes old)
final cachedProducts = await _database.getProducts();
if (cachedProducts.isNotEmpty &&
_isCacheFresh(_database.getProductsCacheTime())) {
return cachedProducts;
}
// Fetch from API
try {
final remoteProducts = await _apiClient.getProducts();
await _database.saveProducts(remoteProducts);
_cache.set('products', remoteProducts);
return remoteProducts;
} catch (e) {
// Fallback to stale database cache if API fails
if (cachedProducts.isNotEmpty) {
return cachedProducts;
}
rethrow;
}
}
Future getProductDetail(String id) async {
// Similar layered approach: in-memory → database → API
final key = 'product_$id';
if (_cache.has(key)) {
return _cache.get(key) as Product;
}
final product = await _database.getProduct(id) ??
await _apiClient.getProduct(id);
if (product != null) {
_cache.set(key, product);
return product;
}
throw ProductNotFoundException(id);
}
bool _isCacheFresh(DateTime? cacheTime) {
if (cacheTime == null) return false;
return DateTime.now().difference(cacheTime).inMinutes < 5;
}
}
class ProductNotFoundException implements Exception {
final String productId;
ProductNotFoundException(this.productId);
@override
String toString() => 'Product $productId not found';
}Notice the Repository doesn't know or care about the UI. It doesn't emit streams or notify listeners. It's pure data orchestration. The ViewModel wraps the Repository and handles reactivity. This separation means you can test the Repository in isolation—swap out the API client, mock the database, and verify caching logic without any Flutter boilerplate.
Services: the bottom layer
Services are your interface to the outside world: HTTP clients, local storage, Firebase, platform channels, etc. They're thin wrappers around external dependencies.
A service doesn't decide what data to cache or how to combine sources. It just does one thing well:
class ApiClient {
final http.Client _httpClient;
final String baseUrl;
ApiClient({required http.Client httpClient, required this.baseUrl})
: _httpClient = httpClient;
Future> getProducts() async {
final response = await _httpClient.get(
Uri.parse('$baseUrl/products'),
headers: {'Authorization': 'Bearer $_token'},
).timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as List;
return json.map((p) => Product.fromJson(p)).toList();
} else {
throw ApiException('Failed to fetch products: ${response.statusCode}');
}
}
}
class LocalDatabase {
late final Database _db;
Future init() async {
_db = await openDatabase('app.db');
}
Future> getProducts() async {
final rows = await _db.query('products');
return rows.map((row) => Product.fromMap(row)).toList();
}
Future saveProducts(List products) async {
await _db.delete('products'); // Clear old data
for (final product in products) {
await _db.insert('products', product.toMap());
}
}
}Services are straightforward to test—mock the underlying HTTP client or database, verify the service transforms data correctly, and you're done. No Repository complexity, no ViewModel reactivity. Just pure I/O and serialization.
Common mistakes
Mixing layers. The most common mistake is letting widgets call Services directly or Repositories call other Repositories. Stick to the hierarchy: Views → ViewModels → Repositories → Services. When you break this rule, testing becomes a nightmare and changes ripple unexpectedly.
Over-engineering small features. Not every screen needs a ViewModel. For a simple, stateless UI like a settings dialog, a basic StatelessWidget is fine. Use architecture as a tool, not a straitjacket. That said, the moment your feature touches data or has more than one piece of UI state, introduce a ViewModel.
ViewModels that are too fat. If your ViewModel has 20 methods and manages five unrelated pieces of state, split it. A ViewModel should handle one feature or screen. In a large app, you might have a CartViewModel and a separate CheckoutViewModel, not one ShoppingViewModel that does both.
Forgetting about error handling. Every layer should handle errors gracefully. Services throw exceptions, Repositories catch and transform them, ViewModels expose error state to the UI. If you skip this, your app crashes or shows cryptic errors to users.
Key takeaways
- Separate concerns into layers: Views render, ViewModels orchestrate, Repositories manage data, Services handle I/O. Each layer has one job.
- Keep widgets dumb. They should render state and forward events. No business logic, no data fetching, no conditional routing based on app state.
- Repositories are your data source of truth. They combine APIs, databases, and caches. The outside world never knows where data comes from.
- Enforce unidirectional dependencies. A layer talks only to the layer below it. This makes testing and refactoring safe and predictable.
- Test everything except widgets. ViewModels and Repositories are plain Dart and easy to unit test. Keep your test suite fast and reliable.
- Scale gradually. Start simple—a ViewModel and Repository for each feature. As your app grows, split ViewModels, introduce domain models, or add a use-case layer. Architecture evolves with your codebase.






Comments
Loading comments…