spring_core_dart_generator

No backward compatibility before 1.0. Any release may rename or change APIs, properties and behaviour; the breaking changes are listed in each package's CHANGELOG.

spring_core_dart_generator is the code generator of spring_core_dart. It reads the annotations of your classes (@Service, @Value, @Bean, @Aspect, ...) at build time and writes plain Dart code that creates and connects your objects.

Why it exists. Spring finds annotated classes at runtime with reflection. Dart has no reflection in Flutter, AOT executables or on the web (dart:mirrors is not available there), so this step happens at build time instead, through build_runner. Every application that uses spring_core_dart, spring_boot_dart or one of the starters needs it: without it the annotations do nothing. You never call it from your code; you only run build_runner.

The guide to the annotations themselves is the spring_core_dart README.

Installation

dependencies:
  spring_core_dart: ^0.6.0

dev_dependencies:
  build_runner: ^2.17.0
  spring_core_dart_generator: ^0.6.0

Mark a scan root: a top-level variable with @ComponentScan(). Every class in that file's directory and below is scanned.

// lib/src/beans.dart
import 'package:spring_core_dart/spring_core_dart.dart';
import 'package:my_app/src/beans.spring.dart'; // written by the generator

@ComponentScan()
final BeanModule appBeans = $appBeans;

Then generate, once or on every save:

dart run build_runner build
dart run build_runner watch

The generator writes beans.spring.dart next to beans.dart. Pass appBeans to the context: ApplicationContext.run([appBeans]). A runnable walk-through is in the example.

What it generates

The package registers three builders in its build.yaml; all apply automatically to every package that depends on it.

spring_core_dart (the main builder) writes <file>.spring.dart next to two kinds of library:

  • A library with a @ComponentScan() variable. The output holds a BeanModule getter, named after the variable with a $ prefix ($appBeans), for the beans of the file's directory and below. A subdirectory with its own @ComponentScan is left to that scan, and its module is imported instead.
  • A library whose main has @SpringBootApplication() (spring_boot_dart). The output covers the whole package: lib/ and the directory of main (only example/ for an entry point under example/). It also declares springApplication, runApplication and the hot-reload functions, with the auto-configuration modules of every dependency (each starter's lib/spring_auto_configuration.dart) and the --dart-define keys the application may read (below).

Into that file it writes:

  • one BeanDefinition per bean: its types, scope, laziness, primary / fallback, qualifiers, aliases, order, depends-on, conditions, declared dependencies, the create and inject closures, lifecycle callbacks, event listeners and factory bean product types;
  • @PropertySource declarations, @Configurable configurers and introspection metadata (properties, methods, annotations), which the ObjectMapper, validation and SpEL read, for each class as far as the build saw it used (below);
  • for spring_boot_starter_data, one EntityIntrospection for each @Table class and each class a @Query method returns (a DTO), held by one EntityIntrospections bean per module: the properties, and closures that read them, call the constructor and set one, and no more. A @Table class has no BeanIntrospection unless it is also @Introspected. A class the data layer could not create or fill (a required constructor parameter that names no field, a final field nothing sets) fails the build;
  • for spring_boot_starter_data, a class for each repository (an interface extending DataRepository): it extends the starter's SimpleRepository, which has the CRUD methods, and adds one line for each method of the interface, so nothing is parsed or described when the application starts. A derived query (findByTitleAndYear) is read by the build into a constant PartTree and checked against the entity, the parameters and the return type; a wrong name, an unknown property or a parameter that does not fit fails the build, with every other problem of the interface (BookRepository.findByTitel: ... did you mean 'title'?). A @Query method is checked as well (the method against its annotation, the text's parameters and {Entity.property} placeholders, and tables and columns misspelled against the entities and the migrations of lib/resources/) and written as a small QueryMethod; its :#{...} SpEL values are compiled to Dart when the build can (#book.titel is a build error, #book.title is (arguments[0] as Book).title), else left to the interpreter;
  • @Aspect advice metadata and the AOP proxy classes;
  • generated subclasses of full-mode @Configuration classes and of beans with @Lookup methods;
  • the files of lib/resources/, embedded so that classpath: locations work on every platform without Flutter asset declarations. Text files stay text, other files are base64-encoded, and files above 1 MB are skipped with a warning. An application whose main is outside lib/ (example/, tool/demo/) also gets the lib/resources/ of every directory from the package root down to its own; the nearer to main, the higher the precedence.

