- name
- converting-recorders-to-services
- description
- Step-by-step guide for converting Quarkus extensions from the legacy @Record/@Recorder pattern to the ServiceRegistrar service system.
# Converting Recorders to Services
## Architecture
The `ServiceRegistrar` service system replaces `@Recorder` with typed service. Services declare dependencies explicitly and execute in dependency
order, not build step order. The system is designed for incremental
migration — converted and unconverted extensions may coexist.
### Key principles
1. **Explicit dependencies only.** Services get NO implicit ordering from
the build step graph. All ordering comes from `require()`, `after()`,
`before()`, or `afterBuildItem()`.
2. **Services are independent from recorders in the same step.** Even if
a build step method contains both a `@Record` call and an `ServiceRegistrar`
call, the service and recorder are separate nodes with no implicit
ordering between them.
3. **Values flow through the dependency graph.** Service values are passed
from producer to consumer via indexed dependency access on `ServiceNode`,
not through the `StartupContext` maps (except for recorder proxy bridges).
### Node types in the service graph
| Kind | Description | Gets step-graph deps? |
|------|-------------|-----------------------|
| `LEGACY_RECORDER` | Recorder chunk from a `@Record` step | Yes |
| `ALIAS` | Bridges a recorder value to a service key | Yes |
| `SERVICE` | `ServiceRegistrar` service with a lambda body | No |
| `RV_WRAPPER` | Wraps a service value in `RuntimeValue` for recorders | No |
| `CROSS_PHASE_PROXY` | Bridges a static-init service to the runtime graph | No |
Legacy recorders and aliases use step-graph ordering for compatibility.
Service nodes use only explicit declarations.
## Prerequisites
Before converting, verify that all runtime dependencies are available as
services:
| Dependency Type | Availability |
|-----------------|-------------|
| `ArcContainer` | Service in `ArcProcessor#initializeContainer` |
| `BeanContainer` | Service in `ArcProcessor#createBeanContainer` |
| `ScheduledExecutorService` | Aliased by `ThreadPoolSetup` |
| `@ConfigMapping` (`RUN_TIME`) | Auto-registered by `ConfigServiceRegistrationStep` |
| `@ConfigMapping` (`BUILD_AND_RUN_TIME_FIXED`) | Auto-registered as static-init |
| `@ConfigMapping` (`BUILD_TIME`) | **Cannot be a service** — extract values into locals |
| Other runtime objects | Search for `aliasRecorderValue` or `forService` |
## Conversion steps
### 1. Analyze the recorder
For each `@Recorder` method, determine:
- **Return type** — becomes the service type. `RuntimeValue<X>` unwraps
to `X`. `void` becomes `Void.class` with a descriptive name.
- **Parameters** — `RuntimeValue<X>` params become `require(X.class)`.
Simple values (String, int, boolean, enum) can be captured. Immutable
collections (`List.of()`, `Set.of()`, etc.) can be captured.
- **Instance fields** — config `RuntimeValue`s injected via constructor
become `require()` dependencies.
- **Trivial methods** — inline directly into the lambda.
### 2. Convert the recorder class
- **Trivial methods** — delete; inline into the lambda.
- **Complex methods** — remove `@Recorder`, make methods `static`,
unwrap `RuntimeValue` parameters and return types.
- If all methods are converted, delete the class. Otherwise keep
`@Recorder` for remaining methods.
### 3. Convert the build step
**Before:**
```java
@BuildStep
@Record(ExecutionTime.RUNTIME_INIT)
ServiceStartBuildItem setup(MyRecorder recorder, SomeBuildItem item) {
recorder.initialize(item.getValue());
return new ServiceStartBuildItem("my-feature");
}
```
**After:**
```java
@BuildStep
ServiceStartBuildItem setup(ServiceRegistrar serviceRegistrar, SomeBuildItem item) {
serviceRegistrar
.forService("io.quarkus.my-feature.setup")
.onStart(ctx -> MyRecorder.initialize());
return new ServiceStartBuildItem("my-feature");
}
```
Key changes:
- Remove `@Record(ExecutionTime.*)` annotation.
- Replace recorder parameter with `ServiceRegistrar serviceRegistrar`.
- Use `.atPhase(Phase.STATIC_INIT)` for static-init services (default
is runtime-init / `Phase.APPLICATION`).
- For void services, use hierarchical dot-separated names:
`"io.quarkus.<extension>.<purpose>"`.
### 4. Declare dependencies
**Every dependency must be explicit.** There is no implicit ordering
from the build step graph.
| Old Pattern | New Pattern |
|------------|-------------|
| `recorder.method(beanContainer.getValue())` | `.require(BeanContainer.class)` |
| `recorder.method(someRuntimeValue)` | `.require(SomeType.class)` |
| `recorder.method(config.maxSize())` | Capture `int maxSize = config.maxSize()` before lambda |
| `@Consume(SyntheticBeansRuntimeInitBuildItem.class)` on step | `.afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class)` on service |
#### `afterBuildItem()` — bridging to build step ordering
When a service depends on state produced by a legacy recorder (e.g.,
synthetic beans must be initialized), declare the dependency via
`afterBuildItem()`:
```java
serviceRegistrar
.forService("io.quarkus.arc.lifecycle")
.afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class)
.onStart(ctx -> ArcRecorder.fireLifecycleEvent(new StartupEvent()));
```
This resolves the producing step's nodes (with passthrough resolution
for steps that have no service graph nodes) and creates ordering edges.
**`afterBuildItem()` is deprecated** — it exists only for recorder
coexistence. Once the producing recorder is converted to a service,
replace with `require()` or `after()`, or drop it, as appropriate.
#### Cross-phase dependencies
Runtime services can `require()` static-init services. The framework
automatically creates a `CROSS_PHASE_PROXY` node that reads the value
from the `serviceValues` map (populated during static-init and retained
via `retainServiceValues()`).
```java
// static-init service
serviceRegistrar.forService(ArcContainer.class)
.atPhase(Phase.STATIC_INIT)
.afterBuildItem(ResourcesGeneratedPhaseBuildItem.class)
.onStart(ctx -> Arc.initialize());
// runtime service that depends on it
serviceRegistrar.forService("io.quarkus.my-ext.setup")
.require(ArcContainer.class) // cross-phase: resolved via proxy
.onStart((ctx, container) -> { ... });
```
#### Config mappings
- `BUILD_TIME` — extract values into locals before the lambda.
- `BUILD_AND_RUN_TIME_FIXED` — use `.require(ConfigType.class)` (resolved
directly from SmallRye Config, no graph edge).
- `RUN_TIME` — use `.require(ConfigType.class)` (same mechanism).
### 5. Stop ordering with `before()`
`before(X)` declares "X depends on me" — this service starts before X
and stops after X. Use it for cleanup services that must outlive their
dependents:
```java
serviceRegistrar
.forService("io.quarkus.vertx.netty-thread-local-cleanup")
.atPhase(Phase.STATIC_INIT)
.before(IOThreadDetector.class)
.before(ArcContainer.class)
.onStart(ctx -> {
ctx.onStop(() -> InternalThreadLocalMap.remove());
});
```
### 6. Bridge to legacy build items
Build items consumed by unconverted extensions need bridge proxies:
| Build Item Stores | Bridge Method |
|-------------------|---------------|
| `RuntimeValue<T>` | `serviceRegistrar.staticInitServiceAsRuntimeValue(T.class)` or `serviceRegistrar.serviceAsRuntimeValue(T.class)` |
| Bare `T` (interface) | `serviceRegistrar.staticInitServiceAsRecorderValue(T.class)` or `serviceRegistrar.serviceAsRecorderValue(T.class)` |
| Bare `T` (concrete) | Use `RuntimeValue` variant — concrete classes may not be proxyable |
Recorder proxies resolve via `startupContext.getServiceValue()` (for
`__service$$value()` proxies) or `startupContext.getValue()` (for
standard recorder proxies). The service deploy body stores values in
the `serviceValues` map for this resolution.
### 7. Synthetic beans
**Before:**
```java
Supplier<MyBean> supplier = recorder.createBeanSupplier(args);
SyntheticBeanBuildItem.configure(MyBean.class).supplier(supplier).done();
```
**After:**
```java
serviceRegistrar.forService(MyBean.class, "my-bean-name")
.onStart(ctx -> new MyBean(args));
SyntheticBeanBuildItem.configure(MyBean.class)
.serviceValue(MyBean.class, "my-bean-name")
.done();
```
### 8. Shutdown handlers
Register cleanup via `ctx.onStop(Runnable)` or
`ctx.onStopAsync(Consumer<AsyncStopContext>)`. Stop handlers run in
reverse dependency order.
`addLastShutdownTask()` is **deprecated** — its per-node "last" semantics
don't provide global ordering. Use `before()` to ensure your service
stops after the services it cleans up for.
### 9. Runtime-optional services
A value-producing service must return a non-`null` value — the
generated code throws `"...returned null; use a void service if no value is
produced"` otherwise. For a service whose *identity* is known at build time
but which may be **absent at runtime** (e.g. a handler gated by
`quarkus.log.file.enabled`), declare `optional()` before `onStart()`. This
flips the service's return type from `T` to `Optional<T>`; returning
`Optional.empty()` marks the service absent (nothing constructed, no side
effects) instead of failing.
```java
serviceRegistrar.forService(Handler.class, "file")
.atPhase(Phase.LOGGING)
.require(LogRuntimeConfig.class)
.optional()
.onStart((ctx, config) -> config.file().enabled()
? Optional.of(buildFileHandler(config.file()))
: Optional.empty());
```
Consuming an optional service:
- `consumeAll(T.class)` skips absent producers (the map omits them).
- `request(T.class)` yields `Optional.empty()` when absent.
- `require(T.class)` on an optional service is a **build error** — a
mandatory dependency cannot depend on a maybe-absent value. Use
`request()` or `consumeAll()` instead.
Do not return `null` from a normal (non-`optional`) service, and do not use a
sentinel value — `optional()` is the supported mechanism. (The `null`
tolerance in `consumeAll` applies only to aliased recorder values that
resolve to `null`, not to direct services.)
### 10. Lambda capture rules
Supported captures:
- Primitives, `String`, `Class`, enum values
- Immutable collections: `List.of()`, `Set.of()`, `Map.of()`,
`Collections.singletonList/Set/Map()`, `Collections.empty*()`,
`Collections.unmodifiable*()`, `Set.copyOf()`, `List.copyOf()`,
`Map.copyOf()`
- `BUILD_AND_RUN_TIME_FIXED` config mappings (via `ConfigCaptureInterceptor`)
- `Constable` values — any type implementing `java.lang.constant.Constable`
is captured via its `describeConstable()`. To make a custom value type
capturable, implement `Constable` with a `describeConstable()` that
reconstructs it (e.g. a `DynamicConstantDesc` invoking a static factory),
rather than registering a recorder `ObjectSubstitution`. Immutable
collections of `Constable` values (via `Map.copyOf`/`List.copyOf`) are
themselves capturable.
- `record` values — captured by reconstructing via the canonical
constructor, as long as every component is itself capturable (recursively).
Note this is the **capture** path; it is unrelated to the *proxy* path — a
`record` still cannot be used as an aliased recorder return type (see
"Aliased recorder return types must be proxyable" below).
**Not capturable:**
- Mutable collections — use `Set.copyOf()` / `List.copyOf()` first
- `BUILD_TIME` config objects — extract values into locals
- `RUN_TIME` config objects — use `require()` instead
- Arbitrary runtime objects — use `require()` instead
## Common pitfalls
### No implicit ordering from build steps
Services get NO ordering from `@Consume`/`@Produce` annotations or
build item parameters on their build step method. Every ordering
dependency must be declared on the service itself.
If a service needs to run after a legacy recorder step, use
`afterBuildItem()` to depend on a build item that step produces.
### Concrete class proxying
`staticInitServiceAsRecorderValue(ConcreteClass.class)` fails if the
class has a non-trivial constructor. Use
`staticInitServiceAsRuntimeValue()` instead — `RuntimeValue` is always
proxyable.
### Aliased recorder return types must be proxyable
A value published via `aliasRecorderValue(T.class, recorderValue)` — or any
`@Recorder` method whose return value is used as a recorder proxy — must have
a **proxyable** type: an interface or a non-`final` class. A `record` is
`final`, so it fails with *"the return type cannot be proxied; use
RuntimeValue to wrap the return value instead"*. When bridging a bundle of
recorder values into a single service value, make the holder an **interface**
(with a nested `record` impl for the runtime value), or wrap it in
`RuntimeValue`. Abstract JDK types such as `java.util.logging.Formatter` and
`java.util.logging.Handler` are already proxyable.
### Package-private visibility
Lambda bytecode runs at runtime but references runtime classes directly.
Package-private classes become inaccessible from the generated
consolidated class.
Solutions:
1. Add a public static factory method on a public runtime class.
2. Move the class to a `.impl` subpackage and make it public.
### Never reference deployment classes from within service lambdas
Action lambda bytecode (defined inside `.onStart(ctx -> { ... })`) is executed at **runtime**. Therefore, the lambda body must **never** reference, instantiate, or invoke methods on any deployment classes (such as your `@BuildStep` processor classes). Doing so will compile successfully but will throw a `ClassNotFoundException` / `NoClassDefFoundError` at runtime because deployment modules are absent from the runtime classpath.
* **The Anonymous Inner Class Trap**: Writing an anonymous inner class (e.g. `new MyInterface() {}`) inside an instance-method lambda forces `javac` to generate a constructor that captures `EnclosingProcessor.this` (via a synthetic `this$0` parameter) to bind the inner class to its enclosing processor scope. This turns your lambda into an instance-capturing lambda (throwing a `TransliterationException` at build time).
Voir sur GitHub