Repository Pattern
Understanding the Repository Pattern for clean data access and separation of concerns in Flutter applications.
The Repository Pattern is a design pattern that mediates between the domain and data mapping layers. It provides a clean API for data access, abstracting the underlying data sources (APIs, databases, etc.) behind a consistent interface.
๐ก Key Concept
Think of a repository as a collection of data that your app can query. It acts as a middleman between your business logic and your data sources, providing a clean, consistent way to access data regardless of where it comes from.
Why Use Repositories?
The Repository Pattern is a perfect example of Separation of Concerns in action. It clearly separates:
โ Benefits of This Separation
- Maintainability โ Changes to data sources don't affect business logic
- Testability โ Business logic can be tested with mock repositories
- Flexibility โ You can change data sources without rewriting business logic
- Clarity โ Each layer has a clear, single responsibility
Data sources are the actual sources of data in your application. A repository can work with multiple data sources, deciding which one to use based on the situation.
Types of Data Sources
import
'package:http/http.dart'
as
http;
import
'dart:convert'
;
/// Remote data source for weather data
class
WeatherRemoteDataSource
{
final
http.Client client;
final
String baseUrl;
final
String apiKey;
WeatherRemoteDataSource
({
required
this
.client,
required
this
.baseUrl,
required
this
.apiKey,
});
/// Fetch weather from remote API
Future
<Map<String, dynamic>>
fetchWeather
(String city) async {
final
url = Uri.parse(
'$baseUrl/weather?q=$city&appid=$apiKey'
);
final
response =
await
client.
get
(url);
if
(response.statusCode ==
200
) {
return
json.
decode
(response.body);
}
else
{
throw
Exception(
'Failed to fetch weather: ${response.statusCode}'
);
}
}
/// Fetch weather forecast from remote API
Future
<List<Map<String, dynamic>>>
fetchForecast
(String city) async {
final
url = Uri.parse(
'$baseUrl/forecast?q=$city&appid=$apiKey'
);
final
response =
await
client.
get
(url);
if
(response.statusCode ==
200
) {
final
data = json.
decode
(response.body);
return
List<Map<String, dynamic>>.from(data[
'list'
]);
}
else
{
throw
Exception(
'Failed to fetch forecast: ${response.statusCode}'
);
}
}
}
import
'package:hive/hive.dart'
;
import
'dart:convert'
;
/// Local data source for weather data (caching)
class
WeatherLocalDataSource
{
static
const
String weatherBoxKey =
'weather_cache'
;
static
const
Duration cacheDuration = Duration(minutes:
30
);
Future
<Box<String>>
_getBox
() async {
return
await
Hive.
openBox
<String>(weatherBoxKey);
}
/// Get cached weather data if not expired
Future
<Map<String, dynamic>?>
getCachedWeather
(String city) async {
final
box =
await
_getBox
();
final
key =
'weather_$city'
;
final
cached = box.
get
(key);
if
(cached !=
null
) {
final
data = json.
decode
(cached);
final
timestamp = DateTime.
parse
(data[
'_timestamp'
]);
// Check if cache is still valid
if
(DateTime.now().
difference
(timestamp) < cacheDuration) {
data.
remove
(
'_timestamp'
);
return
data;
}
}
return
null
;
}
/// Cache weather data
Future
<
void
>
cacheWeather
(String city, Map<String, dynamic> weatherData) async {
final
box =
await
_getBox
();
final
key =
'weather_$city'
;
// Add timestamp to track cache age
final
dataWithTimestamp = {...weatherData,
'_timestamp'
: DateTime.now().
toIso8601String
()};
await
box.
put
(key, json.
encode
(dataWithTimestamp));
}
/// Clear all cached weather data
Future
<
void
>
clearCache
() async {
final
box =
await
_getBox
();
await
box.
clear
();
}
}
The repository combines multiple data sources and provides a clean interface for the business logic layer.
import
'../../data/models/weather.dart'
;
/// Repository interface for weather data
abstract
class
WeatherRepository
{
Future
<Weather>
getWeather
(String city);
Future
<List<Weather>>
getForecast
(String city);
Future
<
void
>
clearCache
();
}
import
'package:supabase_flutter/supabase_flutter.dart'
;
import
'../../domain/repositories/weather_repository.dart'
;
import
'../../domain/models/weather.dart'
;
import
'../datasources/weather_remote_datasource.dart'
;
import
'../datasources/weather_local_datasource.dart'
;
/// Implementation of WeatherRepository
class
WeatherRepositoryImpl
implements
WeatherRepository {
final
WeatherRemoteDataSource remoteDataSource;
final
WeatherLocalDataSource localDataSource;
WeatherRepositoryImpl
({
required
this
.remoteDataSource,
required
this
.localDataSource,
});
@override
Future
<Weather>
getWeather
(String city) async {
// 1. Try to get from cache first
final
cached =
await
localDataSource.
getCachedWeather
(city);
if
(cached !=
null
) {
return
Weather.
fromJson
(cached);
}
// 2. If not in cache, fetch from remote
try
{
final
remoteData =
await
remoteDataSource.
fetchWeather
(city);
// 3. Cache the result for next time
await
localDataSource.
cacheWeather
(city, remoteData);
return
Weather.
fromJson
(remoteData);
}
catch
(e) {
// 4. If remote fails, try to return expired cache
final
expiredCache =
await
localDataSource.
getCachedWeather
(city);
if
(expiredCache !=
null
) {
return
Weather.
fromJson
(expiredCache);
}
// 5. If everything fails, throw the error
rethrow
;
}
}
@override
Future
<List<Weather>>
getForecast
(String city) async {
// Similar logic for forecast
final
remoteData =
await
remoteDataSource.
fetchForecast
(city);
return
remoteData
.
map
((data) => Weather.
fromJson
(data))
.
toList
();
}
@override
Future
<
void
>
clearCache
() async {
await
localDataSource.
clearCache
();
}
}
๐ก Repository Logic Flow
- Check cache โ Return cached data if available and fresh
- Fetch remote โ If not cached, fetch from remote data source
- Cache result โ Store fetched data for next time
- Fallback โ Use expired cache if remote fails
- Throw error โ If all sources fail, propagate the error
Business logic components (ViewModels, UseCases, Services) use the repository interface without knowing about the underlying data sources.
import
'../repositories/weather_repository.dart'
;
import
'../models/weather.dart'
;
/// Use case for getting weather data
class
GetWeatherUseCase
{
final
WeatherRepository repository;
GetWeatherUseCase
(
this
.repository);
Future
<Weather>
execute
(String city) async {
if
(city.
isEmpty
) {
throw
ArgumentError(
'City cannot be empty'
);
}
return
await
repository.
getWeather
(city);
}
}
import
'package:flutter_riverpod/flutter_riverpod.dart'
;
import
'../../domain/usecases/get_weather_usecase.dart'
;
import
'../../domain/models/weather.dart'
;
class
WeatherViewModel
extends
StateNotifier<WeatherState> {
final
GetWeatherUseCase getWeatherUseCase;
WeatherViewModel
(
this
.getWeatherUseCase) :
super
(WeatherState.
initial
());
Future
<
void
>
loadWeather
(String city) async {
state = state.
copyWith
(isLoading:
true
);
try
{
final
weather =
await
getWeatherUseCase.
execute
(city);
state = state.
copyWith
(
isLoading:
false
,
weather: weather,
error:
null
,
);
}
catch
(e) {
state = state.
copyWith
(
isLoading:
false
,
error: e.
toString
(),
);
}
}
}
class
WeatherState
{
final
bool
isLoading;
final
Weather? weather;
final
String? error;
WeatherState
({
this
.isLoading =
false
,
this
.weather,
this
.error});
WeatherState
copyWith
({
bool
? isLoading, Weather? weather, String? error}) {
return
WeatherState(
isLoading: isLoading ??
this
.isLoading,
weather: weather ??
this
.weather,
error: error ??
this
.error,
);
}
factory
WeatherState.
initial
() => WeatherState();
}
Follow these steps to implement the Repository Pattern in your Flutter app:
Don't expose data transfer objects (DTOs) directly. Convert them to domain models inside the repository.
The repository should convert DTOs to domain models before returning them. This decouples the data layer from the domain layer.
Repositories should only handle data access. Business logic belongs in UseCases or Services.
Keep repositories focused on getting and storing data. Business logic should be in a separate layer.
Repositories should handle errors from data sources and provide meaningful error messages.
Use try-catch in repositories, log errors, and throw domain-specific exceptions with clear messages.
Don't let data source-specific details leak through the repository interface.
The repository interface should be completely agnostic to where the data comes from.
๐ฏ Key Takeaway
The Repository Pattern is a crucial part of clean architecture that separates business logic from data access. By providing a clean interface between your domain layer and data sources, repositories make your code more maintainable, testable, and flexible . Always use repositories to abstract away the details of where and how data is stored.