Building Your Own Dev Service

Arconia provides Dev Services for many popular services out of the box, but you can also build your own Dev Service for any service that can run as an OCI container. This page walks you through the process, using the same infrastructure the built-in Dev Services are based on.

The Dev Services APIs are currently marked as @Incubating and may change in future releases.

Module Structure

The Dev Services infrastructure is split into two modules:

  • arconia-dev-services-api: lightweight contracts with no Docker/Testcontainers dependencies, such as the configuration property interfaces (BaseDevServicesProperties, JdbcDevServicesProperties), the runtime descriptors (DevServiceRegistration, ContainerInfo), and the container labels Dev Services are identified and described by (DevServiceLabels). It’s designed to be safely usable in contexts where Testcontainers should not be on the classpath, such as tooling or user interfaces built around Dev Services.

  • arconia-dev-services-core: the machinery for registering and configuring Dev Services, including DevServicesRegistrar, DevServicesRegistry, and @ConditionalOnDevServicesEnabled. This is the module to build your own Dev Service on.

First, add the arconia-dev-services-core dependency to your Dev Service module, together with the Testcontainers module for your service (or the plain org.testcontainers:testcontainers dependency if none exists):

dependencies {
    api 'io.arconia:arconia-dev-services-core'
    api 'org.testcontainers:testcontainers'
}

Configuration Properties

Define a configuration properties class implementing BaseDevServicesProperties (or JdbcDevServicesProperties for relational databases), using a prefix under your own namespace. Structure the prefix as <namespace>.<service-name> (e.g. acme.dev.services.fancydb), mirroring the built-in Dev Services (arconia.dev.services.<service-name>): the enablement condition used later in the auto-configuration relies on this structure.

Do not use the arconia. prefix for your own properties: it’s reserved for the framework, and using it may conflict with current or future built-in Dev Services.

@ConfigurationProperties(prefix = "acme.dev.services.fancydb")
public class FancyDbDevServicesProperties implements BaseDevServicesProperties {

    /**
     * Whether the dev service is enabled.
     */
    private boolean enabled = true;

    /**
     * Full name of the container image used in the dev service.
     */
    private String imageName = "acme/fancydb:1.0.0";

    // Additional common properties...

    // More properties of your own...

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    @Override
    public String getImageName() {
        return imageName;
    }

    public void setImageName(String imageName) {
        this.imageName = imageName;
    }

    // Additional getters and setters...

}

BaseDevServicesProperties defines the read contract only. For every property you expose to your users, declare a field, a getter, and a setter in your class so that Spring Boot can bind user configuration to it.

Container

The Dev Service runs in a Testcontainers container, which you create when registering the Dev Service (see Auto-Configuration and Registration). The container is configured from your properties when the framework creates it: the common properties (environment, network-aliases, port, startup-timeout, resources, volumes, reuse-strategy) are applied by Arconia, so your code must not apply them itself. What is left to you is the image and, optionally, the exposed ports and the wait strategy.

If a Testcontainers module exists for your service, use its container class directly, with the image parsed from your properties. For example:

static FancyDbContainer createContainer(FancyDbDevServicesProperties properties) {
    return new FancyDbContainer(DockerImageName.parse(properties.getImageName()));
}

If no Testcontainers module exists, define a small GenericContainer subclass declaring the image, the exposed ports, and the wait strategy:

public final class FancyDbContainer extends GenericContainer<FancyDbContainer> {

    static final int FANCYDB_PORT = 8090;

    static final int CONSOLE_PORT = 8091;

    public FancyDbContainer(DockerImageName image) {
        super(image);
        this.withExposedPorts(FANCYDB_PORT, CONSOLE_PORT);
        this.waitingFor(Wait.defaultWaitStrategy());
    }

}

A subclass is also the place for behavior a module class doesn’t offer, such as running a command once the container has started, by overriding the protected hooks of GenericContainer.

Declare a wait strategy unless the container class already has one of its own, as in the example above. The startup-timeout property is applied to the container’s wait strategy, and Testcontainers shares a single default wait strategy instance across all containers that don’t declare one: without an explicit strategy, the timeout configured for your Dev Service would change the startup timeout of every other container relying on the shared instance. Container classes provided by Testcontainers vary in this respect: GenericContainer, MySQLContainer, and OllamaContainer rely on the shared instance, whereas PostgreSQLContainer and RabbitMQContainer declare their own.

For relational databases, use the service’s JdbcDatabaseContainer and implement JdbcDevServicesProperties: credentials, database name, and init scripts are then applied by the framework as well.

