flutter
/

Repository Pattern with Bloc: Clean Data Layer Architecture

Last Sync: Today

On this page

10
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

Repository Pattern with Bloc: Clean Data Layer Architecture

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

ComponentResponsibility
Data SourcesLow-level API calls, database queries, etc. (e.g., `RemoteDataSource`, `LocalDataSource`)
Repository InterfaceDefines the contract for data operations (abstract class)
Repository ImplementationImplements the interface, uses one or more data sources
BlocCalls 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).

DARTRead-only
1
// lib/features/auth/data/datasources/auth_remote_datasource.dart
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 {
    // Implementation
  }
}

// Optional local data source for caching
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.

DARTRead-only
1
// lib/features/auth/domain/repositories/auth_repository.dart
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).

DARTRead-only
1
// lib/features/auth/data/repositories/auth_repository_impl.dart
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.

DARTRead-only
1
// lib/features/auth/bloc/auth_bloc.dart
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.

DARTRead-only
1
// lib/di/injection.dart
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 {
  // External
  final prefs = await SharedPreferences.getInstance();
  getIt.registerLazySingleton(() => prefs);
  getIt.registerLazySingleton(() => http.Client());

  // Data sources
  getIt.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(getIt()),
  );
  getIt.registerLazySingleton<AuthLocalDataSource>(
    () => AuthLocalDataSourceImpl(getIt()),
  );

  // Repositories
  getIt.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(
      getIt<AuthRemoteDataSource>(),
      getIt<AuthLocalDataSource>(),
    ),
  );

  // Blocs
  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.

DARTRead-only
1
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.

DARTRead-only
1
class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource remote;
  final UserLocalDataSource local;

  @override
  Future<User> getUser(String id) async {
    // Try local first
    final cached = await local.getUser(id);
    if (cached != null) {
      return cached;
    }
    // Fallback to remote
    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.

DARTRead-only
1
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.

Next, explore Bloc architecture and Dependency injection with get_it.

Test Your Knowledge

Q1
of 3

What is the main purpose of the repository pattern?

A
To improve UI performance
B
To separate data access from business logic
C
To manage app navigation
D
To handle dependency injection
Q2
of 3

Which of the following is a benefit of using repositories with Bloc?

A
Automatic UI rebuilds
B
Easier testing by mocking data sources
C
Faster API calls
D
Built-in state persistence
Q3
of 3

Where should you define the repository interface?

A
In the presentation layer
B
In the data layer
C
In the domain layer
D
In the bloc layer

Frequently Asked Questions

Do I always need a repository? Can't I just call API directly from the bloc?

For very small apps, you can skip the repository. But as soon as you need caching, multiple data sources, or testing, the repository pattern becomes essential. It's a best practice for any app beyond a simple prototype.

Should repositories be singletons or created per bloc?

Repositories are typically stateless and can be shared across multiple blocs. Use a singleton (e.g., with get_it). If a repository holds state (like a cache), it should still be a singleton to maintain consistency.

How do I handle authentication tokens in repositories?

You can inject an AuthRepository into other repositories to get the token, or use an interceptor that adds the token automatically. A common pattern is to have an ApiClient that depends on AuthRepository and attaches the token to outgoing requests.

What's the difference between repository and service?

A repository typically deals with data operations (CRUD) for a specific domain. A service often contains business logic that spans multiple repositories. In practice, the terms are sometimes used interchangeably, but the repository pattern is specifically about data access abstraction.

Can I use repositories with Cubit?

Yes, absolutely. The repository pattern is independent of whether you use Bloc or Cubit. Cubit can also depend on repositories for data operations.

Previous

bloc clean architecture

Next

bloc usecase layer

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.