--- 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` unwraps to `X`. `void` becomes `Void.class` with a descriptive name. - **Parameters** — `RuntimeValue` 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.."`. ### 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` | `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 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)`. 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`; 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). * **The Static Helper Method Trap**: Moving the anonymous class instantiation to a `private static` helper method inside your processor class solves the `EnclosingProcessor.this` capture, but because the compiled lambda still references the static helper method on your deployment processor, it causes a `NoClassDefFoundError` at runtime. * **Solution**: Always place helper methods, custom interfaces, or static factory methods inside your **runtime** module classes (which are safely present at both build time and runtime). ### Use `require()` instead of `Arc.container()` Access CDI beans through `require(BeanContainer.class)` and `beanInstance()`. Direct `Arc.container()` calls bypass the dependency graph and can cause ordering failures. ### Mutable collections must be copied ```java Set captured = Set.copyOf(endpoints); // before the lambda ``` ## Behavioral parity with recorders Conversion must preserve behavior the recorder model provided *implicitly*. These are recurring, high-cost regressions — check each when converting a step that touches classloaders, threads, cleanup, or failure handling. ### TCCL parity (thread context classloader) Recorder bytecode always ran with the **runtime classloader as the TCCL** (the generated `doStart` sets it). Any recorder code that captured `Thread.currentThread().getContextClassLoader()` for later use — request handlers, thread factories, config/`ServiceLoader` lookups — relied on that. - After converting, ensure the service still runs with the runtime CL as TCCL, and that captured/inherited CLs are the runtime CL (not the system `AppClassLoader`). A wrong TCCL surfaces as `ServiceLoader` "not a subtype" errors or dev-mode failures, not as an obvious classloader bug. - **Threads created during startup inherit the creating thread's TCCL.** A long-lived or library thread that captures the per-app runtime CL and outlives the app **leaks the `QuarkusClassLoader`** (metaspace growth across test/dev restarts). Internal/scheduler threads that run no user code should not hold an app CL. Clean up threads/`ThreadLocal`s your service owns in `onStop`; never rely on a global "reset all TCCLs" sweep. ### Teardown-ordering parity Start ordering is explicit (`require`/`after`/`before`) — 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 (e.g. bean destruction before a "last" task, or a listener firing before container teardown), declare it with `before()`/`after()`. Lost teardown ordering surfaces as unrelated downstream symptoms, so verify shutdown explicitly. ### Failure-path parity Recorders logged **nothing in-band** on failure — `doStart` caught, wrapped in `RuntimeException("Failed to start quarkus", cause)`, and rethrew. The service graph rethrows the first failure (concurrent ones attach as suppressed), so nothing is lost by staying quiet. **Do not add ERROR/WARN logging in service bodies for expected failures** — let them propagate. Some tests assert clean propagation (no WARNING+ log records, no suppressed exceptions) for a static-init failure, e.g. `StaticInitFailureTest`. ### `consumeAll` uses indexed access `consumeAll` dependencies expand to multiple consecutive entries in the ServiceNode's dependency array (one per matching service, sorted by name). Both `ServiceGraphBuilder` and `LambdaTransliterator` compute the same match list using the same prefix and sort order, ensuring index alignment. ## Checklist - [ ] Identify all `@Record` build steps in the processor - [ ] For each, determine if all dependencies are available as services - [ ] Convert recorder methods (inline trivial ones, make complex ones static) - [ ] Update build steps: remove `@Record`, add `ServiceRegistrar`, register services - [ ] Declare ALL dependencies explicitly (`require`, `after`, `before`, `afterBuildItem`) - [ ] Do NOT rely on build step ordering for service execution order - [ ] Bridge values to legacy build items where needed - [ ] Convert synthetic beans from `supplier()`/`runtimeValue()` to `serviceValue()` - [ ] Check for package-private visibility issues - [ ] `./mvnw install` the modified modules before running integration tests