Repository Pattern, Use Cases, DI & Boundaries
The Architecture Layer Every Senior Flutter Dev Must Master
Open interactive version (quiz + challenge)Real-world analogy
What is it?
The Repository pattern is an abstraction layer between your domain (business logic) and data sources (APIs, databases). A Use Case encapsulates one business operation and depends on repository interfaces. GetIt/injectable wires everything together via Dependency Inversion. Together they form the infrastructure of Clean Architecture in Flutter.
Real-world relevance
In a fintech claims app, ProcessRefundUseCase depends on ClaimsRepository (interface). ClaimsRepositoryImpl calls Dio for the remote API and Floor for local audit logging. When testing ProcessRefundUseCase, a FakeClaimsRepository returns mock data instantly. When the API changes from REST to GraphQL, only ClaimsRepositoryImpl changes — UseCase and UI are untouched.
Key points
- Repository Pattern — Abstraction Over Data Sources — A Repository is an interface defined in the domain layer that abstracts all data access. It hides whether data comes from a REST API, SQLite, Hive, or in-memory cache. The ViewModel/Use Case only knows the interface, never the implementation. This is the single most important testability pattern in Flutter architecture.
- Data Flow Direction — Data flows inward: UI → ViewModel → Use Case → Repository Interface. Implementations flow outward: RepositoryImpl (data layer) → API/DB. Dependency arrows always point inward toward the domain. This is the Dependency Rule — violating it is the #1 architecture mistake seen in Flutter interviews.
- Use Case (Interactor) Pattern — A Use Case encapsulates a single business operation: GetUserProfileUseCase, SendMessageUseCase, ProcessRefundUseCase. It has one public method (call() or execute()), receives repository interfaces via constructor, and contains no Flutter or platform code. One class, one responsibility.
- Dependency Inversion Principle (DIP) — High-level modules (Use Cases) should not depend on low-level modules (Dio, SQLite). Both should depend on abstractions (Repository interfaces). This is the 'D' in SOLID. Without DIP, changing your HTTP client requires touching business logic — a maintainability disaster at scale.
- GetIt for Service Location — GetIt is a service locator (not true DI). You register singletons, factories, or lazy singletons: GetIt.I.registerLazySingleton(() => AuthRepositoryImpl(dio)). Then retrieve: GetIt.I(). It's simple but requires manual registration — the injectable package generates this code automatically.
- injectable Code Generation — @injectable, @lazySingleton, @singleton annotations on implementations plus @module for third-party registrations. Run build_runner and injectable generates configureDependencies(). This eliminates hand-written registration boilerplate and is the production standard in 2024+ Flutter projects.
- Layer Boundary Enforcement — Domain layer: pure Dart, no Flutter imports, no package:dio, no package:hive. Data layer: RepositoryImpl, DTOs, mappers, API clients. Presentation layer: Widgets, ViewModels, BLoC/Cubit. If you see package:dio in a Use Case, fail the code review. This boundary is what makes the codebase testable and maintainable.
- DTOs and Domain Models — DTOs (Data Transfer Objects) are data-layer classes that mirror API JSON structure. Domain Models are clean Dart classes with business logic. Mappers (toEntity/fromEntity) convert between them. Never expose DTOs to the domain or UI layer — they're an implementation detail.
- Multiple Data Sources — A RepositoryImpl often coordinates two sources: remote (API) and local (cache). Pattern: try local cache → if stale, fetch remote → save to cache → return. This is where offline-first logic lives. The Use Case never sees this complexity — it just calls repository.getUser(id).
- Testing With Fakes — With proper repository abstraction, tests are trivial: class FakeUserRepository implements UserRepository { ... }. No Dio, no HTTP, no SQLite in unit tests. The Use Case is tested with a FakeRepository that returns controlled data. This is the payoff of Clean Architecture — tests run in milliseconds.
- Common Interview Questions — 'How would you add offline support?' — RepositoryImpl coordinates API and local DB. 'How would you swap your HTTP client?' — Only the data layer changes, domain/presentation untouched. 'How do you test business logic without hitting the network?' — Use Case takes repository interface, test passes a fake.
- Anti-Pattern: Fat ViewModel — A common mistake is putting API calls directly in the ViewModel: _dio.get('/users'). This couples presentation to infrastructure, makes testing require mocking Dio, and duplicates business logic across ViewModels. Fat ViewModels are a red flag in senior-level code reviews.
Code example
// === DOMAIN LAYER (pure Dart, no Flutter imports) ===
// 1. Domain Entity
class UserProfile {
final String id;
final String name;
final String email;
final bool isPremium;
const UserProfile({required this.id, required this.name, required this.email, required this.isPremium});
}
// 2. Repository Interface — defined in domain, implemented in data
abstract class UserRepository {
Future<UserProfile> getUserProfile(String userId);
Future<void> updateProfile({required String userId, required String name});
}
// 3. Use Case — one responsibility, depends on interface
class GetUserProfileUseCase {
final UserRepository _repository;
const GetUserProfileUseCase(this._repository);
Future<UserProfile> call(String userId) async {
if (userId.isEmpty) throw ArgumentError('userId cannot be empty');
return _repository.getUserProfile(userId);
}
}
// === DATA LAYER ===
// 4. DTO — mirrors API JSON
class UserProfileDto {
final String id;
final String name;
final String email;
final bool isPremium;
UserProfileDto({required this.id, required this.name, required this.email, required this.isPremium});
factory UserProfileDto.fromJson(Map<String, dynamic> json) => UserProfileDto(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
isPremium: json['is_premium'] as bool? ?? false,
);
// Mapper: DTO → Domain Entity
UserProfile toEntity() => UserProfile(
id: id, name: name, email: email, isPremium: isPremium,
);
}
// 5. Repository Implementation — knows about Dio and local DB
class UserRepositoryImpl implements UserRepository {
final Dio _dio;
final UserLocalDataSource _localCache;
const UserRepositoryImpl(this._dio, this._localCache);
@override
Future<UserProfile> getUserProfile(String userId) async {
// Try cache first
final cached = await _localCache.getUser(userId);
if (cached != null && !cached.isStale) return cached.toEntity();
// Fetch remote
try {
final response = await _dio.get('/users/$userId');
final dto = UserProfileDto.fromJson(response.data as Map<String, dynamic>);
await _localCache.saveUser(dto); // Update cache
return dto.toEntity();
} on DioException catch (e) {
if (cached != null) return cached.toEntity(); // Stale fallback
throw _mapDioError(e);
}
}
@override
Future<void> updateProfile({required String userId, required String name}) async {
await _dio.patch('/users/$userId', data: {'name': name});
await _localCache.invalidate(userId);
}
Exception _mapDioError(DioException e) {
if (e.response?.statusCode == 404) return NotFoundException('User not found');
if (e.response?.statusCode == 401) return UnauthorizedException();
return NetworkException(e.message ?? 'Unknown error');
}
}
// === DI WITH GetIt + injectable ===
// 6. Registration (generated by injectable's build_runner)
@module
abstract class NetworkModule {
@lazySingleton
Dio get dio => Dio(BaseOptions(baseUrl: 'https://api.example.com'));
}
@LazySingleton(as: UserRepository)
class UserRepositoryImplAnnotated extends UserRepositoryImpl {
UserRepositoryImplAnnotated(Dio dio, UserLocalDataSource local) : super(dio, local);
}
@injectable
class GetUserProfileUseCaseAnnotated extends GetUserProfileUseCase {
GetUserProfileUseCaseAnnotated(UserRepository repo) : super(repo);
}
// 7. In main.dart
// await configureDependencies(); // Generated by injectable
// final useCase = GetIt.I<GetUserProfileUseCase>();Line-by-line walkthrough
- 1. UserProfile is a pure Dart domain entity — no Flutter, no Dio, no JSON dependencies
- 2. UserRepository is an abstract interface defined in the domain layer
- 3. GetUserProfileUseCase depends only on the UserRepository interface — not any implementation
- 4. Input validation in the Use Case — business rules live here, not in the UI
- 5. UserProfileDto mirrors the API JSON structure exactly — this is the data layer's concern
- 6. fromJson factory parses raw JSON into the DTO
- 7. toEntity() maps the DTO to the domain entity — the boundary between data and domain
- 8. UserRepositoryImpl coordinates the API and cache — domain layer never sees this complexity
- 9. Try cache first — if fresh, return immediately without hitting the network
- 10. On cache miss, call the API and parse the response into a DTO
- 11. Save to cache and return the domain entity
- 12. Map DioException to domain exceptions — UI sees DomainException, never DioException
Spot the bug
// Domain Use Case
import 'package:dio/dio.dart';
class GetOrdersUseCase {
final Dio _dio;
GetOrdersUseCase(this._dio);
Future<List<Order>> call(String userId) async {
final response = await _dio.get('/orders?userId=$userId');
return (response.data as List)
.map((json) => Order.fromJson(json))
.toList();
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Very Good Ventures Flutter Architecture (Very Good Ventures)
- GetIt Package (pub.dev)
- injectable Package (pub.dev)
- Reso Coder Clean Architecture (Reso Coder)
- Dependency Inversion Principle (Dart Official)