The common port property binds the main port of your service to a fixed port on the host. If your service exposes more ports worth binding, such as a management console, declare a property of your own for each of them. Both the main port and the additional ones are declared when registering the Dev Service (see the next sections). The framework applies the bindings.

Auto-Configuration and Registration

Register the Dev Service through an auto-configuration class guarded by @ConditionalOnDevServicesEnabled, importing a registrar that extends DevServicesRegistrar. The condition ensures the Dev Service only activates in dev and test mode and honors the global arconia.dev.services.enabled kill switch, so users can turn off all Dev Services at once, including yours. By specifying your own prefix, the condition also honors the service-specific toggle under your namespace (here, acme.dev.services.fancydb.enabled) with the same semantics as the built-in Dev Services: alternative boolean values like on/off are accepted, and invalid values fail at startup instead of silently disabling the Dev Service.

@AutoConfiguration(after = DevServicesAutoConfiguration.class, before = ServiceConnectionAutoConfiguration.class)
@ConditionalOnDevServicesEnabled(name = "fancydb", prefix = "acme.dev.services")
@EnableConfigurationProperties(FancyDbDevServicesProperties.class)
@Import(FancyDbDevServicesRegistrar.class)
public final class FancyDbDevServicesAutoConfiguration {

    static class FancyDbDevServicesRegistrar extends DevServicesRegistrar {

        @Override
        protected void registerDevServices(DevServicesRegistry registry, Environment environment) {
            var properties = bindProperties("acme.dev.services.fancydb", FancyDbDevServicesProperties.class);

            registry.registerDevService(service -> service
                    .name("fancydb")
                    .description("FancyDB Dev Service")
                    .properties(properties)
                    .container(FancyDbContainer.class, () -> createContainer(properties))
                    .port(FancyDbContainer.FANCYDB_PORT)
                    .additionalPort(FancyDbContainer.CONSOLE_PORT, properties.getConsolePort()));
        }

    }

    static FancyDbContainer createContainer(FancyDbDevServicesProperties properties) {
        return new FancyDbContainer(DockerImageName.parse(properties.getImageName()));
    }

}

The container(…​) call declares the container type and how to create an instance, which the framework then configures and starts. The port(…​) call declares the container port of the main service, which the common port property binds to a fixed port on the host when a user sets it. Each additionalPort(…​) call declares another container port together with the host port a property of your own binds it to; a value of 0, the default of such a property, binds nothing and the port is published on a random available port.

A few constraints apply to the registration, enforced at startup:

  • The name of the Dev Service must contain only lowercase letters, digits, and dashes ([a-z0-9]([a-z0-9-]*[a-z0-9])?), since it becomes part of bean names, container labels, and the network alias of the container. It must be unique in the application: registering a second Dev Service with the same name fails.

  • The description is required: it’s shown in developer tooling, such as the devservices actuator endpoint.

  • The port property can only be set for a Dev Service declaring its container port with port(…​). Otherwise, the property would be silently ignored, so the application fails to start instead.

  • The common properties must return a value: a properties class returning null for one of them is reported by property name at startup.

  • The auto-configuration must declare before = ServiceConnectionAutoConfiguration.class, as in the example above: the container bean definition must exist by the time Spring Boot looks for the @ServiceConnection beans to provide the connection details for. Without it, the container starts, but the application is not connected to it.

  • serviceConnectionName(…​) can’t be combined with serviceConnection(false) (see Wiring the Application to the Service).

  • Links (see Exposing Links) are resolved as http:// URLs.

  • SSL support (see Supporting SSL) can only be declared by a Dev Service whose properties implement SslDevServicesProperties, and properties implementing it require the SSL support to be declared: ssl.enabled fails at startup otherwise.

Finally, register the auto-configuration in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:

com.example.acme.FancyDbDevServicesAutoConfiguration

That’s it. When your module is on the dev or test classpath, the container starts automatically with the application, lifecycle management (startup, shutdown, DevTools restarts) is handled by the framework, and users can configure the service via your acme.dev.services.fancydb.* properties or a DevServiceContainerCustomizer bean.

If your service has endpoints worth surfacing to developers, such as a management console or a telemetry endpoint, declare them as links when registering the Dev Service (see Auto-Configuration and Registration), in terms of the container port rather than a URL: Arconia resolves the URL once the port mapping is known.

registry.registerDevService(service -> service
        .name("fancydb")
        // ...
        .link("FancyDB Console", FancyDbContainer.CONSOLE_PORT, "/console"));

