From @DynamicPropertySource to @ServiceConnection: what changes

A typical integration test starts out simple: a class annotated with @Testcontainers, a static @Container field holding a PostgreSQLContainer, and a static @DynamicPropertySource method that copies getJdbcUrl(), getUsername() and getPassword() into spring.datasource.url, spring.datasource.username and spring.datasource.password. That works for a single container.

When you add a Kafka broker and Redis, the method grows. It needs bootstrap servers from the Kafka container, plus host and mapped port for Redis. Then a second test class needs the same infrastructure, so the block gets copied. Over time the copies drift. One class sets the Redis port but forgets the host. Another still points at a property name the application no longer reads. These mistakes rarely fail loudly. The context often starts against a default such as localhost:6379, and the failure shows up later as a confusing timeout.

What a service connection is

A service connection replaces that hand-written mapping. You annotate the container field, or a @Bean method that returns a container, with @ServiceConnection. Spring Boot inspects the container type and image, then registers the matching ConnectionDetails bean, such as JdbcConnectionDetails for Postgres or RedisConnectionDetails for Redis. Auto-configuration reads connection settings from those beans, and they take precedence over the equivalent application properties. Nothing has to be copied into the Environment by hand.

This helps backend and platform engineers who maintain large Spring Boot test suites. Each service needs one annotation instead of several property lines. Spring Boot decides which settings each service requires, so wiring mistakes have fewer places to hide.

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {

    // Replaces the old @DynamicPropertySource lines for spring.datasource.*.
    // Spring Boot registers JdbcConnectionDetails from this container.
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    // Bootstrap servers come from KafkaConnectionDetails, not copied properties.
    @Container
    @ServiceConnection
    static KafkaContainer kafka =
            new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));

    // No dedicated Redis module: name = "redis" tells Spring Boot which
    // ConnectionDetails to create, so host AND mapped port are both wired.
    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine")).withExposedPorts(6379);

    @Test
    void contextUsesContainerConnections() {
        // Datasource, Kafka and Redis auto-configuration now point at the
        // containers; matching application properties are overridden.
    }
}

A single test class where @ServiceConnection on Postgres, Kafka and Redis containers replaces the hand-written @DynamicPropertySource mapping, letting Spring Boot derive ConnectionDetails for each service.

How service connections work under the hood

Spring Boot's auto-configuration does not read connection settings straight from properties. It reads them from a ConnectionDetails bean, such as JdbcConnectionDetails or RedisConnectionDetails. Without a service connection, Boot builds that bean from your spring.* properties. When you annotate a container with @ServiceConnection, a ConnectionDetailsFactory builds the bean from the running container instead. It takes the mapped host, port and credentials from the container.

@Container field / @Bean container (with @ServiceConnection)
        │
        ▼
ConnectionDetailsFactory  ── chosen by container type or image name
        │
        ▼
ConnectionDetails bean  (JdbcConnectionDetails, RedisConnectionDetails, …)
        │                          ▲
        │                          │ used only if no bean above exists
        │              application properties (spring.datasource.*, spring.data.redis.*)
        ▼
Auto-configured DataSource / Redis client

Matching works in two ways. A typed container class such as PostgreSQLContainer maps directly to the matching factory. A GenericContainer carries no type information, so Boot looks at the Docker image name. If the image name is ambiguous, for example a private registry mirror, set the annotation's name attribute to state the service explicitly.

The precedence rule matters when you debug. Suppose a test profile sets spring.datasource.url and the test still connects somewhere else. The service connection won, and Boot ignored that property. Inspect the ConnectionDetails bean, not your property files.

You need two kinds of dependency. The first is Spring Boot's Testcontainers test support module. The second is the Testcontainers module for each service you run. Supported container types and images vary between versions, so check the reference documentation for your Spring Boot and Testcontainers versions.

┌────────────────────────────────────────┐
│ @Container field or @Bean container    │
│ annotated with @ServiceConnection      │
└────────────────────┬───────────────────┘
                     │ matched by container class, or by
                     │ image name or @ServiceConnection(name)
                     ▼
┌────────────────────────────────────────┐
│ ConnectionDetailsFactory               │
│ reads mapped host, port, credentials   │
└────────────────────┬───────────────────┘
                     │ creates
                     ▼
┌────────────────────────────────────────┐      ┌──────────────────────────┐
│ ConnectionDetails bean                 │      │ application properties   │
│ JdbcConnectionDetails,                 │◄─────│ spring.datasource.*      │
│ RedisConnectionDetails, ...            │      │ spring.data.redis.*      │
│ overrides properties when present      │      │ (lower precedence)       │
└────────────────────┬───────────────────┘      └──────────────────────────┘
                     │                          fallback: used only if
                     │                          no container-backed bean
                     ▼
┌────────────────────────────────────────┐
│ Auto-configured DataSource /           │
│ Redis connection factory               │
└────────────────────────────────────────┘

A @ServiceConnection container is matched to a ConnectionDetailsFactory by class or image name, and the resulting ConnectionDetails bean feeds auto-configuration ahead of spring. properties, which apply only when no container-backed bean exists.*

