Skip to main content

One post tagged with "build_runner"

View All Tags

Beyond build_runner: Implementing Dart Macros and Metaprogramming in Large-Scale Apps

Published: · 12 min read
Don Peter
Cofounder and CTO, Appxiom

Modern Flutter apps lean heavily on code generation for DTOs, dependency injection, providers, and mapping layers. In large repos, build_runner becomes a tax: slow rebuilds, watch-mode flakiness, generated-file churn in PRs, cache invalidation, and complex CI. Dart macros metaprogramming is the emerging Flutter build_runner alternative that moves generation into the compiler and analyzer - no sidecar build processes, no .g.dart files.

This post shows how to evaluate, adopt, and scale Dart macros in production Flutter codebases. We’ll cover when macros are the right fit, how they impact Dart code generation, Flutter serialization performance, and static analysis, and how to migrate incrementally from build_runner with minimal risk.

Note on versions and stability:

  • Dart macros are under active development. As of Dart 3.x, macros are available as a preview feature in the SDK and tooling. Expect APIs and flags to evolve; always check the official “Static Metaprogramming” docs for your specific Dart/Flutter channel.
  • The guidance below is designed for incremental adoption: you can keep shipping with build_runner while enabling macros for select features in parallel.

Why macros, and why now?

  • Compiler-integrated generation. Macros expand during compilation/analyzer phases, not via a separate process. This removes a whole class of “watch” and caching issues.
  • Fewer files, fewer conflicts. No checked-in .g.dart files (unless you choose to), which eliminates codegen churn in PRs and monorepo conflicts.
  • Faster edit-refresh in large apps. Incremental rebuilds happen inside the frontend server/analyzer without shelling out to build_runner.
  • Better Dart static analysis. Macros can emit structured diagnostics (errors, warnings, hints) at compile-time, enabling powerful design-guardrails beyond lints.

Typical high-impact areas in large Flutter apps:

  • Provider scaffolding (e.g., Riverpod)
  • Serialization and mapping
  • Boilerplate for equality, copy, union/sealed classes
  • Dependency registration and module wiring
  • Design-rule enforcement (diagnostics)

Prerequisites

  • Flutter 3.22+ and Dart 3.4+ recommended.
  • IDE with up-to-date Dart/Flutter plugins.
  • For macro authoring or using preview packages, you may need to opt into the macros experiment in your toolchain (consult the SDK release notes for flags on your channel).

Tip: Keep your existing build_runner workflows intact while piloting macros in a small, isolated module to validate tooling and CI.

How Dart macros metaprogramming works (in practice)

  • You add annotations like @Foo() to your types, methods, or fields.
  • At compile time, the Dart frontend/analyzer invokes macro code (in a separate package) to:
    • Augment your declarations (e.g., inject toJson, fromJson, providers, etc.).
    • Optionally emit diagnostics (e.g., “non-final field in an @immutable class”).
  • Generated augmentations don’t need to live as source files. They’re fed directly into the compiler/analyzer pipeline, which improves incremental build performance and reduces repo noise.

This is a conceptual replacement for many source_gen builders run by build_runner.

Where macros fit vs build_runner

Use macros when:

  • You want less infrastructure and fewer generated files.
  • You need compile-time guarantees or diagnostics that feel like “first-class” analysis errors.
  • You want faster incremental builds in very large projects.

Keep build_runner when:

  • You depend on packages that haven’t adopted macros yet (e.g., json_serializable today in many apps).
  • You need stable, fully documented generation paths across all channels, including CI that can’t enable experiments yet.

Most large teams will run both in parallel for some time.

A production-ready path: Riverpod macro adoption (no build_runner)

Many teams start with provider generation because it delivers immediate DX wins with minimal risk. Riverpod has been an early adopter of macros; depending on your Riverpod version, you can often use annotations without running build_runner in dev.

Example: fetching paginated articles with Dio + Riverpod macros.

Dependencies:

# pubspec.yaml
environment:
sdk: ">=3.3.0 <4.0.0"

dependencies:
flutter:
sdk: flutter
dio: ^5.5.0
riverpod: ^2.5.0
# Annotations for macros-based APIs (version may vary by channel)
riverpod_annotation: ^2.3.0

dev_dependencies:
flutter_test:
sdk: flutter
# Keep build_runner only if you still have generator-based features elsewhere.
build_runner: ^2.4.9

A DTO you can keep hand-written for now (or still use json_serializable while you pilot macros elsewhere):

// lib/features/articles/data/article.dart
class Article {
final String id;
final String title;
final String body;

const Article({
required this.id,
required this.title,
required this.body,
});

factory Article.fromJson(Map<String, Object?> json) => Article(
id: json['id'] as String,
title: json['title'] as String,
body: json['body'] as String,
);

Map<String, Object?> toJson() => {
'id': id,
'title': title,
'body': body,
};
}

Repository:

// lib/features/articles/data/article_repository.dart
import 'package:dio/dio.dart';
import 'article.dart';

