spring_boot_dart 0.5.0
spring_boot_dart: ^0.5.0 copied to clipboard
Spring Boot for Dart and Flutter: @SpringBootApplication, SpringApplication.run, @ConfigurationProperties, auto-configuration and starters, runners, failure reports.
spring_boot_dart #
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.
The part of Spring for Dart that starts an application. With
spring_core_dart alone you have a container of
beans (your services, created and wired together), but you still write
the start-up yourself: reading configuration, creating the container,
running start-up work. spring_boot_dart does all of it from one
annotation on main:
- it reads the configuration from files, environment variables and the
command line, with profiles such as
devandprod; - it creates the container with every bean of your package, and adds the beans of the libraries ("starters") you depend on, such as a web server, logging or a cache;
- it binds groups of settings into typed classes and validates them;
- it runs your start-up tasks, publishes start-up events, and closes everything on shutdown;
- when something cannot start (a missing bean, a bad property), it stops with a report that says what is wrong and what to do.
Use it for any application built on spring_core_dart: a server, a
command-line tool, a Flutter app. It is Spring Boot for Dart. It
exports spring_core_dart, so one import is enough, and it depends on
nothing else.
dependencies:
spring_boot_dart: ^0.5.0
dev_dependencies:
build_runner: ^2.17.0
spring_core_dart_generator: ^0.5.0
In the guides: server, 1. The application, 2. Configuration.
The whole application entry point #
// bin/main.dart (or lib/main.dart in Flutter)
import 'package:spring_boot_dart/spring_boot_dart.dart';
import 'main.spring.dart'; // generated next to this file
@SpringBootApplication()
void main(List<String> args) => runApplication(args);
Dart has no runtime reflection, so dart run build_runner build does
the scanning at build time. It reads the whole package (lib/ and the
directory of main; no @ComponentScan, no lists of classes) and
writes main.spring.dart with the beans, the files of lib/resources/,
the auto-configuration of the dependencies, and the runApplication and
runApplicationAsync functions.
runApplication(args) is Spring Boot's SpringApplication.run. It
goes through these steps:
- Sources of properties: the command-line arguments
(
--key=value) and the OS environment variables. - Config files:
application.propertiesorapplication.ymlfrom the classpath (lib/resources/), then theapplication-{profile}files, then the files next to the program (./,./config/,./config/*/), which override the packaged ones as in Spring Boot. Imports and profile groups are followed. Placeholders are resolved:${ENV_VAR:default},${random.uuid},${random.int[1,10]}. Names are relaxed:app.read-timeoutalso findsapp.readTimeout,app.read_timeoutandAPP_READTIMEOUT. Indexed keys work too (spring.profiles.include[0],SPRING_PROFILES_INCLUDE_0_), and a list comes whole from the highest source that has it. Placeholders in a file's imports and on-profile guards see the command line first; anoptional:import whose placeholder cannot be resolved is skipped. - Log levels:
logging.level.root=debugand the like are applied toSpringLog. - The context: the container is created and filled, with
ApplicationArgumentsas a bean and the@ConfigurationPropertiesclasses bound. - The report: start-up information is logged at INFO, as in Spring
Boot (
Starting neon-books using Dart 3.13.5 with PID 4242,The following 1 profile is active: "dev",Started neon-books in 0.07 seconds;spring.main.log-startup-info=falseturns it off). If the start fails, anAPPLICATION FAILED TO STARTreport explains a missing bean, an ambiguity, a cycle, an unresolved placeholder or invalid properties. Your ownFailureAnalyzers, as beans or inSpringApplication(failureAnalyzers:), are asked first. - The runners:
ApplicationRunnerandCommandLineRunnerbeans run one after another.
Beans that finish starting only after the refresh (an AsyncStartup,
such as the web starter's server, which binds its port asynchronously)
are waited for before step 5's Started ... line, the
ApplicationStartedEvent and the runners: a server that cannot start
(a port in use) fails the start as in Spring Boot, with no runner and no
ApplicationReadyEvent.
Application events are published along the way (see Application events).
Runners: work at start-up #
A runner is a bean whose run method is called once the application
has started: database migrations, a warm-up, a command-line task.
Runners run one after another in @Order, as in Spring Boot. A runner
that returns a Future is finished before the next one starts. A
failing runner is reported (Application run failed) and the context is
closed.
// The schema must be up to date before anything else touches the database.
@Component()
@Order(1)
class Migrations implements ApplicationRunner {
Migrations(this.db);
final Database db;
@override
Future<void> run(ApplicationArguments args) => db.migrate(); // awaited
}
@Component()
@Order(2)
class Warmup implements ApplicationRunner { ... } // starts after Migrations
CommandLineRunner.run gets the raw arguments; ApplicationRunner.run
gets them parsed (--mode=batch options and plain values).
runApplication returns once the synchronous part of the start is done;
the runners then go on one after another (after the web server
listens, when there is one). await runApplicationAsync(args)
completes after the last runner, as Spring Boot's run does.
Shutdown and exit codes #
A server or a scheduler runs until it is stopped. To stop it cleanly on
Ctrl+C or kill, the context must close when the process gets
SIGINT or SIGTERM: then @PreDestroy methods and ContextClosedEvent
listeners run, and the process ends with 130 or 143. Turn it on with
spring.main.register-shutdown-hook=true or
SpringApplication(registerShutdownHook: true); the property wins. It
uses the context's registerShutdownHook().
Unlike in Spring Boot, it is off by default. A Dart program that
listens for signals does not end by itself, so a program that has
finished its work would never exit. Turn it on in programs that run
until stopped (servers, scheduled tasks); the listener goes away when
the context closes. It listens through dart:io and does nothing on the
web.
Exit codes. A command-line tool often has to tell its caller how it
went. SpringApplication.exit(context) closes the context and returns
the exit code, as in Boot 3+. The code comes from the generators passed
as the second argument and from the ExitCodeGenerator beans, sorted by
order (Ordered, @Order): the first non-zero code wins. A generator
that throws gives 1, unless a later one has a code. A code other than
0 is also published as an ExitCodeEvent before the context closes.
// The job tells the shell whether some items failed.
@Component()
class FailedJobs implements ExitCodeGenerator {
int failed = 0;
@override
int getExitCode() => failed > 0 ? 2 : 0;
}
Future<void> main(List<String> args) async {
final context = await runApplicationAsync(args);
exit(SpringApplication.exit(context)); // dart:io
}
When a runner fails, ExitCodeExceptionMapper beans map the exception
to a code; the exception may also implement ExitCodeGenerator itself.
The code is published as an ExitCodeEvent and returned by
SpringApplication.exitCodeOf(error), for main to pass to exit.
Resources (lib/resources/ = the classpath) #
Configuration files, message bundles and templates go into
lib/resources/, which plays the role of Java's classpath:
lib/resources/
application.properties (or application.yml)
application-prod.properties
messages/messages.properties
templates/welcome.txt
The generator embeds these files into the generated code, so
classpath: works the same with dart run, in AOT executables, in
Flutter and on the web, with no asset declarations in pubspec.yaml.
Files above 1 MB are skipped with a warning: ship those as assets.
// The welcome mail's text, read from lib/resources/templates/welcome.txt.
@Component()
class Mailer {
Mailer(@Value('classpath:templates/welcome.txt') this.template);
final Resource template;
}
Why lib/resources/ and not a top-level resources/: build_runner
only sees lib/, bin/, test/, ... without extra configuration.
@ConfigurationProperties #
Reading settings one by one with @Value gets tedious when a feature
has several of them. A @ConfigurationProperties class gathers every
property under a prefix into one typed object, converted to the field
types, and is injected like any bean:
@ConfigurationProperties('shop')
@Validated()
class ShopProperties {
@NotBlank()
String name = 'NEON Books';
Duration deliveryTime = const Duration(days: 3); // shop.delivery-time=2d
@Min(1)
int maxBasketSize = 10;
}
maxBasketSize is bound from shop.max-basket-size, or
shop.maxBasketSize, --shop.max-basket-size=… or
SHOP_MAX_BASKET_SIZE. With @Validated(), the constraints are checked
at start-up, as in Spring Boot, and a violation stops the start. The
class is a bean and is introspected automatically.
Lists, maps and nested objects #
They bind as with Spring Boot's Binder:
@ConfigurationProperties('app')
class AppProperties {
List<String> hosts = []; // app.hosts=a,b or app.hosts[0]=a
List<Server> servers = []; // app.servers[0].name=eu-1
Map<String, String> headers = {}; // app.headers.X-Shop=1, app.headers[x.y]=2
Map<String, Backend> backends = {}; // app.backends.eu.url=https://...
}
app:
hosts: [a, b] # YAML lists are indexed keys: app.hosts[0], ...
servers:
- name: eu-1
- A list comes whole from the highest-precedence source that has
it: a comma-separated value, or indexed keys (
APP_HOSTS_0_as an environment variable). So--app.hosts[0]=xreplaces a three-element list fromapplication.yml; it does not change its first element. Indexes start at 0 without gaps. - Map entries are merged across sources, the higher source winning
per key. Keys keep the form they are written in (
X-Shop),[x.y]keeps the dots, and for scalar values the rest of the name is the key (app.headers.a.b=1gives the keya.b). - Elements, keys and values are converted through the conversion
service (
List<Duration>,Map<String, int>, enums by lenient name). A value that cannot be converted stops the start and names the property (app.ports[0]: failed to convert to int (was "x")).
Immutable classes: constructor binding #
A class whose only constructor has parameters is bound through that constructor, also for nested classes. This is Boot 3's default for immutable classes:
@ConfigurationProperties('pool')
class PoolProperties {
const PoolProperties(
this.url, { // required: pool.url must be set
@DefaultValue('10') required this.maxSize,
this.timeout = const Duration(seconds: 5),
this.owner, // nullable: null when not set
this.replicas = const [], // pool.replicas[0].host=...
});
final String url;
final int maxSize;
final Duration timeout;
final String? owner;
final List<Endpoint> replicas;
}
A parameter without a property takes its @DefaultValue, else its Dart
default value, else null when it is nullable. A required non-nullable
parameter without any of them stops the start (pool.url: no value bound for the required constructor parameter 'url' of PoolProperties ...). A
nullable nested object none of whose own keys is set stays null.
More binding #
- On a
@Beanmethod,@ConfigurationProperties('pool')binds the object the method returns, even a class of another package (the generator writes its introspection).@Validated()on the method validates it. - Another property name:
@Name('import')binds a field or constructor parameter from that name. - Units for plain numbers:
@DurationUnit(ChronoUnit.seconds)and@DataSizeUnit(DataUnit.megabytes).DataSizeproperties read10MBor512KB. - Environment variables bind nested objects under dashed names
(
APP_MAIN_SERVER_HOSTgivesapp.main-server.host). - Strict binding:
@ConfigurationProperties('db', ignoreUnknownFields: false)stops the start for properties under the prefix that bind to nothing (The elements [db.urll] were left unbound.), which catches typos; OS environment variables are not checked.ignoreInvalidFields: truekeeps the default of a value that cannot be converted instead of failing. - Validation: a
Validatorbean namedconfigurationPropertiesValidatorchecks every bound object it supports;@Validated()classes are checked against their constraints; and a properties class that is itself aValidatorvalidates itself. - Without a class:
Binder.get(environment).bind('app.hosts', const ListType(ClassType<String>()))binds one property anywhere. TheBindResulthasmap,ifBound,orElse,orElseGetandorElseThrow, and aBindHandlercan be told about each property.
Start-up settings #
Changing the environment before the context exists.
SpringApplication(environmentPostProcessors: [...]) is Boot's
EnvironmentPostProcessor: code that adds or changes property sources
and profiles. One ordered before
EnvironmentPostProcessor.configDataOrder runs before the config files
are read, so it can add imports, profiles and sources; the others run
after them.
JSON in one variable. SPRING_APPLICATION_JSON (or
--spring.application.json) adds its JSON as properties: {"app": {"hosts": ["a"]}} is app.hosts[0]. It sits below the command line and
above the OS environment, as in Boot. Handy in containers, where one
variable is easier to pass than many.
The banner is printed at start-up: SpringBootBanner, a
classpath:banner.txt or spring.banner.location, or
SpringApplication(banner:). spring.main.banner-mode (or bannerMode:)
is console (the default), log or off. With runAsync, a
banner.txt that can only be read asynchronously, such as a Flutter
asset, is printed too.
Lazy start. spring.main.lazy-initialization=true creates every
singleton on first use instead of at start-up, which shortens the start.
A LazyInitializationExcludeFilter bean keeps the beans it excludes
eager: LazyInitializationExcludeFilter.forBeanTypes([CacheWarmer]).
Overriding. spring.main.allow-bean-definition-overriding=true lets
a bean definition replace another of the same name; it is off by
default, so a duplicate name stops the start.
Validation #
Every application gets validation without setup, as with Boot's
ValidationAutoConfiguration:
defaultValidator, aBeanValidatorthat checks constraint annotations such as@NotBlank(). InjectValidatorto validate objects by hand. Your ownBeanValidatorbean replaces it; a bean that only implements the plainValidatorinterface does not.methodValidationPostProcessor, which checks the parameters and return values of the methods of@Validated()beans. It uses the singleBeanValidatorbean, or a new one when there is none or several.@ConfigurationPropertiesclasses are left out: there,@Validated()means validation after binding.
The default validator has no message source, so a {key} in a
constraint message stays as it is instead of being looked up in
messages.properties.
JSON: the ObjectMapper bean #
Every application gets an ObjectMapper bean to read and write JSON,
set up from the spring.jackson.* properties as with Spring Boot's
JacksonAutoConfiguration. Inject it anywhere; the web starter uses the
same bean for request and response bodies. It is named
jacksonJsonMapper (Boot 4's name). It knows every @Introspected
class of the application, and adds every JsonSerializer and
JsonDeserializer bean (Boot's @JsonComponent) and every JsonModule
bean. Declare your own ObjectMapper bean to replace it completely.
// Exports and imports the catalog as JSON files.
@Service()
class Catalog {
Catalog(this.mapper);
final ObjectMapper mapper;
String export(List<Book> books) => mapper.writeValueAsString(books);
List<Book> import(String json) => mapper.readList<Book>(json);
}
| Property | Default | Meaning |
|---|---|---|
spring.jackson.property-naming-strategy |
LOWER_CAMEL_CASE |
SNAKE_CASE, UPPER_SNAKE_CASE, UPPER_CAMEL_CASE, KEBAB_CASE, LOWER_CASE, LOWER_DOT_CASE |
spring.jackson.default-property-inclusion |
always |
non_null, non_absent, non_empty, non_default |
spring.jackson.deserialization.fail-on-unknown-properties |
false |
reject JSON properties a class does not have |
spring.jackson.deserialization.accept-float-as-int |
true |
1.5 into an int truncates; false rejects it |
spring.jackson.deserialization.fail-on-null-for-primitives |
true |
a JSON null (or a missing constructor value) for a non-nullable int, double, num or bool fails, as in Jackson 3; false reads 0 or false |
spring.jackson.datatype.datetime.write-dates-as-timestamps (Boot 4) or spring.jackson.serialization.write-dates-as-timestamps (Boot 3; the former wins) |
false |
DateTime as epoch seconds with the fraction (Jackson 3) |
spring.jackson.datatype.datetime.write-durations-as-timestamps (or serialization.) |
false |
Duration as seconds |
spring.jackson.datatype.datetime.write-date-timestamps-as-nanoseconds (or serialization.) |
true |
false: timestamps in milliseconds |
spring.jackson.datatype.datetime.read-date-timestamps-as-nanoseconds (or deserialization.) |
true |
integer timestamps are seconds; false: milliseconds |
spring.jackson.mapper.default-view-inclusion |
false |
properties without @JsonView are written with a view |
spring.jackson.serialization.indent-output |
false |
pretty-print |
spring.jackson.mapper.sort-properties-alphabetically |
true |
properties sorted by JSON name, constructor parameters first (Jackson 3's and Boot 4's order); false: declaration order |
spring.jackson.time-zone |
each date in its own zone | UTC or a fixed offset such as +02:00 |
A boolean property that is not true counts as false, so a typo such
as ture silently turns a feature off. Any other spring.jackson.*
key (Boot binds Jackson's feature flags, date-format, locale, ...)
is logged as a warning naming the key and ignored. Dart has no time zone
database, so a named zone such as Europe/Berlin stops the start with a
message saying so.
To change one thing without losing the properties, declare a
JsonMapperBuilderCustomizer bean (Boot 4's). Customizers run after the
properties are applied, in @Order, so they win over them:
// Pretty JSON, whatever the properties say.
@Bean()
JsonMapperBuilderCustomizer indented() =>
JsonMapperBuilderCustomizer.of(
(builder) => builder.enable(SerializationFeature.indentOutput),
);
objectMapperFromProperties(environment) builds the same mapper by
hand, for a tool without beans, a test, or a second mapper with other
settings.
Auto-configuration and starters #
A starter is a library that brings ready-made beans: add it to
pubspec.yaml, rebuild, and its feature is there with no code of yours.
It ships them as auto-configuration in lib/spring_auto_configuration.dart,
the Dart counterpart of Spring Boot's
META-INF/spring/...AutoConfiguration.imports:
// lib/spring_auto_configuration.dart of package `neon_cache`
import 'package:spring_boot_dart/spring_boot_dart.dart';
import 'src/cache.spring.dart';
@AutoConfigurationModule()
final BeanModule neonCacheAutoConfiguration = $neonCacheBeans;
// lib/src/cache.dart
@AutoConfiguration()
class CacheAutoConfiguration {
@Bean()
@ConditionalOnMissingBean(Cache) // the application's own Cache wins
Cache cache() => InMemoryCache();
}
The generator of every application that depends on the library,
directly or through other packages, finds these modules and passes them
to the generated springApplication; nothing is listed by hand. They
come after the application's classes, so @ConditionalOnMissingBean
backs off when the application declares its own bean.
Conditions #
Conditions decide whether a bean or a configuration class is registered at all:
| Condition | Registers when |
|---|---|
@ConditionalOnBean, @ConditionalOnMissingBean |
a bean exists, or does not. They take a type, .named('...'), or .where(types:, names:, annotations:, search:) (ignored: too for the missing one). Without arguments, they look for the @Bean method's return type. search: SearchStrategy.current looks at this context only, not its parents |
@ConditionalOnSingleCandidate |
exactly one bean of a type exists, or one is primary |
@ConditionalOnProperty |
a property has a value (havingValue:, matchIfMissing:, prefix:; several names with .names([...])) |
@ConditionalOnBooleanProperty |
a property is true |
@ConditionalOnExpression |
a SpEL expression is true (placeholders resolved first) |
@ConditionalOnResource |
a resource exists |
@ConditionalOnCloudPlatform |
the application runs on a platform: CloudPlatform.kubernetes, heroku, cloudFoundry, azureAppService, amazonEcs, ..., detected from environment variables as in Boot, or set with spring.main.cloud-platform |
Bean conditions (@ConditionalOnBean, @ConditionalOnMissingBean,
@ConditionalOnSingleCandidate) depend on what was registered before
them, so their timing matters. As with Boot's OnBeanCondition, they
are decided when the configuration class is read (Spring's
registerBean phase), class by class. A scanned @Component and an
@Imported class count as classes too, at their place in that order. By
then every earlier class has registered its @Bean methods and run its
@Imported BeanRegistrar or ImportBeanDefinitionRegistrar, so a
bean registered in any of those ways counts. A component declared
before the class whose bean it looks for is decided first, as in
Spring.
On a class, a condition decides the class, its @Bean methods and its
imports at once, all or nothing, as in Spring. A skipped class runs no
registrar, and what it selects is not registered. A class whose
condition looks for the bean its own imported class defines stays
registered (Boot's PooledDataSourceConfiguration pattern). Its
@PropertySources load anyway, because they are read while parsing, as
in Spring.
Like Boot 3.4+, beans declared with autowireCandidate: false or
defaultCandidate: false count for neither @ConditionalOnBean nor
@ConditionalOnMissingBean.
To see why a bean is there or not, start with --debug (or
debug=true): the condition evaluation report (ConditionEvaluationReport,
with the positive and negative matches) is logged at INFO after the
refresh or a failed start.
Order #
Auto-configurations are registered in Boot's order
(AutoConfigurationSorter): by order (@AutoConfigureOrder in Boot;
0 by default), modules with the same order by name, then before and
after move them. A cycle stops the start with AutoConfigure cycle detected between .... A module is named like its BeanModule (by
convention, the variable); an @AutoConfiguration class in it can be
named too, by its short name only:
@AutoConfigurationModule(
order: 10,
after: ['springBootAutoConfiguration'], // a module or an @AutoConfiguration class
before: ['neonMetricsAutoConfiguration'],
)
final BeanModule neonCacheAutoConfiguration = $neonCacheBeans;
Excluding auto-configuration #
When a starter's beans are not wanted, exclude them before their conditions run:
@SpringBootApplication(exclude: [CacheAutoConfiguration])takes classes. ATypeis compared by its simple name, so classes of the same name in different libraries are excluded together.@SpringBootApplication(excludeName: ['springBootStarterCacheAutoConfiguration'])takes the name of a module or of a class (short, orpackage:x/src/y.dart#CacheAutoConfiguration).- The property
spring.autoconfigure.exclude(comma-separated, or a YAML list) adds to them.
Excluding a class removes its beans, its imported registrars and
selectors, and its conditional @PropertySources; configuration classes
it @Imports stay. Excluding a module removes all of it. Excluding a type that is no auto-configuration stops
the start; an unknown name is ignored. The built-in ones are
MessageSourceAutoConfiguration, JacksonAutoConfiguration,
ValidationAutoConfiguration and LifecycleAutoConfiguration:
spring.autoconfigure.exclude=JacksonAutoConfiguration
What spring_boot_dart configures itself #
spring_boot_dart is a starter too (springBootAutoConfiguration): the
ObjectMapper and validation beans above, the lifecycleProcessor
whose timeout per shutdown phase is
spring.lifecycle.timeout-per-shutdown-phase (30 s by default, as in
Boot's LifecycleAutoConfiguration; without it the context waits 10 s,
Spring Framework's default), and MessageSourceAutoConfiguration. That one registers a messageSource
for classpath:<spring.messages.basename> (messages by default) when
the context, not counting a parent, has no bean named messageSource.
It reads spring.messages.encoding, use-code-as-default-message,
always-use-message-format, common-messages,
fallback-to-system-locale and cache-duration.
Starters of this repository #
| Starter | What adding the dependency gives |
|---|---|
spring_boot_starter_logging |
Console output with patterns, log files with rollover, logging.* properties, an in-memory LogBuffer for an in-app log screen. |
spring_boot_starter_web |
A web server: @RestController classes, JSON bodies, @Valid, ProblemDetail errors, server-sent events, filters, CORS. |
spring_boot_starter_security |
Security for the web starter: a filter chain configured with HttpSecurity, HTTP Basic, form login, sessions, CSRF, JWT resource servers. |
spring_boot_starter_feign |
HTTP clients from annotated interfaces (@FeignClient), with interceptors, retries and fallbacks. |
spring_boot_starter_websocket |
WebSocket clients from annotated interfaces (@WebSocketClient), and endpoints with the web starter. |
spring_boot_starter_cache |
@Cacheable, @CachePut, @CacheEvict on bean methods, a CacheManager with TTL and size limits. |
spring_boot_starter_scheduling |
@Scheduled methods (fixed rate, fixed delay, cron) and a TaskScheduler bean. |
spring_boot_starter_flutter |
Flutter apps: SpringApplicationWidget, beans in widgets, the app lifecycle, platform profiles. |
spring_boot_starter_test (dev) |
springBootTest (@SpringBootTest), BeanOverride (@MockitoBean), ApplicationContextRunner, bean matchers, captureLogs. |
Test a starter's auto-configuration with ApplicationContextRunner from
spring_boot_starter_test.
Writing your own starter #
Any package can be a starter. An application that adds it to
pubspec.yaml and runs build_runner again gets its beans with no code
at all; this is how spring_boot_starter_logging is picked up.
1. Declare the module in lib/spring_auto_configuration.dart (the
file name is fixed): a public top-level BeanModule variable annotated
with @AutoConfigurationModule.
// lib/spring_auto_configuration.dart of package `neon_cache`
import 'package:spring_boot_dart/spring_boot_dart.dart';
import 'src/cache.spring.dart';
@AutoConfigurationModule(requires: '^0.1.0') // compatible spring_boot_dart versions
final BeanModule neonCacheAutoConfiguration = $neonCacheBeans;
2. Describe the beans, either with the generator (@AutoConfiguration,
@Bean and conditions, scanned by a @ComponentScan in the starter) or
as hand-written BeanDefinitions, as the starters of this repository
do:
// lib/src/cache.dart
@AutoConfiguration()
class CacheAutoConfiguration {
@Bean()
@ConditionalOnMissingBean(Cache) // the application's own Cache wins
@ConditionalOnProperty('neon.cache.enabled', matchIfMissing: true)
Cache cache(@Value(r'${neon.cache.size:100}') int size) => InMemoryCache(size);
}
3. Publish the generated code. The generator writes files only into
the package being built (build_to: source), never into dependencies.
So run dart run build_runner build in the starter and publish its
*.spring.dart files with the package.
4. Test it with ApplicationContextRunner (spring_boot_starter_test):
the starter alone, with an application bean that makes it back off, and
with properties that switch it off.
How it is found. When an application with @SpringBootApplication
is built, the generator walks every package of its dependency graph,
direct and transitive, reads lib/spring_auto_configuration.dart where
there is one, and lists the annotated modules in the generated
springApplication (by package name; SpringApplication puts them in
order, see Order):
// generated bin/main.spring.dart of the application
final springApplication = SpringApplication(
[$application],
autoConfigurations: [
neonCacheAutoConfiguration, // found in neon_cache
springBootAutoConfiguration, // spring_boot_dart
],
// only where a starter sets order, before or after
autoConfigurationMetadata: {neonCacheAutoConfiguration: AutoConfigurationModule(order: 10)},
);
Good to know:
- Only with
@SpringBootApplication. Applications that create the context by hand (AnnotationConfigApplicationContext) list the module themselves. - Rebuild after adding the dependency. Discovery happens at build
time, not by scanning a classpath at start-up as in Java: until
build_runnerruns again, the generated file does not know the new starter. - Incompatible versions stop the build. If
requiresdoes not match the application'sspring_boot_dart, the generator says which version to use (see Versions). - Registration order: application classes first, then
auto-configuration in Boot's order. Bean conditions are decided class
by class in that order, so
@ConditionalOnMissingBeansees what the application declared, including the beans of its@Imported registrars. A hand-written auto-configuration bean needs asource(its class,library#Class, as the generator sets it) or afactoryMethodName(a@Beanmethod) for its bean conditions to be decided in that order too; a definition with neither is decided at once. - Keep the starter's third-party constraints wide (
dio: '>=5.0.0 <7.0.0') so applications can resolve them together with other starters.
Logging levels #
SpringApplication applies the logging.level.* properties to the
SpringLog facade, with or without the logging starter. It is Spring
Boot's LoggingApplicationListener:
logging.level.root=info
logging.level.neon.checkout=debug # and every neon.checkout.* logger
logging.group.payments=neon.card,neon.cash # a log group (or a YAML list)
logging.level.payments=trace
LOGGING_LEVEL_NEON_CHECKOUT=debug as an environment variable works
too. A value that is not a level (trace, debug, info, warn,
error, fatal, off) stops the start, as Boot's binding does.
Context initializers and test properties #
These hooks change the context before it is filled; tests use them most.
SpringApplication(..., initializers: [...])runsApplicationContextInitializers before the refresh, sorted byOrdered: a class implementinginitialize, orApplicationContextInitializer.of((context) => ...).run(args, properties: {...})adds properties above every other source, which is what@SpringBootTest(properties = ...)does.spring_boot_starter_testis built on these two.SpringApplication(defaultProperties: {...})orsetDefaultPropertiesadds thedefaultPropertiessource below every other one, config files included (Boot'ssetDefaultProperties).additionalProfiles: [...]activates profiles on top of the configured ones (Boot'ssetAdditionalProfiles).
Application events #
Some code has to act at a precise moment of the start: once the
properties are known, or once the application is ready to serve. As in
Spring Boot, run and runAsync publish, in this order:
ApplicationStartingEvent: the run has begun;ApplicationEnvironmentPreparedEvent: properties and profiles are known;ApplicationContextInitializedEventandApplicationPreparedEvent: the context exists, no bean is created yet;- the context's own
ContextRefreshedEvent: every bean is created; ApplicationStartedEvent, then the runners;ApplicationReadyEvent.
If the start or a runner fails, ApplicationFailedEvent (with the
exception) is published instead, before the failure report and the
rethrow.
The early events happen before any bean exists, so their listeners are registered on the application itself:
// The active profiles, before any bean is created.
void main(List<String> args) => (springApplication
..addListener<ApplicationEnvironmentPreparedEvent>(
(event) => log.info(event.environment.activeProfiles),
))
.run(args);
// or SpringApplication([...], listeners: [ApplicationListenerAdapter.of<ApplicationStartingEvent>(...)])
From ApplicationPreparedEvent on, these listeners are also listeners
of the context. ApplicationStartedEvent, ApplicationReadyEvent and
the ApplicationFailedEvent of a failing runner go through the context,
so @EventListener methods and ApplicationListener beans receive
them. Most applications only need ApplicationReadyEvent, in a bean:
// Fill the cache once the application is up, without delaying the start.
@Component()
class Warmup {
@EventListener()
void onReady(ApplicationReadyEvent event) => cache.fill();
}
Versions and starter compatibility #
Java solves library conflicts with a BOM. pub resolves exactly one
version of every package per application, so conflicts cannot hide at
runtime: pub get fails and names the culprits. What remains is keeping
the family consistent and making incompatibilities readable:
- Release train.
spring_core_dart,spring_core_dart_generator,spring_boot_dartand thespring_boot_starter_*packages always share one version and depend on each other with^<version>(dart run tool/version.dart 0.2.0, checked in CI). - Starters declare the
spring_boot_dartversions they support:
The generator compares the range with@AutoConfigurationModule(requires: '^0.4.0') final BeanModule neonCacheAutoConfiguration = $neonCacheBeans;springBootVersionand stops the build: Incompatible starter: neon_cache requires spring_boot_dart ^0.4.0, but 0.1.0 is used. Upgrade spring_boot_dart to a version in ^0.4.0, or use a version of neon_cache made for spring_boot_dart 0.1.0. - Starters keep third-party constraints wide (
dio: '>=5.0.0 <7.0.0') so the solver finds a common version; CI resolves the minimum versions (dart pub downgrade) and analyzes them. - Escape hatch:
dependency_overridesin the application'spubspec.yaml, like overriding a managed version in Maven.
Build-time values (--dart-define) #
A Flutter app has no command line, and on phones and in browsers no
useful environment variables. Values chosen when the app is built come
in as --dart-defines instead (-DKEY=value for dart run and
dart compile). They become a property source right below the
command-line arguments, above the OS environment and the config files:
flutter build apk --dart-define=SPRING_PROFILES_ACTIVE=prod \
--dart-define=API_URL=https://api.neon.books
# or --dart-define-from-file=config/prod.json
@Value(r'${API_URL}') String apiUrl // https://api.neon.books
@Value(r'${api.url}') String sameUrl // relaxed, like env variables
SPRING_PROFILES_ACTIVE activates application-prod.properties, so
Spring profiles can replace build flavors. Names are relaxed as for
environment variables (API_URL answers api.url,
SHOP_MAXBASKETSIZE binds shop.max-basket-size).
On the web and in AOT builds, String.fromEnvironment works only as a
const with a literal key, so a define must be known at build time. The
generator therefore lists every key the application can read, each also
in its environment form (SPRING_PROFILES_ACTIVE), as
SpringApplication(dartDefines: ...):
- the
${...}placeholders of annotations; - the
@ConditionalOnPropertynames; - the fields of
@ConfigurationPropertiesclasses; - the keys and placeholders of the
application*config files; spring.profiles.active.
A key read only in code (environment.getProperty('x')) is not listed:
put it in application.properties (even empty), or pass it to
SpringApplication(dartDefines: {...}) yourself.
Defines are compiled into the program and can be read from an APK, an
IPA or main.dart.js. Never pass passwords or private keys this way; a
client app gets tokens from a server instead.
Spring has no counterpart; the closest is passing -Dkey=value system
properties to the JVM.
Hot reload (Flutter) #
A hot reload does not run main again, so the running context would
keep the old beans. The generator writes springApplication and the
bean module as getters, plus a fingerprint of the generated code, and
the generated reloadApplication(context) decides what to do:
- only method bodies changed (same fingerprint): the context is kept; the beans keep their state and already run the new code;
- beans, annotations or
lib/resources/changed (withbuild_runner watchrunning): the context is closed and started again with the same arguments.
Call it from reassemble() of the root widget's State, which Flutter
calls on every hot reload:
late ConfigurableApplicationContext context;
@SpringBootApplication()
Future<void> main() async {
context = await runApplicationAsync(const []);
runApp(const App());
}
class _AppState extends State<App> {
@override
void reassemble() {
super.reassemble();
context = reloadApplication(context); // generated next to main
}
@override
Widget build(BuildContext context) => ... // look beans up here, not in initState
}
Objects you keep in fields (a bean fetched once in initState) still
point to the old context after a restart; look beans up in build or
after reassemble. A hot restart always starts from scratch.
Runners after a restart (runners:) #
Runners are the place for "on start-up" work:
@Component()
@Log()
class StartupTasks implements CommandLineRunner {
StartupTasks(this.db, this.analytics);
final Database db;
final Analytics analytics;
@override
void run(List<String> args) {
db.migrate(); // update the database schema
analytics.track('app_started'); // "the app was opened"
log.info('Shop is open!');
}
}
When a hot reload restarts the context (beans, annotations or properties changed), the context starts like a fresh launch, so by default the runners run again:
hot reload after adding a bean
→ old context closed, new one started
→ StartupTasks.run(): migrate() again, a second 'app_started' event,
"Shop is open!" printed again
The app was not really opened twice, so this is often unwanted. Pass
runners: false to restart the beans without repeating the start-up
work:
@override
void reassemble() {
super.reassemble();
context = reloadApplication(context, runners: false);
}
runners: true (default) |
runners: false |
|
|---|---|---|
| Beans after a restart | new | new |
CommandLineRunner / ApplicationRunner |
run again | skipped |
| Good for | runners that only prepare in-memory state the new beans need | migrations, analytics events, notifications, network calls |
Notes:
- When only method bodies changed, the context is not restarted, so runners never run on such a reload, whatever the flag says.
runApplicationandrunApplicationAsync(a real start) always run the runners.- The default matches Spring Boot DevTools, which runs the runners after every automatic restart.
Hot reload (Dart VM: servers, CLIs) #
A program on the Dart VM, such as a server of spring_boot_starter_web
or a command-line tool, hot reloads too, like Spring Boot DevTools'
restart. The generated runApplication registers the context. After the
VM has reloaded the code (reloadSources, called by a tool), the tool
calls the service extension ext.spring_boot.reload
(SpringHotReload.extensionName):
- only method bodies changed: the context keeps running, with the new code;
- the generated code changed (beans, annotations,
lib/resources/; for example a new@GetMappingor anotherserver.port): the context is closed and starts again with the same arguments, so the web server stops and listens again. If that start fails (a bug in the new code), the extension answers with the error, and the next reload starts the application again, as DevTools restarts at the next change.
The dart run: <file> tasks of the Flutter tools Zed extension do this
on save: they run the program with the VM service on, reload it when a
.dart file of the package changes (generated ones included:
build_runner watch rewrites *.spring.dart) or a config file changes,
then call the extension. Any tool can do the same with the VM service
protocol.
spring.devtools.restart.enabled=false keeps the context (the server,
its connections, the beans' state) running whatever changed; new beans
and config then take effect when the program restarts. Hot reload is not
available in AOT or product builds, on the web, nor in Flutter (there,
the root widget restarts the context, see above).
Flutter #
runApplicationAsync(args) (also generated) reads the config files
asynchronously. Resources from lib/resources/ are embedded, so they
need no asset declarations. Other assets plug in as a ProtocolResolver
on the context's resource loader (for asset: or package: locations);
config imports and @PropertySource files from it are read
asynchronously too. The
spring_boot_starter_flutter
starter does this for you and starts the application from a widget.
Differences from Spring #
| Spring Boot | Here | Why |
|---|---|---|
Constructor binding passes null for a missing property without @DefaultValue |
A missing non-nullable scalar stops the start; a missing collection is empty and a missing nested object is created with its own defaults (what Boot does with an empty @DefaultValue) |
Dart's non-nullable types |
@ConstructorBinding picks one of several constructors |
Constructor binding only for a class whose only public generative constructor has parameters | Not implemented yet |
.properties config files are read as ISO-8859-1 |
UTF-8 (pass PropertiesPropertySourceLoader(encoding: latin1) to ConfigDataEnvironment for ISO-8859-1, or name one file's encoding where it is named, classpath:legacy.properties[encoding=iso-8859-1], as in Boot 4.1) |
Dart files are UTF-8 |
| YAML 1.1 (SnakeYAML) | YAML 1.2 (package:yaml): on, off, yes, no stay strings (converting them to bool still works), 0777 is 777 |
The Dart team's YAML parser |
No spring.messages.default-locale |
spring.messages.default-locale: the locale tried before the system locale |
Apps (Flutter) often have no meaningful system locale |
Started app in 0.07 seconds (process running for 0.3) |
Without the process uptime; logged only at INFO (visible with spring_boot_starter_logging or logging.level.root=info) |
Dart has no process start time; no logging backend by default |
| The shutdown hook is registered by default and runs when the JVM exits | Off by default (spring.main.register-shutdown-hook=true turns it on); only on SIGINT and SIGTERM (then exit(130) or exit(143)); the listener keeps the process alive until the context closes |
Dart has no exit hooks, and a signal listener is an open handle that would keep a finished program running |
EnvironmentPostProcessors listed in META-INF/spring.factories |
SpringApplication(environmentPostProcessors: [...]), ordered by Ordered around config data (EnvironmentPostProcessor.configDataOrder) |
No class path to scan |
@DurationUnit(ChronoUnit.X), @DataSizeUnit(DataUnit.X) |
The same with Dart enums (ChronoUnit.seconds, DataUnit.megabytes; no months or years) |
Dart naming; Duration has no calendar units |
BindHandler.onFailure returns a replacement value or rethrows |
onFailure(name, error) returns whether the failure counts (false leaves the property unbound) |
Errors are collected and reported together |
JsonMapperBuilderCustomizer gets Jackson's JsonMapper.Builder |
It gets the core JsonMapperBuilder (naming, inclusion, time zone, features, serializers, modules); Jackson's other builder methods are missing |
Only what the Dart mapper supports |
A spring.jackson.* boolean that is not true or false fails the binding |
It counts as false, silently |
Not checked yet |
The condition evaluation report explains each outcome (found bean 'x', @ConditionalOnProperty (a=b) matched) |
It names each condition and whether it matched; a configuration class's conditions are listed under its key | Conditions are plain Dart objects |
The banner is printed to System.out; banner.txt may use ${application.version}, ${AnsiColor.*} |
print (spring.main.banner-mode=log logs it); banner.txt gets ${spring-boot.version}, ${spring-boot.formatted-version} and environment placeholders |
Dart has no manifest version or ANSI property source |
@AutoConfiguration(before:, after:) and @AutoConfigureOrder on the class |
before, after and order of @AutoConfigurationModule (a module is the unit; classes can be named in them, by short name) |
The generator discovers modules, not classes |
exclude compares classes |
A Type in exclude is compared by its simple name |
A Type gives only its name at runtime |
spring.main.allow-circular-references=true allows circular references |
Not supported: a cycle is always an error (mark an injection point @Lazy() or inject an ObjectProvider) |
No early singleton references |
@ConditionalOnBean(annotation = Service.class) also finds meta-annotated beans |
annotations: [Service] matches that exact annotation class |
Annotations are plain objects at runtime |
Not yet #
AvailabilityChangeEvent, test slices (@WebMvcTest-like), Actuator.