The Dart ecosystem equips developers with a variety of powerful tools that provide a blazingly fast and productive development experience. Yet, there is an opportunity to push these capabilities further by adopting techniques like code generation.
This guide will cover all aspects of integrating code generation in day-to-day development, including:
- Code generation mechanism: we’ll discuss how the code generation mechanism with
build_runner
works. - Useful code-generating packages: we’ll look into useful packages such as json_serializable, freezed, retrofit, injectable, bdd_widget_test, barrel_files, and learn how they simplify typical Flutter apps development tasks.
- Efficient codebase maintenance: you’ll get practical advice on efficiently maintaining projects that extensively use code generation. This section will teach you how to: optimize the code generator input, configure static analysis, simplify generation launching, create code snippets, collapse generated files in IDE, update code coverage reports, and control code generation order. We’ll also discuss how practices like keeping packages small, adding generated files to Git, locking dependencies versions, and reusing configured annotations can improve code-generation performance and enhance the developers’ experience.
Even if you are familiar with this topic, you will certainly get valuable takeaways from this guide.
Code Generation Mechanism with build_runner
Creating Dart and Flutter apps across various domains often includes a range of typical tasks, such as implementing JSON deserialization, consuming backend APIs, creating a dependency inversion mechanism, implementing navigation and localization, managing assets, writing tests, and more.
Completing most of these tasks involves writing repetitive boilerplate code, which is time-consuming, error-prone, and tedious. Fortunately, we can minimize the amount of code we have to write manually and let build_runner generate the rest of the required code. As stated in the documentation (as of the date of publication of this guide):
The Dart build system is a good alternative to reflection (which has performance issues) and macros (which Dart's compilers don't support).
Essentially, build_runner
is a tool that provides an extendable mechanism for generating output files from input files. We can implement code generators that work with this mechanism, allowing them to read any input file, typically Dart code, and produce corresponding output files, typically also in Dart.
This allows users of these generators to specify only the minimum configuration for the desired code with the special syntax, and get more valid Dart code after the code generation is executed. We will look into several examples of useful generators in the next section, but before that, let’s see how to use build_runner
.
First, we can add it as a dev dependency in the pubspec.yaml
file:
dev_dependencies:
build_runner: x.y.z
Now, we can launch the code generation with this command:
dart run build_runner build -d
At this point, build_runner
triggers all the code generators that are added as a dependency, providing them with all relevant package files as input, and allowing them to produce corresponding outputs. Depending on the set of generators, the command output may look similar to:
[INFO] Generating build script completed, took 636ms
[INFO] Precompiling build script... completed, took 12.8s
[INFO] Building new asset graph completed, took 1.8s
[INFO] Checking for unexpected pre-existing outputs. completed, took 7ms
[INFO] 22.6s elapsed, 224/228 actions completed.
[INFO] Running build completed, took 23.7s
[INFO] Caching finalized dependency graph completed, took 149ms
[INFO] Succeeded after 23.8s with 139 outputs (611 actions)
In the example above, build_runner
is launched in build
mode, performing a one-time generation. This mode is useful when the code is checked out in a new environment or when one of the code-generating dependencies is updated, and thus, regenerating the code once is sufficient.
build_runner
also supports a watch
mode that monitors the file system and instantly updates output files whenever input files change. This is useful when we're actively working on a package.
If build_runner
discovers generated files that it believes it did not generate, it will show a similar prompt during code generation:
[INFO] Found 12 declared outputs which already exist on disk. This is likely because the`.dart_tool/build` folder was deleted, or you are submitting generated files to your source repository.
Delete these files?
1 - Delete
2 - Cancel build
3 - List conflicts
The -d
parameter, short for --delete-conflicting-outputs
, silences such prompts and automatically removes any existing conflicting files before the code generation starts. It’s more convenient when this flag is provided with every build_runner
launch.
It’s important to realize that
build_runner
itself does not generate the code; rather, it serves as a runner for generators that do.
Useful Code-Generating Packages
Let’s explore some popular code-generating packages that work with the build_runner
mechanism. Each of these packages improves the way to solve one typical app development task, allowing engineers to focus on “what” rather than “how”. When used together, they amplify the productivity gain and make us even more efficient.
The “Fun with code generation” live-coding session at Flutter Vikings showcases most of the packages below used together in a single Flutter project.
JSON Deserialization with json_serializable
JSON is a de facto standard format for exchanging data. Thus, JSON serialization and deserialization are among the most common tasks in app development.
For example, the app that displays a list of space-flight-related news may receive article details in a similar JSON:
{
"id": 15870,
"title": "Rocket Report: A heavy-lift rocket funded by crypto; Falcon 9 damaged in transport",
"imageUrl": "https://cdn.arstechnica.net/wp-content/uploads/2022/07/F28-BW-Low2.jpg",
"summary": "EcoRocket Heavy is an ecological, reusable, unprecedentedly low-cost rocket.",
"publishedAt": "2022-07-22T11:00:53.000Z",
"featured": false,
"launches": [
{
"id": "f33d5ece-e825-4cd8-809f-1d4c72a2e0d3",
"provider": "Launch Library 2"
}
]
}
To be further used in the app and leverage Dart’s strongly typed nature, it has to be converted to an instance of a Dart class:
class Article {
const Article({
required this.id,
required this.title,
this.image,
this.summary,
this.publishedAt,
this.featured = false,
this.launches = const <SpaceLaunch>[],
});
final String id;
final String title;
final Uri? image;
final String? summary;
final DateTime? publishedAt;
final bool featured;
final List<SpaceLaunch> launches;
}
It is an industry-wide convention to implement JSON deserialization in fromJson()
and toJson()
methods within the class to enable the following usage:
assert(Article.fromJson(article.toJson()) == article);
Here is a typical implementation of such methods for the example above:
class Article {
// ...
static Article fromJson(Map<String, dynamic> json) => Article(
id: (json['id'] as int).toString(),
title: json['title'] as String,
image: json['imageUrl'] == null ? null : Uri.tryParse(json['imageUrl'] as String),
summary: json['summary'] as String?,
publishedAt: json['publishedAt'] == null ? null : DateTime.parse(json['publishedAt'] as String),
featured: json['featured'] as bool? ?? false,
launches: (json['launches'] as List<dynamic>?)
?.map((e) => SpaceLaunch.fromJson(e as Map<String, dynamic>))
.toList() ?? const [],
);
Map<String, dynamic> toJson() => <String, dynamic>{
'id': int.parse(id),
'title': title,
if (image != null) 'imageUrl': image!.toString(),
if (summary != null) 'summary': summary,
if (publishedAt != null) 'publishedAt': publishedAt!.toIso8601String(),
'featured': featured,
'launches': launches.map((e) => e.toJson()).toList(),
};
}
Here, a static Article.fromJson()
method handles the parsing logic for various Article
field types, such as int
, String
, bool
, DateTime
, Uri
, List
, and the custom SpaceLaunch
class, which also has a static SpaceLaunch.fromJson()
method.
Moreover, it’s common for methods like Article.fromJson()
to incorporate logic for adapting input data structure to better suit app needs. In the example above, the id
field type is changed from int
to String
, the name of the class field image
is different from the imageUrl
JSON key, and the featured
and launches
fields are given fallback values in case the JSON value is missing.
Additionally, the example above includes an Article.toJson()
method which performs the opposite transformation and ensures that only non-null values are included in the resulting map.
Related article: How to Parse JSON in Dart/Flutter: The Ultimate Guide
The json_serializable package allows for the same outcome with much less code. It can generate methods like Article.fromJson()
and Article.toJson()
, and it also provides mechanisms to make all the data format adjustments as in the manual implementation above.
To leverage code generation for JSON deserialization, add these dependencies in pubspec.yaml
file:
dependencies:
json_annotation: x.y.z
dev_dependencies:
build_runner: x.y.z
json_serializable: x.y.z
And reimplement the Article
class in the article.dart
file:
import 'package:json_annotation/json_annotation.dart';
part 'article.g.dart';
@JsonSerializable(explicitToJson: true, includeIfNull: false)
class Article {
const Article({
required this.id,
required this.title,
this.image,
this.summary,
this.publishedAt,
this.featured = false,
this.launches = const <SpaceLaunch>[],
});
@IntToStringConverter()
final String id;
final String title;
@JsonKey(name: 'imageUrl')
final Uri? image;
final String? summary;
final DateTime? publishedAt;
final bool featured;
final List<SpaceLaunch> launches;
factory Article.fromJson(Map<String, dynamic> json) => _$ArticleFromJson(json);
Map<String, dynamic> toJson() => _$ArticleToJson(this);
}
class IntToStringConverter implements JsonConverter<String, int> {
const IntToStringConverter();
@override
String fromJson(int json) => '$json';
@override
int toJson(String object) => int.parse(object);
}
The @JsonSerializable()
annotation above the Article
class indicates that it is a subject for code generation, and a part 'article.g.dart'
file declaration ensures that the future code is a part of the same article.dart
library.
Check the
Article
andSpaceLaunch
classes from the example repository for the full implementation.
After the code has been generated, a new article.g.dart
file is added with private _$ArticleFromJson()
and _$ArticleToJson()
methods, enabling the same outcome as the manual implementation before.
Thanks to the @IntToStringConverter()
annotation above the id
field, its type is properly converted from int
to String
, the @JsonKey(name: 'imageUrl')
annotation ensures the image
field gets value from imageUrl
JSON key, and the featured
and launches
fields receive the correct fallback values in case the JSON value is missing thanks to their default constructor values.
The includeIfNull: false
parameter ensures that only non-null fields get serialized when the Article.toJson()
is called, and explicitToJson: true
is required for it to correctly serialize lists and custom types.
This behavior is desired in most cases, so configuring it as a default for the entire package is more efficient. This can be done in a special build.yaml
file located next to the pubspec.yaml
file where all code generators can be configured:
targets:
$default:
builders:
json_serializable:
options:
include_if_null: false
explicit_to_json: true
Now, parameters of the @JsonSerializable()
annotation above the Article
class can be skipped without outcome change.
Using code generation significantly reduces the amount of code to write compared to a manual JSON deserialization implementation. Also, whenever the JSON format or the Dart class structure is updated, the manual implementation requires changes in multiple places, which is easy to miss until a serialization exception is thrown at runtime. With code generation, we can be sure that all necessary changes are applied automatically.
This guide only demonstrates the basic usage of the json_serializable package. Check the official documentation for more information.
Read more about using json_serializable and freezed packages in the “Basic and advanced networking in Dart and Flutter” series Part 1 and Part 2.
Enhanced Dart Classes with Freezed
There are a few operations developers perform on Dart class instances daily, explicitly or implicitly:
- value-based comparison
- hash code calculation
- using a string representation
- cloning with modification
Implementations of some of these operations, like operator ==()
, hashCode
, and toString()
, are built into the language but do not provide the best development experience and thus require enhancement, and others, like copyWith()
method, have to be written from scratch.
Let’s consider the Article
class from above as an example. Two instances with the same field values are not equal, and their hash value is also different:
final article1 = Article(id: '0', title: 'title');
final article2 = Article(id: '0', title: 'title');
assert(article1 == article2); // fails
assert(article1.hashCode == article2.hashCode); // fails
This can cause issues when Article
instances are added to Set
, used as Map
keys, or verified in tests.
At the same time, the string representation of two instances with different field values are equal and return Instance of 'Article’
:
final article1 = Article(id: '1', title: 'title 1');
final article2 = Article(id: '2', title: 'title 2');
assert(article1.toString() == article2.toString()); // succeeds
To enhance the Article
class, we have to provide implementations of the following methods:
class Article {
// ...
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Article &&
runtimeType == other.runtimeType &&
id == other.id &&
title == other.title &&
image == other.image &&
summary == other.summary &&
publishedAt == other.publishedAt &&
featured == other.featured &&
launches == other.launches;
@override
int get hashCode =>
id.hashCode ^
title.hashCode ^
image.hashCode ^
summary.hashCode ^
publishedAt.hashCode ^
featured.hashCode ^
launches.hashCode;
@override
String toString() => 'Article(id: $id, title: $title, '
'image: $image, summary: $summary, publishedAt: $publishedAt, '
'featured: $featured, launches: $launches)';
}
Did you spot the error here? The launches == other.launches
check will fail most of the time even if both collections are empty or contain the same objects. To compare collections by their contents, it should be replaced with:
const DeepCollectionEquality().equals(launches, other.launches)
The freezed package allows us to stop bothering about these kinds of nuances and have all these methods generated.
To leverage code generation for enhancing Dart classes, add these dependencies in pubspec.yaml
file:
dependencies:
freezed_annotation: x.y.z
dev_dependencies:
build_runner: x.y.z
freezed: x.y.z
And reimplement the Article
class in the article.dart
file:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'article.freezed.dart';
@freezed
class Article with _$Article {
const factory Article({
required String id,
required String title,
Uri? image,
String? summary,
DateTime? publishedAt,
@Default(false) bool featured,
@Default([]) List<SpaceLaunch> launches,
}) = _Article;
}
The @freezed
annotation above the Article
class indicates that it is a subject for code generation, and a part 'article.freezed.dart'
file declaration ensures that the future code is a part of the same article.dart
library.
Check the
Article
andSpaceLaunch
classes from the example repository for the full implementation.
After the code has been generated, a new article.freezed.dart
file is added with implementations of operator ==()
, hashCode
, toString()
, and copyWith()
methods, enabling the same outcome as the manual implementation above.
Like with JSON deserialization, using code generation to enhance Dart classes reduces the effort to create and maintain them, ensuring that all necessary changes are applied automatically when the class structure changes.
This guide only demonstrates the basic usage of the freezed package. It has many useful features and also can be easily combined with json_serializable. Check the official documentation for more information.
RESTful API Consumption with Retrofit in Flutter
Many Flutter apps have to communicate with their backend via RESTful API. In a project that uses dio for network communication, obtaining a list of articles via GET
request to /articles
path may look similar to:
class SpaceFlightNewsApi {
const SpaceFlightNewsApi(this._dio);
final Dio _dio;
Future<List<Article>> getArticles() async {
final response = await _dio.get<List<dynamic>>('/articles');
final json = response.data!;
final articles = json
.map((dynamic i) => Article.fromJson(i as Map<String, dynamic>))
.toList();
return articles;
}
}
When the getArticles()
method is called, a GET
request is performed by Dio
, followed by list deserialization logic involving Article.fromJson()
method we have built previously. Such requests may also include query and path parameters, body, and headers.
The retrofit package allows reducing the getArticles()
method implementation to a single line. To leverage code generation to optimize REST API consumption, add these dependencies to the pubspec.yaml
file:
dependencies:
retrofit: x.y.z
dev_dependencies:
build_runner: x.y.z
retrofit_generator: x.y.z
And reimplement the SpaceFlightNewsApi
class in the api.dart
file:
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
part 'api.g.dart';
@RestApi()
abstract class SpaceFlightNewsApi {
factory SpaceFlightNewsApi(Dio dio) = _SpaceFlightNewsApi;
@GET('/articles')
Future<List<Article>> getArticles();
}
The @RestApi()
annotation above the SpaceFlightNewsApi
class indicates that it is a subject for code generation, and a part 'api.g.dart'
file declaration ensures that the future code is a part of the same api.dart
library.
Check the
SpaceFlightNewsApi
class from the example repository for the full implementation.
After the code has been generated, a new api.g.dart
file is added with the getArticles()
method implementation, enabling the same outcome as the manual implementation above.
The @GET('/articles')
annotation above the getArticles()
method results in performing a GET
request to /articles
path, and thanks to the method return type, Future<List<Article>>
, the generated code will correctly convert the response data to a list of Article
objects with the help of the same Article.fromJson()
method. Isn’t it nice that the code generated by one package uses the code generated by another!
This guide only demonstrates the basic usage of the retrofit package. It supports all methods and attributes for a feature-rich REST API consumption. Check the official documentation for more information.
Read more about using the retrofit package in the “Basic and advanced networking in Dart and Flutter” series Part 5 and Part 6.
Flutter Dependency Injection with Injectable
Following SOLID principles is considered a good practice. When applied in Flutter apps development, the Single Responsibility Principle suggests splitting some complex functionality between multiple classes, each responsible for a single task, and the Dependency Inversion Principle often means these classes get connected via constructor injection.
In a project that uses layered architecture, applying these principles may look as follows:
@RestApi()
abstract class SpaceFlightNewsApi {
factory SpaceFlightNewsApi(Dio dio) = _SpaceFlightNewsApi;
// ...
}
class SpaceFlightNewsRepository {
const SpaceFlightNewsRepository(this._api);
final SpaceFlightNewsApi _api;
// ...
}
class GetSpaceFlightNewsUseCase {
const GetSpaceFlightNewsUseCase(this._repository);
final SpaceFlightNewsRepository _repository;
// ...
}
If the
@RestApi()
aboveSpaceFlightNewsApi
does not look familiar, check the “RESTful API Consumption with Retrofit” section above.
Thus, instantiating GetSpaceFlightNewsUseCase
will look like this:
final dio = Dio(...);
final usecase = GetSpaceFlightNewsUseCase(
SpaceFlightNewsRepository(
SpaceFlightNewsApi(dio),
),
);
The more dependencies GetSpaceFlightNewsUseCase
has, the longer the constructors’ chain will be.
One way to simplify the instantiation of the usecase is by using the get_it service locator. First, it has to be “taught” how to instantiate GetSpaceFlightNewsUseCase
and all of its dependencies with:
GetIt getIt = GetIt.instance;
getIt.registerSingleton(Dio(...));
getIt.registerLazySingleton(() =>
SpaceFlightNewsApi(getIt<Dio>()));
getIt.registerLazySingleton(() =>
SpaceFlightNewsRepository(getIt<SpaceFlightNewsApi>()));
getIt.registerFactory(() =>
GetSpaceFlightNewsUseCase(getIt<SpaceFlightNewsRepository>()));
The configuration above demonstrates various ways of registering types in GetIt.instance
, which impacts when and how often they are instantiated.
The registerSingleton
call accepts an already created instance of Dio
, while registerLazySingleton
accepts a factory that will be called the first time anyone requests a SpaceFlightNewsApi
or SpaceFlightNewsRepository
instance.
Notice how the SpaceFlightNewsRepository
constructor parameter is obtained from GetIt
without passing the Dio
instance because GetIt
already “knows” where to get one. The registerFactory
means a new instance is created every time GetSpaceFlightNewsUseCase
is requested from GetIt
.
Now GetSpaceFlightNewsUseCase
can be instantiated with:
final usecase = GetIt.instance<GetSpaceFlightNewsUseCase>();
Still, in larger projects, the GetIt
configuration may become quite lengthy due to the numerous classes and their dependencies.
The injectable package allows reducing the GetIt
configuration to a few lines of code, no matter how big the package is. To leverage code generation for facilitating dependency injection, add these dependencies in the pubspec.yaml
file:
dependencies:
get_it: x.y.z
injectable: x.y.z
dev_dependencies:
build_runner: x.y.z
injectable_generator: x.y.z
Declare an initDI()
method in the di_initializer.dart
file and call it at the app start:
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:space_flight_news/di/di_initializer.config.dart';
@injectableInit
void initDI() => GetIt.instance.init();
The @injectableInit
annotation above the initDI
method indicates that it is a subject for code generation, and a di_initializer.config.dart
import ensures that the future code is accessible here.
And finally, annotate classes as follows:
@lazySingleton
@RestApi()
abstract class SpaceFlightNewsApi {
@factoryMethod
factory SpaceFlightNewsApi(Dio dio) = _SpaceFlightNewsApi;
// ...
}
@lazySingleton
class SpaceFlightNewsRepository {
const SpaceFlightNewsRepository(this._api);
final SpaceFlightNewsApi _api;
// ...
}
@injectable
class GetSpaceFlightNewsUseCase {
const GetSpaceFlightNewsUseCase(this._repository);
final SpaceFlightNewsRepository _repository;
// ...
}
After the code has been generated, a new di_initializer.config.dart
file is added with the init()
extension on GetIt
type, enabling the same outcome as the manual implementation above.
The @lazySingleton
annotation ensures SpaceFlightNewsApi
and SpaceFlightNewsRepository
are lazily created only once, while the @injectable
annotation means that a new instance of GetSpaceFlightNewsUseCase
gets created each time it’s requested. The @factoryMethod
above SpaceFlightNewsApi
factory constructor shows how to make GetIt
use non-default constructors.
Since the Dio
parameter of SpaceFlightNewsApi
comes from another package, it cannot be easily annotated with the @singleton
annotation to match the previous behavior. However, it still can be registered in GetIt
as follows:
import 'package:injectable/injectable.dart';
@module
abstract class DIModule {
@singleton
Dio createDio() => Dio(...);
}
@module
annotation ensures all annotated getters and methods of DIModule
are also registered in GetIt
.
Check the
initDI
method, and theDIApiModule
andArticlesListBloc
classes from the example repository for the full implementation.
This guide only demonstrates the basic usage of the injectable package. It also allows registering primitive types and implementations of abstract classes, passing parameters, and enables different behaviors depending on the build flavor. Check the official documentation for more information.
Documentation and Tests with bdd_widget_test
Whenever a new Flutter project is created with flutter create
, it contains a “Counter” example app, accompanied by a widget test:
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}
Here, after the MyApp
widget is created, the test checks for the presence of the "0" text on the screen, taps the “+” icon, and expects to see the text change to "1”.
While this test may seem trivial for any Flutter developer, other team members may find it hard to understand.
Moreover, tests are typically much more complex than this example. If the product owner or QA engineer has any questions about this feature, they either have to refer to documentation stored somewhere outside the codebase (which most likely means it becomes outdated sooner rather than later) or ask developers directly (which is counterproductive and may cause delays).
The most efficient way to ensure the technical documentation is always up to date with the actual implementation is to generate it from the codebase. Yet, the challenge is to make it comprehensible for non-technical team members.
The bdd_widget_test package enables writing technical documentation right in the codebase in a natural-like language called Gherkin. Corresponding Flutter widget tests are then generated to cover the functionality described.
To leverage code generation for documentation and tests maintenance, add these dependencies in the pubspec.yaml
file:
dev_dependencies:
build_runner: x.y.z
bdd_widget_test: x.y.z
Then, create a counter.feature
file inside the test
folder. For the “Counter” app example, it may look as follows:
Feature: Counter
Scenario: Initial counter value is 0
Given the Counter app is running
Then I see {'0'} text
Scenario: Tapping the Plus icon once increases the counter to 1
Given the Counter app is running
When I tap {Icons.add} icon
Then I see {'1'} text
After the code has been generated, a companion counter_test.dart
file is added, enabling the same outcome as the manual widget test implementation above. The Dart test file contains one group called “Counter” and two widget tests matching scenarios in the feature file:
// GENERATED CODE - DO NOT MODIFY BY HAND
void main() {
group('''Counter''', () {
testWidgets('''Initial counter value is 0''', (tester) async {
await theCounterAppIsRunning(tester);
await iSeeText(tester, '0');
});
testWidgets('''Tapping the Plus icon once increases the counter to 1''', (tester) async {
await theCounterAppIsRunning(tester);
await iTapIcon(tester, Icons.add);
await iSeeText(tester, '1');
});
});
}
The iSeeText
, iTapIcon
methods, as well as many more frequent actions and verifications like iEnterText
, iSeeWidget
, and iDontSeeWidget
, are provided by the package. Moreover, we can declare our own steps to combine multiple primitive actions or accommodate complex business logic under a simplified description.
For each such custom step, method stubs get generated for us to implement according to the project needs. For “the Counter app is running” step above, the step/the_counter_app_is_running.dart
file with theCounterAppIsRunning
method stub is created, which then can be updated to:
Future<void> theCounterAppIsRunning(WidgetTester tester) async {
await tester.pumpWidget(MyApp());
}
As a result, the project has human-readable *.feature
files that can be either auto-uploaded to a documentation management system, or accessed by the entire team right in the codebase. These files always contain the latest implementation requirements, which are confirmed by passing tests generated from this documentation.
Check the
articles_list.feature
file from the example repository for the full implementation.
This guide only demonstrates the basic usage of the bdd_widget_test package. It also supports outlines, tags, etc., and can be combined with golden and integration tests. For more information, check the official documentation and the BDD in Flutter playlist.
Automated Encapsulation with barrel_files
When organizing a project by splitting it into multiple packages or maintaining an OSS package, there is often a mix of classes you want package users to access, and others that should remain internal for a cleaner package's public API.
In Dart, every symbol is public by default unless its name starts with
_
, in which case it is only accessible within the same Dart library/file.
To make the code accessible to other code within the same package but not outside of it, a widely accepted convention is to organize it under the lib/scr
folder. If package users attempt to access any file under that folder, they will get the implementation_imports
rule violation warning: “Don't import implementation files from another package”.
To expose a limited set of the package’s public API, maintainers can create one or more “barrel” files just under the lib
folder with a list of export
directives specifying only Dart files that can be accessed outside the package. This approach ensures that only the public API is available to package users, allowing maintainers to safely refactor the internal implementation without introducing breaking changes.
For a space_flight_news
package that consists of a usecase.dart
file:
class SpaceFlightNewsApi {
// ...
}
class GetSpaceFlightNewsUseCase {
// ...
}
and a model.dart
file:
class Article {
// ...
}
class SpaceLaunch {
// ...
}
The barrel file lib/space_flight_news.dart
may look as follows:
export 'package:space_flight_news/src/model.dart';
export 'package:space_flight_news/src/usecase.dart';
With this approach, the information about code visibility is detached from the code itself and is controlled in a barrel file in a different location.
This can easily lead to a situation when the barrel file is not reviewed and updated regularly, potentially exposing more code than intended. In the example above, the SpaceFlightNewsApi
class got accidentally exposed even though it should not be a part of this package's public API.
The barrel_files package creates barrel files based on annotations placed directly above the code elements that should be visible outside the package. To leverage code generation for automated barrel file management, add these dependencies in the pubspec.yaml
file:
dependencies:
barrel_files_annotation: x.y.z
dev_dependencies:
barrel_files: x.y.z
build_runner: x.y.z
Next, annotate classes, enums, global constants, and any other top-level elements that are not private to the package with @includeInBarrelFile
. Here is the updated usecase.dart
file:
import 'package:barrel_files_annotation/barrel_files_annotation.dart';
class SpaceFlightNewsApi {
// ...
}
@includeInBarrelFile
class GetSpaceFlightNewsUseCase {
// ...
}
and an updated model.dart
file:
import 'package:barrel_files_annotation/barrel_files_annotation.dart';
@includeInBarrelFile
class Article {
// ...
}
@includeInBarrelFile
class SpaceLaunch {
// ...
}
After the code has been generated, a new lib/space_flight_news.dart
file is added with a list of export
directives, improving the outcome from the manual implementation above:
// GENERATED CODE - DO NOT MODIFY BY HAND
export 'package:space_flight_news/src/model.dart' show Article, SpaceLaunch;
export 'package:space_flight_news/src/usecase.dart' show GetSpaceFlightNewsUseCase;
The barrel_files package helps keep the information about code visibility as close to the code itself as possible. It ensures that barrel files are always up-to-date, and follows a minimalistic approach by exposing only explicitly annotated code while encapsulating the rest. Check the official documentation for more information.
Other Code-Generation Applications
As demonstrated above, code generation can simplify typical app development tasks like JSON serialization, enhancing Dart classes, consuming REST APIs, dependency injection, documentation, and efficient barrel files maintenance.
Thanks to the active Flutter community, there are even more great code-generating packages to explore:
- flutter_gen simplifies Flutter package assets usage
- auto_route provides a popular navigation mechanism
- i69n simplifies app localization
Read more about app localization with the i69n package in “Yet another localization approach in Flutter” article.
- dart_mappable provides a good alternative for a combination of freezed and json_serializable
- riverpod_generator helps with defining providers for riverpod state management solution
Read more in the “How to Auto-Generate your Providers with Flutter Riverpod Generator” tutorial.
- mobx_codegen simplifies state management implementation with mobx
The list goes on and on, so code generation can enhance many aspects of app development. Let’s now discuss best practices for maintaining projects that extensively use of all these code-generating packages.
Efficient Codebase Maintenance
Extensive usage of code generation in Dart and Flutter projects can significantly boost development productivity. Yet, this benefit comes with additional maintenance costs. Therefore, we should adopt certain practices to keep productivity gains high. Let’s now discuss techniques that help maintain such projects efficiently.
Optimize Code Generator Input
We already discussed how build_runner
runs all code generators added to a package and, by default, feeds all package files as input to each generator. Thus, it should be no surprise that the size of such input directly impacts the time it takes to generate the necessary code.
After all, the code generator is just a script that converts some input string to an output string (which happens to be a valid Dart code) and writes it to the disk. Depending on the project size and the number of generators, running code generation may take from a few seconds to over an hour. So, it's essential to optimize and speed up this process by minimizing each generator input.
We previously looked into a way to control generators’ behavior with options
configuration provided in a special build.yaml
file. The latter also supports generate_for
configuration that accepts a list of files to be served as input for a generator. The following example demonstrates how to specify individual files or to include all files within specific directories:
targets:
$default:
builders:
json_serializable:
generate_for:
- lib/src/example.dart # a single `example.dart` file
- lib/src/foo/*.dart # all `.dart` files under the `foo` directory
- lib/src/bar**/*.dart # all `.dart` files under the `bar` directory and its subdirectories
Once the input for code generators (where it makes sense) is reduced to a limited number of files, the code-generation process will execute faster.
Note that when the
generate_for
configuration is specified in thebuild.yaml
file, the corresponding code generator will disregard input files that do not match the specified list. As a result, this particular generator will not generate code for these files, and no warning will be issued.
In addition to improving code generation speed, configuring generators with generate_for
helps ensure the package structure is aligned with predefined expectations.
Keep Packages Small
We already saw that when we execute dart run build_runner
, code generation is performed for the entire package. Thus, despite minimizing the generators' input with the technique above, generating code in a large package may still take a long time. Naturally, dividing the project into smaller packages reduces the generators’ inputs even further, leading to faster code generation for each package.
While recommending this technique solely to improve the code generation speed may be debatable, adopting a smaller packages philosophy offers other advantages like enhanced encapsulation, faster tests, and reduced cognitive load.
Earlier, we saw that some generators, like freezed
or json_serializable
, create output files for each input file within the package, while others, like injectable
, produce a single output file per package. When a package is being developed by multiple contributors, files from the latter category are more prone to merge conflicts in a big package than in a small one. This highlights another benefit of breaking down the project into smaller packages.
With this approach, we typically execute code generation only in packages where we've made changes. But there may still be cases when we have to run build_runner
again in all project packages, for example, after updating one of the code-generating dependencies across the entire project. One of the ways to solve this task efficiently is by using the Melos tool.
Melos is a CLI tool used to help manage Dart projects with multiple packages. Follow the official Getting Started guide to configure it for any Flutter project.
Melos enables us to create custom scripts in a dedicated melos.yaml
file and execute any command on a filtered set of project packages. The following example shows how to create a generate
melos script that launches code generation in all packages that depend on build_runner
:
name: <project_name>
scripts:
generate:
run: melos exec -c 1 --depends-on="build_runner" -- \
"dart run build_runner build --delete-conflicting-outputs"
It can then be executed with:
melos run generate
Add Generated Files to Git
Adding generated files to Git alongside manually created and maintained files offers many advantages.
Without generated files in place, the project cannot be compiled and run, as they contain important implementation parts. Thus, it takes time and resources to bring the project to life in a fresh environment when the generated files are not added to Git. This is problematic in many cases, and requires code generation to be executed:
- every time the project is pulled on a new development machine.
- after pulling the latest changes from the remote repository (ensuring that locally generated files are still valid).
- on CI every time it pulls the project into a clean environment to run PR checks or make a new build.
Considering the potentially significant time consumed by code generation, especially in larger projects, it becomes evident that excluding generated files from Git leads to higher costs. On the contrary, adding these files to Git is absolutely necessary to maintain a codebase ready to run and release at any given moment, which is more efficient in terms of both time and money.
While you should add the generated files to Git, you don't need to review them when you open a PR. To learn how to hide them by default in your PRs and diffs, read: How to Hide Generated Dart Files in GitHub PRs.
Configure Static Analysis
The Dart static analysis tool can be configured on a per-package basis to satisfy the needs of different teams through a dedicated analysis_options.yaml
file located next to pubspec.yaml
file.
We can use one of the community-curated rule sets or produce a custom list picked from all available rules. Such flexibility poses a challenge for code generators as they cannot possibly satisfy all analyzer rules configured in a particular project. As a result, generated code may become the source of numerous analyzer warnings, causing clutter and making it challenging to notice real problems.
The simplest way to eliminate this inconvenience is to exclude generated files from static analysis. For the project utilizing all code-generating packages from the list above, the analysis_options.yaml
file should be configured as follows:
analyzer:
exclude:
- '**/*.g.dart'
- '**/*.freezed.dart'
- '**/*.config.dart'
- '**/*.gen.dart'
- '**/feature**/*_test.dart'
Having these files analyzed is only helpful in rare cases, but excluding generated files from static analysis significantly improves analyzer performance, which is a frequent problem for larger-scale projects.
Lock Dependencies Versions
The most popular way to specify package dependencies in pubspec.yaml
is by using caret syntax: build_runner: ^2.4.6
. This implies that a range of build_runner
versions from 2.4.6
to 3.0.0
excluded is allowed.
When the flutter pub get
command is executed, the pubspec.lock
file next to pubspec.yaml
”locks” the exact version of a dependency that is now used on this machine until a developer explicitly updates it. The version locked will be either the latest matching from the local pub cache, if available, or the latest matching from pub.dev.
The Dart team advises against committing pubspec.lock
files of Dart and Flutter packages “except for application packages”. Consequently, in projects with non-application packages where pubspec.lock
is omitted from Git, and code-generating dependencies are specified with version ranges in their pubspec.yaml
files, we may face situations where different versions of code generators are used on different development machines.
In this case, if the generator was updated to produce different code between versions, we will see changes in generated files without any intentional modifications from our side.
To avoid these situations, always specify exact versions for all code-generating dependencies in the pubspec.yaml
file:
dependencies:
barrel_files_annotation: 0.1.1
freezed_annotation: 2.4.1
injectable: 2.3.2
json_annotation: 4.8.1
retrofit: 4.0.3
dev_dependencies:
barrel_files: 0.1.1
bdd_widget_test: 1.6.4
build_runner: 2.4.6
flutter_gen_runner: 5.3.2
freezed: 2.4.5
injectable_generator: 2.4.1
json_serializable: 6.7.1
retrofit_generator: 8.0.2
It also goes without saying that all packages across the project should use the same versions of dependencies.
Simplify Generation Launching
In projects extensively leveraging code generation across different code areas, we have to rerun the code generation process quite often. Thus, it’s worth investing in creating shortcuts to avoid typing lengthy commands regularly.
In Unix-based systems, like macOS or Linux, it is possible to reassign commands with the help of aliases. Traditionally, they are defined in the .zshrc
file placed under the user’s home directory. Here are some handy aliases to simplify the process of launching code generation:
alias fpg="flutter pub get"
alias brb="dart run build_runner build --delete-conflicting-outputs"
alias brw="dart run build_runner watch --delete-conflicting-outputs"
alias fpgbrb="fpg && brb"
alias fpgbrw="fpg && brw"
Shortcuts brb
and brw
will launch build_runner in build or watch mode, respectively. fpg
is a simple shortcut to download package dependencies, which may need to be executed before running the code generation. Thus, the aliases fpgbrb
and fpgbrw
represent a combination of the two.
Create Code Snippets
Writing the configuration code for the future generated code is a task performed daily by many developers. Some packages like injectable require as little as adding an annotation, while others like freezed require memorizing a non-trivial syntax.
All popular IDEs support code snippets, offering a convenient way to perform a one-time generation of simple code, which is particularly useful for simplifying tasks like declaring freezed
classes.
Follow this brief video tutorial to configure Live Templates for Android Studio and IntelliJ IDEA. Here is the template text used in the video:
import 'package:freezed_annotation/freezed_annotation.dart';
part '$FILE_NAME$.freezed.dart';
part '$FILE_NAME$.g.dart';
@freezed
class $CLASS_NAME$ with _$$$CLASS_NAME$ {
const factory $CLASS_NAME$ ({
$END$
}) = _$CLASS_NAME$;
factory $CLASS_NAME$.fromJson(Map<String, dynamic> json) =>
_$$$CLASS_NAME$FromJson(json);
}
Variables values:
FILE_NAME = fileNameWithoutExtension()
CLASS_NAME = capitalize(camelCase(fileNameWithoutExtension()))
For configuring VSCode Code Snippets, check out this video tutorial. Here is the content of the .code-snippets
file:
{
"Serializable freezed model": {
"prefix": "fmodel",
"description": "Declare a serializable freezed model",
"body": [
"import 'package:freezed_annotation/freezed_annotation.dart';",
"",
"part '${TM_FILENAME_BASE}.freezed.dart';",
"part '${TM_FILENAME_BASE}.g.dart';",
"",
"@freezed",
"class ${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g} with _$${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g} {",
" const factory ${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}({",
" ${0}",
" }) = _${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g};",
"",
" factory ${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}.fromJson(Map<String, dynamic> json) => ",
" _$${TM_FILENAME_BASE/(.*)/${1:/pascalcase}/g}FromJson(json);",
"}"
]
}
}
By using these code snippets, you can declare freezed classes in seconds.
Collapse Generated Files
Naturally, in projects with extensive code generation usage, the number of generated files is quite high. By default, these files are displayed in the project tree alongside regular files, cluttering the view. Fortunately, all IDEs support organizing generated files by hiding them under the main file with the same name.
To configure Android Studio or IntelliJ IDEA to nest generated files, in the Project view go to Options → File nesting. Locate .dart
Parent File Suffix and add .g.dart; .freezed.dart; .config.dart
to the Child File Suffix.
To configure VSCode, open Settings → Features → Explorer, turn on “File nesting” and configure .dart
entry under “File Nesting: Patterns” to include ${capture}.g.dart, ${capture}.freezed.dart, ${capture}.config.dart
.
For more useful tips about configuring your IDE, read: VSCode Shortcuts, Extensions & Settings for Flutter Development and IntelliJ / Android Studio Shortcuts for Flutter Development.
Update Code Coverage Report
Test coverage is one of the metrics commonly used for evaluating code quality, and we can easily generate a test coverage report by running this command:
flutter test --coverage
The lcov.info
report file will be created under the coverage
folder.
Related article: How to Generate and Analyze a Flutter Test Coverage Report in VSCode
By default, generated files are included in the test coverage report. Since not all code in these files is always covered by tests, this reduces the overall coverage percentage. However, fully covering generated code with tests is challenging and often redundant.
The simplest way to address this issue is to exclude generated files from the code coverage report. This can be done with the help of the remove_from_coverage tool, which allows manipulating lcov.info
coverage files to ignore files matching given patterns.
Once the tool is installed with:
dart pub global activate remove_from_coverage
The coverage/lcov.info
report file can be modified with:
dart pub global run remove_from_coverage:remove_from_coverage -f coverage/lcov.info \
-r '.g.dart$' -r '.freezed.dart$' -r '.config.dart$'
Reuse Configured Annotations
Most code-generating packages offer the flexibility to configure generated code with annotation parameters. For example, using the @JsonSerializable()
annotation versus @JsonSerializable(includeIfNull: false, explicitToJson: true)
leads to different serialization logic.
When similar configurations are required across the project, it leads to code duplication. This can be avoided by extracting frequently used annotation configurations to reusable constants, as shown below:
const serializableModel = JsonSerializable(
includeIfNull: false,
explicitToJson: true,
);
@serializableModel
class ExampleModel {...}
Control Code Generation Order
In some cases, we may require to define the sequence in which code generators are executed. For example, the retrofit generator decides whether to generate a .toJson()
call for a model that is a part of the request body based on whether the model class has the .toJson()
method declared.
If we use json_serializable in combination with freezed for model serialization, the .toJson()
method is not explicitly declared until both these generators are executed. Thus, we should run them before the retrofit
generator, to ensure the latter produces valid code.
The generators’ sequence can be controlled in the build.yaml
file as follows:
global_options:
freezed:
runs_before:
- json_serializable
json_serializable:
runs_before:
- retrofit_generator
In this case, the freezed
generator is executed first, json_serializable
is executed next, and the retrofit_generator
is executed last.
Source Code
A Flutter demo project that uses all of the mentioned code-generating packages is available on GitHub.
This project contains the source code for this space flight news app:
This app is implemented twice: without code generation and with code generation, demonstrating the usage of various code-generating packages and some maintenance best practices.
Conclusion
Code generation is a powerful tool that can make us more productive by facilitating many typical tasks in Flutter app development.
In this tutorial, we explored the code generation mechanism with build_runner
, used multiple code-generating packages to facilitate tasks like JSON serialization, consuming REST APIs, dependency injection, documentation, and others, and discussed best practices for efficient maintenance of projects that extensively use code generation.
Without a doubt, code generation brings many benefits, and I hope that the tools and techniques shared in this guide will help you in your coding journey.
Happy coding!