The links appear in the startup message and in the devservices actuator endpoint (see Startup Feedback), both for a container the application started and for one it discovered from another application. Declare a port your container actually exposes: a link whose port can’t be resolved is skipped with a warning, and the remaining links are still reported.

Wiring the Application to the Service

The container is registered with Spring Boot’s Service Connections mechanism, which resolves a ConnectionDetailsFactory for the container. There are three ways to make the connection work, depending on what’s available for your service:

  • By container type (the default): if a ContainerConnectionDetailsFactory exists for your container’s type in Spring Boot (as is the case for many containers shipped with Testcontainers modules, like PostgreSQLContainer), no further configuration is needed.

  • By connection name: if the factory for your service matches by name rather than type, set it explicitly with serviceConnectionName("<name>") when registering the service. The name must match the one the factory requires, which is typically the container image name without registry and tag.

  • By dynamic properties: if no ConnectionDetails abstraction exists for your service, disable the service connection with serviceConnection(false) and contribute configuration properties lazily from the registrar instead:

@Override
protected void registerDevServices(DevServicesRegistry registry, Environment environment) {
    var properties = bindProperties("acme.dev.services.fancydb", FancyDbDevServicesProperties.class);

    registry.registerDevService(service -> service
            .name("fancydb")
            .description("FancyDB Dev Service")
            .properties(properties)
            .container(FancyDbContainer.class, () -> new FancyDbContainer(properties))
            .serviceConnection(false));

    addDynamicProperty("acme.fancydb.url", () ->
            getBeanFactory().getBean(FancyDbContainer.class).getUrl());
}

The property value is resolved lazily, every time the property is read, mirroring the semantics of Spring Boot’s @DynamicPropertySource. Looking up the container bean from the supplier starts the container on demand if it isn’t running yet, so the value is always the one of the running container.

Supporting Discovery

To let multiple applications running simultaneously use the same container of your Dev Service through the framework reuse strategy (see Reusing Dev Services), declare how to connect to a container started by another application when registering the service: the ConnectionDetails type and a factory that builds it for a discovered container:

registry.registerDevService(service -> service
        .name("fancydb")
        .description("FancyDB Dev Service")
        .properties(properties)
        .container(FancyDbContainer.class, () -> new FancyDbContainer(properties))
        .discovery(FancyDbConnectionDetails.class,
                container -> new FancyDbDiscoveredConnectionDetails(
                        container.host(), container.mappedPort(FancyDbContainer.FANCYDB_PORT))));

Declaring the discovery is what opts your Dev Service into it: a service that declares none never participates, even when the reuse-strategy property is set to framework. In that case the strategy is not applied, and the startup message of the Dev Service says so. The testcontainers reuse strategy needs no support from your Dev Service: it is applied by the framework when the container is created. The two strategies are mutually exclusive, so a container kept running by Testcontainers is never advertised for discovery.

The factory receives a DiscoveredContainer holding the discovered container’s information, the host address, and the mapped ports, and returns the ConnectionDetails implementation for your service, which is the same abstraction a ConnectionDetailsFactory would produce for an owned container. Declare the module providing the ConnectionDetails type as a regular dependency of your Dev Service module: applications using your Dev Service use the corresponding integration anyway, and the dependency only lands on their development and test classpaths. If it must remain optional (for example, when one Dev Service serves alternative integrations), guard the discovery declaration with ClassUtils.isPresent(…​) and keep the class literal in a method that is only invoked when the type is present. The declared type also tells the framework which ConnectionDetails the application may define on its own: when the application defines a bean of that type, the Dev Service is neither started nor discovered, mirroring Spring Boot’s service connections behavior (see Service Connections).

The reuse-strategy property is part of the BaseDevServicesProperties contract and defaults to none. Choose the default for your Dev Service in your properties class: framework if it declares discovery and is the kind of service applications typically share, such as a message broker, and none otherwise.

A discovered container is used as it runs, with the configuration of the application that started it. In particular, credentials must be the same across the applications using the Dev Service; if your service is credential-protected, document this constraint for your users.

For a relational database, declare JdbcConnectionDetails as the connection details type and return a JdbcDiscoveredConnectionDetails (which requires the spring-boot-jdbc module as a dependency of your Dev Service), building the JDBC URL from the discovered container’s host and mapped port in the same format your container reports.

Supporting SSL

