Have you ever been developing an application when the backend service stops working? Or perhaps you needed to work offline while traveling? These scenarios can completely halt your development process unless you have a robust simulation strategy.
Introduction
In this article, I’ll show you how to implement simulated remote data sources in Flutter using the get_it
and injectable
packages. This approach will allow you to seamlessly switch between real and simulated data sources according to your environment configuration.
The Problem
Managing simulated implementations manually can be complicated, especially in large projects with multiple developers. Not everyone on your team may be familiar with the manual configuration process, and maintaining these configurations individually by data source becomes increasingly difficult as your application grows.
This is where dependency injection comes to the rescue, specifically using the get_it
and injectable
packages to automate the configuration of data sources based on different environments.
Initial Setup
Before diving into the implementation, make sure you have the following packages in your pubspec.yaml
:
dependencies:
get_it:
injectable:
http:
dev_dependencies:
build_runner:
injectable_generator:
The Solution: Environment-Based Switching
Our approach will revolve around defining different environments and injecting the appropriate implementation according to the current environment. Let’s start by setting up our environments.
Defining Custom Environments
First, let’s extend the base Environment
class from the injectable
package to define our custom environments:
📖 injectable comes with 3 predefined environments: qa, dev, prod. In this example we’ll create 2 custom environments, for example purposes: mock, stg
import 'package:injectable/injectable.dart';
/// 'mock' Environment
const mock = Environment(EnvironmentExtension.newMock);
/// 'stg' Environment
const stg = Environment(EnvironmentExtension.newStg);
/// List of mocked environments (environments used to get simulated data)
const mockedEnv = [EnvironmentExtension.newMock];
/// List of non-mocked environments (environments used to get remote data)
const noMockedEnv = [
Environment.dev,
Environment.prod,
EnvironmentExtension.newStg,
];
extension EnvironmentExtension on Environment {
/// preset of common environment name 'stg'
static const newStg = 'stg';
/// preset of common environment name 'mock'
static const newMock = 'mock';
/// Using the Flavor value, gets the appropriate environment used to configure
/// get_it's 'di' instances
static Environment getEnvByFlavor(Flavor flavor) => switch (flavor) {
Flavor.development => dev,
Flavor.staging => stg,
Flavor.production => prod,
Flavor.mock => mock,
};
}
📖 Flavor is a created
enum
that is used within the application to standardize environments📖 The variables
mock
andstg
are defined to be able to make individual use of the environments across layers, when we want to do it individually, through@mock
and@stg
decorators
Creating a Base Abstraction for the Data Source
Now, let’s create a base class that will handle common functionality for all data sources, including both HTTP requests and mock data handling:
📖 In this example we directly use the utilities from the http package, but through dependency injection, we can create and pass a separate client for better modularization
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:injectable/injectable.dart';
@lazySingleton
final class DataSourceBase {
DataSourceBase(this._baseEndpoints);
/// Class with the appropriate endpoints used throughout the app
final BaseEndpoints _baseEndpoints;
/// Map with decoded data from a JSON string
Future<K> _decodeData<K extends Object>(String data) async =>
json.decode(data) as K;
/// Simulates a network request by reading data from a mock JSON string.
///
/// Args:
/// [endpoint]: An enum value representing the endpoint to get data from
/// [mapper]: A function that converts the JSON data into an appropriate object
/// [fakeLoadingTime]: Simulated network delay
Future<T> handleMockRequest<T extends Object, K extends Object>({
required EndpointsEnum endpoint,
required T Function(K response) mapper,
Duration fakeLoadingTime = Durations.medium3,
}) async {
final endPoint = _baseEndpoints.getEndpointValue(endpoint);
try {
// Create fake loading time to simulate network latency
await Future<void>.delayed(fakeLoadingTime);
// Get data from the endpoint (which contains mock JSON)
final fakeBEResponse = await _decodeData<K>(endPoint);
return mapper(fakeBEResponse);
} catch (e) {
// Your error code...
}
}
/// Real network request handling.
///
/// Args:
/// [endpoint]: An enum value representing the endpoint to get data from
/// [mapper]: A function that converts the JSON data into an appropriate object
Future<T> handleHttpRequest<T extends Object, K extends Object>({
required EndpointsEnum endpoint,
required T Function(K response) mapper,
}) async {
Response? response;
try {
final url = Uri.parse(_baseEndpoints.getEndpointValue(endpoint));
response = await get(url);
final data = await _decodeData<K>(response.body);
return mapper(data);
} catch (e) {
// Your error code...
}
}
}
Creating Endpoint Abstractions
Next, we’ll define an abstract class for endpoints and create concrete implementations for both real and simulated environments:
import 'package:freezed_annotation/freezed_annotation.dart';
abstract base class BaseEndpoints {
/// Endpoint to get user posts
@mustBeOverridden
late String getPosts;
/// Get the appropriate endpoint value based on the enum value
String getEndpointValue(EndpointsEnum endpoint) => switch (endpoint) {
EndpointsEnum.getPosts => getPosts,
};
}
For simulated endpoints, we can add the possible BE response directly in the string:
import 'package:injectable/injectable.dart';
@LazySingleton(as: BaseEndpoints, env: mockedEnv)
final class MockLocalEndpoints extends BaseEndpoints {
@override
String getPosts = '''
[
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit suscipit recusandae consequuntur expedita et cum reprehenderit..."
},
{
"userId": 1,
"id": 2,
"title": "qui est esse",
"body": "est rerum tempore vitae sequi sint nihil reprehenderit dolor beatae ea..."
},
{
"userId": 1,
"id": 3,
"title": "ea molestias quasi exercitationem repellat qui ipsa sit aut",
"body": "et iusto sed quo iure voluptatem occaecati omnis eligendi aut ad..."
}
]
''';
}
📖 Note that the injectable decorator we use allows us to define that the class is an instance of BaseEndpoints and that we are going to use it in simulated environments, through the array definition we stipulated in
EnvironmentExtension
For real HTTP endpoints, we can only define the endpoint we are going to use:
import 'package:injectable/injectable.dart';
@LazySingleton(as: BaseEndpoints, env: noMockedEnv)
final class HttpEndpointsV1 extends BaseEndpoints {
@override
String getPosts = 'https://jsonplaceholder.typicode.com/posts';
}
Implementing Specific Data Sources
Now, let’s create specific implementations for our posts, one for each environment.
📖 Let’s assume we have a PostsDataSource interface that only has a getPosts() method defined. Also we have a model class call PostDTO and an entity called Post with the proper value from the BE response
For the simulated environment, we again define in the LazySingleton
decorator the environments for which this class will be injected, and use the handleMockRequest
method previously defined in the DataSourceBase
class:
import 'package:injectable/injectable.dart';
@LazySingleton(as: PostsDataSource, env: mockedEnv)
final class MockPostsDataSourceImpl implements PostsDataSource {
MockPostsDataSourceImpl(this._dataSourceBase);
/// Base data source that handles actions with Http clients
final DataSourceBase _dataSourceBase;
@override
Future<List<PostDTO>> getPosts() =>
_dataSourceBase.handleMockRequest<List<PostDTO>, List<dynamic>>(
endpoint: EndpointsEnum.getPosts,
mapper: (data) {
return data
.map((e) => PostDTO.fromJson(e as Map<String, dynamic>))
.toList();
},
);
}
For real environments, we again define the LazySingleton
decorator, but this time with non-simulated environments (for which this class will be injected), and use the handleHttpRequest
method previously defined in the DataSourceBase
class:
import 'package:injectable/injectable.dart';
@LazySingleton(as: PostsDataSource, env: noMockedEnv)
final class PostsDataSourceImpl implements PostsDataSource {
PostsDataSourceImpl(this._dataSourceBase);
/// Base data source that handles actions with Http clients
final DataSourceBase _dataSourceBase;
@override
Future<List<PostDTO>> getPosts() =>
_dataSourceBase.handleHttpRequest<List<PostDTO>, List<dynamic>>(
endpoint: EndpointsEnum.getPosts,
mapper: (data) {
return data
.map((e) => PostDTO.fromJson(e as Map<String, dynamic>))
.toList();
},
);
}
📖 A big advantage of endpoint definitions based on
enum
is to give the responsibility toBaseEndpoints
to return the data defined in the concrete implementation ofBaseEndpoints
(Take in count that get_it inject one or another based on the env) without major modifications when increasing the number of endpoints in our project
Implementing Repositories and Notifiers for Data Flow
Once we have resolved the logic of our data source, we will create our repository and notifier, which will be responsible for completing a correct data flow from the UI layer to the Data layer.
For our repository, we will create a connection with the abstract PostsDataSource
class. Later Injectable
and get_it
will do the necessary magic to be able to add the correct dependency:
import 'package:advance_app/features/home/home.dart';
import 'package:injectable/injectable.dart';
/// Proper implementation of the PostsRepository
@named
@LazySingleton(as: PostsRepository)
final class PostsRepositoryImpl implements PostsRepository {
PostsRepositoryImpl(this._postsDataSource);
/// Data source to interact with user post
final PostsDataSource _postsDataSource;
@override
Future<List<Post>> getPosts() async {
final response = await _postsDataSource.getPosts();
return response.toDomain;
}
}
📖 The repository decorator changes a bit with respect to the decorators of
PostsDataSourceImpl
andMockPostsDataSourceImpl
. Since we don’t need classes according to environments, we can do the injection through the name of the instance. Later in the notifier, we will inject this instance according to the name of this implementation.
For our notifier, we will also create a connection with the abstract PostsRepository
class, just as we did previously with the data source:
import 'package:advance_app/core/core.dart';
import 'package:advance_app/features/home/home.dart';
import 'package:flutter/material.dart';
import 'package:injectable/injectable.dart';
/// Notifier to handle the posts information
@injectable
final class PostsNotifier extends ChangeNotifier {
PostsNotifier(@Named.from(PostsRepositoryImpl) this._postsRepository);
/// Repository to interact with posts
final PostsRepository _postsRepository;
/// Get the posts of all users
Future<void> getUsersPosts() async {
// Your logic to get the data and notify
}
}
📖 By defining the repository implementation as
@named
, we can do a specific injection, using the name of the class with@Named.from()
Configuring Dependency Injection
Finally, let’s configure dependency injection with the appropriate environment:
First we define the initial point of our configuration passing the proper environment
@InjectableInit(
initializerName: 'init',
preferRelativeImports: false,
asExtension: true,
)
Future<void> configureDependencies({required String environment}) async =>
getIt.init(environment: environment);
And also call the appropriate environment in our initial app file main_stg.dart
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
const flavor = Flavor.staging;
const appEnv = AppEnvironment(flavor: flavor);
final diEnv = EnvironmentExtension.getEnvByFlavor(flavor);
await configureDependencies(environment: diEnv.name);
// More code ...
runApp(const App(environment: appEnv));
}
Don’t forget to run the command to build your defined dependency
dart run build_runner build --delete-conflicting-outputs
And the output of the generated injections will look like this:
// ... Imports
// The defined environments
const String _mock = 'mock';
const String _dev = 'dev';
const String _prod = 'prod';
const String _stg = 'stg';
extension GetItInjectableX on _i174.GetIt {
// initializes the registration of main-scope dependencies inside of GetIt
Future<_i174.GetIt> init({
String? environment,
_i526.EnvironmentFilter? environmentFilter,
}) async {
// ... Generated code
// ... Instance of BaseEndpoints register for _mock env
gh.lazySingleton<_i403.BaseEndpoints>(
() => _i998.MockLocalEndpoints(),
registerFor: {_mock},
);
// ... Instance of BaseEndpoints register for _dev, _prod and _stg env
gh.lazySingleton<_i403.BaseEndpoints>(
() => _i643.HttpEndpointsV1(),
registerFor: {_dev, _prod, _stg},
);
gh.lazySingleton<_i952.DataSourceBase>(
() => _i952.DataSourceBase(gh<_i403.BaseEndpoints>()),
);
gh.lazySingleton<_i403.RemoteConfigRepository>(
() =>
_i140.RemoteConfigRepositoryImpl(gh<_i403.RemoteConfigDatasource>()),
instanceName: 'RemoteConfigRepositoryImpl',
);
// ... Instance of PostsDataSource register for _mock env
gh.lazySingleton<_i96.PostsDataSource>(
() => _i165.MockPostsDataSourceImpl(gh<_i403.DataSourceBase>()),
registerFor: {_mock},
);
// ... Instance of PostsDataSource register for _dev, _prod and _stg env
gh.lazySingleton<_i96.PostsDataSource>(
() => _i435.PostsDataSourceImpl(gh<_i403.DataSourceBase>()),
registerFor: {_dev, _prod, _stg},
);
gh.lazySingleton<_i96.PostsRepository>(
() => _i732.PostsRepositoryImpl(gh<_i96.PostsDataSource>()),
instanceName: 'PostsRepositoryImpl',
);
gh.factory<_i415.PostsNotifier>(
() => _i415.PostsNotifier(
gh<_i96.PostsRepository>(instanceName: 'PostsRepositoryImpl'),
),
);
// ... Generated code
return this;
}
}
Example in a Real App
Here’s an example of how information loads when creating our app with a mock
environment and stg
environment:
Information in a mock
environment, called main_mock.dart
(mock flavor):
Information in a stg
environment, called main_stg.dart
(stg flavor):
Advantages of This Approach
- Uninterrupted Development: Continue developing even when the backend is down.
- Simplified Testing: You can use such a mocked data source to test your UI with controlled and predictable data.
- Smooth Transition: Easily switch between real and simulated data with a simple environment configuration.
- Increased Team Efficiency: All team members can easily switch between environments and implement mocked data sources, without crucially affecting development times, centralizing the logic of obtaining mocked data.
Important Point
When using @env
and @named
decorators simultaneously in a class that we want to alternate between implementations, errors may arise in the injectable package when reading the appropriate instance to inject. If we want to use the @env
decorator to switch between injections, we should not use the @named
decorator for implementations of the same class.
Resources
Conclusion
Implementing simulated data sources using the get_it
and injectable
packages significantly improves the development workflow and allows for greater flexibility in your development process, enabling parallel work while the BE team deploys new or adjusted services.
Have you used any other strategy to handle simulated data sources in your Flutter projects? I’d love to hear about your experiences in the comments!
Top comments (0)