spring_core_dart 0.4.1
spring_core_dart: ^0.4.1 copied to clipboard
Spring Core for Dart and Flutter: annotation-based dependency injection, application.properties configuration, profiles, events, SpEL, validation and AOP.
spring_core_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.
Spring Core for Dart: IoC container, environment and application.properties
configuration, events, messages, resources, type conversion, SpEL, validation
with data binding, and AOP. Pure Dart, works in Flutter (no dart:mirrors):
annotations are processed at build time by spring_core_dart_generator.
Repository: https://github.com/farum-azula/spring_core_dart. Flutter widgets (bean builders, event listeners in the widget tree) will be a separate library.
Packages #
| Package | Path | What it is |
|---|---|---|
spring_core_dart |
spring_core_dart/ |
Runtime: annotations, container, environment, events, SpEL, validation, AOP. Only the Dart team's package:yaml. |
spring_core_dart_generator |
spring_core_dart_generator/ |
build_runner builder that turns annotations into *.spring.dart modules. |
neon_books_server, neon_books_app |
example/ |
The guides: a bookstore's server and its Flutter app using every feature, chapter by chapter, with tests. |
Libraries of the runtime (import one, or package:spring_core_dart/spring_core_dart.dart for all):
| Library | Spring counterpart |
|---|---|
core.dart |
spring-core: Environment, property sources, profiles, ConversionService, resources, AntPathMatcher, Spring Boot config data |
beans.dart |
spring-beans: BeanDefinition, DefaultListableBeanFactory, FactoryBean, post-processors, scopes, BeanWrapper, introspection |
context.dart |
spring-context: annotations, ApplicationContext, conditions, events, lifecycle, MessageSource |
expression.dart |
spring-expression (SpEL) |
validation.dart |
org.springframework.validation + Bean Validation style constraints |
aop.dart |
spring-aop: aspects, advice, pointcut expressions, advisors |
json.dart |
Jackson's ObjectMapper: JSON on generated metadata, Jackson-style annotations |
Installation #
dependencies:
spring_core_dart: ^0.4.1
dev_dependencies:
build_runner: ^2.17.0
spring_core_dart_generator: ^0.4.1
Generator option (in your build.yaml):
targets:
$default:
builders:
spring_core_dart_generator:spring_core_dart:
options:
aop_proxies: all # all (default) | interfaces | none
aop_prune: true # all: skip proxies of beans no pointcut matches
Quick start #
# config/application.properties
app.title=NEON
spring.profiles.active=dev
// lib/src/beans.dart — everything under lib/src/ is scanned
import 'package:spring_core_dart/spring_core_dart.dart';
import 'package:my_app/src/beans.spring.dart';
@ComponentScan()
final BeanModule appBeans = $appBeans;
abstract interface class Greeter {
String greet(String name);
}
@Service()
class EnglishGreeter implements Greeter {
EnglishGreeter(@Value(r'${app.title}') this.title);
final String title;
@override
String greet(String name) => 'Hello $name from $title';
}
dart run build_runner build # or watch
Future<void> main(List<String> args) async {
final environment = StandardEnvironment()
..propertySources.addFirst(SimpleCommandLinePropertySource(args));
await ConfigDataEnvironment(environment).processAndApply();
final context = ApplicationContext.run([appBeans], environment: environment);
print(context.getBean<Greeter>().greet('Ada'));
context.close();
}
Guide #
Declaring beans #
| Annotation | Meaning |
|---|---|
@Component([name]), @Service, @Repository, @Controller, @Named |
A bean. Default name: lowerCamelCase class name (URLService stays); an explicit name or alias must not be empty, blank or padded with spaces. Subclass Component for your own stereotypes. Only classes are beans: on a mixin, enum, extension or extension type (erased at runtime) the build fails and says what to use instead. The scan skips abstract classes, as Spring's does; a generic class is registered under its raw type (Repo<T> as Repo<dynamic>, type arguments at their bounds). |
@Configuration(name:, proxyBeanMethods:) |
A bean whose @Bean methods declare more beans. Like Spring's default (proxyBeanMethods: true), the container creates a generated subclass whose @Bean methods return its beans, so a @Bean method calling another one gets the singleton (a prototype: a new instance; static methods are not intercepted); proxyBeanMethods: false ("lite", also @Component classes and @AutoConfiguration) calls them as written. @Lazy / @Profile / conditions on it (also on a lite @Component with @Bean methods) apply to its @Bean methods, unless a method says otherwise (@Lazy(false)). |
@Bean(name:, aliases:, initMethod:, destroyMethod:, autowireCandidate:) |
A bean produced by a method; type = what the method body returns (DataSource ds() => HikariDataSource() is a HikariDataSource, also found as DataSource); parameters are injected. Destroy method is inferred (close, shutdown, dispose) unless destroyMethod: ''. The method may return null (declared T?): the bean is a NullBean, found by name but absent for type lookups and injection (getBean<T>() and required points fail, findBean<T>() and optional points get null, collections leave it out), and an inter-bean call returns null. A function type (String Function(String), a typedef) or record type is a bean too, injected by that exact type into single injection points. |
@Scope('prototype', proxyMode:) / ScopeType.* / custom name |
Scope; custom scopes via registerScope(name, CustomScope) (e.g. SimpleMapScope). proxyMode: ScopedProxyMode.targetClass (or interfaces) injects a generated scoped proxy that asks the scope for the current instance on every call, so singletons can hold scoped beans (by hand: BeanDefinition(scopedProxy:)); without it a scoped bean injected directly into a singleton is logged as a warning. |
@Lazy() |
Singletons are eager by default; lazy ones are created on first use. |
@Primary(), @Fallback(), @Qualifier('x') |
Choosing between candidates. |
@DependsOn([...]), @Order(n), @Description('...') |
Creation order, collection order, documentation. |
A bean is registered under its own type and all its public supertypes.
Generic supertypes are matched by the full type (Store<User>).
A FactoryBean<T> exposes its product by name and type; &name returns the
factory. A FactoryBean given to registerSingleton is found by its
objectType (T).
Injection #
@Service()
class OrderService {
OrderService(
this.repository, // single bean
@Qualifier('fast') this.cache, // by name / qualifier
this.listeners, // List<Listener>: all, ordered
{this.audit, // nullable = optional
required this.reports, // ObjectProvider<Report>: lazy
@Value(r'${orders.limit:100}') required this.limit});
@Autowired()
late final Clock clock; // field
@Autowired()
set metrics(Metrics value) {} // setter
@Autowired()
void init(Environment environment) {} // method
...
}
@Lazy() on an injection point injects a generated proxy that finds the
bean on first use (Spring's lazy-resolution proxy). It breaks cycles and
lets a bean call itself through its AOP proxy:
@Service()
class BookService implements Books {
BookService(@Lazy() this._self); // also on fields and setters
final Books _self;
@Timed()
@override
Book find(String isbn) => ...;
List<Book> findAll(List<String> isbns) =>
[for (final isbn in isbns) _self.find(isbn)]; // advised; this.find() is not
}
The bean must exist and be unique (checked at refresh). The type must be
an interface or a class without private members, not generic, and the
point must not be nullable; otherwise the build stops and suggests
ObjectProvider<T>.
Candidates follow Spring's DefaultListableBeanFactory: a bean is never
injected into itself, and a configuration's own @Bean products are left
out of its collections and single dependencies unless nothing else matches
(a collection then gets them, never the configuration itself). Beans of
parent contexts take part (their @Primary, @Order,
autowireCandidate and qualifiers kept; a local bean of the same name
hides them); for a single dependency a local @Primary beats a parent's,
the parameter name matches bean names and aliases (not qualifier values),
then the @Qualifier value as a bean name, and two @Primary candidates
are an error.
The JSR-330 / JSR-250 names that clash with other libraries live in
package:spring_core_dart/jakarta.dart: a Provider<T> injection point
gets the same lazy provider as ObjectProvider<T> (get()), and
@Resource on a field or setter injects by bean name (the member name, or
by type when no bean has it; @Resource(name: 'x') exactly that bean):
import 'package:spring_core_dart/jakarta.dart' as jakarta;
@Service()
class Reports {
Reports(this.clock);
final jakarta.Provider<Clock> clock; // clock.get() on every use
@jakarta.Resource(name: 'archiveStore')
late final Store store;
}
Lookup methods
A method marked @Lookup() returns a bean of the container on every call
(Spring's lookup method injection): the bean of its return type, or the
bean named by @Lookup('name'); a nullable return type gives null when
no bean matches. The usual case is a singleton that needs a new prototype
per call. The bean is created as a generated subclass overriding the
methods, so they can be abstract, and an abstract @Component class with
@Lookup methods is a bean (the scan skips other abstract classes). A
concrete method works too; its body never runs.
@Component()
@Scope(ScopeType.prototype)
class Command { ... }
@Component()
abstract class CommandManager {
@Lookup()
Command createCommand(); // a new Command per call
@Lookup('auditLog')
AuditLog auditLog();
void process() => createCommand().run();
}
The build fails when a @Lookup method takes parameters, is private,
static or generic, or (looked up by type) returns no class type, and when
the class cannot be extended from the generated library (final,
sealed, interface, only factory or private constructors, several
@Autowired(required: false) constructors) or is a full-mode
@Configuration class. If the bean is advised, its AOP proxy wraps the
generated subclass (a subclass proxy extends it), so lookup methods can be
advised too.
Primary constructors and other new syntax
Dart 3.13+ syntax works with the generator: primary constructors (also on
@Configuration and @Introspected classes, with annotations on their
parameters), class const, in-body new(...) / new name(...) /
factory name(...) constructors and private named parameters (injected
under their public name).
@Component()
class Settings(@Value(r'${pool.size:1}') final int capacity);
@Service()
class Inventory {
final Settings settings;
final Clock _clock;
new(this.settings, {required this._clock}); // called as clock: ...
}
@Service()
class Report {
final Inventory inventory;
new _(this.inventory);
@Autowired()
factory of(Inventory inventory) => Report._(inventory);
}
@Introspected()
class StockForm(@NotBlank() var String sku, {@Min(1) var int quantity = 1});
Choosing the constructor
Like Spring's AutowiredAnnotationBeanPostProcessor: an @Autowired()
constructor is used (it must be the only @Autowired one); several
@Autowired(required: false) constructors are candidates, and the bean is
created with the one with the most parameters whose beans exist (checked
when the bean is created, the constructor callable without arguments as
the last resort). Without @Autowired: the only public constructor
(generative ones count before factories), else the primary constructor
(like Kotlin's in Spring), else the one callable without arguments;
anything else fails the build and lists the constructors.
required: false only applies to constructors; on fields, setters and
methods a nullable type makes the dependency optional.
A super parameter carries the @Value / @Qualifier / @Named / @Lazy
of the super constructor's parameter unless it has its own:
abstract class Client {
Client(@Value(r'${api.url}') this.url);
final String url;
}
@Component()
class OrdersClient extends Client {
OrdersClient(super.url); // injected from ${api.url}
}
Selection when several beans match: the only candidate, else the single
@Primary, else the only non-@Fallback, else the one named like the
parameter. A bean's name or alias (also one from registerAlias) matches
@Qualifier and the parameter name. A bean with autowireCandidate: false
is never injected, not even through a @Qualifier, but getBean still
finds it by name or as the only bean of its type. A bean is never injected
into itself (Spring's self-reference rule), so a @Primary composite or
decorator of Greeter receives the other greeters. A constructor or
@Bean parameter with a Dart default value keeps the default when no bean
(or property) is found, and a required List/Set/Map there is empty
when nothing matches (Spring's single-constructor fallback; field and setter
injection still require a bean). List<T> / Set<T> / Map<String, T>
receive every candidate (also from parent contexts); only a List is
sorted, by PriorityOrdered → Ordered → @Order (an @Order on a
@Bean method beats the bean's Ordered.order, an @Order on its class
does not), a Set or Map keeps registration order. This works for
primary constructors too (class Greeter(this._all) { final List<String> _all; }), with the field declared in the header or in the body. BeanFactory,
ApplicationContext, ApplicationEventPublisher, ResourceLoader and
Environment can be injected.
A dependency that cannot be resolved (no bean, several candidates, a
dependency that fails to create) fails refresh() or the bean's creation
with an UnsatisfiedDependencyException naming the bean and the injection
point (Bean 'shop' has an unsatisfied dependency Greeter 'greeter': ...);
its cause is the lookup error or the nested bean's failure, so the message
shows the chain of beans.
@Value resolves ${key:default} placeholders, evaluates #{...} SpEL and
converts the result to the target type (int, Duration, List<String>,
enums with addEnum, ...). A nullable target gets null when a placeholder
has no value.
refresh() validates the whole graph before creating the application's
beans (missing and ambiguous dependencies, unresolvable placeholders,
@DependsOn names, cycles), so configuration errors fail at startup. Only
post-processors and infrastructure beans (messageSource, ...) are created
before that check.
Lifecycle #
Creation: constructor → fields/setters/methods → BeanNameAware /
BeanFactoryAware → context aware interfaces (EnvironmentAware,
ApplicationContextAware, ...) → postProcessBeforeInitialization →
@PostConstruct → InitializingBean.afterPropertiesSet → init method →
postProcessAfterInitialization. Destruction (on close(), dependents
first): DestructionAwareBeanPostProcessor → @PreDestroy →
DisposableBean.destroy → destroy method.
Asynchronous callbacks: an init callback (@PostConstruct,
afterPropertiesSet, init method) or a destroy callback (@PreDestroy,
DisposableBean.destroy, destroy method) may return a Future.
refreshAsync() (also ApplicationContext.runAsync, Spring Boot's
runAsync) awaits the init callbacks of the beans created during the
refresh — each bean's one after another; the bean is injected into others
meanwhile — before SmartInitializingSingleton, the lifecycle start and
ContextRefreshedEvent; a failure fails the refresh naming the bean. The
synchronous refresh() fails for such a callback, and a bean created
after the refresh (lazy, prototype, scoped) needs synchronous ones.
closeAsync() awaits the destroy callbacks, dependents first; close()
logs an asynchronous one and goes on without waiting.
SmartInitializingSingleton runs after all eager singletons;
Lifecycle / SmartLifecycle beans are started by phase (auto-startup at
refresh) and stopped in reverse on stop() / close(); a bean's
dependencies start before it and its dependents stop before it, whatever
their phase (a plain Lifecycle dependency of an auto-startup bean starts
at refresh); a lazy plain Lifecycle bean is not created for that, like
Spring. A created singleton counts by its instance too (a @Bean Object server() returning a SmartLifecycle), like Spring matching the
singleton's actual type. Mix in SmartLifecycle (with) for Spring's defaults
(auto-startup, default phase). closeAsync() waits for the
stopWithCallback callbacks of each phase, at most
DefaultLifecycleProcessor.timeoutPerShutdownPhase (10 seconds) or the
phase's entry in timeoutsForShutdownPhases (declare a
lifecycleProcessor bean to change them); then the beans still running
are logged and the next phase is stopped. When a refresh fails, the beans
started so far are stopped. Like Spring's doClose, close() never throws because of a failing ContextClosedEvent
listener or a bean that fails to stop: the error is logged and shutdown
continues (a close() from a ContextClosedEvent listener is ignored; one
from a ContextRefreshedEvent listener closes the context, like Spring).
Like Spring 7, context.pause() stops the running lifecycle beans except
the SmartLifecycle beans whose isPauseable is false, and
context.restart() (stopping the pauseable beans first if still running)
starts the auto-startup beans again, restoring the state after refresh —
a fit for Flutter's paused / resumed. They publish
ContextPausedEvent (a ContextStoppedEvent) and ContextRestartedEvent
(a ContextStartedEvent); pauseAsync() waits for the stopWithCallback
callbacks like closeAsync(). A custom LifecycleProcessor implements
onPause / onPauseAsync / onRestart.
context.registerShutdownHook() closes the context on SIGINT / SIGTERM
and ends the process with 128 + signal, like Spring's JVM hook (once per
context; the signals are released when the context closes; nothing on the
web). Subclasses of AnnotationConfigApplicationContext can override
Spring's template hooks: postProcessBeanFactory(beanFactory) (all
definitions loaded, before the bean factory post-processors), onRefresh()
(message source and multicaster ready, before the listeners and the eager
singletons) and onClose() (last in close() / closeAsync(), after the
singletons are destroyed).
Problems the framework recovers from are logged through SpringLog
(warnings are printed by default): set SpringLog.level and
SpringLog.handler to route them, e.g. to package:logging.
Environment and configuration #
StandardEnvironment: property sources (command line, OS environment, config files,@PropertySource),${...}placeholders with defaults, nesting and escapes, typedgetPropertyAs<T>, required properties.- Profiles:
spring.profiles.active/.default, expressionsdev & (eu | us) & !prod. ConfigDataEnvironment(Spring Boot config data):application.properties/application.yml/application.yamland theirapplication-{profile}variants, in Boot's two location groups:classpath:/;classpath:/config/(=lib/resources/), overridden byfile:./;file:./config/;file:./config/*/(profile-specific files win within a group, so./application.propertiesbeats a packagedapplication-prod.properties;.propertiesbeats YAML in one location). Alsospring.config.location/additional-location(,separates groups,;joins locations into one) /name, multi-doc files (#---, YAML---) withspring.config.activate.on-profileandon-cloud-platform(theCloudPlatformdetected from environment variables like Boot's, orspring.main.cloud-platform),spring.config.import(files or directories, relative to the importing file, with their-{profile}variants; also from the command line orSPRING_CONFIG_IMPORT, above every location),spring.profiles.include/group.*(expanded depth-first, a group's members right after it),optional:locations (a non-optional file or directory that does not exist fails the start, unlessspring.config.on-not-found=ignore; a directory location must end in/), extension hints for files without a usable extension (file:./conf[.yml]) andconfigtree:imports (a file per property, Kubernetes-style:configtree:/etc/config/,configtree:/etc/*/). Profiles follow Boot's rules: the command line wins over a file'sspring.profiles.active, profiles set withsetActiveProfilesare kept and the file's added after them,spring.profiles.default(file, command line or environment) selects the profile files while none is active, groups expanded. AdefaultPropertiessource stays below the config files and the@PropertySourcefiles. Works with async-only resources (Flutter assets) viaprocessAndApply().- Binding follows Boot's binder: indexed keys (
spring.profiles.include[0],SPRING_PROFILES_ACTIVE_0_) form a list, and a list comes whole from the highest source that has it (--spring.profiles.active[0]=devreplaces a file's list).${...}in a file'sspring.config.importand on-profile guard is resolved against the command line and environment first, then the files; an unresolvable placeholder in anoptional:import just skips it. A non-optional wildcard location (file:./config/*/) needs at least one subdirectory. ${random.int},${random.int(10)},${random.int[5,10]},${random.long},${random.uuid},${random.value}(RandomValuePropertySource, added bySpringApplication; callRandomValuePropertySource.addToEnvironmentotherwise).- Placeholders follow Spring 6.2's parser:
${db:url}is first looked up as the keydb:url, then asdbwith the defaulturl; an unclosed${is plain text (${a ${b}resolves${b}). .propertiesfiles are UTF-8; for ISO-8859-1 (what Spring reads) usePropertiesPropertySourceLoader(encoding: latin1)inConfigDataEnvironment(loaders: ...)or@PropertySource(encoding: 'ISO-8859-1').- YAML is flattened like Boot's
YamlPropertySourceLoader:app.hosts[0],app.servers[0].name, empty values and~as''.@ValueandgetProperty('app.hosts[0]')see list items, and@ConfigurationPropertiesbinds them as lists (also of nested objects). Integers beyondintstay exact asBigInt(SnakeYAML'sBigInteger). - Relaxed names (Boot's attached
ConfigurationPropertySources, added byConfigDataEnvironment/SpringApplication):app.read-timeoutingetProperty,${...}and@Valuealso findsapp.readTimeout,app.read_timeoutorAPP_READTIMEOUT; the first property source with any form wins. @PropertySource([...], factory: ...)on a component adds files; the last declared wins. It is loaded only when the declaring class's conditions (@Profile,@Conditional, ...) match; classes pulled in with@Importinherit the importer's conditions. A pattern (file:config/*.properties,classpath*:) loads every match, none being fine (Spring 6.1). As in Spring it reads.properties; for YAML pass aPropertySourceFactorythat usesYamlPropertySourceLoader. Async-only resources (Flutter assets behind aProtocolResolver) work withrefreshAsync()/runAsync, which reads them before the modules are loaded.
There is no .env support by design: configuration lives in property files.
Conditions #
@Profile(['dev']), @Conditional([MyCondition]) (or your own subclass of
either, class Dev extends Profile { const Dev() : super(const ['dev']); },
like a Spring meta-annotation), or any const annotation class that
implements Condition (attribute-style conditions such as
@ConditionalOnProperty('feature.x')). Conditions see the environment, the
definitions registered before them and, as context.definition, the
definition being evaluated (its annotations, name and types — Spring's
AnnotatedTypeMetadata; null for @PropertySource conditions). A
ConfigurationCondition picks its ConfigurationPhase:
parseConfiguration (decided while parsing; a failing one also stops the
class's imports and @PropertySources) or registerBean (decided when
the class is read, after the classes before it registered their beans and
ran their registrars — see Modules); plain conditions count in both.
Modules #
@ComponentScan (on a top-level variable) generates a BeanModule per
package (directory). Combine modules in the application;
@Import([OtherConfig]) pulls in configuration classes outside the scan. A
subdirectory with its own @ComponentScan belongs to that scan (its
filters apply): the enclosing scan, or @SpringBootApplication, imports its
module instead of scanning the files again, so every bean is registered
once even when both modules are used. One directory has one scan root. Contexts can have a parent; beans of the parent
are visible to the child.
Programmatic registration follows Spring 7. A BeanRegistrar registers
beans with a supplier and Spring's Spec options; put it in
@Import([...]) (the generator creates it with its no-argument
constructor) or context.register(...) it. ImportSelector (returns
BeanModules) and ImportBeanDefinitionRegistrar (gets the
BeanDefinitionRegistry) work the same way and may implement
EnvironmentAware, BeanFactoryAware, ResourceLoaderAware.
context.registerBean<T>(supplier, ...) is Spring's
GenericApplicationContext.registerBean. Registering anything after
refresh() fails with a StateError saying so.
Imports follow Spring's configuration class parser and reader. Parsing
(only parse-phase conditions count) loads a class's @PropertySources
and runs its selectors; the modules they select are parsed at once and
their classes are read before the importing class. The reader then goes
class by class (application classes before auto-configurations) and
decides each class once — skipped when every class importing it was
skipped, else by its registerBean-phase conditions
(ConfigurationCondition, such as Boot's @ConditionalOnBean) — for the
class, its @Bean methods and its imports alike (a scanned component or
an @Imported class with such a condition is a class too, decided at its
place in that order). A kept class registers its @Bean methods (each
one's own conditions decided at that moment), then runs its registrars,
so every later class sees those beans; a skipped class registers nothing
and runs no registrar.
class MessagingRegistrar implements BeanRegistrar {
const MessagingRegistrar();
@override
void register(BeanRegistry registry, Environment env) {
registry.registerBean<Clock>((_) => SystemClock(), name: 'clock');
if (env.containsProperty('mail.host')) {
registry.registerBean<Mailer>(
(context) => SmtpMailer(context.dependency<Clock>()),
name: 'mailer', primary: true, lazy: true, description: 'SMTP',
); // unnamed: `Mailer#0`, like Spring's uniqueBeanName
}
}
}
@Configuration()
@Import([MessagingRegistrar]) // or: context.register(const MessagingRegistrar())
class AppConfig {}
final context = AnnotationConfigApplicationContext([appBeans])
..registerBean<Clock>((_) => FixedClock(), name: 'testClock', primary: true)
..refresh();
The scan takes Spring's filters, evaluated at build time: includeFilters
make classes beans without @Component (Filter(classes: [Plugin]) for an
annotation, Filter(type: FilterType.assignableType, classes: [Handler]),
Filter(type: FilterType.regex, pattern: ['.*Repository']) on the class
name; abstract classes are skipped), excludeFilters keep classes out
(they win), useDefaultFilters: false scans only the includes and
lazyInit: true makes every scanned bean (components and their @Bean
methods) lazy unless annotated @Lazy(false).
@Bean methods, @Autowired members, @PostConstruct / @PreDestroy and
@EventListener methods are also found in superclasses and mixins (Spring
scans the hierarchy), with the type arguments of a generic superclass
(UserService extends BaseService<User>) substituted. Generated code cannot
name private declarations: a private bean class, @Bean return type,
injected type or default value fails the build with a message naming it,
its library and the fix.
Events #
@Component()
class Shipping {
@EventListener() // payloads or ApplicationEvents
OrderShipped onPlaced(OrderPlaced order) => OrderShipped(this, order.id);
@EventListener(condition: isBigOrder) // top-level / static predicate
@Order(1)
void onBig(OrderPlaced order) {}
@EventListener(classes: [ContextRefreshedEvent, ContextClosedEvent])
void onContext() {}
}
Non-null results are published again; a Future result is published
when it completes, and its error goes to the multicaster's error handler
(or is logged). ApplicationListener<E>, GenericApplicationListener and
SmartApplicationListener beans work too, also behind another declared
@Bean type, as do listener objects given to registerSingleton (an
ApplicationListener<E> object needs
ApplicationListenerAdapter.forListener<E>); a listener is registered
once. context.addListener<E>(...) returns a removable registration.
Events published before the listeners are registered (while the bean
factory post-processors run) are replayed once they are; later ones, e.g.
from a bean's constructor, reach the listeners right away (a listener bean
is created for it if needed). Events propagate to the parent context.
A published object that is not an ApplicationEvent is wrapped in a
PayloadApplicationEvent<T> of its static type: listeners of T get the
payload, listeners of PayloadApplicationEvent<T> the event; generated
listeners of PayloadApplicationEvent<T> match on the payload's runtime
type, like Spring. @EventListener methods of a FactoryBean run on the
factory, not on its product.
Asynchronous delivery: SimpleApplicationEventMulticaster(taskExecutor: SimpleAsyncTaskExecutor()) (as an applicationEventMulticaster bean)
calls every listener on the executor, like Spring's setTaskExecutor;
errors go to its errorHandler (without one the executor logs them).
@Async() on an @EventListener method runs just that method on a
TaskExecutor bean (@Async('name') picks one; otherwise the only or
@Primary one, else taskExecutor), its failure going to the error
handler or the log and its result being published when it arrives. Like
@EnableAsync, it is switched on by having a TaskExecutor bean: the
scheduling starter registers applicationTaskExecutor (alias
taskExecutor); without one @Async is ignored. The generator accepts
@Async on @EventListener methods only. As in Spring, a listener's
condition runs inside the multicaster's error handling (on the executor
when there is one), so a failing condition reaches the errorHandler.
@Component()
class Mailer {
@EventListener()
@Async()
Future<void> onPlaced(OrderPlaced order) => sendConfirmation(order);
}
Messages, resources, conversion #
-
MessageSource:StaticMessageSource,ReloadableResourceBundleMessageSource(messages_ru_RU.properties→messages_ru→ default or system locale → base,cacheSecondsreloading),MessageSourceAccessor, locale fallback,MessageFormatplaceholders, quotes,number,date/time(styles andSimpleDateFormatpatterns),choice. Define a bean namedmessageSource; the context delegates to it. In a child context it falls back to the parent's message source (not the parent context), like Spring. -
Resources:
lib/resources/is the classpath — the generator embeds its files, soclasspath:...works in CLI, AOT, Flutter and web without asset declarations;@Value('classpath:templates/mail.txt') Resource template(resolved by the context's resource loader, so custom protocols and asset loaders apply).ResourceLoaderwithclasspath:(also plain paths such asapp.properties, as in Spring),file:(percent-decoded:file:my%20dir/a.txt),package:my_app/config/app.yml(the package's file through the package config; not on the web or in AOT executables)http:/https:(UrlResource, read asynchronously withdart:io) and custom protocols (ProtocolResolver, tried first: this is how a Flutter asset loader plugs in),PathMatchingResourcePatternResolverwithAntPathMatcherforfile:and classpath patterns:classpath:searches the first classpath root with the pattern's base directory,classpath*:every root (added directories, then the embedded resources); matching directories are returned too and directories the pattern cannot match are not walked.FileSystemResourceof a directory exists but is not readable, like Spring's.Why
lib/resources/and notbin/or the project root:lib/is the only part of a Dart package that other packages can use, so a starter keeps its defaults and messages in its ownlib/resources/and they join the application's classpath exactly like the application's files (its generated module embeds them). Files elsewhere would stay invisible for dependencies and would be missing in AOT executables, Flutter apps and the web, which have no project folder at runtime. An app with onlybin/main.dartstill keeps its resources inlib/resources/;lib/may contain nothing else:my_app/ bin/main.dart # @SpringBootApplication lib/resources/ application.properties # classpath:application.propertiesTests see
test/resources/first (spring_boot_starter_test), likesrc/test/resourcesbeforesrc/main/resourcesin Java. -
DefaultConversionServicewith Spring Boot duration syntax (10s,PT5M), collections (also a single value as a one-element list), maps (Map→Map<String, V>,addMapConverters<K, V>()),Encoding(Spring'sCharset),BigIntand enums (an enum is its name as a string, also in${...}). AconversionServicebean (also in an ancestor context) is used for@Valueconversion.
SpEL #
final parser = SpelExpressionParser();
parser.parseExpression("name.toUpperCase()").getValue(context, user);
parser.parseExpression('Total: #{price * 2}', ParserContext.template).getValue(context);
Literals, inline lists/maps, properties, indexing, safe navigation (?.,
one step each, like Spring), methods, variables and functions, T(Type)
statics, constructors, @bean references, selection/projection,
assignment and ++/--, all operators (div, between, matches,
Elvis, ternary). Numbers follow Spring 7's operators: int arithmetic
for integers (7 / 2 is 3, 1 / 0 throws UnsupportedError like
Java's ArithmeticException), double once a real number is involved
(7.0 / 2 is 3.5, also on the web, where 7.0 is an int at run time:
real literals, T(Math) functions and the operators on them are tracked),
^ like OpPower (a long above Integer.MAX_VALUE, saturating), ==
with Java's double semantics (NaN != NaN) and deep for lists and maps,
'd' - 3 is 'a'. Selection (.?[...]) keeps the element type of the
source list. T() knows the common java.lang names (T(Integer),
T(Character), T(Math), new int[4], new char[2]), and String has
Java's format / formatted, getBytes and chars on top of the Dart
methods; T(Objects), T(System) and new StringBuilder() work too.
Dart has no reflection: user classes are reached through @Introspected
metadata (read by default from BeanIntrospector.shared),
FunctionPropertyAccessor / FunctionMethodResolver or maps. SimpleEvaluationContext restricts expressions for data binding
(no types, constructors, bean references or variable assignment).
Safe navigation is Spring 7's: a ?. step on null gives null and the
chain goes on, so a?.b.c fails with EL1007E on .c when a is null
(write a?.b?.c); the same for ?.[i], ?.?[...], ?.![...], ?.^[...]
and ?.$[...]. An exception thrown by a called
method or constructor is rethrown as is; failures of accessors and of SpEL
itself are SpelParseException / SpelEvaluationException with Spring's
SpelMessage codes and the position (Expression [1 + * 2] @4: EL1070E: Problem parsing left operand).
SpelParserConfiguration (Spring 7's builder) sets auto-grow and the limits
against hostile expressions: length 10 000, nesting depth 1000, operations
per evaluation 10 000 (EL1085E; the default follows
-Dspring.expression.maxOperations), 'x' * n up to 256 characters, string
+ up to 100 000, matches patterns (Dart RegExp syntax) up to 1000
characters and without nested quantifiers ((a+)+ is refused as EL1073E:
Dart's RegExp cannot be interrupted like Spring's matcher).
final parser = SpelExpressionParser(
SpelParserConfiguration.builder().maximumOperations(50000).build(),
);
In @Value('#{...}') the root is the bean factory (#{mailer}, #{@mailer})
with systemEnvironment (the environment variables), systemProperties
(Dart has no JVM system properties: the --key=value command-line
arguments, the commandLineArgs property source of the environment bean,
stand in), environment['app.name'] and
environment.getProperty('app.name', 'default'). Conversions use the
factory's ConversionService, parsed expressions are cached by the
resolver, and -Dspring.context.expression.maxLength changes the length
limit of @Value expressions.
Validation and data binding #
@Introspected()
@PasswordsMatch() // class-level constraint
class SignupForm {
@NotBlank() @Size(max: 20) String name = '';
@NotNull() @Email() String? email;
@Min(18) int age = 0;
@Valid() Address address = Address();
}
final binder = DataBinder(form, objectName: 'signup')
..validators.add(BeanValidator(introspector: context.getBean<BeanIntrospector>()))
..bind({'name': 'Ada', 'age': '36'})
..validate();
binder.bindingResult.fieldError('age'); // typeMismatch / Min ... with Spring codes
Constraints: NotNull, MustBeNull, NotEmpty, NotBlank, Size, Min,
Max, DecimalMin, DecimalMax, Positive(OrZero), Negative(OrZero),
Matches (@Pattern), Email, Digits, AssertTrue/False, Past,
PastOrPresent, FutureDate (@Future), FutureOrPresent, groups,
group sequences (@GroupSequence, BeanValidator(groupSequences:)),
@ConvertGroup, container element constraints (@Each([NotBlank()]),
@EachKey), ${validatedValue} in messages, @Valid cascade, custom
Constraint subclasses. A constraint on a type it cannot check (@Size on
an int) or with invalid attributes (Size(min: -1)) fails the build, and
at runtime throws UnexpectedTypeException / ConstraintDeclarationException
like Hibernate Validator. Error codes and arguments follow
Spring (NotBlank.signup.name, NotBlank.name, NotBlank.String,
NotBlank), so messages come from a MessageSource.
Method validation works like Spring's @Validated: with a
MethodValidationPostProcessor bean, constraints on the parameters and the
return value of a @Validated bean's methods are checked through the
generated proxy, and a violation throws ConstraintViolationException:
@Service()
@Validated()
class Registration {
@NotBlank()
String register(@Email() @NotBlank() String email) => 'id-$email';
}
@Configuration()
class ValidationConfig {
@Bean()
MethodValidationPostProcessor methodValidationPostProcessor(
BeanIntrospector introspector,
) => MethodValidationPostProcessor(BeanValidator(introspector: introspector));
}
Constraints on the parameters and return value of an overridden declaration (an implemented interface, a superclass, a mixin) apply too, like Hibernate Validator's constraint inheritance.
Immutable classes are bound through their constructor with
DataBinder.construct (Spring 6.1's constructor binding).
JSON (ObjectMapper) #
ObjectMapper converts objects to JSON and back, like Jackson — on metadata
the generator writes, so no reflection and no fromJson / toJson
boilerplate. One @Introspected() on a model is enough: classes of the same
package that it uses (property and constructor types) are introspected
automatically.
enum Availability {
inStock('in_stock'),
soldOut('sold_out');
const Availability(this.json);
@JsonValue() // "in_stock" instead of "inStock"
final String json;
}
class Author { // no annotation needed: used by Book
const Author(this.name, {this.born});
final String name;
final int? born;
}
@Introspected()
class Book {
const Book({
required this.isbn,
required this.title,
required this.author,
this.tags = const [],
this.availability = Availability.inStock,
this.publishedAt,
});
final String isbn;
final String title;
final Author author;
final List<String> tags;
final Availability availability;
@JsonProperty('published_at')
final DateTime? publishedAt;
}
final mapper = context.getBean<ObjectMapper>(); // Spring Boot: auto-configured
final json = mapper.writeValueAsString(book);
// {"author":{"born":1920,"name":"Herbert"},"availability":"in_stock",
// "isbn":"1","published_at":null,"tags":[],"title":"Dune"}
final again = mapper.readValue<Book>(json);
final books = mapper.readList<Book>('[{"isbn":"2","title":"Ubik","author":{"name":"Dick"}}]');
Immutable classes are created through their constructor: final fields,
required and default values (a missing optional property keeps its
default). Classes with setters and a no-argument constructor work too. The
constructor is the unnamed one, or the one marked @JsonCreator().
| Method | Does |
|---|---|
writeValueAsString(value) |
JSON text |
valueToTree(value) |
maps / lists / scalars (what jsonDecode returns) |
readValue<T>(json) |
a T from JSON text |
readList<E>(json), readMap<V>(json) |
List<E>, Map<String, V> |
readValue(json, const ListType<Book>(ClassType<Book>())) |
any generic type, described with TypeInfo |
treeToValue<T>(tree), convertValue<T>(value) |
from decoded JSON; from another object |
| Annotation | Meaning |
|---|---|
@JsonProperty('published_at') |
the JSON name (also on enum constants); access: JsonPropertyAccess.readOnly / writeOnly for one direction only. Jackson's required = true is the Dart parameter: a constructor parameter that is neither nullable nor has a default must be present |
@JsonIgnore() |
left out in both directions |
@JsonInclude(JsonInclusion.nonNull) |
on a property or class: skip null values (nonAbsent, nonEmpty, nonDefault: also empty / default values) |
@JsonIgnoreProperties(value: ['x'], ignoreUnknown: true) |
on a class: names to ignore; accept unknown properties |
@JsonPropertyOrder(['id', 'title']) |
on a class: these properties first, the rest after; @JsonPropertyOrder.alphabetic() sorts the rest whatever the mapper says |
@JsonCreator() |
the constructor to read with |
@JsonValue() |
on an enum field or getter: the JSON form of each value (also for enum map keys; an int one is matched by value, never as an index); on a property or method of a class: the object is written as that value (also as a map key) and read back through its one-parameter constructor; one per class (else the build fails) |
@JsonAlias(['name', 'fullName']) |
other JSON names accepted when reading |
@JsonFormat(pattern: 'dd.MM.yyyy', timezone: '+01:00', shape:) |
date pattern and offset; JsonFormatShape.number / string |
@JsonAnyGetter() / @JsonAnySetter() |
a map property written as extra properties / receiving the unknown ones |
@JsonUnwrapped(prefix:, suffix:) |
the nested object's properties inline |
@JsonNaming(PropertyNamingStrategy.snakeCase) |
on a class: its naming strategy |
@JsonView(Public) |
written / read only with that view (writerWithView, readerWithView) |
@JsonTypeInfo(...), @JsonSubTypes([...]), @JsonTypeName('cat') |
polymorphic types: the id as a property, an existing property, a wrapper object or array, or by deduction; defaultImpl |
Property order is Jackson 3's / Spring Boot 4's: the properties of an
object are written sorted by JSON name (SORT_PROPERTIES_ALPHABETICALLY),
the constructor parameters first and the other properties after them
(SORT_CREATOR_PROPERTIES_FIRST), each group sorted. This is a change from
earlier versions, which wrote the declaration order (superclass first):
ObjectMapper(sortPropertiesAlphabetically: false) — in Spring Boot
spring.jackson.mapper.sort-properties-alphabetically=false — brings that
back. @JsonPropertyOrder wins either way: the listed names (JSON or Dart
names) come first, the others follow in the mapper's order. Reading does not
depend on the order.
Built in: String, int, double, num, bool (numbers and booleans in
strings are accepted; NaN / infinities as "NaN", "Infinity",
"-Infinity"), DateTime (ISO-8601 like Jackson writes an
OffsetDateTime: 2026-01-01T12:00:00.000+02:00 for local times, Z for
UTC, so a Spring Instant reads it; with writeDatesAsTimestamps epoch
seconds with the fraction, 1700000000.123456, like Jackson 3),
Duration (ISO-8601 PT1M30S, or seconds with
writeDurationsAsTimestamps; also reads 90s and numbers as seconds), Uri, BigInt (as a string), enums, List, Set,
Map, nullable types and nested objects; readValue<Map<String, dynamic>>,
readValue<List<dynamic>> and readValue<Object?> give the plain decoded
JSON. A class that already has fromJson / toJson (json_serializable,
freezed) is converted with them. Anything else: a JsonSerializer<T> /
JsonDeserializer<T>, passed to ObjectMapper(serializers:, deserializers:), declared as a bean or grouped in a JsonModule bean
(Spring Boot adds every serializer, deserializer and JsonModule bean, like
@JsonComponents and Jackson Modules).
Settings: namingStrategy (snakeCase, kebabCase, upperCamelCase,
...), inclusion, failOnUnknownProperties (off, like Spring Boot),
writeDatesAsTimestamps, timeZone (UTC or a fixed offset such as
+02:00), indentOutput, sortPropertiesAlphabetically (on) — in Spring Boot from
spring.jackson.* (see spring_boot_dart).
Errors name the place: items[2].price: expected a number, got "abc",
missing property "isbn" (required by Book).
Classes of other packages are introspected with
@Introspected(classes: [ExternalDto]). Generic model classes
(Page<T>) are read with their type arguments:
mapper.readValue(json, const GenericType<Page<Book>>([ClassType<Book>()])). readTree gives a
JsonNode tree; JsonMapper.builder() / mapper.rebuild() configure with
Jackson's feature enums (SerializationFeature, DeserializationFeature,
DateTimeFeature, MapperFeature). Not supported yet: getters without a
field.
AOP #
AOP (aspect-oriented programming) runs your code before, after or around methods of other beans without editing them. Use it for things that cut across many classes: logging, timing, auditing, permission checks, retries, caching.
| Term | What it is | In code |
|---|---|---|
| Aspect | A bean holding the extra code | a class with @Aspect() and @Component() |
| Advice | One piece of extra code and when it runs | a method with @Before, @After, @AfterReturning, @AfterThrowing or @Around |
| Pointcut | Which methods the advice applies to | the string in the annotation: '@annotation(Timed)', 'execution(* OrderService.*(..))' |
| Join point | One intercepted call | the JoinPoint / ProceedingJoinPoint parameter |
| Proxy | The generated wrapper that calls the advice | written by the generator, injected instead of the bean |
A first aspect
Time every method marked @Timed():
// An annotation is a plain class with a const constructor.
class Timed {
const Timed();
}
@Aspect()
@Component()
class TimingAspect {
@Around('@annotation(Timed)')
Object? time(ProceedingJoinPoint call) {
final watch = Stopwatch()..start();
try {
return call.proceed(); // runs the real method
} finally {
print('${call.signature.name}: ${watch.elapsedMilliseconds} ms');
}
}
}
@Service()
class Search {
@Timed()
List<String> find(String text) => ['dart', 'spring'];
}
// context.getBean<Search>().find('d'); // prints "find: 0 ms"
How it works: the generator (spring_core_dart_generator, required)
writes a proxy class for each bean. When the context starts, every bean
that some pointcut matches is replaced by its proxy, and the proxy is what
other beans and getBean receive. A call to search.find(...) goes
through the proxy, which runs the advice. Beans that no pointcut matches
are not wrapped and cost nothing.
Rules to remember:
- The aspect must be a bean:
@Aspect()plus@Component()(or a@Beanmethod). Aspects can inject other beans as usual; they are never proxied themselves. - Only calls from outside go through the proxy. A call through
thisinside the bean (findcallingcount) skips the advice, exactly as in Spring. Call through another bean, or inject the bean into itself with@Lazy()(see Injection) orObjectProvider<T>. - The proxy implements the bean class or its interfaces, so the bean needs public members or an interface with the advised methods (the error message says which). See "Proxies" below.
Kinds of advice
| Annotation | Runs | Parameters (all optional unless noted) | Can change the result |
|---|---|---|---|
@Before |
before the method | JoinPoint |
no; throwing stops the call |
@After |
after the method, whether it returned or threw (like finally) |
JoinPoint |
no |
@AfterReturning |
after it returned normally | JoinPoint, then the result |
no |
@AfterThrowing |
after it threw; the error is rethrown afterwards | JoinPoint, then the error |
no |
@Around |
instead of the method; you decide if and how to call it | ProceedingJoinPoint (required) |
yes |
@Aspect()
@Component()
class AuditAspect {
// A named pointcut: the method body is empty, its name is the pointcut.
@Pointcut('within(OrderService)')
void orders() {}
@Before('orders()')
void before(JoinPoint call) => print('calling ${call.toShortString()} '
'with ${call.args}');
// Runs only when the result is a double: the parameter type filters.
@AfterReturning('orders()')
void returned(JoinPoint call, double total) => print('total: $total');
// Runs only for StateError; other errors pass by.
@AfterThrowing('orders()')
void failed(StateError error) => print('failed: ${error.message}');
@After('orders()')
void after() => print('done');
// Change arguments, the result, or skip the call entirely.
@Around('execution(* OrderService.discount(..))')
Object? clamp(ProceedingJoinPoint call) {
final [percent] = call.args;
return call.proceed([(percent! as int).clamp(0, 50)]);
}
}
Parameters are positional and matched by type: JoinPoint /
ProceedingJoinPoint by exact type, and in @AfterReturning /
@AfterThrowing the first other parameter gets the result or the error.
Anything else is a build error.
Async methods: for a method or getter returning a Future (or a
FutureOr whose value is a future), @After,
@AfterReturning and @AfterThrowing run when the future completes
(with its value or error), not when the method returns. In @Around,
proceed() returns the future; make the advice async and await it to
work with the value. Other advice may be async too: on a method returning
a Future it is awaited before the call goes on; on a synchronous method
an async advice is an error:
@Around('execution(Future *(..))')
Future<Object?> timeAsync(ProceedingJoinPoint call) async {
final watch = Stopwatch()..start();
try {
return await (call.proceed()! as Future<Object?>);
} finally {
print('${call.signature.name}: ${watch.elapsedMilliseconds} ms');
}
}
What a JoinPoint gives you:
| Member | Example value |
|---|---|
signature.name |
find |
signature.declaringType |
Search |
signature.targetType |
the bean class, e.g. DefaultSearch |
signature.returnType, signature.parameterTypes |
List, [String] |
signature.kind |
MemberKind.method, getter or setter |
signature.annotations |
the method's annotation objects, e.g. [Timed()] |
args |
the arguments, ['d'] |
target, proxy |
the bean, the proxy |
toShortString() |
execution(Search.find(..)) |
proceed([args]) (ProceedingJoinPoint only) |
calls the next advice or the method; args replaces the arguments |
Pointcut syntax
A pointcut is one or more designators combined with operators:
designator(pattern) [&& | || | ! designator(pattern) ...]
| Designator | Matches | Example |
|---|---|---|
execution(ret Type.name(params)) |
methods by return type, declaring type, name and parameters | execution(* OrderService.find*(..)) |
within(TypePattern) |
every member whose body is in a matching class: an inherited, not overridden member is within its superclass (AspectJ's lexical scope); Type+ also subtypes |
within(*Repository), within(Repository+) |
target(Type), this(Type) |
every member of beans of that type or a subtype (interfaces too) | target(PaymentGateway) |
args(types) |
methods whose arguments have these types: the declared parameter type, else the runtime argument (.. any number) |
args(String, ..) |
@args(Annotation, ..) |
methods whose arguments' classes carry the annotations | @args(Audited) |
bean(namePattern) |
every member of beans with that name, an alias or a qualifier value | bean(order*) |
@annotation(Annotation) |
methods carrying the annotation (also when it is on the interface method they implement, not when only a superclass method they override has it, like AspectJ; annotation-driven advisors such as @Cacheable and @Validated groups read MethodSignature.mergedAnnotations, which include the overridden methods' annotations, like Spring) |
@annotation(Timed) |
@within(Annotation) |
every member whose body is in a class carrying the annotation | @within(Service) |
@target(Annotation) |
every member of beans whose class carries the annotation | @target(Service) |
name() |
a named pointcut: a @Pointcut method of the same aspect; another aspect's as Type.name() |
orders(), AuditAspect.orders() |
Patterns:
*in a name matches any characters:find*,*Service,*.- In
executionparameters,..matches any number of parameters (including none) and*exactly one:(..),(),(String, ..),(*, int). - The return type comes first;
*means any:execution(* *(..)),execution(void *(..)),execution(Future *(..)). The type before the method name is optional:execution(* find*(..))matchesfind*methods of every bean. - Generics and
?are ignored when matching:Future<int>matchesFuture,int?matchesint. - Type names are simple names. Dart has no packages, so
com.x.ServicemeansService, and a package wildcard likewithin(com.x..*)is an error instead of silently matching everything. Type+inwithinandexecutionmeans the type and its subtypes:execution(* Repository+.*(..)).executionmodifiers:public,protected(Dart has no such modifier: it matches@protectedmembers ofpackage:meta) andprivate(never matches: private members are not proxied). Athrowsclause is an error, since Dart methods declare no exceptions; select errors with a typed@AfterThrowingparameter instead.
Operators: && / and, || / or, ! / not, and parentheses.
Examples:
| Pointcut | Advises |
|---|---|
execution(* *(..)) |
every method of every bean (methods only: execution never matches getters or setters, explicit or the implicit ones of a public field) |
within(OrderService) |
everything of the OrderService bean, getters and setters included |
execution(* OrderService.*(..)) |
the methods of OrderService |
execution(* find*(..)) |
methods whose name starts with find, in any bean |
execution(Future *(..)) |
async methods |
execution(void *(String, ..)) |
void methods whose first parameter is a String |
target(PaymentGateway) |
every bean implementing PaymentGateway |
bean(order*) |
beans named order… |
@annotation(Timed) |
methods marked @Timed() |
@within(Service) |
every member of @Service classes |
within(*Service) && !execution(* get*(..)) |
services, except get… methods |
orders() || bean(payments) |
a named pointcut or the payments bean |
Named pointcuts are shared by all aspects: refer to them as orders() or,
to be explicit, AuditAspect.orders().
A pointcut binds advice parameters by name, as in AspectJ: a lowercase
identifier in @annotation(timed), @within(timed), @target(timed),
@args(audited), this(proxy), target(bean) or args(id, ..) names a
parameter of the advice method, which receives the annotation, the proxy,
the bean or the argument (a type name starts with an uppercase letter; int, double,
num, bool, dynamic and void are types). The bound value must have
the parameter's type, else the call does not match.
@AfterReturning(.., returning: 'result') / @AfterThrowing(.., throwing: 'error') name the parameter that receives the return value or error (by
default the first one that is neither a JoinPoint nor bound). Advice and
@Pointcut methods of the aspect's superclasses and mixins are inherited.
A parameter the pointcut does not bind, or a bound name without a
parameter, fails the build; an invalid pointcut fails when the context
starts, with the reason; the Zed extension (dart_spring) underlines it
while you type.
@Around('@annotation(timed) && args(isbn, ..)')
Object? time(ProceedingJoinPoint joinPoint, Timed timed, String isbn) =>
joinPoint.proceed();
Order of advice
- Several aspects:
@Orderon the aspect class decides; the lower value is the outer one (its@Beforeruns first, its@Afterlast). Aspects without@Ordercome last. - Inside one aspect: around → before → after → afterReturning → afterThrowing, as in Spring.
@Aspect() @Component() @Order(1) class SecurityAspect { ... } // outer
@Aspect() @Component() @Order(2) class TimingAspect { ... } // inner
Advice without annotations
A PointcutAdvisor bean is applied like an aspect. Write the pointcut as
an ExpressionPointcut (same syntax) or as your own AopPointcut class:
class UpperCase implements MethodInterceptor {
@override
Object? invoke(MethodInvocation invocation) =>
(invocation.proceed()! as String).toUpperCase();
}
@Configuration()
class AopConfig {
@Bean()
PointcutAdvisor upperCase() =>
DefaultPointcutAdvisor(ExpressionPointcut('bean(slogans)'), UpperCase());
}
Aspects and advisors are ordered by @Order or by implementing Ordered
(lower runs outside); those of parent contexts apply too.
Aspects can be per instance, as in Spring: @Aspect('perthis(pointcut)')
or @Aspect('pertarget(pointcut)') on a @Scope('prototype') aspect gives
one aspect instance per proxy or per target bean (a singleton per-instance
aspect, percflow and pertypewithin fail the build).
@DeclareParents('Type+', defaultImpl: Impl) static Iface? mixin; on an
aspect makes the generated proxies (subclass proxies too) of the matching
beans of its module implement Iface, forwarding to one Impl per proxy; this(iface) binds
it. Proxies are Advised: addAdvisor, removeAdvisor, addAdvice and
removeAdvice change the advice of a live proxy. ComposablePointcut
combines pointcuts with union and intersection;
ProxyFactory.forBean(beanFactory, name) and AspectJProxyFactory
(addAspect) create more proxies of a bean's generated proxy class.
A call a bean makes to its own method skips the advice, as in Spring, also
for a generated subclass proxy (the bean is its own proxy): while one of
its advised members runs, including the code a Future member runs after
an await, calls on the bean go straight to its code, except calls
through a @Lazy reference to the bean itself (@Lazy() this._self),
which are advised as in Spring. The body of an
async* (Stream) or sync* (Iterable) member runs only when its result
is listened to or iterated, after the member returned: the calls it makes
on the bean are advised. Call through
AopContext.currentProxy() to get the advice (on a subclass proxy it
advises the one call made right after it, so call it for each call); unlike Spring the proxy is
always exposed (no exposeProxy flag), also in the asynchronous code an
advised Future method starts:
Future<Book> refresh(String isbn) =>
(AopContext.currentProxy() as Books).find(isbn); // advised
Proxies
Proxies are generated classes that implement the bean class (when it has
no private members and is not final, base or sealed) and its
interfaces, so the bean can be injected by either. A component class that
cannot be implemented (private members, operators, base) is created as a
generated subclass instead, also when it has an interface, superclass or
mixin that could be implemented, which runs its
public methods, getters and setters through the advice (Spring's CGLIB
proxy): the bean is its own proxy, AopUtils.isAopProxy is true once
advice is attached, and a member the subclass cannot override (a private
type, a default value that is not a public constant, @nonVirtual) is
inherited and not advised, with a build warning. A component class that can be neither
implemented nor extended (final, sealed, interface, a factory
constructor, generic) is proxied through the interfaces it implements;
without one, like a @Bean method's type that cannot be proxied, it keeps
its signatures: advice matching it fails the context at startup with the
reason and what to change; add an interface with the advised methods and
inject the bean by it. AopUtils.getTarget(bean) unwraps a
proxy. The generator option aop_proxies (all by default, interfaces,
none) controls which proxies are generated (subclass proxies only in
all; see spring_core_dart_generator). Annotations are
matched by names the generator writes, so @annotation(Timed) also works in
minified and obfuscated release builds.
Public fields are advised like explicit getters and setters: a field int count is an implicit getter count and (when not final) setter count=,
and the proxy overrides both. They are MemberKind.getter / setter
members, reached through within, bean, target, @within, custom
advisors and @annotation(X) (an annotation on the field counts for both
accessors, except constraints and @Valid, which validate the field). Spring
has no implicit accessors, so there is no get* name to match with
execution.
The build parses every advice and @Pointcut expression with the runtime's
own parser: a syntax error fails the build with the aspect, the advice and
the character position, instead of the context at startup (a reference to a
pointcut of a class outside the module is left to the runtime). In
aop_proxies: all the @SpringBootApplication output, which knows every
module of the application (its scan, the nested @ComponentScan modules it
imports, the auto-configurations), also skips the proxy class of a bean no
pointcut of the application can match (execution and within with a type
pattern, @annotation when no member carries the annotation, bean(...);
decided with the same matcher the runtime uses). It keeps the bean's
signatures, so an advisor that turns out to match it anyway (a module added
by hand) fails the startup with the reason instead of being skipped
silently. Nothing is skipped in a @ComponentScan module (any other module
may import it), when no module has advice, when a module declares an
advisor bean, when an auto-configuration declares aspects or an advisor
bean whose pointcut is not an AnnotationPointcut (by the static type
the build sees: DefaultPointcutAdvisor(const CacheAnnotationPointcut(), ...) keeps pruning on), for a bean whose
class or members carry an annotation from outside spring_core_dart or
@Validated (annotation-driven advisors such as @Cacheable), for beans
with a @DeclareParents introduction, or for subclass proxies.
aop_prune: false in
build.yaml generates the proxies of every bean again.
Logging #
@Log() on a class gives it a logger, like Lombok's @Slf4j: the
generator writes a log getter (an extension in the scan's .spring.dart
file, which the class's library imports) returning the logger
<package>.<Class>; topic: sets another name, fieldName: another
getter name (needed where dart:math or dart:developer, which declare a
function log, are imported). The logger is a SpringLog, the logging
facade (Spring's commons-logging / SLF4J): the framework, starters and
applications log through it; a backend such as
spring_boot_starter_logging decides where records go. Without one,
warnings and errors are printed.
import 'package:neon/app.spring.dart'; // the generated file
@Service()
@Log(topic: 'neon.checkout')
class Checkout {
void pay(Order order) {
log.info('order placed', fields: {'order': order.id});
log.debug(() => 'basket: ${order.dump()}'); // built only if enabled
try {
gateway.charge(order);
} on PaymentException catch (e, s) {
log.error('payment failed', error: e, stackTrace: s);
}
}
}
@Component()
@Log(fieldName: 'logger') // logger 'neon.Payments'
class Payments {
void refund() => logger.warning('refund');
}
Fields for every line logged inside a call (like an MDC):
SpringLog.withFields({'request': id}, () => handle(request)). With
Spring Boot, levels come from logging.level.* properties
(logging.level.neon.checkout=debug).
Other extension points #
BeanFactoryPostProcessor / BeanDefinitionRegistryPostProcessor,
BeanPostProcessor, custom scopes, hand-written BeanDefinitions and
BeanModules, registerSingleton, @Configurable + autowireBean for
objects created elsewhere (e.g. Flutter State).
Post-processor beans are created in Spring's phases
(PostProcessorRegistrationDelegate): PriorityOrdered, then Ordered
(or @Order), then the rest — each phase only after the previous one is
registered or invoked, so a PriorityOrdered BeanPostProcessor already
processes the Ordered ones; the names are queried again before each
phase (registry post-processors repeat the last phase while they register
more of themselves). A regular bean created while the BeanPostProcessors
are registered (a dependency of one of them) misses the later ones; like
Spring's BeanPostProcessorChecker this is logged at info level ("Bean
'repo' of type [Repo] is not eligible for getting processed by all
BeanPostProcessors ...", naming the post-processor being created) — make
such a dependency an ObjectProvider or @Lazy. EmbeddedValueResolverAware
gets Spring's EmbeddedValueResolver: ${...} placeholders, then #{...}
SpEL (resolver(r"#{'${app.home}' + '/cache'}")).
Your own interface implementations: mark an annotation class with
@InterfaceProxy() and every abstract class carrying that annotation
becomes a bean whose implementation the generator writes — each method hands
the call, with its annotations, parameter names and types, to an
InvocationHandler that an InvocationHandlerFactory bean creates (Java's
dynamic proxies, without reflection). This is how @FeignClient works, and
how Spring Data repositories will:
@InterfaceProxy()
class Remote {
const Remote(this.name);
final String name;
}
@Remote('greetings')
abstract interface class Greeter {
Future<String> greet(String name);
}
final class RemoteHandlers implements InvocationHandlerFactory {
@override
bool supports(InterfaceMetadata<Object> metadata) =>
metadata.annotation<Remote>() != null;
@override
InvocationHandler create(InterfaceMetadata<Object> metadata) => MyHandler(metadata);
}
Your own method annotations: mark the annotation class with
@BeanMethodAnnotation() and the generator lists every bean method that
carries it in BeanDefinition.annotatedMethods, with its annotations and a
closure that calls it — what Spring does with reflection
(MethodIntrospector). This is how @Scheduled finds its methods:
@BeanMethodAnnotation()
class Poll {
const Poll(this.every);
final Duration every;
}
@Component()
class Inbox {
@Poll(Duration(seconds: 30))
void check() { ... }
}
// In a BeanPostProcessor (with BeanFactoryAware) or after refresh:
for (final method in beanFactory.getBeanDefinition(name).annotatedMethods) {
final poll = method.annotations.whereType<Poll>().firstOrNull;
if (poll != null) {
Timer.periodic(poll.every, (_) => method.invoke(bean, const []));
}
}
Differences from Spring #
| Spring | Here | Why |
|---|---|---|
| Reflection, runtime classpath scanning | build_runner code generation, @ComponentScan per directory |
No reflection in Flutter/AOT |
PropertySource class |
AbstractPropertySource |
@PropertySource is the annotation |
Scope SPI, Pointcut interface |
CustomScope, AopPointcut |
@Scope, @Pointcut are the annotations |
@Null, @Pattern, @Future |
MustBeNull, Matches, FutureDate (codes unchanged) |
Clash with Dart's Null, Pattern and Future |
List<@NotBlank String> (container element constraints) |
@Each([NotBlank()]) on the list (map values too), @EachKey([...]) for map keys |
Dart has no annotations on type arguments |
@GroupSequence on a group interface |
BeanValidator(groupSequences: {Complete: [Default, Strict]}) (@GroupSequence on a validated class works as in Java) |
Dart group types carry no annotations readable at runtime |
Validator.supports(Class) |
supports(Object target) |
Dart cannot test subtypes on a Type |
@Autowired on a private field or setter (reflection) |
Only public fields and setters; a private field is injected through a constructor parameter (this._repo, Dart 3.13 primary constructors included) |
The generated code is another library and cannot reach private members |
CGLIB-enhanced @Configuration classes |
A generated subclass in the .spring.dart file: a class with instance @Bean methods must be extendable from another library (not final, sealed or interface, public generative constructor) or use proxyBeanMethods: false, else the build fails; an inter-bean call of a FactoryBean method returns the container's factory itself (Spring: an enhanced factory whose getObject() returns the container's product) |
No runtime bytecode generation |
@Bean definitions know only the declared return type until the bean is created (beanClassName is null) |
The type of the returned expression(s) is known at build time and the definition is registered under it and its supertypes: getBean<HikariDataSource>(), getBeanNamesForType, @ConditionalOnBean / @ConditionalOnMissingBean and AOP (within(Impl), annotations on implementation methods) see it before the bean exists |
Deliberately better: the generator sees the method body |
| Duplicate bean names are found at startup (Boot disables overriding) | Two unconditional declarations of one name fail the build; declarations with conditions (@Profile, @Conditional...) may share a name and are checked at refresh (BeanDefinitionOverrideException when both match) |
Fail early when the clash is certain |
| CGLIB / JDK proxies | Generated implements proxies; a class that cannot be implemented (private members, base) is created as a generated subclass, its own proxy; a final, sealed or interface class (or one with only factory constructors) is advised only through an interface it implements |
No runtime bytecode generation; Dart cannot extend such classes from another library |
A CGLIB proxy wraps a separate target, so only the bean's this. calls skip the advice |
A generated subclass is the bean itself: while one of its advised members runs (also after an await in a Future member), every call on the bean skips the advice, also one coming back through another bean; a call through a @Lazy reference to the bean is advised, and AopContext.currentProxy() advises the one call made right after it. The body of a Stream (async*) or lazy Iterable (sync*) member runs later, when the result is listened to or iterated, so the calls it makes on the bean are advised |
Dart cannot tell a this. call from another call on the same object |
| Circular references (opt-in) | Always an error | Same as Spring Boot's default |
A ConfigurationCondition of the registerBean phase on a @Bean method sees the @Bean methods of the configuration classes processed before it |
It is decided when its class is read, in the same class order, but @Bean methods without a registerBean-phase condition (in classes not decided by the reader) register while parsing, so it also sees those of later classes and modules |
Only conditional definitions wait for the reader; the order of the others stays the definition order |
UrlResource.exists() sends a HEAD request |
exists() is true; existsAsync() sends the HEAD request (config data imports, optional: and @PropertySource(ignoreResourceNotFound: true) use it, so a 404 is a missing file); reading fails for a status of 400 or above |
No synchronous HTTP in Dart |
SmartFactoryBean.getObject(Class) |
getObjectForType(Type); the extra product types are served once the factory exists |
No overloading; no reflection on types not created yet |
@Lookup methods with arguments (passed to the bean's constructor by getBean(name, args)) |
Lookup methods take no parameters (build error): inject an ObjectProvider<T> or a factory and pass the values yourself |
Generated bean definitions resolve the constructor arguments themselves |
CGLIB subclass for @Lookup methods |
A generated subclass in the .spring.dart file: the class must be extendable from another library and the methods public; an abstract class may have no abstract members besides its lookup methods (a build error; Spring fails when one is called) |
No runtime bytecode generation |
registerSingleton(name, object) is found by every type of the object |
Without types: it is found only by its runtime type: pass types: [SystemClock, Clock] to find it by its interfaces |
Dart cannot list the supertypes of an object at runtime |
SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference / determineCandidateConstructors |
Only predictBeanType |
No early references (circular references are errors); the generator picks the constructor |
| Asynchronous initialization | refresh() is synchronous (load config first with processAndApply()); refreshAsync() awaits Future-returning init callbacks, the bean being injectable meanwhile |
Dart cannot block on a Future |
execution(* getName(..)) matches an explicit or generated getter (they are methods in Java) |
execution matches methods only; a public field is an implicit getter / setter that the proxy advises (MemberKind.getter / setter, reached by within, bean, @annotation, custom advisors); an explicit getter is a getter too |
Dart has getters and setters of its own and fields have implicit ones |
| Package-qualified AOP type patterns | Simple type names; qualified wildcards are rejected | No packages in Dart |
execution(Animal+ *(..)) matches subtypes of any return or parameter type |
Type+ as an execution return or parameter type knows Object, num, Iterable and Pattern only; another fails the build (list the types: (Dog || Cat)) |
Signatures carry the declared type's name, not its supertypes |
| Annotations are read by reflection, whatever their values | An annotation whose values include a private constant, function or symbol (#_name) is left out of the runtime metadata (public symbols and records are kept) (@annotation(X) does not see it); a constraint or @Valid with one fails the build |
Generated code cannot reference private declarations of another library |
execution(protected ...), throws patterns |
protected matches @protected members (package:meta), private matches nothing; a throws clause fails the build |
Dart has no protected modifier and declares no exceptions |
args(Type) / @args(A) test any runtime class (reflection) |
Type tests the generator writes for the types named in the aspect and the scanned classes carrying the annotation | No reflection |
@DeclareParents applies to any matching bean |
To the generated proxies (interface and subclass) of the matching beans of the aspect's module; a subclass proxy implements the interface even when a condition leaves the aspect out (its members then throw) | Proxy classes are written at build time |
ProxyFactory proxies any object |
ProxyFactory / AspectJProxyFactory need the generated proxy class of a bean (ProxyFactory.forBean) |
No runtime proxy classes |
A generic bean class matches generic injection points (ResolvableType) |
Registered under its raw type (Repo<dynamic>); inject it by that type, or declare a @Bean Repo<User> |
Type arguments of a class bean are not known at runtime |
| Beans of functional interfaces and records are classes | Function and record beans are found by their exact type, in single injection points only | Dart function and record types are structural |
Jackson ObjectMapper (reflection) |
ObjectMapper on generated metadata; a model needs @Introspected() (its classes of the same package are included) |
No reflection |
Optional @Value with ${x:#{null}} |
A nullable target gets null when a placeholder is missing |
Dart null safety |
Lazy beans, prototypes and @Lazy injection points fail on first use |
refresh() validates them too (missing or ambiguous beans, cycles, placeholders) |
Fail at startup, not later |
getBean does not return registerResolvableDependency values |
getBean<BeanFactory>(), getBean<ApplicationContext>() return them |
Same objects that injection gets |
| Custom qualifier annotations match by type and attributes | They match by their qualifier value only | Annotations are not read at runtime |
@EventListener(condition = "#event.big") (SpEL) |
condition: takes a Dart function |
Type-checked, no reflection |
@EnableAsync switches @Async on; @Async on any bean method (proxies) |
A TaskExecutor bean switches it on (the scheduling starter has one); @Async on @EventListener methods only; a TaskExecutor runs tasks in a later event-loop turn |
No runtime proxies; Dart isolates have no threads |
ImportSelector.selectImports(metadata) returns class names; registrars get the importing class's metadata; imported classes get fully-qualified names; BeanRegistry.registerBean(Class) instantiates the class |
selectImports() returns BeanModules, no metadata; simple bean names; registerBean<T> needs a supplier |
No reflection, no packages in Dart |
SmartLifecycle.stop(Runnable callback) blocks close(), stop() and restart() per phase |
stopWithCallback(callback); closeAsync() waits for a later callback (per-phase timeouts), close() goes on; a bean asked to start while its callback is pending (restart(), start() after stop()) starts when the callback fires |
No overloading; Dart cannot block on a Future |
A null result reaches an @AfterReturning parameter of any type |
Only Object? / dynamic (and nullable types) get null |
Dart null safety |
Jackson writes Java enums by name (IN_STOCK) |
Enums go over the wire by their Dart name (inStock); use @JsonValue to talk to Java peers |
Dart naming conventions |
Jackson BigInteger as a JSON number |
BigInt as a string |
jsonEncode has no arbitrary-precision numbers |
Jackson sets a JSON null or a missing creator value of a reference type (String, a class) to null; a primitive one fails with FAIL_ON_NULL_FOR_PRIMITIVES (Jackson 3's default) |
A JSON null or missing constructor value for any non-nullable type is an error; int, double, num and bool follow DeserializationFeature.failOnNullForPrimitives (on; off reads 0 / false, like Jackson) |
Dart null safety |
spring.jackson.time-zone with any zone ID |
UTC / Z or a fixed offset (+02:00, GMT+2); a named zone (Europe/Berlin) fails at startup with a message saying so |
Dart has no time zone database |
Boot binds every spring.jackson.* key (Jackson features, date-format, locale, ...) |
Only the keys of the spring_boot_dart table are read; any other spring.jackson.* key is logged as a warning and ignored |
The mapper has only the feature enums JsonMapper.builder() lists |
SpEL matches and String methods are Java's |
Dart's RegExp syntax and Dart's String methods (replaceAll with a string is literal) |
Dart runtime |
SpEL T(java.lang.Math) finds any class |
T() needs registered types and takes Dart type names (T(int)) and the common java.lang / java.util ones (Math, Integer, Objects, System, StringBuilder, ...); T(System).getProperty(name, default) gives the default |
No reflection; Dart has no system properties |
SpEL on the JVM knows 7.0 from 7 |
On the web an integral double that comes from outside SpEL (a variable, a property not declared double on an @Introspected class, an element of a collection SpEL did not build) counts as an int |
JavaScript has one number type |
| SpEL reads the public properties and methods of any object (reflection) | StandardEvaluationContext and SimpleEvaluationContext read @Introspected classes of BeanIntrospector.shared (methods: withInstanceMethods() for the simple one); other objects need a PropertyAccessor / MethodResolver |
No reflection |
| Jackson reads and writes any enum | An enum that no @Introspected class uses has no values known at runtime: readValue<Color> fails with a message; pass EnumType<Color>(Color.values) or introspector.registerEnum(...) |
No reflection |
Jackson reads Range<Integer> of class Range<T extends Number> |
A generic class with bounded type parameters is described raw (Range<num>): writing works, reading it with type arguments fails with a message (register a JsonDeserializer) |
Dart cannot build Range<X> for a type parameter X without that bound |
SpEL 1 instanceof T(Long) is false (an Integer) |
true: T(Long), T(Integer), T(Short) and T(Byte) are all Dart's int |
Dart has one integer type |
Boot reads .properties config files as ISO-8859-1 |
UTF-8 unless the loader says otherwise (PropertiesPropertySourceLoader(encoding: latin1)) |
Dart files are UTF-8 |
SnakeYAML reads YAML 1.1: yes / no / on / off are booleans (a String property gets true / false), 017 is octal (15), 1_000 is 1000 |
YAML 1.2 (core schema): those stay the strings yes, on, ... (a bool property still converts them), 017 is 17, 0o17 is octal, 1_000 is a string; quote such values to read the same in both |
Dart's package:yaml implements YAML 1.2 |
ConversionService.canConvert(Class, Class) |
canConvertType<S>(Type target); canConvert(Object? source, Type target) checks a value |
Dart cannot pass a source type and test subtypes on a Type value |
ChronoUnit (all java.time units) |
ChronoUnit with nanos … days (no months / years), for DurationStyle.parse(unit:) |
A Dart Duration has no calendar units |
configtree: reads any file system |
File system paths only (not classpath: or assets); values are strings |
Config trees are mounted volumes |
ReloadableResourceBundleMessageSource reload checks every resource's timestamp |
File resources by modification time; others are re-read; preloaded (async-only) resources change only through preload() |
Not every resource has a timestamp or sync reads |
Roadmap #
Plans and sketches of coming features, one file per package: roadmap/.
License #
Apache License 2.0. Spring is a trademark of Broadcom Inc.; this project is an independent port and is not affiliated with or endorsed by the Spring team.