To let users enable SSL for your Dev Service (see SSL with Dev Services), implement SslDevServicesProperties next to BaseDevServicesProperties in your properties class, and declare the SSL configuration as a nested property named ssl of the SslConfig type, which holds the switch and the bundle properties with the keys of a Spring Boot SSL bundle:

@ConfigurationProperties(prefix = "acme.dev.services.fancydb")
public class FancyDbDevServicesProperties implements BaseDevServicesProperties, SslDevServicesProperties {

    // The common properties and the properties of your own...

    @NestedConfigurationProperty
    private final SslConfig ssl = new SslConfig();

    @Override
    public SslConfig getSsl() {
        return ssl;
    }

}

SslDevServicesProperties is a read contract next to BaseDevServicesProperties, not an extension of it. Since SslConfig lives in another module, the configuration processor generates the metadata of the ssl keys under your prefix without their descriptions and defaults. To document them in your module, declare them in its META-INF/additional-spring-configuration-metadata.json, as the built-in Dev Services do (see the one of the Redis Dev Service for the entries to copy, with your prefix).

Then declare how to configure your container to serve SSL when registering the Dev Service, with ssl(…​). The configurer receives the SslBundle built from the properties when ssl.enabled is true, before the container is started and before any DevServiceContainerCustomizer runs. Whatever form the user configured the bundle in, SslBundleFiles renders what your container needs from it as PEM files to copy into the container: the certificate chain, the private key, both in a single file, or the chain again as the certificate authorities file some services require. The output is deterministic, so that a container reused across runs keeps the same configuration.

registry.registerDevService(service -> service
        .name("fancydb")
        .description("FancyDB Dev Service")
        .properties(properties)
        .container(FancyDbContainer.class, () -> createContainer(properties))
        .ssl(FancyDbDevServicesAutoConfiguration::configureSsl));

static void configureSsl(FancyDbContainer container, SslBundle bundle) {
    container.withCopyToContainer(SslBundleFiles.certificateChain(bundle), "/fancydb/ssl/server.crt");
    container.withCopyToContainer(SslBundleFiles.privateKey(bundle), "/fancydb/ssl/server.key");
    container.withEnv("FANCYDB_TLS_CERTIFICATE", "/fancydb/ssl/server.crt");
    container.withEnv("FANCYDB_TLS_KEY", "/fancydb/ssl/server.key");
}

The framework takes care of the application side. It declares on the container bean the annotations Spring Boot reads to build the SSL bundle of the connection details, that is, the certificate chain to trust, inlined, and the SSL options. It also registers the bundle the application connects with in the SslBundles of the application, under the reserved dev-services-<name> name, and reports it at startup and in the devservices actuator endpoint. For the application to connect over SSL, the ConnectionDetails of your service must expose the bundle, as the Spring Boot ones listed in its documentation do, which is why SSL support requires the service connection: it can’t be combined with serviceConnection(false). If your Dev Service declares discovery (see Supporting Discovery), return the bundle the DiscoveredContainer carries from your ConnectionDetails: it trusts the certificate the Dev Service is configured to present.

A service generating its own certificate when none is configured, as Elasticsearch does, declares its support with ssl(configurer, SslKeyStoreRequirement.OPTIONAL): the configurer is then only called when the user configures a key store, and only the @Ssl annotation is declared otherwise, so the protocol and options of the ssl properties don’t apply. This relies on the ContainerConnectionDetailsFactory of the service extracting the generated certificate from the container when @Ssl alone is present, which only the Elasticsearch one does in Spring Boot. For a discovered container, DiscoveredContainer.copyFileFromContainer(…​) reads such a certificate from the container.

Supporting the Shared Network

The shared network (see Shared Network) is a global feature: when the user sets arconia.dev.services.network.enabled, every Dev Service container joins the network automatically. Your Dev Service needs no special declaration to participate.

The only requirement is that your container is reachable by a stable name. A networked container is reachable by its service name by default, and users can add aliases with the common network-aliases property, which the framework applies to the container. Aliases only resolve on the shared network: without it, containers sit on the default network of the container runtime, where they can’t reach each other by name.

Mutual Exclusion

If your Dev Service provides the same capability as other Dev Services (for example, another JDBC-compatible database), declare its category when registering it. Use one of the DevServiceCategories constants when the overlap is with built-in Dev Services, or your own category string when the exclusion is among your own Dev Services. When multiple Dev Services of the same category are active at once, the application fails fast at startup with an actionable error message.

registry.registerDevService(service -> service
        .name("fancydb")
        .category(DevServiceCategories.JDBC)
        // ...