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 aBeanModulegetter, named after the variable with a$prefix ($appBeans), for the beans of the file's directory and below. A subdirectory with its own@ComponentScanis left to that scan, and its module is imported instead. - A library whose
mainhas@SpringBootApplication()(spring_boot_dart). The output covers the whole package:lib/and the directory ofmain(onlyexample/for an entry point underexample/). It also declaresspringApplication,runApplicationand the hot-reload functions, with the auto-configuration modules of every dependency (each starter'slib/spring_auto_configuration.dart) and the--dart-definekeys the application may read (below).
Into that file it writes:
- one
BeanDefinitionper bean: its types, scope, laziness, primary / fallback, qualifiers, aliases, order, depends-on, conditions, declared dependencies, thecreateandinjectclosures, lifecycle callbacks, event listeners and factory bean product types; @PropertySourcedeclarations,@Configurableconfigurers and introspection metadata (properties, methods, annotations), which theObjectMapper, validation and SpEL read, for each class as far as the build saw it used (below);- for
spring_boot_starter_data, oneEntityIntrospectionfor each@Tableclass and each class a@Querymethod returns (a DTO), held by oneEntityIntrospectionsbean per module: the properties, and closures that read them, call the constructor and set one, and no more. A@Tableclass has noBeanIntrospectionunless it is also@Introspected. A class the data layer could not create or fill (a required constructor parameter that names no field, afinalfield nothing sets) fails the build; - for
spring_boot_starter_data, a class for each repository (an interface extendingDataRepository): it extends the starter'sSimpleRepository, 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 constantPartTreeand 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@Querymethod 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 oflib/resources/) and written as a smallQueryMethod; its:#{...}SpEL values are compiled to Dart when the build can (#book.titelis a build error,#book.titleis(arguments[0] as Book).title), else left to the interpreter; @Aspectadvice metadata and the AOP proxy classes;- generated subclasses of full-mode
@Configurationclasses and of beans with@Lookupmethods; - the files of
lib/resources/, embedded so thatclasspath: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 whosemainis outsidelib/(example/,tool/demo/) also gets thelib/resources/of every directory from the package root down to its own; the nearer tomain, 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
@Pointcutexpression 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 (
@Sizeon anint,@NotBlankon aList), and invalid attributes (@Size(min: -1),min > max, malformed@DecimalMinvalues and regular expressions). - JSON: more than one
@JsonValuein 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 underflutter: assets:inpubspec.yaml. - Starters: a starter whose
requiresrange does not allow thespring_boot_dartversion in use. - Declarations:
@Componenton a mixin, enum, extension or extension type;@Beanon a top-level function;@ComponentScananywhere but on a top-level variable, or two@ComponentScanroots 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,@Beanreturn 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 buildfails, 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_runnerentry points ofspring_core_dart_generator. - spring_core_dart_generator
- The code generator of
spring_core_dart: abuild_runnerbuilder that turns annotated classes into plain Dart code at build time.