Implementing it step by step: refactor, centralise, verify

Step 1: Swap the annotation

Start with one existing test class. Delete the @DynamicPropertySource method and annotate the static @Container PostgreSQLContainer field with @ServiceConnection. Keep @Testcontainers on the class so JUnit still starts the container before the context loads. Spring Boot now derives the connection details from the container itself. You no longer copy the URL, username and password into properties by hand, and you can't mistype a property key.

Step 2: Verify the wiring

Don't assume the swap worked. A leftover spring.datasource.url in a test profile, or an embedded database on the classpath, can make tests pass against the wrong database. Inject the DataSource, open a connection, and assert that the URL from its metadata matches postgres.getJdbcUrl(). As a second check, run SELECT 1 or SELECT version() through a JdbcTemplate.

Step 3: Centralise

Copying the same field into every test class is the next source of drift. Move it into a @TestConfiguration(proxyBeanMethods = false) class with a @Bean method that returns the container and carries @ServiceConnection. Pull it into each test with @Import. Identical imports also keep test configurations alike, which helps Spring reuse a cached context instead of building a new one.

Bean-defined containers follow the application context's lifecycle, not JUnit's @Container semantics. Spring starts them when it creates the context and stops them when the context closes. Do not add @Container to these beans.

Step 4: Reuse it for local runs

In src/test/java, add a main method that calls SpringApplication.from(Application::main).with(ContainersConfig.class).run(args). Developers can then run the app locally against the same containers, and you have no separate compose file to keep in sync.

