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 |
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,SharedDevServicesProperties) and the runtime descriptors (DevServiceRegistration,ContainerInfo). 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, includingDevServicesRegistrar,DevServicesRegistry,ContainerConfigurer, 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 |
@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: environment, networkAliases, port,
// resources, shared, startupTimeout, volumes...
@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...
}
|
|
Container Class
Define a container class for your service. Extend the service’s Testcontainers class if one exists, or GenericContainer otherwise. Use ContainerConfigurer to apply the common configuration properties, and asCompatibleSubstituteFor() so users can override the image with a compatible one (e.g. a mirror from an internal registry).
public final class FancyDbContainer extends GenericContainer<FancyDbContainer> {
static final String COMPATIBLE_IMAGE_NAME = "acme/fancydb";
static final int FANCYDB_PORT = 8090;
private final FancyDbDevServicesProperties properties;
public FancyDbContainer(FancyDbDevServicesProperties properties) {
super(DockerImageName.parse(properties.getImageName()).asCompatibleSubstituteFor(COMPATIBLE_IMAGE_NAME));
this.properties = properties;
withExposedPorts(FANCYDB_PORT);
ContainerConfigurer.base(this, properties);
}
@Override
protected void configure() {
super.configure();
if (ContainerUtils.isFixedPort(properties.getPort())) {
addFixedExposedPort(properties.getPort(), FANCYDB_PORT);
}
}
}
For relational databases, also apply ContainerConfigurer.jdbc(this, properties) to configure credentials, database name, and init scripts.
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")
.container(container -> container
.type(FancyDbContainer.class)
.supplier(() -> new FancyDbContainer(properties))
));
}
}
}
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, sharing) is handled by the framework, and users can configure the service via your acme.dev.services.fancydb.* properties or a DevServiceContainerCustomizer bean.
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
ContainerConnectionDetailsFactoryexists for your container’s type in Spring Boot (as is the case for many containers shipped with Testcontainers modules, likePostgreSQLContainer), 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>")on the container spec. 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
ConnectionDetailsabstraction exists for your service, disable the service connection by passingserviceConnectionName(null)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")
.container(container -> container
.type(FancyDbContainer.class)
.serviceConnectionName(null)
.supplier(() -> new FancyDbContainer(properties))
));
addDynamicProperty("acme.fancydb.url", () ->
getBeanFactory().getBean(FancyDbContainer.class).getUrl());
}
The property value is resolved lazily, only after the container has started, mirroring the semantics of Spring Boot’s @DynamicPropertySource.
Supporting Shared Dev Services
To let multiple applications running simultaneously share one container of your Dev Service (see Sharing Dev Services), have your properties class implement SharedDevServicesProperties (instead of BaseDevServicesProperties), which adds the shared property to its contract, and declare a discovery specification when registering the service. Pass the value of your shared property and a factory building the ConnectionDetails for connecting to a shared container started by another application:
registry.registerDevService(service -> service
.name("fancydb")
.description("FancyDB Dev Service")
.container(container -> container
.type(FancyDbContainer.class)
.supplier(() -> new FancyDbContainer(properties))
)
.discovery(discovery -> discovery
.shared(properties.isShared())
.connectionDetails(FancyDbConnectionDetails.class,
container -> new FancyDbDiscoveredConnectionDetails(
container.host(), container.mappedPort(FancyDbContainer.FANCYDB_PORT)))
));
The shared toggle and the connection details factory are declared together in the discovery specification, so a service can never enable sharing without providing the means to adopt a discovered container. Container reuse is a separate, orthogonal concern: it is applied from the reuse property by ContainerConfigurer when the container is created (via ContainerConfigurer.base(…)), so a reused container can also be shared.
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. If the application already defines a ConnectionDetails bean of the declared type, it takes precedence: the shared container is still adopted, but the dev service doesn’t register its own connection details, mirroring Spring Boot’s service connections behavior.
Adopt SharedDevServicesProperties and the discovery specification together: a Dev Service that declares no discovery specification never participates in discovery, so it should not expose the shared property either. The framework still applies the identifying labels to the container, and users can still keep containers across restarts via the reuse property.
A discovered container is adopted as it runs, with the configuration of the application that started it. In particular, credentials must be the same across the applications sharing the Dev Service; if your service is credential-protected, document this constraint for your users.
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 override that with the common network-aliases property, so make sure your container applies it: using ContainerConfigurer.base(…), which calls withNetworkAliases(…), is enough.
Mutual Exclusion
If your Dev Service provides the same capability as other Dev Services (for example, another JDBC-compatible database), declare a DevServiceProvider bean with the corresponding category. 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.
@Bean
DevServiceProvider fancyDbDevServiceProvider() {
return DevServiceProvider.of("fancydb", DevServiceCategories.JDBC);
}