spring_test (the test builder) writes test/spring/<name>.test.spring.dart for every test/<name>_test.dart that declares @SpringBootTest or @DataTest classes. The file has a runSpringTests() function that registers the tests of those classes with package:test; the test's main calls it. A @DataTest class (of spring_boot_starter_data) starts a slice of the application, the data beans on an in-memory database, and runs each test in a transaction that is rolled back: the generated file calls the data starter's package:spring_boot_starter_data/testing.dart for it, so the starters stay independent. @Container() and @ServiceConnection() fields start Docker containers before the class's context and remove them after it. See spring_boot_starter_test and the testing guide (data tests, containers).

spring_flutter_web writes web_dev_config.yaml, the file flutter run reads on the web (Flutter 3.38 and later), from the spring.flutter.web.* properties of lib/resources/ (see Flutter below). Without those properties it does not load the config, and writes and reports nothing, whatever the config files hold (imports, placeholders an environment variable sets, a file that does not parse): a pure Dart package builds as if it were not there.

A short example of the main builder's output (simplified; the real file uses import prefixes such as _i1.):

// You write:
@Service()
class Greeter {
  Greeter(@Value(r'${greeting.suffix:!}') this.suffix, this.clock);
  final String suffix;
  final Clock clock;
}

// It generates:
BeanModule get $appBeans => BeanModule(r'appBeans', [
  BeanDefinition<Greeter>(
    name: r'greeter',
    types: const [Greeter],
    dependencies: const [
      Dependency.value(r'${greeting.suffix:!}'),
      Dependency(Clock, name: r'clock'),
    ],
    create: (r) => Greeter(
      r.value<String>(r'${greeting.suffix:!}'),
      r.dependency<Clock>(name: r'clock'),
    ),
  ),
]);

Plain constructor calls and closures: fast, available in AOT, Flutter and on the web, and unaffected by minification.

Generate only what runs