// ContainersConfig.java (src/test/java): one shared definition, imported by every test
@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfig {
    @Bean
    @ServiceConnection // Boot derives spring.datasource.* from the running container
    PostgreSQLContainer<?> postgres() {
        // Lifecycle follows the application context, so there is no @Container here
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

// OrderRepositoryIT.java: an identical @Import in each class keeps the context cacheable
@SpringBootTest
@Import(ContainersConfig.class)
class OrderRepositoryIT {
    @Autowired DataSource dataSource;
    @Autowired JdbcTemplate jdbc;
    @Autowired PostgreSQLContainer<?> postgres;

    @Test
    void datasourcePointsAtContainer() throws Exception {
        // Catches a stray spring.datasource.url or an embedded DB taking over
        try (Connection c = dataSource.getConnection()) {
            assertThat(c.getMetaData().getURL()).isEqualTo(postgres.getJdbcUrl());
        }
        // Trivial round trip through the real driver
        assertThat(jdbc.queryForObject("SELECT 1", Integer.class)).isEqualTo(1);
    }
}

// TestApplication.java (src/test/java): run the app locally against the same containers
public class TestApplication {
    public static void main(String[] args) {
        SpringApplication.from(Application::main)
                .with(ContainersConfig.class)
                .run(args);
    }
}

A shared @TestConfiguration exposes a PostgreSQL container as a @ServiceConnection bean. A test imports it and asserts that the DataSource URL matches the container's JDBC URL, and a test-scoped main method reuses the same configuration for local runs. Imports are omitted for brevity.

Where the speed comes from: container lifecycle and context caching

Service connections remove property wiring, but they do not make tests faster on their own. Speed depends on how often containers start, and that depends on which lifecycle you tie them to.

Strategy Isolation Container starts Typical failure mode
static @Container field per test class (@Testcontainers) Strong: each class gets a fresh container One per test class Slow suites. A cached Spring context can also outlive the stopped container and fail with connection errors in a later class
Static singleton started once (shared base class or holder) Weak: all classes share state One per JVM Data leaks between classes. Cleanup relies on Ryuk at JVM exit
@Bean @ServiceConnection in a shared @TestConfiguration Weak within a context One per distinct cached context Context cache fragmentation silently multiplies starts

The bean approach ties the container to the application context, so Spring's test context cache decides how often the container starts. The cache key includes active profiles, @TestPropertySource and inline properties, @DynamicPropertySource methods, the configuration classes, and mock bean overrides. Any test class that adds one @MockitoBean or a different profile gets a new context, and that new context starts its own containers. Fifteen slightly different test classes can mean fifteen PostgreSQL starts. The cache is also bounded and evicts least-recently-used contexts, which stops their containers and forces later restarts. To limit this, standardise mocks and properties in a few shared base configurations.

Testcontainers reuse

Reuse is a separate mechanism. It requires two things: withReuse(true) on the container, and testcontainers.reuse.enable=true in the developer's Testcontainers configuration file. With both set, Testcontainers finds a matching running container and attaches to it instead of starting a new one, so the container survives across JVM runs. Nothing removes these containers automatically. They also keep whatever schema and data the last run left behind, which can hide broken migrations. Use reuse for fast local iteration and leave it disabled in CI.

// Shared container config. Every test class that imports it with the same
// profiles, properties and mock beans reuses one cached context and one container.
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
public class PostgresTestConfig {

    @Bean
    @ServiceConnection // Spring Boot derives the DataSource URL, username and password
    PostgreSQLContainer<?> postgres() {
        return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
                // Opt-in only. Has no effect unless ~/.testcontainers.properties
                // contains: testcontainers.reuse.enable=true
                // A reused container survives JVM exit, is not removed automatically,
                // and keeps whatever schema and data earlier runs left behind.
                // Leave reuse disabled in CI.
                .withReuse(true);
    }
}

// Shared base class: one place for profiles and mocks, so subclasses
// produce the same context cache key.
// @SpringBootTest
// @ActiveProfiles("test")
// @Import(PostgresTestConfig.class)
// abstract class IntegrationTestBase { }
//
// Adding @MockitoBean or a different @ActiveProfiles value in a subclass
// changes the cache key. That creates a new context, which starts another container.

A shared @TestConfiguration that exposes PostgreSQL as a @ServiceConnection bean, so its lifecycle follows Spring's test context cache, with optional local-only reuse enabled through withReuse(true).

Keeping shared containers safe: managing state between tests

A shared container makes tests fast, but it also lets one test's leftovers reach the next. A test that inserts a user with a unique email passes alone. It fails when another test has already inserted the same row. It passes again when the runner happens to pick a different order. Most order-dependent flakiness in container-backed suites comes from this, not from the containers themselves. The fix is to make every test start from a known state.

Resetting the database

The cheapest option is to annotate the test with @Transactional. Spring's test framework rolls back the transaction after each method by default. This only works when the code under test runs inside that same transaction, on the same thread.

When rollback doesn't fit, truncate the affected tables in a @BeforeEach hook or a @Sql script. Truncating before each test, rather than after, means a crashed test can't poison the next one.

For schema-level resets, your migration tool can clean and re-migrate. This is slower, and the clean operation is often disabled by default as a safety guard, so enable it only in the test profile.

When rollback does not apply

Rollback doesn't cover:

  • Requests sent through a real HTTP port (RANDOM_PORT), because the server handles them on its own threads.
  • @Async methods and listeners that run after commit.
  • Message consumers.
  • Stores that don't take part in the transaction at all, such as Redis, Kafka or RabbitMQ.

For these, wait for the async work to finish before asserting or cleaning up. Then flush the cache, purge queues, or give each test its own topic names and key prefixes so tests can't collide.

When to use service connections, when to avoid them, and the alternatives

Adopt service connections when your containers map to connection types Spring Boot already recognises, such as PostgreSQL, MySQL, Redis, Kafka, RabbitMQ or MongoDB. The payoff grows with the number of services in a suite. Each @DynamicPropertySource method is a hand-copied list of property names that can drift from what the application actually reads. Teams that keep fixing tests broken by a renamed property gain the most, because Boot derives the connection details from the container itself.

Avoid them, or mix approaches, in three situations:

  • Custom images or services without built-in ConnectionDetails. Boot cannot infer how to connect, so you need explicit wiring anyway.
  • Teams that want visible, explicit wiring. Some teams prefer property mappings they can read in the test class over configuration inferred from the container type and image name.
  • CI agents where reuse is wrong. Long-lived containers on shared agents leak state between unrelated builds. Keep reuse to developer machines, and let CI start clean containers on every run.
Approach Setup effort Fidelity Speed
@DynamicPropertySource Low, but grows with each property Real service Container startup cost
Custom ConnectionDetailsFactory Moderate, written once Real service Container startup cost
Docker Compose support or a hand-maintained compose file Moderate; YAML lives outside the tests Real services, closer to production topology Container startup cost, often slower for full stacks
In-memory or embedded substitutes Low Partial; SQL dialects and broker semantics differ Fast
Unit tests with mocks Low No real I/O Fastest

Service connections remove wiring code, not container startup time. Container-backed tests remain slower than unit tests, and every developer machine and CI agent needs a Docker-compatible runtime to run them.

Impact on cost and engineering time, and how to measure it

The cost picture has two sides. On the savings side, you delete per-class property wiring that someone had to write, review, and update whenever a property name changed. One container definition serves both tests and local runs, so onboarding shrinks to "have a container runtime, run the app" instead of a page of setup steps. On the cost side, the wiring is now implicit. When a connection is not applied, the failure often looks like a generic connection error rather than a missing property, which takes longer to diagnose. The speed gain also depends on discipline. A stray @MockBean or per-class property override creates a new context, which may start fresh containers and quietly erase the benefit.

Measuring it on your own suite

  • Count container starts. Run the full suite and count the Testcontainers startup lines in the log for each image. If shared containers are working, each image should appear about once per JVM.
  • Compare wall time. Record total suite time in CI before and after the refactor, using the same agent type. Use several runs, not one.
  • Check context cache misses. Set org.springframework.test.context.cache to DEBUG and review the cache statistics. If the miss count grows over time, a configuration difference is splitting contexts.

Illustrative estimate (assumed numbers, not a benchmark): saved time = (starts before − starts after) × average start time. Assume 12 test classes each start PostgreSQL at 8 s each, reduced to 1 shared start: (12 − 1) × 8 s = 88 s per run. Real results depend on images, hardware, and suite structure.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot testcontainers service connection, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.