From Annotations to Generation: Building Your First Dart Code Generator
Dinko Marinac
Introduction
Code generation is probably one of the most hated parts of being a Flutter developer.
We all know the scenario:
-
Add 1 property to the model
-
Run
dart run build_runner build --delete-conflicting-outputs -
You get slapped with this:

It’s probably the worst feeling in the world, however, code generation has become such an instrumental tool in Flutter development. In this article, I’ll do my best to explain why we need code generation and how to write your own generator so you can skip copy-pasting the boilerplate.
Why Code Generation?
The main reason why we need code generation in Dart is the fact that we have access to a very powerful tree-shaking mechanism available. As a reminder:
-
Reflection is the ability of a process to examine, introspect, and modify its structure and behavior
-
Tree Shaking means eliminating dead code or unused modules during build time
Tree shaking relies on the ability to detect unused code, which doesn’t work if reflection can use any of it dynamically (it essentially makes all code implicitly used).
With dart:mirrors being discontinued (though still usable with JIT compiler), code generation has emerged as the preferred solution for generating boilerplate code.
Now, you might think: “What about macros?”
Macros ARE code generation. The main difference to build_runner is that macros generate code in the background and do not require compilation. However, they are still experimental and we don’t know how they will be fully designed. My opinion is that they will not replace code generation.
According to the documentation, the main use cases are:
-
JSON serialization
-
Data classes
-
Flutter verbosity
Code generation in the Dart ecosystem serves various purposes:
-
Routing: Packages like
go_router_builderandauto_route_generatorhelp manage navigation -
Dependency Injection: Tools like
injectable_generatorfacilitate service location -
Model Utilities:
freezedgenerates immutable model classes -
Serialization:
json_serializablehandles JSON conversion -
Asset Management:
flutter_genmanages asset references -
Network Layer:
retrofitandchoppersimplify API client creation -
Platform Channel Communication:
pigeonhandles native code integration -
Configuration Management*: Generating config files
-
Data Mapping*: Automating object transformations
-
Form Handling*: Managing form state and validation
⚠️ The last 3 use cases have an asterisk (*) sign because while there are no official packages for them, but I’ve seen this in various code bases.
The main thing is that macros don’t solve is generating new files, whether they would be .dart or any other type. That means that build_runner will still be needed for these cases.
Now that I’ve explained why we need code generation let’s move on to the building blocks of a generator.
The Building Blocks of Code Generation
Most code generation packages consist of two parts:
-
An annotation package defining the annotations (eg.
@freezed) and the util classes -
A builder package containing the generation logic
Annotations
Annotations are classes with const constructors that serve as markers for code generation.
Defining an annotation would usually look like this:
class Http {
const Http();
}
They can be applied to:
-
Classes
-
Methods
-
Fields/variables
-
Constructors
-
Functions
-
Libraries
// Applied to a class
@Body
class Todo {...}
// Applied to a function
@Http
Future<Response> updateTodo(Todo todo) async {...}
To make it clear where an annotation should be used, we can use themeta package while defining the annotation.
@Target({TargetKind.function})
class Http {
const Http();
}
A compiler warning will appear in the IDE if we now try to annotate something other than a function.
Builder
The builder package can leverage the following packages to generate code:
-
build: Provides the foundation for code generation
-
build_runner: CLI tool that executes the generation process
-
source_gen: Offers utilities and builders for code generation
-
analyzer: Enables static analysis of Dart code
-
code_builder: Helps build valid Dart code programmatically
There are 2 recommended ways to generate code: the build package or the source_gen package.
The build package offers a barebones but flexible approach. You can read and write to any type of file, but this comes with a drawback: a lot of manual work like getting the file, reading from it then writing to it. I would suggest using this only if you don’t have any other choice.
On the other hand,source_gen package provides a more developer-friendly API that builds on top of build and analyzer. It’s specifically designed for creating Dart code and handles writing to files for you.
It comes with the following builder classes:
-
SharedPartBuilder- used for generating.g.dartfiles -
PartBuilder- used for generating.extension.dartfiles, you decide on the extension -
Generator- used for generating standalone.dartfiles
Your job is to provide the code that will be written into the files.
The last piece of the builder package is build.yaml file. It’s used for specifying all the generators and registering them to be used with build_runner.
builders:
shelf_code_generator_example|shelf_route_builder:
import: "lib/builder.dart"
builder_factories: ["shelfRouteBuilder"]
build_extensions: {".dart": [".route.dart"]}
auto_apply: dependents
build_to: source
applies_builders: []
Let’s explain what happens here:
-
shelf_code_generator_example|shelf_route_builder:- Package name followed by builder name, could use only builder name if in a separate package -
import: "lib/builder.dart"- Imports the builder from the locallib/builder.dartfile -
builder_factories: ["shelfRouteBuilder"]- Names the factory function that creates the builder, must match a top-level function inbuilder.dart -
build_extensions: {".dart": [".route.dart"]}- For each.dartinput file, generate a.route.dartfile -
auto_apply: dependents- Automatically applies this builder to any package that depends on it -
build_to: source- Generated files are written directly to the source directory -
applies_builders: []- No additional builders need to run after this one
Now that you know how to define a generator package and annotations, let’s take a look at the example.
Example: Building a Shelf Endpoint Generator
Let’s examine a practical example of code generation by creating a generator for Shelf endpoints. Shelf is a web server middleware for Dart, similar to express.js, and we can simplify registering endpoints using a generator.
Requirements
Our generator should handle:
-
HTTP method specification
-
Middleware integration
-
Path parameter extraction
-
JSON body serialization
We want to build something like this:
@RouteController()
class UserController {
@Use(authMiddleware)
@Http(HttpMethod.post, '/users/<id>')
Future<Response> updateUser(
Request request,
@Path('id') String userId,
@Body() User user,
) async {
// Implementation
return Response.ok('User updated: $userId');
}
}
You might notice a lot of annotations, and that’s on purpose, let’s go trough them:
-
@RouteController→ used to generate the router with attached endpoints -
@Use→ define which middleware we should attach to the endpoint -
@Http→ define HTTP method and path, support path parameters -
@Path→ define the path parameter which should be extracted -
@Body→ define the type of body that our request supports and serialize it
The generated code would look something like this:
// Generated code - do not modify by hand
part of 'user_controller.dart';
// **************************************************************************
// RouteGenerator
// **************************************************************************
class UserControllerRouter {
final Router _router = Router();
final UserController _controller;
UserControllerRouter(this._controller) {
_router.add(
HttpMethod.post.name,
'/users/<id>',
RouteMiddlewareHandler(
(request, id) async {
final bodyJson = await request.readAsString();
final user = User.fromJson(json.decode(bodyJson));
return _controller.updateUser(request, id, user);
},
[authMiddleware],
).call,
);
}
Handler get handler => _router.call;
}
Implementation
You can find the full implementation on Github, I’ll just show the important parts to keep the length of the article manageable.
Let’s define our annotations:
typedef MiddlewareFunction = Handler Function(Handler handler);
enum HttpMethod { get, post, put, delete, patch }
@Target({TargetKind.method, TargetKind.function})
class Http {
final HttpMethod method;
final String path;
const Http(this.method, this.path);
}
@Target({TargetKind.method, TargetKind.function})
class Use {
final MiddlewareFunction middleware;
const Use(this.middleware);
}
This code example shows how we would define Http and Use annotations. As you can see, they are nothing more than just objects carrying the data that’s useful for code generation. Notice how I’m using the meta package here to specify what should be annotated.
Next, we need the builder:
class RouteGenerator extends GeneratorForAnnotation<RouteController> {
@override
String generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
// Implementation
}
}
Builder shelfRouteBuilder(BuilderOptions options) => PartBuilder(
[RouteGenerator()],
'.route.dart',
header: '// Generated code - do not modify by hand\n\n',
);
I chose GeneratorForAnnotation because I want to build a router for each controller that’s annotated with @RouteController .
I later define that as PartBuilder with .route.dart extension. This means that for each controller, a new dart file will be generated and the original file will require the part statement at the top of the file.
One thing to notice here is that generateForAnnotatedElement returns a String, and that’s the power of source_gen that I mentioned earlier. Your job is to write the code, not handle writing to files.
Lastly, I need to show you how to extract data from the annotations and the annotated functions or classes. I’ll use @Http annotation as an example:
_HttpAnnotation? _getHttpAnnotation(MethodElement method) {
final annotation =
const TypeChecker.fromRuntime(Http).firstAnnotationOf(method);
if (annotation == null) return null;
final reader = ConstantReader(annotation);
// Get the method enum
final methodObj = reader.read('method');
final pathObj = reader.read('path');
return _HttpAnnotation(
method: methodObj.revive().accessor,
path: pathObj.stringValue,
);
}
This code demonstrates a common pattern for extracting data from annotations in Dart code generation:
-
TypeChecker.fromRuntime(Http)creates a checker that can find annotations of typeHttp -
firstAnnotationOf(method)finds the first matching annotation on the provided method -
ConstantReaderwraps the annotation object to provide easy access to its values -
reader.read()extracts specific fields from the annotation -
For enums, use
revive().accessorto get the enum value -
For strings, use
stringValueto get the string value
Another example is the @Body annotation where we have to get the class name:
for (var param in method.parameters) {
if (const TypeChecker.fromRuntime(Body).hasAnnotationOf(param)) {
bodyParam = _BodyParameter(
name: param.name,
type: param.type.getDisplayString(),
);
}
}
Again, we are using a checker to see if one of our parameters has @Body annotation. We can then get the type of body using param.type.getDisplayString().
Other examples can be found in the Github repo.
Tips and recommendations
Writing your first generator is a journey. You will make many mistakes and probably be very frustrated at times. I want to give you a few pointers that will make this a lot easier.
-
Documentation doesn’t really exist
This is a problem because you have nothing to refer to. Basically, the code in the repos IS the documentation. If you are just starting out, I would highly suggest looking at the Flutter Observable #35 episode on YouTube. It lays out a great foundation on how code generation should work and covers some topics from this article in greater depth.
-
Use AI to help you
Since there are a lot of moving parts to a generator, using AI to write the first version is a great choice to make sure you don’t forget something. Moreover,
analyzeris a huge package with lots of options, but not enough documentation. AI knowsanalyzera lot better then you do, so it makes sense to ask it how to extract some data or check something. I use Claude, but ChatGPT will do just fine. -
No best practices
Since there is no documentation, there are no best practices. If you are ever wondering what would be the best way to do something, you can always refer to other open-source code generation packages like
freezedorauto_route. Experiment and iterate on your codebase, and you will create your own best practices with time.
Conclusion
While it may initially seem daunting to create your own generator, understanding the core components - annotations, builders, and the generation process itself - makes it much more approachable. The example of building a Shelf endpoint generator demonstrates how code generation can significantly simplify common development tasks while maintaining type safety and compile-time checks.
As the Dart ecosystem continues to evolve with features like macros, code generation will remain a valuable tool in a developer’s arsenal, particularly for cases requiring new file generation or complex transformations. While the learning curve might be steep due to limited documentation, resources like existing open-source packages and AI tools can help guide you through the process of building your first generator.
If you have found this useful, make sure to like and follow for more content like this. To know when the new articles are coming out, follow me on Twitter and LinkedIn.