The repository pattern is a crucial architectural concept that separates data sources from business logic. When combined with Bloc, it creates a clean, testable, and maintainable data layer. This guide explains how to implement the repository pattern in your Flutter Bloc applications.
What is the Repository Pattern?
A repository is an abstraction over data sources. It acts as a single source of truth for a specific domain (e.g., UserRepository). The repository hides the details of where data comes from – whether it's a remote API, a local database, or a cache – and provides a clean interface for the rest of the application.
Why Use Repositories with Bloc?
- Separation of concerns – Blocs focus on business logic; repositories handle data fetching and persistence.
- Testability – You can mock repositories to test blocs in isolation, without needing real network or database calls.
- Flexibility – Swap data sources (e.g., from REST API to GraphQL) without changing blocs.
- Caching – Implement caching strategies inside the repository without affecting the rest of the app.
- Reusability – Share repositories across multiple blocs or features.
Core Components
| Component | Responsibility |
|---|
| Data Sources | Low-level API calls, database queries, etc. (e.g., `RemoteDataSource`, `LocalDataSource`) |
| Repository Interface | Defines the contract for data operations (abstract class) |
| Repository Implementation | Implements the interface, uses one or more data sources |
| Bloc | Calls repository methods, processes results, emits states |
Step-by-Step Implementation
Start by creating abstract interfaces for your data sources, then implement them. This allows you to swap implementations later (e.g., for testing).
abstract class AuthRemoteDataSource {
Future<String> login(String email, String password);
Future<void> logout();
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final http.Client client;
AuthRemoteDataSourceImpl(this.client);
@override
Future<String> login(String email, String password) async {
final response = await client.post(
Uri.parse('https://api.example.com/login'),
body: {'email': email, 'password': password},
);
if (response.statusCode == 200) {
return jsonDecode(response.body)['token'];
} else {
throw Exception('Login failed');
}
}
@override
Future<void> logout() async {
}
}
abstract class AuthLocalDataSource {
Future<void> cacheToken(String token);
Future<String?> getToken();
}
class AuthLocalDataSourceImpl implements AuthLocalDataSource {
final SharedPreferences prefs;
AuthLocalDataSourceImpl(this.prefs);
@override
Future<void> cacheToken(String token) async {
await prefs.setString('auth_token', token);
}
@override
Future<String?> getToken() async {
return prefs.getString('auth_token');
}
}
Define the abstract repository that your blocs will depend on. This interface hides the data source details.
abstract class AuthRepository {
Future<String> login(String email, String password);
Future<void> logout();
Future<bool> isLoggedIn();
}
The implementation uses data sources and may combine them (e.g., fetch from remote and cache locally).
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
AuthRepositoryImpl(this.remoteDataSource, this.localDataSource);
@override
Future<String> login(String email, String password) async {
try {
final token = await remoteDataSource.login(email, password);
await localDataSource.cacheToken(token);
return token;
} catch (e) {
throw Exception('Authentication failed: $e');
}
}
@override
Future<void> logout() async {
await remoteDataSource.logout();
await localDataSource.cacheToken('');
}
@override
Future<bool> isLoggedIn() async {
final token = await localDataSource.getToken();
return token != null && token.isNotEmpty;
}
}
Inject the repository into your bloc via the constructor. The bloc never knows whether the data comes from an API or a database.
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository repository;
AuthBloc(this.repository) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
on<LogoutRequested>(_onLogoutRequested);
on<CheckAuthStatus>(_onCheckAuthStatus);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
final token = await repository.login(event.email, event.password);
emit(AuthSuccess(token));
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onLogoutRequested(
LogoutRequested event,
Emitter<AuthState> emit,
) async {
await repository.logout();
emit(AuthUnauthenticated());
}
Future<void> _onCheckAuthStatus(
CheckAuthStatus event,
Emitter<AuthState> emit,
) async {
final isLoggedIn = await repository.isLoggedIn();
if (isLoggedIn) {
emit(AuthAuthenticated());
} else {
emit(AuthUnauthenticated());
}
}
}
Dependency Injection with get_it
Use a service locator like get_it to wire up your dependencies. This keeps your code clean and makes testing easier.
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart' as http;
final getIt = GetIt.instance;
Future<void> setup() async {
final prefs = await SharedPreferences.getInstance();
getIt.registerLazySingleton(() => prefs);
getIt.registerLazySingleton(() => http.Client());
getIt.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(getIt()),
);
getIt.registerLazySingleton<AuthLocalDataSource>(
() => AuthLocalDataSourceImpl(getIt()),
);
getIt.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
getIt<AuthRemoteDataSource>(),
getIt<AuthLocalDataSource>(),
),
);
getIt.registerFactory<AuthBloc>(() => AuthBloc(getIt()));
}
Testing with Mock Repositories
The repository pattern makes testing blocs straightforward. You can create mock implementations of your repositories and inject them into the bloc.
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
late MockAuthRepository repository;
late AuthBloc bloc;
setUp(() {
repository = MockAuthRepository();
bloc = AuthBloc(repository);
});
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthSuccess] when login succeeds',
build: () => bloc,
act: (bloc) {
when(() => repository.login('test@example.com', 'password'))
.thenAnswer((_) async => 'token');
bloc.add(LoginRequested('test@example.com', 'password'));
},
expect: () => [
AuthLoading(),
AuthSuccess('token'),
],
);
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthFailure] when login fails',
build: () => bloc,
act: (bloc) {
when(() => repository.login('wrong@example.com', 'wrong'))
.thenThrow(Exception('Invalid credentials'));
bloc.add(LoginRequested('wrong@example.com', 'wrong'));
},
expect: () => [
AuthLoading(),
AuthFailure('Exception: Invalid credentials'),
],
);
}
Advanced Patterns
Repositories can orchestrate multiple data sources. For example, check local cache first, then fallback to remote.
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remote;
final UserLocalDataSource local;
@override
Future<User> getUser(String id) async {
final cached = await local.getUser(id);
if (cached != null) {
return cached;
}
final user = await remote.fetchUser(id);
await local.cacheUser(user);
return user;
}
}
If your data source provides streams (like Firebase or a local database), your repository can expose a stream that blocs can listen to.
abstract class MessageRepository {
Stream<List<Message>> getMessages();
Future<void> sendMessage(Message message);
}
class MessageRepositoryImpl implements MessageRepository {
final FirebaseFirestore firestore;
@override
Stream<List<Message>> getMessages() {
return firestore.collection('messages').snapshots().map(
(snapshot) => snapshot.docs.map((doc) => Message.fromFirestore(doc)).toList(),
);
}
}
Best Practices
- Define repository interfaces in the domain layer – Keep them abstract and platform-agnostic.
- Inject repositories into blocs – Never instantiate repositories inside blocs directly.
- Handle errors in repositories – Catch and wrap exceptions with meaningful messages, or throw custom exceptions.
- Use immutable data classes – Return plain Dart objects (entities) from repositories.
- Keep repositories focused – One repository per domain (e.g.,
AuthRepository, UserRepository). - Write tests for repositories – Test both happy and error paths using mock data sources.
Common Mistakes
- ❌ Putting data source logic directly in blocs – Makes blocs hard to test and violates separation of concerns.
- ❌ Not using interfaces – Without abstraction, swapping implementations becomes difficult.
- ❌ Returning data source objects (like http.Response) from repositories – Return domain objects instead.
- ❌ Ignoring error handling – Always catch and transform errors to something meaningful for the UI.
- ❌ Mixing repositories – A repository should not depend on another repository unless absolutely necessary.
What's Next?
Now that you have a clean data layer, learn how to structure your entire application with Clean Architecture and Bloc.