class ArticleRepository {
final Dio _client;
const ArticleRepository(this._client);

Future<List<Article>> fetchPage({required int page, int pageSize = 20}) async {
final res = await _client.get<Map<String, Object?>>(
'/articles',
queryParameters: {'page': page, 'pageSize': pageSize},
);
final data = res.data?['data'] as List<dynamic>? ?? const [];
return data
.cast<Map<String, Object?>>()
.map(Article.fromJson)
.toList(growable: false);
}
}

Provider with Riverpod macros:

// lib/features/articles/providers.dart
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/article.dart';
import '../data/article_repository.dart';

part 'providers.g.dart';

// Dio singleton
@Riverpod(keepAlive: true)
Dio dio(DioRef ref) => Dio(BaseOptions(baseUrl: 'https://api.example.com'));

// Repository
@Riverpod(keepAlive: true)
ArticleRepository articleRepository(ArticleRepositoryRef ref) =>
ArticleRepository(ref.watch(dioProvider));

// Paginated list
@riverpod
Future<List<Article>> articles(ArticlesRef ref, {int page = 1}) async {
final repo = ref.watch(articleRepositoryProvider);
return repo.fetchPage(page: page);
}

Notes

  • With macros enabled in your toolchain, Riverpod will synthesize providers without build_runner.
  • Many teams keep build_runner in the repo for other features while gradually flipping provider code to macros. That’s safe and incremental.

UI:

// lib/features/articles/articles_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';

class ArticlesPage extends ConsumerWidget {
const ArticlesPage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(articlesProvider(page: 1));

return Scaffold(
appBar: AppBar(title: const Text('Articles')),
body: state.when(
data: (items) => ListView.builder(
itemCount: items.length,
itemBuilder: (c, i) => ListTile(
title: Text(items[i].title),
subtitle: Text(items[i].body, maxLines: 2, overflow: TextOverflow.ellipsis),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Error: $e')),
),
);
}
}

This is a concrete example of using a macros-backed feature today, in a way you can deploy and maintain.

Authoring a simple macro for compile-time checks (Dart static analysis)

Beyond generation, macros shine at guardrails. Here’s a small, practical macro that enforces DTO naming in large repos - useful when you’re standardizing layers.

Create a separate Dart package for your macro, e.g., naming_conventions_macro.

pubspec.yaml:

name: naming_conventions_macro
environment:
sdk: ">=3.3.0 <4.0.0"

dependencies:
macros: ^0.1.0

Macro implementation (API is subject to change between SDK releases):

// lib/naming_conventions.dart
import 'package:macros/macros.dart';

/// Annotate data-transfer objects to enforce a naming scheme in large repos.
class Dto implements ClassTypesMacro {
const Dto();

@override
Future<void> buildTypesForClass(
ClassDeclaration clazz,
TypeBuilder builder,
) async {
final name = clazz.identifier.name;
if (!name.endsWith('Dto')) {
// Emit a compile-time diagnostic (appears like analyzer error/hint)
builder.report(
Diagnostic(
DiagnosticMessage(
'Classes annotated with @Dto must end with "Dto".',
target: clazz.identifier,
),
Severity.error,
),
);
}
}
}

Use it in your app:

# app/pubspec.yaml
dependencies:
naming_conventions_macro:
path: ../naming_conventions_macro
// lib/shared/models/user_model.dart
import 'package:naming_conventions_macro/naming_conventions.dart';

@Dto() // This will fail the build if the class name doesn’t end with Dto
class User { // <- change to UserDto to satisfy the macro
final String id;
const User(this.id);
}

What this demonstrates:

  • “Dart static analysis” with macros lets you enforce architectural rules at compile time without maintaining a custom analyzer plugin.
  • In large-scale apps, these checks stop drift (e.g., domain vs DTO vs entity suffixes), improving readability and refactor safety.

Enabling in your toolchain:

  • Depending on your Dart/Flutter channel, you may need to enable the macros experiment to see diagnostics during dart analyze, flutter test, or IDE analysis. Consult the release notes for your SDK version.

What about Dart code generation for JSON? Serialization performance

Serialization is the heaviest codegen footprint in most apps. Today, json_serializable + build_runner is production-proven and fast at runtime, but can be slow to rebuild and noisy in repos. Macros promise equivalent runtime performance with a better dev story.

Options you can adopt now:

  • Keep json_serializable for DTOs while you move providers/DI to macros.
  • For hand-written mappers (low-volume DTOs), write simple fromJson/toJson as shown above.
  • Track packages adding macros support (e.g., Riverpod) and migrate those first.

Runtime performance considerations:

