Skip to content

Spring Boot

etcd-recipes-spring-boot-starter turns a few lines of application.yml into a connected, shared, gracefully-closed etcd client. It targets Spring Boot 4.1.x.

implementation("com.pambrose:etcd-recipes-spring-boot-starter:0.12.0")

That is the entire installation. There is no @EnableEtcd, no annotation to add, and nothing to import: the jar ships a META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports naming EtcdAutoConfiguration, so Boot finds it on the classpath and activates it.

What gets auto-configured

@AutoConfiguration
@EnableConfigurationProperties(EtcdProperties::class)
@ConditionalOnClass(Client::class)
class EtcdAutoConfiguration

@ConditionalOnClass(Client::class) keeps the whole thing inert unless jetcd is actually present, and every bean below is @ConditionalOnMissingBean:

Bean Type Notes
etcdClient Client @Bean(destroyMethod = "close") — closed on context teardown
etcdRecipes EtcdRecipes The factory from Integrations, bound to etcdClient
etcdHealthIndicator HealthIndicator Only with Actuator present — see below

Because they are all conditional, defining your own bean of the same type makes the starter back off and use yours — including for the beans built on top of it. Declare your own Client and the auto-configured EtcdRecipes and health indicator will both wrap it:

// Every contributed bean is @ConditionalOnMissingBean, so defining your own Client makes
// the auto-configuration back off and use yours — including for the EtcdRecipes bean and
// the health indicator.
@Configuration
class CustomEtcdConfig {
  @Bean(destroyMethod = "close")
  fun etcdClient(): Client =
    connectToEtcd(
      EtcdConnectionConfig(endpoints = ["http://localhost:2379"], namespace = "/myapp/"),
      // The initReceiver escape hatch: raw jetcd builder options the config does not model.
      // Pass it by name — a trailing lambda would bind to the scoping `block` overload.
      initReceiver = { maxInboundMessageSize(8 * 1024 * 1024) },
    )
}

Configuration

EtcdProperties is constructor-bound and immutable, under the prefix etcd.recipes. Relaxed binding applies, so connect-timeout, connectTimeout, and CONNECT_TIMEOUT all land in the same place.

Property Type Default
etcd.recipes.endpoints List<String> (empty — you must set this)
etcd.recipes.user String? null
etcd.recipes.password String? null
etcd.recipes.namespace String? null
etcd.recipes.connect-timeout Duration 5s
etcd.recipes.retry-max-duration Duration 30s
etcd.recipes.tls.ca-cert-path String? null
etcd.recipes.tls.client-cert-path String? null
etcd.recipes.tls.client-key-path String? null
etcd:
  recipes:
    endpoints:
      - "http://etcd-1:2379"
      - "http://etcd-2:2379"
    # Prefixes every key this client touches. The recipes never see it.
    namespace: /myapp/
    user: orders-svc
    password: ${ETCD_PASSWORD}
    connect-timeout: 5s
    retry-max-duration: 30s
    tls:
      ca-cert-path: /etc/etcd/ca.crt
      client-cert-path: /etc/etcd/client.crt
      client-key-path: /etc/etcd/client.key
etcd.recipes.endpoints[0]=http://etcd-1:2379
etcd.recipes.endpoints[1]=http://etcd-2:2379
# Prefixes every key this client touches. The recipes never see it.
etcd.recipes.namespace=/myapp/
etcd.recipes.user=orders-svc
etcd.recipes.password=${ETCD_PASSWORD}
etcd.recipes.connect-timeout=5s
etcd.recipes.retry-max-duration=30s
etcd.recipes.tls.ca-cert-path=/etc/etcd/ca.crt
etcd.recipes.tls.client-cert-path=/etc/etcd/client.crt
etcd.recipes.tls.client-key-path=/etc/etcd/client.key

endpoints is the only property with no workable default. Everything else is optional; omit the tls block entirely for a plaintext connection.

toConnectionConfig() maps EtcdProperties onto the EtcdConnectionConfig that connectToEtcd consumes — the properties type exists only to be a Boot-shaped front end for it.

Durations are Spring-parsed, not raw numbers

connect-timeout: 5s works because Boot's relaxed binding converts a duration string into a java.time.Duration. 5s, 500ms, and PT5S are all valid. A bare 5 is interpreted as milliseconds — write the unit and avoid the question.

Injecting EtcdRecipes

The factory bean is the intended entry point for application code:

// The starter contributes an EtcdRecipes bean, so a service just asks for it. The client
// underneath is shared and closed with the application context.
@Service
class OrderService(
  private val recipes: EtcdRecipes,
) {
  fun processExclusively() {
    recipes.mutex("/locks/orders").use { mutex ->
      mutex.withLock {
        logger.info { "Only one instance of this service runs this at a time" }
      }
    }
  }
}
// The starter contributes an EtcdRecipes bean, so a service just asks for it. The client
// underneath is shared and closed with the application context.
@Service
public static class OrderService {
  private final EtcdRecipes recipes;

  public OrderService(EtcdRecipes recipes) {
    this.recipes = recipes;
  }

  public void processExclusively() throws InterruptedException {
    try (DistributedMutex mutex = recipes.mutex("/locks/orders")) {
      mutex.lock();
      try {
        System.out.println("Only one instance of this service runs this at a time");
      } finally {
        mutex.unlock();
      }
    }
  }
}

The recipe is not a bean — do not make it one

recipes.mutex(...) constructs a new DistributedMutex on every call, and you own its lifecycle. That is why the examples above scope it to the unit of work with use / try-with-resources rather than holding it in a field.

Promoting a lock to a singleton bean is the tempting mistake here: a mutex held open for the lifetime of the context holds an etcd lease for that long too, and a @Bean-managed close() only runs at shutdown. Long-lived recipes (a PathChildrenCache, a LeaderLatch) are reasonable beans; short-lived ones (locks, queue handles) are not.

Actuator health

The nested EtcdHealthConfiguration is @ConditionalOnClass(HealthIndicator::class), so the health indicator appears only when the health API is on your classpath. The starter declares it compileOnly — it will never pull it in for you.

implementation("org.springframework.boot:spring-boot-starter-actuator")

Where HealthIndicator comes from in Spring Boot 4

Boot 4 extracted the health API out of spring-boot-actuator into its own spring-boot-health artifact, moving Health, HealthIndicator and Status to org.springframework.boot.health.contributor. The starter compiles against both (compileOnly), and the @ConditionalOnClass guard keys on the relocated HealthIndicator. If you are porting health code from a Boot 3 app, that import is the thing to change — the types themselves are the same shape. If the indicator does not appear, check that spring-boot-health actually resolved onto your classpath.

With it present, EtcdHealthIndicator maps Client.ping() — a bounded, non-mutating, count-only GET through the RPC retry funnel — onto Actuator's UP / DOWN:

{
  "status": "UP",
  "components": {
    "etcd": { "status": "UP" }
  }
}

This probes etcd, it does not just check a flag

ping() issues a real RPC, which is what makes it worth having: a client object exists and looks fine long after the cluster it points at has gone away. It returns false rather than throwing when etcd is unreachable or the client is closed, so the indicator reports DOWN instead of erroring the health endpoint.

Expose it deliberately, though. If your readiness probe includes this indicator, an etcd blip will roll your pods out of service — which is correct for a service that cannot function without etcd, and wrong for one that merely prefers it.