The build knows what reflection would know, so a class is described only for what the application does with it. Each use asks for some of the parts below; a class reached by several uses gets the union, and what it is made of (property types, constructor parameter types, Page<Dto>'s argument, the subtypes of @JsonSubTypes) takes the same. The annotations and the list of properties are always written.

Part What Written when
getters BeanProperty.get the class is written out: a controller's return type (also in Future, Stream, ResponseEntity, Page), an @ExceptionHandler's answer, a WebSocket reply, a client's argument, the static type of a value given to ObjectMapper.writeValueAsString, valueToTree or convertValue, or to a WebSocket session's sendJson and the registry's broadcast; @ConfigurationProperties
constructor and setters BeanConstructor, instantiate, set, fromJson the class is read in: @RequestBody and @RequestPart, a controller's return type and an @ExceptionHandler's answer (a test reads the answer back with mapper.readValue<BookDto>(res.body), and test/ is not scanned), a WebSocket message, a client's answer (Feign, @WebSocketClient), the type argument of ObjectMapper.readValue<T>, treeToValue, readList, readMap or convertValue<T>; @ConfigurationProperties
the getters of constrained properties get of the properties with a constraint, @Valid() or @Each() the class is validated: @RequestBody @Valid()
methods BeanMethod a SpEL expression may call them: @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter and @Cacheable, @CachePut, @CacheEvict (keys and conditions) over the arguments or the result they name, a @Query with a :#{...} the build does not compile; also the methods a JSON annotation marks (@JsonValue, @JsonAnyGetter with the getters, @JsonAnySetter with the setters)

The calls of the mapper count where the build sees their static types and the class is in the package of the call (a library that names none of those methods is not even resolved; sendJson and broadcast count only in a library that imports the WebSocket starter); a value of type Object or dynamic names no class.

@Introspected() keeps everything, and so does a class listed in @Introspected(classes: [...]) (and what they are made of): you asked for it. A @JsonView class is only known. A class with @JsonInclude(JsonInclusion.nonDefault) that is written out is also described for creation, since the mapper compares each value with the one of a new instance. A method of a custom @BeanMethodAnnotation() keeps everything of its types; @Scheduled, @Secured, and a WebSocket @OnOpen, @OnClose or @OnError describe nothing.

The description says what it holds (BeanIntrospection.parts). What the build did not see used is left out, and code that needs it anyway, such as a value cast to Object that is written as JSON, fails with a message that names the class, the missing part and the fix, never with an empty object:

Bad state: Book is described without its getters, but it is written out as
JSON. The build described only what it saw used, and did not see that: add
@Introspected() to Book.

New Dart syntax

The generator understands Dart 3.13 syntax: primary constructors (annotations on declaring parameters inject through the constructor, not as fields), class const, in-body new / factory constructor declarations, and private named parameters (passed under their public name).

--dart-define keys

flutter build --dart-define=SPRING_PROFILES_ACTIVE=prod passes values into the compiled program, but the program can only read them with a constant key (const String.fromEnvironment('KEY')); on the web and in AOT builds there is no other way. So the @SpringBootApplication output lists every property name the application may read, each also in its environment-variable form (shop.currency and SHOP_CURRENCY). The names come from the ${...} placeholders in annotation arguments, @ConditionalOnProperty names, the fields of @ConfigurationProperties classes, the keys and placeholders of the application* files in lib/resources/, and a few properties read before the config files.

@Log

A class of the scan with @Log() gets an extension extension $<Class>Log on <Class> with a getter (log, or the fieldName:) that returns const SpringLog('<package>.<Class>'), or the topic:. The build fails for a private class (an extension elsewhere cannot name it), an invalid or reserved getter name, or a member of the class or its supertypes with that name (it would win over the getter).

Flutter (spring_boot_starter_flutter)

For apps on spring_boot_starter_flutter. Its annotations are matched by URL, like those of the other starters, so the generator does not depend on that package. Nothing Flutter-specific is written for modules that do not use them.

@BuildContextAccess() on a bean class or a @Bean method adds a getter to extension SpringBeans on BuildContext. It is written into the .spring.dart file of the @SpringBootApplication or @ComponentScan (after the run and reload functions); import that file where widgets use the getters.

@Component()
@BuildContextAccess()
class Basket { ... }

// generated
extension SpringBeans on BuildContext {
  Basket get basket => SpringScope.beanOf<Basket>(this, qualifier: r'basket');
}

// in a widget
Text('${context.basket.count}');

The getter is named after the bean (@BuildContextAccess(name: 'cart') for another name) and typed with the bean's class: a @Bean method's declared return type, a FactoryBean's product. The build fails for a name that is not a public identifier, a reserved word, a member of BuildContext, Object or the starter's SpringBuildContext (bean, message, springContext), two beans with one getter name, or @BuildContextAccess on a class that is no bean.

Widgets with beans. Every class of the scan that extends SpringStatelessWidget<R> (or a State that extends SpringState<W, R>) with a concrete record type R gets a binding in one SpringWidgetBindings bean of the module (flutterSpringWidgetBindings#<module>, not an autowire candidate). The binding fills the record: positional fields are beans by type, named fields by type and then by the field's name among several candidates, nullable fields are optional (findBean). SpringApplicationWidget checks at startup that the required beans exist. The build fails for a field that is no class or interface type (dynamic, Object, functions, records), or a concrete widget whose R is a type parameter; an abstract base class with a type parameter is skipped.

@ChangeNotifierComponent and @NotifyListeners. The generated proxy of such a bean notifies the listeners after the marked methods and setters, so widgets rebuild when the bean's state changes. For a @ChangeNotifierComponent class that is not a ChangeNotifier itself, the proxy also mixes ChangeNotifier in. The build fails when a name in exclude is not a public method, setter or field of the class, or when a @ChangeNotifierComponent class that is not a ChangeNotifier declares one of ChangeNotifier's members itself.

The web dev server. The spring_flutter_web builder turns the spring.flutter.web.* properties (host, port, base-href, HTTPS certificate, headers, proxy rules) into web_dev_config.yaml in the package directory, so flutter run -d chrome serves the app on a fixed port without flags. It runs for every package that depends on the generator, pure Dart servers included, so it never fails a build, and says nothing, unless the app evidently uses the file: a config file it can read without loading the config (any .properties, .yml or .yaml file of lib/resources/, in any document, for any profile, or a file an import names with a file hint, [.yaml] or [encoding=iso-8859-1] (a file that is not UTF-8 is read only that way), its placeholders resolved with their defaults or the files' values) sets a spring.flutter.web.* key or spring.flutter.security.web.mode. An import whose name only the running app knows (classpath:${ENV}.conf[.yaml]) cannot be followed there, so keys that only its file sets do not count. Without one, the config is not loaded: nothing is written, a file generated before is deleted, and imports, placeholders or a file that does not parse are left to the app's start (except that while a generated file exists, a config file the app reads that does not parse fails the build and keeps it). With one, the config files are read as the app reads them in a browser: application.*, then application-web.*, the profiles that spring.profiles.active names in them and the files that spring.config.import names (relative or classpath: ones, from lib/resources/); environment variables and --dart-define values do not count. A placeholder only they can resolve is unknown at build time, never a build error: a profile name with one in spring.profiles.active, .include, .default or .group.* is left out, whatever else the name holds (dev,${ENV}-eu is read as dev; a key in any form the app reads, such as spring.profiles.group[prod] or spring.profiles[active]), spring.profiles.validate or spring.main.cloud-platform with one is read as unset, an import location with one is not read (the other entries of its ; group are), and a document whose spring.config.activate.* guard has one is skipped, each with a warning naming it. Where a key may be is still checked: spring.profiles.active in application-web.yaml fails the build whatever its value, as it fails the app's start. A placeholder in spring.flutter.security.web.mode, as in a spring.flutter.web.* value, must resolve at build time. A file:, configtree: or package: import (or an absolute path), with a wildcard or not, is not read from the build machine's disk, as a browser has none: it is skipped, optional: or not; when the properties give a file to write, a warning names the first required one as written (the web version would fail to start with it). An import the build cannot read (asset:, http:, https:, or a classpath: file missing from lib/resources/) is skipped silently, even a required one, as it may exist at run time. An invalid value, or a config file the app reads that does not parse, fails the build with the error; the generated file is then left as it is.

# lib/resources/application-web.yaml
spring:
  flutter:
    web:
      port: 5050

The file is no declared build output, because build_runner would overwrite a hand-written web_dev_config.yaml of that name and delete it once the builder stopped writing it. The builder manages the file itself and only touches one that starts with its # GENERATED FILE - DO NOT MODIFY BY HAND. header: it rewrites it while the properties are set and deletes it when they are gone. With the properties set and a hand-written file present, the build fails and asks to remove one of them. build_runner re-runs the builder when a file of lib/resources/ changes, not when web_dev_config.yaml does: after deleting the file, run dart run build_runner clean or change a config file. See the starter for the properties.

Options

# build.yaml of your package
targets:
  $default:
    builders:
      spring_core_dart_generator:spring_core_dart:
        options:
          aop_proxies: interfaces
Option Values Default
aop_proxies all: a proxy class for every bean whose class or one of its interfaces can be implemented (and subclass proxies for the classes that can only be extended); interfaces: proxies only through a bean's abstract supertypes, so a bean without one gets no proxy; none: no proxies, so AOP cannot advise the beans of the module all
aop_prune true: with aop_proxies: all, the @SpringBootApplication output (which knows every module of the application), and the @ComponentScan modules of an application package (publish_to: none: nothing outside imports them), generate no proxy class for a bean that no advice of the application can match; only the reason is generated (AopProxyFactory.pruned), so advice registered at run time without the build seeing it is not applied to such a bean. The @ComponentScan modules of a library package are never pruned, since any module may import them. false: every bean keeps its proxy class true

Any other value of either option fails the build with an ArgumentError.

The options only change how much code is generated: a bean without matching advice is never wrapped at runtime either way. Public fields are proxied as implicit getters and setters.

Pruning reads the advice of the application's other modules too (@Aspect classes, aspects returned by @Bean methods, and advisor beans whose @Bean method builds DefaultPointcutAdvisor(ExpressionPointcut( '<a constant expression>'), ...), AnnotationMatchingPointcut<A>() or a pointcut declaring its annotations), and the advisors of the auto-configurations: a starter's annotation pointcut declares the annotations it matches in a static const annotationTypes list (@Transactional, @Cacheable, method security, @Validated), and only the beans carrying one keep their proxy. Nothing is pruned when a module declares an advisor bean whose pointcut the build cannot read (an expression from a variable, a hand-written pointcut), or when an auto-configuration declares aspects or an advisor the build cannot read. Annotations of the SDK (@override) and of package:meta never keep a proxy.

Scan options

@ComponentScan itself takes Spring's scan options, evaluated at build time:

  • includeFilters / excludeFilters: Filter(classes: [Plugin]) for an annotation, Filter(type: FilterType.assignableType, classes: [Handler]) for subtypes, Filter(type: FilterType.regex, pattern: ['.*Repository']) on the class name. Excludes win; abstract classes never match.
  • useDefaultFilters: false: only the includes make beans.
  • lazyInit: true: every scanned bean is lazy unless @Lazy(false).

A filter without the argument its type uses, or with an invalid pattern, fails the build.

Build-time checks

The generator checks what it can before the app ever runs, and every error points at the source element:

@Service()
class Report {
  @Autowired()
  final Clock clock;
  // ✗ Injected field clock must be settable: declare it `late final` or non-final.
}

@Service()
class Shop {
  Shop(@Lazy() this.repo);
  final Repository? repo;
  // ✗ @Lazy on repo: a lazy dependency cannot be nullable; use
  //   ObjectProvider<Repository> for an optional lazy bean.
}

Among the checks:

  • AOP. Every advice and @Pointcut expression is parsed with the runtime's own parser, so a pointcut accepted here is accepted at startup. A syntax error fails the build with the aspect, the advice and the character position. A reference to a pointcut of a class outside the module is left to the runtime.
  • Validation constraints on a type they cannot check (@Size on an int, @NotBlank on a List), and invalid attributes (@Size(min: -1), min > max, malformed @DecimalMin values and regular expressions).
  • JSON: more than one @JsonValue in a class, or one on a member it cannot work on.
  • Flutter assets: a literal @Value('asset:<key>') whose asset does not exist or is not declared under flutter: assets: in pubspec.yaml.
  • Starters: a starter whose requires range does not allow the spring_boot_dart version in use.
  • Declarations: @Component on a mixin, enum, extension or extension type; @Bean on a top-level function; @ComponentScan anywhere but on a top-level variable, or two @ComponentScan roots in one directory; an empty, blank or space-padded bean name or alias; two unconditional beans with the same name; an ambiguous constructor choice; injecting or returning an extension type (erased at runtime).
  • Private declarations. Generated code cannot name a private declaration (_Foo, _default). A private bean class, @Bean return type, injected type (also as a type argument), event type or default value (a private function, class or constructor) fails the build with a message naming the declaration, its library and the fix (make it public, move it out of the scan, declare a public supertype, or drop the default), instead of a generated file that does not compile.

Independent problems of one build are reported together (N problems in this build:, each with its message, library URI and location) instead of one per build run.

Missing or ambiguous beans and dependency cycles depend on the whole application, so they are reported when the context starts, before any of your objects is created.

The generated code is free of analyzer warnings and of the package:lints recommended lints. type=lint is still ignored in it, because your own lint set may differ.

An error never deletes the generated file

An error in the sources never removes or changes an existing *.spring.dart. Otherwise build_runner watch would delete and regenerate it on every keystroke, and every file that imports it would turn red while you type. The existing file is read from the directory of its own package, also when build_runner build --workspace builds every package of a pub workspace from one directory.

  • While a scanned library has syntax errors, the builder skips generation, writes the existing file again unchanged and logs one warning per output until the next successful generation.
  • A generator error keeps the file too and is reported as the build error: build_runner build fails, the file just stays.
  • Without an existing output (a cold build), both fail the build.

Public API

lib/builder.dart has the three builder factories that build.yaml names; applications do not import it.

Factory Builder build.yaml entry
springCoreDartBuilder the main builder, with the aop_proxies and aop_prune options spring_core_dart: auto_apply: dependents, build_to: source, .dart → .spring.dart
springTestBuilder the test builder, no options spring_test: auto_apply: dependents, build_to: source, test/<name>_test.dart → test/spring/<name>.test.spring.dart
springFlutterWebBuilder the web_dev_config.yaml builder, no options spring_flutter_web: auto_apply: dependents, build_to: source, $package$ → no declared output (writes web_dev_config.yaml itself)

Dependencies

Package Why
analyzer The element model of the scanned libraries (moves with the Dart SDK)
build, glob The builder API; scanning directories
source_gen TypeChecker, ConstantReader, build errors
code_builder, dart_style Emitting and formatting code with prefixed imports
path Relative imports for scan roots outside lib/
pub_semver A starter's requires range against the spring_boot_dart version
yaml Reading pubspec.yaml (asset check) and the YAML config files (--dart-define keys)
spring_core_dart Its pointcut parser: the build parses pointcuts with the parser the application runs. The build script resolves packages with your package config, so this is your version of spring_core_dart

Annotations are matched by name and URL (package:spring_core_dart, spring_boot_dart, the starters), not imported, so the generator does not depend on spring_boot_dart or the starters.

Dev dependencies: build_test, test, coverage, package_config, and spring_boot_dart (generated code under test imports the runtime).

Structure

lib/
  builder.dart                  build_runner entry points and options
  src/
    spring_builder.dart         finds @ComponentScan / @SpringBootApplication, scans, writes output
    bean_scanner.dart           analyzer elements → models, validation
    bean_model.dart             intermediate models
    bean_type_inference.dart    the type a @Bean method really returns
    module_emitter.dart         models → Dart source
    configuration_scanning.dart full-mode @Configuration classes
    configuration_emitter.dart  their generated subclasses
    lookup_scanning.dart        @Lookup methods
    lookup_emitter.dart         their generated subclasses
    aop_analysis.dart           pointcut checks and proxy pruning
    constraint_checker.dart     validation constraints against their types
    json_annotation_checker.dart  @JsonValue and @JsonProperty checks
    log_scanning.dart           @Log
    flutter_scanning.dart       spring_boot_starter_flutter
    asset_check.dart            @Value('asset:...') against pubspec.yaml
    dart_define_keys.dart       --dart-define keys
    test_builder.dart           @SpringBootTest, @DataTest → test/spring/*.test.spring.dart
    web_dev_config_builder.dart spring.flutter.web.* → web_dev_config.yaml
build.yaml
test/                           generated output and every rejected declaration

Status

Covers every annotation of the runtime. About 96% of the lines are covered by tests.

License

Apache License 2.0.

Libraries

builder
The build_runner entry points of spring_core_dart_generator.
spring_core_dart_generator
The code generator of spring_core_dart: a build_runner builder that turns annotated classes into plain Dart code at build time.