# From Annotations to Generation: Building Your First Dart Code Generator

> How Dart code generation works with build_runner and source_gen, and a step-by-step example building an annotation-based Shelf endpoint generator.

URL: https://dinkomarinac.dev/blog/from-annotations-to-generation-building-your-first-dart-code-generator/
Author: Dinko Marinac
Published: 2025-01-02
Tags: dart, flutter, codegeneration

## Introduction

Code generation is probably one of the most hated parts of being a Flutter developer.

We all know the scenario:

1. Add 1 property to the model
    
2. Run `dart run build_runner build --delete-conflicting-outputs`
    
3. You get slapped with this:
    
    ![build_runner terminal output: build succeeded after 1m 14s with 521 outputs (4200 actions)](https://dinkomarinac.dev/images/posts/from-annotations-to-generation-building-your-first-dart-code-generator/from-annotations-to-generation-building-your-first-dart-code-generator-0.png)
    

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](https://github.com/dart-lang/sdk/issues/44489) (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](https://dart.dev/language/macros#use-cases), 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_builder` and `auto_route_generator` help manage navigation
    
* **Dependency Injection**: Tools like `injectable_generator` facilitate service location
    
* **Model Utilities**: `freezed` generates immutable model classes
    
* **Serialization**: `json_serializable` handles JSON conversion
    
* **Asset Management**: `flutter_gen` manages asset references
    
* **Network Layer**: `retrofit` and `chopper` simplify API client creation
    
* **Platform Channel Communication**: `pigeon` handles 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:

1. An annotation package defining the annotations (eg. `@freezed`) and the util classes
    
2. 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:

```dart
class Http {
	const Http();
}
```

They can be applied to:

* Classes
    
* Methods
    
* Fields/variables
    
* Constructors
    
* Functions
    
* Libraries
    

```dart
// 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 the`meta` package while defining the annotation.

```dart
@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:

1. **build**: Provides the foundation for code generation
    
2. **build\_runner**: CLI tool that executes the generation process
    
3. **source\_gen**: Offers utilities and builders for code generation
    
4. **analyzer**: Enables static analysis of Dart code
    
5. **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.dart` files
    
* `PartBuilder` - used for generating`.extension.dart` files, you decide on the extension
    
* `Generator` - used for generating standalone `.dart` files
    

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`.

```yaml
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 local `lib/builder.dart` file
    
* `builder_factories: ["shelfRouteBuilder"]` - Names the factory function that creates the builder, must match a top-level function in `builder.dart`
    
* `build_extensions: {".dart": [".route.dart"]}` - For each `.dart` input file, generate a `.route.dart` file
    
* `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:

```dart
@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:

```dart
// 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](https://github.com/dinko7/shelf-code-generator-example/tree/master), I’ll just show the important parts to keep the length of the article manageable.

Let’s define our annotations:

```dart
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:

```dart
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:

```dart
_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:

1. `TypeChecker.fromRuntime(Http)` creates a checker that can find annotations of type `Http`
    
2. `firstAnnotationOf(method)` finds the first matching annotation on the provided method
    
3. `ConstantReader` wraps the annotation object to provide easy access to its values
    
4. `reader.read()` extracts specific fields from the annotation
    
5. For enums, use `revive().accessor` to get the enum value
    
6. For strings, use `stringValue` to get the string value
    

Another example is the `@Body` annotation where we have to get the class name:

```dart
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](https://github.com/dinko7/shelf-code-generator-example/tree/master) 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.

1. ***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](https://www.youtube.com/watch?v=ngUiuIdcGjk) 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.
    
2. ***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, `analyzer` is a huge package with lots of options, but not enough documentation. AI knows `analyzer` a 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.
    
3. ***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 `freezed` or `auto_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**](https://twitter.com/dinkomarinac) and [**LinkedIn**](https://www.linkedin.com/in/dinko-marinac/).
