Open any Flutter project that has been alive for more than six months and you will find it: a widget with an http.get buried in initState, a Firestore query inside a build method, a SharedPreferences read next to a setState. It works. And it is exactly how codebases become untestable. The Flutter repository pattern is the fix: a single class per feature that owns all data access, so your widgets never touch a network call, a database, or a cache directly.
The idea is simple. Everything above the repository — your ViewModels, your widgets — asks for data through one clean interface: "give me the products." Everything below the repository — REST APIs, Firestore, local caches — is hidden behind it. When the data source changes, only the repository changes. In this guide I will show you the Flutter repository pattern with a real example, explain why API calls do not belong in widgets, and cover the mistakes that turn repositories into another junk drawer.
Why API Calls Don't Belong in Widgets
A widget's job is to render UI and forward user intent. The moment it also fetches data, four things break:
- Testability dies. You cannot test a widget that makes real network calls without a widget test, a mock HTTP layer, and a prayer. Business logic should be testable with plain Dart tests.
- Sources get tangled. When three screens each call the same endpoint, "add caching" means editing three screens. With a repository, it means editing one class.
- Switching backends is surgery. Moving from REST to Firestore, or adding an offline cache, should not touch a single widget. Without a boundary, it touches everything.
- Error handling scatters. Every widget invents its own way to catch, map, and display failures. A repository centralizes the translation from "what the server said" to "what the app understands."
The Flutter repository pattern draws one line: widgets and ViewModels live above it and never import http, dio, cloud_firestore, or shared_preferences. Everything data-related lives at or below it.
The Flutter Repository Pattern, Step by Step
Let me build a real example: a product catalog that fetches from a REST API today but might gain a cache tomorrow. The repository hides all of that behind an interface.
1. The Model (data the app understands)
// lib/features/products/data/product.dart
class Product {
const Product({
required this.id,
required this.name,
required this.price,
});
final String id;
final String name;
final double price;
factory Product.fromJson(Map<String, dynamic> json) => Product(
id: json['id'] as String,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
);
}
2. The data source (talks to the outside world)
The data source is the only place raw HTTP lives. It knows endpoints, headers, and JSON parsing — nothing about the app's features:
// lib/features/products/data/products_api.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'product.dart';
class ProductsApi {
ProductsApi({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
static const _baseUrl = 'https://api.example.com';
Future<List<Product>> fetchProducts() async {
final response = await _client.get(Uri.parse('$_baseUrl/products'));
if (response.statusCode != 200) {
throw ProductsException('Server returned ${response.statusCode}');
}
final decoded = jsonDecode(response.body) as List<dynamic>;
return decoded
.map((item) => Product.fromJson(item as Map<String, dynamic>))
.toList();
}
}
class ProductsException implements Exception {
ProductsException(this.message);
final String message;
}
3. The repository (the contract your app codes against)
Here is the core of the Flutter repository pattern: an abstract interface first, with the implementation behind it. Widgets depend on the interface; the implementation can be swapped without touching them:
// lib/features/products/data/products_repository.dart
import 'product.dart';
import 'products_api.dart';
/// The contract the rest of the app codes against.
abstract class ProductsRepository {
Future<List<Product>> getProducts();
}
/// Talks to the real backend through ProductsApi.
class ApiProductsRepository implements ProductsRepository {
ApiProductsRepository({required ProductsApi api}) : _api = api;
final ProductsApi _api;
@override
Future<List<Product>> getProducts() async {
// Caching, offline fallback, or a second data source
// can be added here later — callers never change.
return _api.fetchProducts();
}
}
/// In-memory fake for tests and previews.
class FakeProductsRepository implements ProductsRepository {
@override
Future<List<Product>> getProducts() async => const [
Product(id: '1', name: 'Ceramic Mug', price: 14.99),
Product(id: '2', name: 'Steel Bottle', price: 29.99),
];
}
Notice how little the repository does right now: it delegates. That is the point. The repository's job is to be the seam — the place where "add a cache" or "fall back to local storage when offline" gets implemented once, in one file, with zero changes to the code above it.
4. The consumer (a ViewModel that never sees HTTP)
Above the repository, a ViewModel just asks for data. It has no idea whether it comes from REST, Firestore, or a fake:
// lib/features/products/presentation/products_view_model.dart
import 'package:flutter/foundation.dart';
import '../data/product.dart';
import '../data/products_repository.dart';
class ProductsViewModel extends ChangeNotifier {
ProductsViewModel({required ProductsRepository repository})
: _repository = repository;
final ProductsRepository _repository;
List<Product> products = [];
bool isLoading = false;
Future<void> loadProducts() async {
isLoading = true;
notifyListeners();
try {
products = await _repository.getProducts();
} finally {
isLoading = false;
notifyListeners();
}
}
}
Wiring it up is plain constructor injection — no package required:
final viewModel = ProductsViewModel(
repository: ApiProductsRepository(api: ProductsApi()),
);
Repository vs Service vs Data Source
These terms get mixed up constantly. Here is how I draw the lines in production Flutter apps:
| Layer | Job | Example |
|---|---|---|
| Data source | Talks to one specific backend |
ProductsApi (REST), ProductsDao (SQLite) |
| Repository | Combines sources, owns data policy |
ApiProductsRepository (cache + API + offline fallback) |
| Service | Coordinates cross-cutting behavior |
AuthService (token refresh across repositories) |
A repository is feature-scoped: one per feature or aggregate (ProductsRepository, OrdersRepository, AuthRepository). A service is cross-cutting and usually sits beside or above repositories. And a data source is the rawest layer — one per backend. If your "repository" just forwards one call to one API, it is still worth it: the value is the seam, not the current logic inside it.
Why Repositories Make Testing Trivial
This is where the Flutter repository pattern pays for itself. Because consumers depend on the abstract ProductsRepository, tests use the fake — no HTTP mocking, no widget harness:
import 'package:test/test.dart';
import '../lib/features/products/data/products_repository.dart';
import '../lib/features/products/presentation/products_view_model.dart';
void main() {
test('loadProducts populates products from the repository', () async {
final viewModel =
ProductsViewModel(repository: FakeProductsRepository());
await viewModel.loadProducts();
expect(viewModel.products, hasLength(2));
expect(viewModel.products.first.name, 'Ceramic Mug');
expect(viewModel.isLoading, isFalse);
});
}
That is a plain Dart test that runs in milliseconds. Try doing that when the HTTP call lives in initState — you cannot, without dragging the whole widget tree along. Every repository you add converts an integration-testing problem into a unit-testing problem.
5 Pitfalls of the Flutter Repository Pattern
1. One giant repository. AppRepository with forty methods is just the old junk drawer with a new name. One repository per feature, each focused on one aggregate.
2. Leaking backend types upward. If your repository returns http.Response, QuerySnapshot, or raw JSON maps, you have not hidden anything. Repositories return domain models — nothing else.
3. Repositories that know about BuildContext. A repository showing snackbars or reading MediaQuery has crossed the layer boundary. Return results or throw typed exceptions; let the UI layer decide what to show.
4. Skipping the abstract interface. "I'll add the interface when I need it" means you never will — and your first real test becomes a mocking nightmare. Write the abstract class first. It costs five lines.
5. Putting business logic in the repository. The repository owns data policy (which source, caching, fallbacks). It does not own app rules like "discount applies on weekends" — that belongs in the ViewModel or a use case.
When the Repository Pattern Is Overkill
For a prototype with one screen and one endpoint, a repository can wait — but keep the API call out of the widget anyway. Put it in a small data-source class and add the repository interface the moment a second consumer appears or you need your first real test. The rule of thumb I use: the first time you want to test logic that touches data, or the first time two screens need the same data, the repository earns its place.
Stop putting API calls in widgets. Give each feature a repository, hide every data source behind it, and your Flutter codebase will stay testable no matter how many backends it grows.
Need a Flutter codebase that survives contact with real backends — clean layers, real tests, no widget soup? Let's talk about your app.
Originally published at uzairxdev.online
Top comments (0)