  • Whether generated via build_runner or macros, serializer speed is dominated by the generated Dart. A good macro-based serializer that directly reads maps and casts (as T) will match json_serializable and blow reflection-based approaches out of the water.
  • For hot paths, favor:
    • Final fields and const constructors where possible.
    • Avoid intermediate maps; parse directly from Map<String, Object?>.
    • Prefer typed lists and pre-allocated buffers for large batches.

Micro-benchmark (works without macros; shows structure you can adapt):

// test/serialization_benchmark_test.dart
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';

class Article {
final String id;
final String title;
final String body;
const Article({required this.id, required this.title, required this.body});

factory Article.fromJson(Map<String, Object?> json) => Article(
id: json['id'] as String,
title: json['title'] as String,
body: json['body'] as String,
);

Map<String, Object?> toJson() => {
'id': id,
'title': title,
'body': body,
};
}

void main() {
test('serialization throughput', () {
final rnd = Random(42);
final data = List.generate(
10000,
(i) => {
'id': '$i',
'title': 'T$i',
'body': 'B${rnd.nextDouble()}',
},
growable: false,
);

final sw = Stopwatch()..start();
final articles = data.map(Article.fromJson).toList(growable: false);
sw.stop();

// Simple sanity assertion + timing log
expect(articles.length, 10000);
// Use print so results appear in test logs
// On modern devices this should be well under ~20ms.
// This represents the cost floor a macro-generated serializer should match.
// ignore: avoid_print
print('fromJson 10k items: ${sw.elapsedMilliseconds}ms');
});
}

As macros-based JSON solutions mature, they should hit the same numbers as the hand-written code above without any build_runner process.

Incremental migration plan for large apps

  1. Stabilize your baseline

    • Freeze build_runner and generator versions.
    • Record current CI times (analyze, test, build) to compare against macros.
  2. Pilot macros on a low-risk vertical

    • Providers (e.g., Riverpod macros) are a great first target.
    • Keep DTOs on json_serializable for now.
  3. Codify guardrails with diagnostics

    • Introduce one or two simple macros to enforce architectural rules (naming, immutability, layer boundaries).
    • Measure how often they catch issues vs generate noise.
  4. Phase out codegen files where safe

    • For features supported by macros, stop committing .g.dart artifacts.
    • Update contributor docs and CI (no more “please run build_runner”).
  5. Track ecosystem readiness for JSON and DI

    • Migrate DTOs once a macro-based serializer meets your needs and is test-hardened.
  6. Bake it into CI

    • Ensure dart analyze, dart test, and flutter test pick up macro diagnostics in your channel.
    • Run a nightly job on the latest dev/beta to catch macro/tooling regressions early.

Tooling, testing, and CI notes

  • Editor support: Keep your Dart/Flutter plugins up to date to see macro-driven diagnostics and navigation in the IDE.
  • Hot reload/hot restart: Macro augmentations may require a restart to take effect depending on your channel/tooling.
  • Testing: Treat macro-generated behavior as ordinary code. Write unit tests for DTOs, providers, and modules the same way. Avoid snapshot-testing macro outputs; instead, assert functional behavior.
  • CI flags: If your channel requires an experiment flag for macros, configure it in your CI runners consistently for analyze, test, and build steps. Pin SDK versions in CI for reproducibility.

Common pitfalls and how to fix them

  • “This SDK/channel doesn’t support macros”
    • Solution: Either enable the macros experiment for your channel or gate macros usage behind a feature flag while staying on build_runner.
  • Duplicate members (method already defined)
    • You’re mixing generator output and macro augmentations for the same type. Remove the generator or disable the macro for that target.
  • Macro packages pinned to older macros API
    • Align versions across all macro-using packages. Prefer caret constraints with a narrow upper bound, and audit changelogs before bumping.
  • Slower-than-expected rebuilds
    • Check you haven’t left build_runner watch running in parallel. Also reduce unnecessary “catch-all” macro annotations on large class graphs.

Architecture patterns that pair well with macros

  • Clean Architecture and modularization
    • Macros enforce module boundaries (diagnostics) and keep domain entities free of framework details.
  • Riverpod/BLoC
    • Macros remove provider/bloc boilerplate and centralize wiring.
  • DI containers
    • Registration macros reduce manual wiring in large feature modules.
  • DTO mapping
    • Macro-based serializers keep mapping code local, fast, and free of generator artifacts.

Frequently asked questions

  • Are macros faster at runtime than build_runner code?
    • Runtime performance depends on the generated code, not the mechanism. Properly-written macro output matches generator output.
  • Do macros work in release/AOT builds?
    • Yes, macro expansion happens at build time. You ship only the resulting program, not the macro runner itself.
  • Can I delete all .g.dart files now?
    • Only for features handled by macros. Keep .g.dart where generators are still in use.

Key takeaways

  • Dart macros metaprogramming is a practical Flutter build_runner alternative for many workflows, especially providers/DI and compile-time design rules.
  • Start with low-risk, high-DX areas (e.g., Riverpod macros). Keep build_runner only where needed.
  • Expect equal runtime performance for Dart code generation (including Flutter serialization performance) once macro-based serializers mature for your stack.
  • Use macros to enhance Dart static analysis: emit meaningful diagnostics that protect your architecture at compile time.
  • Migrate incrementally, pin toolchains, and measure before/after to validate the investment.

Next steps:

  • Pilot a macros-backed provider flow in one feature module.
  • Add one compile-time diagnostic macro that enforces a rule your team repeatedly reviews in PRs.
  • Track updates to macro-enabled ecosystems (Riverpod, DI, serializers), and expand usage as they stabilize on your channel.