Code Generation with Dart & Flutter: The Ultimate Guide

Source code on GitHub

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 and SpaceLaunch 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 and SpaceLaunch 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() above SpaceFlightNewsApi 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 the DIApiModule and ArticlesListBloc 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.

Read more in the “How to Auto-Generate your Providers with Flutter Riverpod Generator” tutorial.

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 the build.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.

Configuring generated files nesting in Android Studio or IntelliJ IDEA
Configuring generated files nesting in Android Studio or IntelliJ IDEA

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.

Configuring generated files nesting in VSCode
Configuring generated files nesting in VSCode

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:

Screenshots of the Space Flight News app
Screenshots of the 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!

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.