Lesson 21 of 77 advanced

Repository Pattern, Use Cases, DI & Boundaries

The Architecture Layer Every Senior Flutter Dev Must Master

Open interactive version (quiz + challenge)

Real-world analogy

Think of the Repository as a smart librarian. You (the Use Case) say 'give me Book #42'. The librarian decides whether to grab it from the shelf (local DB), order it (API), or find a cached photocopy. You never care how — you just get the book. Dependency Injection is like the library's hiring system: you specify you need 'a librarian', and HR (GetIt) assigns one. You never build the librarian yourself.

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

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. 1. UserProfile is a pure Dart domain entity — no Flutter, no Dio, no JSON dependencies
  2. 2. UserRepository is an abstract interface defined in the domain layer
  3. 3. GetUserProfileUseCase depends only on the UserRepository interface — not any implementation
  4. 4. Input validation in the Use Case — business rules live here, not in the UI
  5. 5. UserProfileDto mirrors the API JSON structure exactly — this is the data layer's concern
  6. 6. fromJson factory parses raw JSON into the DTO
  7. 7. toEntity() maps the DTO to the domain entity — the boundary between data and domain
  8. 8. UserRepositoryImpl coordinates the API and cache — domain layer never sees this complexity
  9. 9. Try cache first — if fresh, return immediately without hitting the network
  10. 10. On cache miss, call the API and parse the response into a DTO
  11. 11. Save to cache and return the domain entity
  12. 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?
Which layer does Dio belong to? What rule does this violate? What are the downstream consequences?
Show answer
The domain Use Case imports and directly uses Dio (a data layer dependency). This violates the Dependency Rule: inner layers must not depend on outer layers. Consequences: (1) unit tests must mock Dio instead of a simple repository interface, (2) swapping HTTP clients requires changing domain code, (3) the domain cannot be a standalone Dart package, (4) Order.fromJson in the domain suggests mixing DTO parsing with business logic. Fix: define abstract OrdersRepository in domain with getOrders(userId), implement with Dio in data layer, inject the interface into GetOrdersUseCase.

Explain like I'm 5

Imagine you want a pizza. You tell the waiter (Use Case) 'I want pepperoni pizza'. The waiter goes to the kitchen (Repository). The kitchen decides: is there leftover pizza in the fridge (cache)? No? OK, they bake it fresh (API call). You never know how the pizza was made. And if the kitchen switches from a wood-fired oven to electric, you still get the same pizza. That's the Repository pattern.

Fun fact

The Repository pattern was first described by Eric Evans in 'Domain-Driven Design' (2003). It was designed for enterprise Java, but it maps perfectly to Flutter's layered architecture. Every major Flutter architecture guide (Very Good Ventures, Reso Coder, Felix Angelov) converges on this pattern because it makes testing trivial.

Hands-on challenge

Design the domain and data layers for a 'ProcessRefund' feature in a fintech app. Create: (1) a RefundRequest domain entity, (2) a RefundsRepository interface with processRefund() and getRefundStatus() methods, (3) a ProcessRefundUseCase that validates the amount > 0 before calling the repository, (4) a stub RefundsRepositoryImpl that simulates an API call. Wire it with GetIt.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery