The English version of quarkus.io is the official project site. Translated sites are community supported on a best-effort basis.
Edit this Page

Writing a Dev Service

Learn how to develop a Dev Service for your extension in order to replace an external service in development mode.

Requisitos previos

To complete this guide, you need:

  • Roughly 15 minutes

  • An IDE

  • JDK 17+ installed with JAVA_HOME configured appropriately

  • Apache Maven 3.9.15

  • A working container runtime (Docker or Podman)

  • An extension structure in place

  • A containerised version of your external service (not all Dev Services rely on containers, but most do)

If your extension provides APIs for connecting to an external service, it’s a good idea to provide a Dev Service implementation. This allows your extension to be used for development and testing in a frictionless way, without the hassle of standing up and configuring external services.

Key concepts

Concept: Understanding the service lifecycle

Dev Services can be created by an extension, or the extension can discover and re-use external services.

Starting and stopping services

For both discovered and owned services, the services are prepared at build time. Discovered services are already started, and will not be started or stopped by the Dev Services infrastructure. For an owned service, the service is started by the Quarkus framework after the build, and before runtime. Extension code should never start or stop a service directly. Instead, extensions provide a Startable to a service builder, so that Quarkus can manage the lifecycle.

Reuse

Services can be re-used between test profiles and across live reload restarts, or a fresh service can be created each time. The extension implementation controls how much reuse there is by setting a serviceConfig(Object) with a uniqueness key each build.

Creating a Dev Service

Dependencies

Add the following dependencies to your extension’s build file.

In your deployment module:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-devservices-deployment</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-devservices-deployment")

In your runtime module:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-devservices</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-devservices")

Implementing a Startable

Provide an implementation of io.quarkus.deployment.builditem.Startable which knows how to start and stop the service. For container-based services, extending GenericContainer and implementing Startable is the recommended pattern. In that case, GenericContainer already provides a start() method, so the only method that needs to be implemented is close() (which can delegate to the superclass).

For example, a minimal Startable implementation might look something like this:

private static class MyContainer extends GenericContainer<MyContainer>
        implements Startable {

    public MyContainer(String imageName) {
        super(imageName);
    }

    @Override
    public void close() {
        super.close();
    }

    @Override
    public String getConnectionInfo() {
        return getHost() + ":" + getPort();
    }

    @Override
    public String getContainerId() {
        return super.getContainerId();
    }
}

Waiting for the container to start

You should add a .waitingFor call to the container, to wait for it to be ready. This is typically done in the configure() method of the GenericContainer subclass. For example:

waitingFor(Wait.forLogMessage(".*Started.*", 1));

Waiting for a port to be open is another option. See the Testcontainers documentation for a full discussion on wait strategies.

Configuring ports

If you’re extending GenericContainer, you should also tell TestContainers what ports to expose. This is also typically done in the configure() override. For example:

    @Override
    protected void configure() {
        super.configure();

        if (fixedExposedPort.isPresent()) {
            addFixedExposedPort(fixedExposedPort.getAsInt(), SERVICE_PORT);
        } else {
            addExposedPort(SERVICE_PORT);
        }

        waitingFor(Wait.forLogMessage(".*Started.*", 1));
    }

    public int getPort() {
        if (fixedExposedPort.isPresent()) {
            return fixedExposedPort.getAsInt();
        }
        return super.getFirstMappedPort();
    }

}

By default, TestContainers will map service ports to random ones on the host. In the above code, users can configure a fixed port for the container image, which is then passed to MyContainer as a fixedExposedPort OptionalInt. Allowing users to set a fixed port makes it easier to attach external tools to the running container, but increases the risk of port conflicts.

Declaring a Build Step

Add a build step that returns a DevServicesResultBuildItem, using the owned() builder. Do not call start() on the Startable – Quarkus will start it at the right time.

@BuildStep(onlyIf = { IsDevServicesSupportedByLaunchMode.class, DevServicesConfig.Enabled.class })
public DevServicesResultBuildItem createContainer(MyConfig config) {
    // Guard to check if if service-specific dev services are enabled and if a server had been enabled for this service
    if (! config.devservices().enabled() || config.myBaseServiceUrl() != null) {
        return null;
    }

    // We should create a dev service, let's do it
    return DevServicesResultBuildItem.owned()
            .feature(FEATURE)
            .serviceConfig(config) // this is a re-use key, so does not have to be the config object
            .startable(() -> new MyContainer(
                    config.imageName(),
                    config.port()))
            .configProvider(
                    Map.of("some-service.base-url",
                            s -> "http://" + s.getConnectionInfo()))
            .build();
}

With this code, you should be able to see your container starting if you add your extension to a test application and run quarkus dev.

To allow applications to connect to the service, the build step needs to provide configuration overrides. This is done lazily, since the actual service port is usually only known at runtime. To configure the port, use .configProvider( Map.of("the-config-key-for-the-base-url", s → "http://" + s.getConnectionInfo()).

If you need to tell the application where the service is living without using Quarkus configuration, you can pass information about the service using recorders.

Understanding the builder

The owned() builder has several methods. Not all are required, but feature(), serviceConfig(), startable(), and configProvider() are needed in most cases.

feature(String)

Identifies the owning feature. Used for identification and lifecycle management.

serviceName(String)

If the feature provides multiple Dev Services, this distinguishes them. Optional.

serviceConfig(Object)

The configuration object for this service. It is compared reflectively to the previous configuration on each restart. If the config has changed, the service is restarted; if it is the same, the running service is reused. This controls service reuse, and should be considered carefully.

startable(Supplier<S>)

A supplier that creates the Startable instance. The service is created at build time, but it is not started until the right point during Quarkus startup.

configProvider(Map<String, Function<T, String>>)

Provides configuration values that are only known after the service is started, such as mapped ports. The key in the map is the config property name; the value is a function that receives the started Startable and returns the config value.

For config values that are known at build time, use config(Map<String, String>) instead.

postStartHook(Consumer<T>)

An action to perform after the service starts. Any methods used here will be invoked at runtime, not build time, and will affect startup times. Optional.

Configuring the Dev Service

To configure the Dev Service launch process, your build step can accept a ConfigPhase.BUILD_TIME config class. For example,

@BuildStep(onlyIf = { IsDevServicesSupportedByLaunchMode.class, DevServicesConfig.Enabled.class })
public DevServicesResultBuildItem createContainer(MyConfig config) {}

You may wish to use this config to set a fixed port, or set an image name. Pass the config object to serviceConfig() on the builder so that Quarkus can detect config changes and restart the service when needed.

Controlling re-use

In dev mode, with live reload, Quarkus may restart frequently. By default, this will also restart test containers. Quarkus restarts are usually very fast, but containers may take much longer to restart.

Re-use of services between live reloads is handled centrally by Quarkus based on the serviceConfig() value. If the config object passed to serviceConfig() is the same as the previous run (compared reflectively), the running service is reused. If it has changed, the service is restarted.

It is an anti-pattern (and unlikely to work) to use static fields on the processor to store service state or handle service reuse.

Discovered services

Rather than starting services, the Dev Services infrastructure can be used to surface existing external services, not managed by Quarkus. To make an externally-managed service available for use as a Dev Service, use the discovered() builder instead:

return DevServicesResultBuildItem.discovered()
        .feature(FEATURE)
        .containerId(existingContainerId)
        .config(Map.of("some-service.base-url", existingUrl))
        .build();

Integrating with the Dev UI

It is a nice practice to include links to your Dev Service(s) on the Dev UI card for your extension. This is particularly useful when the service has been started on a random port, and users might want to connect to an admin console. Because the service url is not known at build time, the dynamicUrlJsonRPCMethodName method should be used, passing in an RPC method name.

   @BuildStep(onlyIf = IsDevelopment.class)
   public CardPageBuildItem pages(List<SomeRelevantBuildItem> containers) {
      CardPageBuildItem cardPageBuildItem = new CardPageBuildItem();

      for (SomeRelevantBuildItem container : containers) {
         cardPageBuildItem.addPage(Page.externalPageBuilder("My Extension Name")
               .dynamicUrlJsonRPCMethodName("getMyUrl")
               .staticLabel(container.label());
      }

      return cardPageBuildItem;
   }

If needed, you can also pass through parameters on the method call. For example,

     .dynamicUrlJsonRPCMethodName("getMyUrl", Map.of("name", "service-name", "configKey", "some-key")

You will need to use a build step to register the providing class:

    @BuildStep(onlyIf = IsLocalDevelopment.class)
    public JsonRPCProvidersBuildItem createJsonRPCService() {
     return new JsonRPCProvidersBuildItem(MyJsonRPCService.class, BuiltinScope.SINGLETON.getName());
    }

The MyJsonRPCService class with a getMyUrl method should live in your extension’s runtime module. It can use injected configuration or injected beans to provide the url.

Related content