Getting to know build_runner — The Consequences of Convenience

Morgan Hunt
7 min readFeb 24, 2024

--

build_runner is a tool provided by the Dart ecosystem for generating code in a Dart or Flutter project. It automates the process of generating code based on annotations or configuration files. This generated code can be used to improve performance, reduce boilerplate code, or facilitate certain tasks like serialization or dependency injection.

A few packages that use build_runner to generate code are:

In the realm of Flutter development, efficiency is paramount. Tools like build_runner play a pivotal role in streamlining the development process, automating repetitive tasks, and enhancing developer productivity. But have you ever wondered how build_runner actually works its magic behind the scenes?

At its core, build_runner operates by analyzing every nook and cranny of your Flutter project, armed with a sophisticated understanding of Abstract Syntax Trees (ASTs). Let's peel back the curtain and delve into the inner workings of this essential tool.

When you invoke build_runner, it journeys through every file within your project, parsing each one. Parsing involves dissecting the raw source code and constructing an Abstract Syntax Tree (AST), a structured representation that captures the syntactic essence of the code. This AST serves as a blueprint of the code’s structure, breaking it down into manageable components such as classes, functions, and variables.

Armed with ASTs, build_runner begins on a systematic traversal of the tree, meticulously exploring each node. This traversal is akin to navigating a labyrinth, as it inspects every aspect of the code's structure and content. During this traversal, build_runner keeps an eye out for special markers known as annotations or metadata. Annotations, denoted by symbols like @, provide insights into how the code should be processed. Whether it's @JsonSerializable() hinting at serialization requirements or @Injectable() signaling the need for dependency injection, these annotations serve as beacons guiding build_runner on its path.

“Why does build_runner seem to perform quickly initially, but then takes forever as the project scales?"
— Pretty much everyone

As you can tell, build_runner has a lot of work to do. The larger the project, the larger volume of code it has to analyze, leading to increased complexity and longer processing times. Factors such as the number of files, complexity of annotations, and the intricacy of code dependencies all contribute to the slowdown.

Let’s learn how we can play nice with build_runner, shall we?

Generate For Explicit Paths

By specifying paths to include (and exclude), build_runner automatically excludes all other directories. This approach reduces the amount of code analysis required and minimizes the number of directories build_runner needs to traverse. As a result, the build process becomes more focused and efficient, leading to faster build times and improved overall performance.

To instruct build_runner to operate on specific paths within your project, you'll utilize the build.yaml configuration file. Once your build.yaml file is configured, build_runner will now adhere to the specified paths, disregarding any other files or directories.

