Coming from Spring
How Spring concepts such as beans, constructor injection, @Scheduled, @KafkaListener, and @Transactional map onto wakuwaku, and where the two differ.
If you have built services with Spring Boot, many wakuwaku concepts will look familiar: a registry of shared components, components built from their dependencies, scheduled jobs, message listeners, repositories, validation. The difference is in when things are checked. Spring assembles the application at startup through reflection. wakuwaku assembles it through types, so most mistakes Spring reports when the application starts are compile errors in wakuwaku.
The Java snippets on this page are illustrations only. They show the Spring idiom being compared, not complete programs.
Concept map
| Spring | wakuwaku | Notes |
|---|---|---|
ApplicationContext, @Configuration + @Bean | ServiceBuilder::new(..).push(..) | You build the registry yourself in main. Lookup is by type, resolved by the compiler. |
| Constructor injection | ServiceCreation::Dep | A component built from one registered dependency names it as Dep. A component that needs several is built by a closure: compose_cron_job, compose_grpc_service, compose_iggy_consumer. |
@Component scanning | none | Every component is registered explicitly. |
@Value, @ConfigurationProperties | none | Build a config struct yourself and register it like any other dependency. |
@Scheduled | CronJob::INTERVAL | A CronJobInterval: AfterFinish ≈ fixedDelay, AtLeast ≈ fixedRate (runs never overlap), AfterStart ≈ fixedRate + @Async (runs may overlap). No cron expressions. |
@KafkaListener | EventHandler + ServiceBuilder::iggy_consumer | The broker is Apache Iggy, not Kafka. A handler receives a batch of events of one key. |
KafkaTemplate.send | Publisher::publish | The event's key picks the topic and the partition. |
| Spring Data repository methods | Query values implementing Query + Execute | Each query is a struct that declares the entities it reads and writes. No ORM, no derived queries. |
@Transactional(readOnly = true) | Db<S, ReadOnly> | A capability of the handle, checked at compile time. See below. |
@Transactional | Transaction + TxFlow | The transaction is a value: a flow whose steps run queries, driven by the library, not a proxy around a method. |
JPA entity listeners, @TransactionalEventListener | BeforeWrite / AfterWrite hooks | Hooks wrap a handle and run around write queries. |
Bean Validation (@Valid, @NotBlank) | wakuwaku::proof | A check returns Proven<T, P>, and functions that need valid input take that type. |
@Cacheable | Redis Cache | Not annotation-driven: you call get and put yourself, as you would with Spring's Cache or RedisTemplate. |
ShedLock (@SchedulerLock) | Redis Lock | Lease-based and non-blocking: acquiring returns a token or None. Take it inside the job's run. |
grpc-spring-boot-starter (@GrpcService) | ServiceBuilder::grpc_service | Builds tonic servers from the registry. You start the tonic server yourself. |
SpringApplication.run | your #[tokio::main] function | Starting and stopping components is explicit; see the lifecycle. |
For Spring modules that have no counterpart yet, see the roadmap.
Beans, injection, and scheduling
In Spring, the container discovers DigestJob, finds beans for its constructor parameters, and starts the scheduled method:
@Component
class DigestJob {
private final JdbcTemplate jdbc;
private final Mailer mailer;
DigestJob(JdbcTemplate jdbc, Mailer mailer) {
this.jdbc = jdbc;
this.mailer = mailer;
}
@Scheduled(fixedRate = 60 * 60 * 1000)
void run() {
// Query `jdbc`, send mail with `mailer`.
}
}Things to notice:
- Registration is explicit.
ServiceBuilder::new(..).push(..)plays the role of@Beanmethods. Nothing is discovered on the classpath. - The constructor is a closure.
compose_cron_jobhands the closure the builder, ands.provide::<T, _>()looks up each registered dependency by type, as constructor injection would. A missing one is a compile error. A component built from a single dependency can instead implementServiceCreationand be registered withcron_job::<Job, _>(). Dependencies are cloned out of the registry, so keep them cheap to clone (pools, handles,Arcs). - The schedule is a constant.
INTERVALis part of the job's type.AtLeaststarts runs at least an hour apart, likefixedRate, but never lets two runs overlap. A zero interval does not build. - Shutdown is yours.
start()returns aCronRuntime. Callingshutdown().awaitstops scheduling and lets a run in progress finish;abort().awaitcancels it instead.
Key differences
Wiring is checked at compile time
Spring resolves beans when the context starts. A missing bean is a NoSuchBeanDefinitionException at startup, or in the first integration test that loads the context.
ServiceBuilder is a type-level list, and finding a dependency is a trait bound. Here, ExpireSessions is a cron job whose Dep is SessionStore, but only a Config was registered:
// `SessionStore` was never registered.
let (_services, cron) = ServiceBuilder::new(Config)
.cron_job::<ExpireSessions, _>()
.start();This does not compile:
error[E0277]: the trait bound `(): ProvideAt<SessionStore, _>` is not satisfied
--> src/main.rs:38:10
|
38 | .cron_job::<ExpireSessions, _>()
| ^^^^^^^^ the trait `ProvideAt<SessionStore, _>` is not implemented for `()`The builder has no runtime map and no Any. A wiring mistake cannot ship. Service builder explains how the lookup works.
No classpath scanning, proxies, or AOP
In Spring, @Transactional, @Cacheable, and @Async work through proxies that the container generates around your beans. Calling an annotated method from inside the same class bypasses the proxy, and the annotation silently does nothing.
wakuwaku has no proxies. Everything that runs around your code is a value you can see in the types:
- A transaction is a
Transactionvalue that you process with a database handle. - A hook around write queries is a wrapper type, such as
AfterWrite<Db<..>, ..>. - A cache read is a call to
Cache::get.
What you read in the code is what runs.
Read-only is a type, not a hint
@Transactional(readOnly = true) is a hint to the transaction manager and the JDBC driver, applied per method at runtime. In wakuwaku, read-only is the capability of a database handle. Code that holds a Db<PgSource, ReadOnly> can run only queries whose declared effect is a read:
/// Wired to a replica: only queries whose effect `ReadOnly` covers compile.
struct UserStats {
db: Db<PgSource, ReadOnly>,
}
impl UserStats {
async fn total(&self) -> Result<i64, sqlx::Error> {
self.db.process(CountUsers).await
}
}Passing a query declared as Write<User> to that handle is a compile error:
error[E0277]: the trait bound `ReadOnly: CanWrite<User>` is not satisfied
--> src/main.rs:25:8
|
25 | db.process(DeleteUser(1)).await
| ^^^^^^^ the trait `CanWrite<User>` is not implemented for `ReadOnly`What the type proves
The capability proves routing: code wired to a read-only handle never sends a query declared as a write. It cannot prove that the SQL inside a query declared as a read does not write. Keep the database's own guard on replicas, such as a hot standby or default_transaction_read_only.
Validation produces a type
Bean Validation checks annotated fields and throws when a constraint fails. Nothing in the type of the validated object records that it was checked. With wakuwaku::proof, a successful check returns Proven<T, P>, and a function that needs valid input asks for it:
record SignUp(@NotBlank String username) {}
@PostMapping("/sign-up")
void signUp(@Valid @RequestBody SignUp request) {
register(request.username()); // register(String) accepts anything
}Predicates are async and may do I/O, such as checking that a username is not taken. They compose with And and Or. See Validation.
Testing without a context
A Spring test that needs real beans usually starts an application context with @SpringBootTest. A wakuwaku component is a plain value: construct it with create_service, call process or handle, and check the result. The compiler has already checked the wiring, so unit tests can focus on behavior. Quick start shows a complete example, and Testing covers more patterns.