Recorders-to-Services Migration Guide
deprecatedThis guide describes how to migrate Quarkus extensions from the legacy @Record/@Recorder pattern to the modern ServiceRegistrar service system.
The ServiceRegistrar service system replaces @Recorder with a typed service model. 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.
Architectural Differences
Explicit Dependencies Only
Services get no implicit ordering from the build step graph. All ordering comes from require(), after(), before(), or afterBuildItem().
Prerequisites
Before converting, verify that all runtime dependencies of your recorder are available as services:
| Dependency Type | Availability |
|---|---|
|
Service in |
|
Service in |
|
Aliased by |
|
Auto-registered by |
|
Auto-registered as static-init |
|
Cannot be a service — extract values into local variables first |
If other runtime objects are needed, search the codebase for aliasRecorderValue or forService to see if they are published as services.
Step-by-Step Conversion
1. Analyze the Recorder
For each @Recorder method, determine:
-
Return type: becomes the service type.
RuntimeValue<X>unwraps toX.voidbecomesVoid.class(usingreg.forService(String name)). -
Parameters:
RuntimeValue<X>parameters become.require(X.class). Simple values (String, int, boolean, enum) can be captured by the lambda. Immutable collections (List.of(),Set.of()) can be captured. -
Instance fields: configuration
RuntimeValue`s injected via a constructor should be resolved via `.require(ConfigType.class). -
Trivial methods: can be inlined directly into the lambda.
2. Convert the Recorder Class
-
Trivial methods: delete them and inline their logic into the lambda.
-
Complex methods: remove the
@Recorderannotation, make methodsstatic, and unwrapRuntimeValueparameters and return types. -
If all methods are converted, delete the recorder class. Otherwise, keep
@Recorderonly for remaining legacy methods.
3. Convert the Build Step
4. Declare Dependencies Explicitly
Every dependency must be explicit. There is no implicit ordering from the build step graph.
| Old Recorder Pattern | New Service Pattern |
|---|---|
|
|
|
|
|
Capture |
|
|
Bridging to Build Step Ordering (afterBuildItem())
When a service depends on state produced by a legacy recorder (e.g., synthetic beans must be initialized), declare the dependency via afterBuildItem():
reg.forService("io.quarkus.arc.lifecycle")
.afterBuildItem(SyntheticBeansRuntimeInitBuildItem.class)
.onStart(ctx -> ArcRecorder.fireLifecycleEvent(new StartupEvent()));
This resolves the producing step’s nodes and creates ordering edges.
afterBuildItem() is deprecated — it exists only for recorder coexistence. Before adding one, check whether the target has already been converted to a service; if so, use require() or after() directly instead.
|
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.
// Static-init service
reg.forService(ArcContainer.class)
.atPhase(Phase.STATIC_INIT)
.afterBuildItem(ResourcesGeneratedPhaseBuildItem.class)
.onStart(ctx -> Arc.initialize());
// Runtime service that depends on it
reg.forService("io.quarkus.my-ext.setup")
.require(ArcContainer.class) // Cross-phase proxy resolution
.onStart((ctx, container) -> { ... });
Coexistence and Bridge APIs
During the transitional period, the ServiceRegistrar provides bridge APIs to allow incremental migration of extensions.
Recorder-to-Service Bridge
If you have a value produced by a legacy recorder that a new service needs to depend on, register it using aliasRecorderValue:
@BuildStep
@Record(ExecutionTime.RUNTIME_INIT)
void setupLegacyAndService(MyRecorder recorder, ServiceRegistrar reg) {
RuntimeValue<MyLegacyValue> val = recorder.createLegacyValue();
// Publish legacy value to the ServiceRegistrar graph
reg.aliasRecorderValue(MyLegacyValue.class, val);
}
Downstream services can now simply use .require(MyLegacyValue.class).
Service-to-Recorder Bridge
If an unconverted legacy recorder or an existing build item consumes a value that is now produced by a service, use the service-to-recorder bridge methods:
-
serviceAsRuntimeValue: Produces aRuntimeValue<T>proxy for a runtime service. -
staticInitServiceAsRuntimeValue: Produces aRuntimeValue<T>proxy for a static-init service. -
getRecorderProxy: Produces a bareTproxy (for proxyable interfaces/classes).
@BuildStep
void bridgeServiceToLegacy(ServiceRegistrar reg, BuildProducer<LegacyBuildItem> producer) {
reg.forService(MyConnection.class)
.onStart(ctx -> new MyConnection());
RuntimeValue<MyConnection> runtimeVal = reg.serviceAsRuntimeValue(MyConnection.class);
producer.produce(new LegacyBuildItem(runtimeVal));
}
Updating Downstream Consumers
After converting a recorder to a service, search for other extensions that use afterBuildItem() referencing build items your old recorder produced. Those consumers can now migrate from afterBuildItem() to require() or after() on your new service directly, removing one more link in the legacy bridge chain.
Common Conversion Pitfalls
Global Mutable State
Recorders ran sequentially, so global mutable state (static maps, shared lists, singletons populated during startup) was incidentally safe. Services run concurrently — any global state that survives conversion must be made thread-safe. Prefer eliminating it entirely: the dependency graph carries values directly, so each entry becomes a named service and each lookup becomes require() or request().
ClassLoader & Thread Context ClassLoader (TCCL)
A wrong TCCL surfaces as ServiceLoader "not a subtype" errors or dev-mode failures. Threads created during startup inherit the creating thread’s TCCL — a long-lived thread that captures the per-app runtime CL leaks the QuarkusClassLoader across restarts. Clean up threads or ThreadLocal`s your service owns in `onStop(); never rely on a global "reset all TCCLs" sweep.
Concrete Class Proxying
staticInitServiceAsRecorderValue(ConcreteClass.class) fails if the class has a non-trivial constructor. Use staticInitServiceAsRuntimeValue() instead — RuntimeValue is always proxyable.
Package-Private Visibility
Lambda bytecode runs at runtime but references runtime classes directly. Package-private classes become inaccessible from the generated consolidated class. Ensure any classes referenced within the lambda are public, or move them to a .impl subpackage and make them public.
Never Reference Deployment Classes inside Service Lambdas
Service lambda bodies execute at runtime — deployment classes are absent from the runtime classpath. Anonymous inner classes inside instance-method lambdas silently capture this (the processor), causing TransliterationException. Moving the instantiation to a private static helper in the processor avoids the capture but still references a deployment class, causing NoClassDefFoundError. Place all helper methods and factories in runtime module classes.
Teardown-Ordering Parity
Start ordering is explicit — but so is stop ordering, and that is the easy thing to lose. The recorder model gave cleanup incidental ordering from build-step order; a converted service’s onStop runs only in reverse dependency order. If a cleanup implicitly depended on running before/after another, declare it with before() or after(). Lost teardown ordering surfaces as unrelated downstream symptoms — verify shutdown explicitly.