targets:
$default:
builders:
json_serializable:
enabled: true
generate_for:
# "include:" can be omitted
- lib/domain/*.dart
# or
include:
- lib/domain/*.dart
exclude:
- lib/domain/some_file_to_exclude.dart

You can read more about these configurations in the build.yaml config docs.

Avoid Nested Files

The biggest factor I’ve found to slowness is nested files. Nested files can significantly slow down build_runner due to the increased complexity and traversal required to analyze them. When files are deeply nested within multiple directories, build_runner must traverse through numerous levels of directory structures to locate and process each file. This additional traversal adds overhead to the analysis process, leading to longer build times.

lib
└── blocs
├── auth
│ ├── auth_bloc.dart
│ ├── auth_state.dart
│ └── auth_event.dart
└── user
├── user_bloc.dart
├── user_state.dart
└── user_event.dart

lib
└── blocs
├── auth_bloc.dart
├── auth_state.dart
├── auth_event.dart
├── user_bloc.dart
├── user_state.dart
└── user_event.dart

If you wanna make your IDE (VS Code) look a little nicer, by making the list of files shorter, you can add the following setting to make your *_bloc.dart file act like a folder, which will nest the *_state.dart and *_event.dart files.

{
"explorer.fileNesting.patterns": {
"*_bloc.dart": "${capture}_event.dart, ${capture}_state.dart, ${capture}_bloc*.dart"
}
}
Look Ma! This file thinks its a folder!

Avoid Barrel Imports

Barrel files serve as both a blessing and a curse. While they effectively reduce the number of imports required within a file, they can significantly impede the performance of build_runner, particularly during watch mode. Although the initial watch and build process are identical, watch mode dynamically tracks file changes and re-generates only affected files. This begs the question: How does build_runner discern which files are affected? The answer lies in the import statements.

Consider a simple folder structure, where each file corresponds to an entity (The .g.dart files are omitted):

lib
└── models
├── user.dart # imports address.dart
├── address.dart
├── location.dart # imports location_type.dart
├── location_type.dart
└── models.dart # barrel file

During build_runner watch, imagine a change is made in location_type.dart. Consequently, build_runner regenerates location.dart because it depends on location_type.dart.

One might be tempted to optimize by adding the import ./models/models.dart into user.dart and location.dart. This seemingly convenient shortcut saves a few lines of code and eliminates the need for future import statements. If a change occurs in location_type.dart, build_runner interprets the barrel file import as a signal to regenerate all files exported from models.dart. This can lead to significant delays and frustration for developers as build_runner churns through unnecessary regenerations.

Stop Generating So Much

Imagine you’re utilizing code generators like freezed, json_serializable, and copy_with_extension for a simple, single-membered class. How much of the generated code is actually needed? While these tools are undoubtedly powerful and can automate tedious tasks, such as serialization and copy methods, their usage should be approached with caution.

Firstly, consider whether the generated code is truly necessary for your project. If the generated code isn’t actively utilized in your application, it adds unnecessary bloat to your compiled project. This excess code not only increases the size of your application but can also impact the performance of your build process, slowing down build_runner and increasing development overhead.

Furthermore, each code generator introduces additional dependencies and complexity to your project. While these tools may solve specific problems efficiently, they also require maintenance and can complicate the debugging process if issues arise.

Instead of indiscriminately incorporating code generators, it’s essential to be intentional and selective about their usage. Focus on addressing existing pain points in your project rather than preemptively optimizing for hypothetical future scenarios. By adopting this approach, you can maintain a leaner, more efficient codebase while minimizing unnecessary overhead and ensuring optimal performance throughout your development workflow.

It’s clear that a proactive approach can go a long way in mitigating slowdowns. By leveraging strategies such as specifying explicit paths for build_runner to operate on, simplifying directory structures to avoid nested files, and carefully considering the usage of code generators, developers can minimize unnecessary overhead. While build_runner may present challenges, it ultimately remains a valuable ally in the world of development.

Okay, but its magic!

Ah, the allure of code generation — it truly does seem like magic. I’ve found myself oscillating between two extremes: staunchly avoiding code generation altogether and gleefully embracing its powers. But here’s the thing: the key to dispelling the magic lies in understanding its inner workings.

If you find yourself mystified by the code generation process, it might be a sign that you’re not quite ready to incorporate it into your workflow. After all, blindly relying on tools you don’t fully comprehend can lead to unforeseen complications down the road.

For me, the tipping point for adopting code generation is when I encounter a specific problem that it can elegantly solve. Take JSON serialization, for instance. If you’ve ever grappled with the tedium and error-prone nature of manual serialization, the allure of an automated solution is irresistible. However, even in these cases, it’s crucial to have a solid understanding of how code generation works and what it’s doing behind the scenes.

So, before succumbing to the allure of code generation, take a moment to assess whether it’s the right solution for your current needs. By approaching it with a clear understanding and purpose, you can harness its powers effectively while avoiding the pitfalls of blind reliance on magic.

Let’s celebrate build_runner as our trusted ally in the world of development. With these strategies in our arsenal, we can optimize our projects and make build_runner work for us. Here's to embracing build_runner and its role in our coding journey!

--

--