Testing

Testcontainers in Spring Boot: Real Database Integration Tests Without Mocks

In-memory databases and hand-rolled mocks give you fast tests, but they lie to you. H2 silently ignores PostgreSQL-specific SQL, and your mock UserRepository never tests the JPA query you spent an hour debugging. Testcontainers solves this by spinning up real Docker containers — PostgreSQL, Redis, Kafka, Elasticsearch — for the duration of your test suite and tearing them down afterward. This guide walks through the full Testcontainers setup for Spring Boot 3.x, from dependency configuration to parallel test execution in GitHub Actions CI.

Md Sanwar Hossain April 4, 2026 20 min read Testing
Testcontainers Spring Boot integration testing with real databases

Table of Contents

  1. Why Testcontainers Over H2 and Mocks
  2. Project Setup: Dependencies and Docker Requirements
  3. PostgreSQL Integration Tests with @DataJpaTest
  4. Singleton Container Pattern: Reusing Containers Across Tests
  5. Kafka Integration Testing with EmbeddedKafka vs Testcontainers
  6. Redis and Multi-Container Compose Tests
  7. Spring Boot 3.1+ ServiceConnection and @ImportTestcontainers
  8. Running Testcontainers in GitHub Actions CI/CD
  9. Key Takeaways

1. Why Testcontainers Over H2 and Mocks

Testcontainers integration testing architecture | mdsanwarhossain.me
Testcontainers Integration Testing Architecture — mdsanwarhossain.me

H2 covers basic CRUD but falls apart the moment you use a PostgreSQL-specific feature: JSONB columns, pg_trgm full-text indexes, advisory locks, or RETURNING clauses. Many teams only discover the mismatch during a production incident — not in CI. Similarly, mocking a JpaRepository method tests your test setup, not your query. A @Query annotation with a typo in the JPQL compiles fine and only fails at runtime.

Testcontainers wraps the Docker daemon via the Docker Java client. Each container defined in your test class starts before the test and stops after, leaving no state between runs. Since Spring Boot 3.1, the @ServiceConnection annotation auto-configures the DataSource, RedisConnectionFactory, or Kafka properties directly from the container — no manual property overrides required.

Rule of Thumb: Use Testcontainers for any test that touches the database layer, a cache, or a message broker. Reserve Mockito for pure business logic with no I/O.

2. Project Setup: Dependencies and Docker Requirements

You need Docker running on the machine where tests execute — either your local workstation or the CI agent. Testcontainers communicates with Docker via the Unix socket or TCP, pulling images on demand and caching them locally. The Spring Boot parent BOM (3.1+) includes managed versions for all Testcontainers modules.

<!-- pom.xml -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>1.19.8</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <!-- Core Testcontainers -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- PostgreSQL module -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- Kafka module -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>kafka</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- Spring Boot test integration -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

3. PostgreSQL Integration Tests with @DataJpaTest

The @DataJpaTest slice loads only the JPA layer — entities, repositories, and data source configuration. By default it configures an in-memory H2 database. To replace H2 with a real PostgreSQL container, annotate the test class with @AutoConfigureTestDatabase(replace = NONE) and declare a PostgreSQLContainer bean with @ServiceConnection.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldPersistAndRetrieveOrderWithJsonbMetadata() {
        Order order = new Order();
        order.setStatus("PENDING");
        order.setMetadata("{\"source\": \"web\", \"promoCode\": \"SAVE10\"}"); // JSONB
        Order saved = orderRepository.save(order);

        Optional<Order> found = orderRepository.findById(saved.getId());

        assertThat(found).isPresent();
        assertThat(found.get().getMetadata()).contains("promoCode");
    }

    @Test
    void shouldFindOrdersByStatusUsingCustomQuery() {
        orderRepository.saveAll(List.of(
            new Order("PENDING", null),
            new Order("SHIPPED", null),
            new Order("PENDING", null)
        ));

        List<Order> pending = orderRepository.findByStatus("PENDING");

        assertThat(pending).hasSize(2);
    }
}
Important: @ServiceConnection replaces the manual @DynamicPropertySource approach. It reads the container's mapped host/port and configures the Spring DataSource automatically. Requires Spring Boot 3.1+.

4. Singleton Container Pattern: Reusing Containers Across Tests

Singleton container pattern Testcontainers Spring Boot | mdsanwarhossain.me
Singleton Container Pattern — mdsanwarhossain.me

If every test class starts its own PostgreSQL container, your build spends minutes on container startup. The Singleton Container pattern declares the container as a static field in a shared base class, allowing JVM-scoped reuse across all test classes that extend it. Testcontainers' Ryuk side-container handles cleanup when the JVM exits.

// Shared base class — all integration tests extend this
public abstract class AbstractIntegrationTest {

    @Container
    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                .withReuse(true); // enable container reuse across runs

    static {
        POSTGRES.start();
    }
}

// Test class — inherits the shared container
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest extends AbstractIntegrationTest {

    @Autowired
    ProductRepository productRepository;

    @Test
    void shouldCountProductsByCategory() {
        // Container already running — no startup cost
        productRepository.saveAll(List.of(
            new Product("TV", "ELECTRONICS"),
            new Product("Shirt", "CLOTHING"),
            new Product("Phone", "ELECTRONICS")
        ));
        assertThat(productRepository.countByCategory("ELECTRONICS")).isEqualTo(2);
    }
}

Enable the container reuse feature by adding testcontainers.reuse.enable=true to ~/.testcontainers.properties on developer machines or setting it as an environment variable in CI. Without this property, withReuse(true) is silently ignored, and the container is still stopped after each test session — which is the safe default.

5. Kafka Integration Testing with EmbeddedKafka vs Testcontainers

Spring's @EmbeddedKafka is fast and requires no Docker, but it runs an older Kafka version and doesn't support all broker configurations. For production-fidelity, use KafkaContainer from Testcontainers, which runs the official Confluent Platform image.

@SpringBootTest
@Testcontainers
class OrderEventPublisherTest {

    @Container
    @ServiceConnection
    static KafkaContainer kafka =
            new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));

    @Autowired
    private OrderEventPublisher publisher;

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @Test
    void shouldPublishOrderCreatedEventToKafka() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        List<OrderEvent> received = new ArrayList<>();

        // Consumer listening on the orders topic
        kafkaTemplate.receive("orders", 0, 0, Duration.ofSeconds(5));

        publisher.publishOrderCreated(new OrderCreatedEvent("order-123", "user-456"));

        // Assert message arrived within 10 seconds
        boolean messageReceived = latch.await(10, TimeUnit.SECONDS);
        assertThat(messageReceived).isTrue();
        assertThat(received).extracting(OrderEvent::getOrderId).contains("order-123");
    }
}

6. Redis and Multi-Container Compose Tests

Testcontainers provides a GenericContainer for Redis. For complex setups with multiple services, the DockerComposeContainer reads an existing docker-compose.yml file, which lets you reuse the same infrastructure definition for both local development and tests.

@SpringBootTest
@Testcontainers
class CacheServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>("redis:7.2-alpine")
                .withExposedPorts(6379);

    @Autowired
    private ProductCacheService cacheService;

    @Test
    void shouldCacheProductLookup() {
        cacheService.cacheProduct("prod-1", new Product("Laptop", 1299.00));

        Optional<Product> cached = cacheService.getCachedProduct("prod-1");

        assertThat(cached).isPresent();
        assertThat(cached.get().getName()).isEqualTo("Laptop");
    }

    @Test
    void shouldEvictCacheOnUpdate() {
        cacheService.cacheProduct("prod-2", new Product("Mouse", 29.99));
        cacheService.evict("prod-2");

        Optional<Product> cached = cacheService.getCachedProduct("prod-2");

        assertThat(cached).isEmpty();
    }
}

// Multi-container with Docker Compose
@Testcontainers
class FullStackIntegrationTest {

    @Container
    static DockerComposeContainer<?> compose =
        new DockerComposeContainer<>(new File("src/test/resources/docker-compose-test.yml"))
            .withExposedService("postgres_1", 5432, Wait.forListeningPort())
            .withExposedService("redis_1", 6379, Wait.forListeningPort())
            .withExposedService("kafka_1", 9092, Wait.forListeningPort());
}

7. Spring Boot 3.1+ ServiceConnection and @ImportTestcontainers

Spring Boot 3.1 introduced two major improvements: @ServiceConnection for zero-boilerplate property wiring, and @ImportTestcontainers for sharing container definitions across tests via a dedicated configuration class. Together they eliminate most of the repetitive infrastructure code.

// Centralized container configuration
public class TestContainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }

    @Bean
    @ServiceConnection
    RedisContainer redisContainer() {
        return new RedisContainer("redis:7.2-alpine");
    }
}

// Test class imports the shared config — no @Container declarations needed
@SpringBootTest
@ImportTestcontainers(TestContainersConfig.class)
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Test
    void shouldCreateOrderAndPublishEvent() {
        OrderRequest req = new OrderRequest("user-1", List.of("sku-A", "sku-B"));
        Order order = orderService.createOrder(req);

        assertThat(order.getId()).isNotNull();
        assertThat(order.getStatus()).isEqualTo("PENDING");
    }
}

8. Running Testcontainers in GitHub Actions CI/CD

GitHub Actions runners have Docker pre-installed. No special configuration is needed — Testcontainers detects the Docker socket automatically. For speed, use Maven's Surefire fork count to run test classes in parallel, and cache the Docker image layer in the Actions cache.

# .github/workflows/ci.yml
name: CI — Integration Tests

on: [push, pull_request]

jobs:
  integration-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven

      - name: Pull Docker images in parallel
        run: |
          docker pull postgres:16-alpine &
          docker pull redis:7.2-alpine &
          docker pull confluentinc/cp-kafka:7.6.1 &
          wait

      - name: Run integration tests
        run: mvn verify -Pfailsafe -T 2
        env:
          TESTCONTAINERS_RYUK_DISABLED: false

For Ryuk (the container cleanup side-car), leave TESTCONTAINERS_RYUK_DISABLED as false. Ryuk ensures all containers are removed even if the JVM crashes, preventing orphaned containers that consume CI agent resources. If your CI environment does not allow privileged containers, set TESTCONTAINERS_RYUK_PRIVILEGED=false and use the unprivileged mode.

9. Key Takeaways

10. Advanced Testcontainers: Custom Images and Network Configuration

While the official Testcontainers modules cover most common infrastructure needs, real-world microservices often require custom Docker images — for example, a PostgreSQL image with specific extensions pre-installed, a custom Kafka configuration, or an in-house service dependency. Testcontainers provides GenericContainer as a flexible base for any Docker image, and ImageFromDockerfile to build images from a Dockerfile at test time without maintaining a separate CI build step for test images.

// Build a custom Postgres image with pgvector extension at test time
GenericContainer<?> customPostgres = new GenericContainer<>(
    new ImageFromDockerfile()
        .withDockerfileFromBuilder(builder ->
            builder
                .from("postgres:16-alpine")
                .run("apk add --no-cache build-base && "
                   + "cd /tmp && git clone https://github.com/pgvector/pgvector.git && "
                   + "cd pgvector && make && make install")
                .build()
        )
)
.withExposedPorts(5432)
.withEnv("POSTGRES_DB", "testdb")
.withEnv("POSTGRES_USER", "test")
.withEnv("POSTGRES_PASSWORD", "test")
.waitingFor(Wait.forListeningPort());

// Reference a pre-built image from a private registry
GenericContainer<?> internalService = new GenericContainer<>(
        DockerImageName.parse("registry.internal.example.com/inventory-service:latest"))
    .withExposedPorts(8080)
    .waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200));

Container networking becomes critical when your service under test depends on other containerized services. Testcontainers supports Docker networks so containers can communicate with each other by hostname, simulating production service-discovery topology. Create an isolated bridge network with Network.newNetwork() and assign network aliases to containers so they can resolve each other without knowing externally mapped ports.

@Testcontainers
class OrderServiceIntegrationTest {

    static final Network NETWORK = Network.newNetwork();

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withNetwork(NETWORK)
        .withNetworkAliases("postgres");

    @Container
    static GenericContainer<?> inventoryService = new GenericContainer<>("inventory-service:test")
        .withNetwork(NETWORK)
        .withNetworkAliases("inventory")
        .withEnv("DB_URL", "jdbc:postgresql://postgres:5432/testdb")
        .dependsOn(postgres)
        .waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200));

    @DynamicPropertySource
    static void configure(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        // The order service calls inventory via http://inventory:8080
        registry.add("inventory.service.url",
            () -> "http://inventory:" + inventoryService.getMappedPort(8080));
    }
}

Network aliases are the key to container-to-container communication. Without a shared network, each container only knows its own externally mapped host port. With a shared network and aliases, containers resolve each other by hostname exactly as they do in Docker Compose or Kubernetes. This enables end-to-end integration tests spanning multiple services without any infrastructure changes to production code.

When building images from Dockerfiles at test time, Testcontainers caches the built image using a hash of the Dockerfile content. Subsequent test runs reuse the cached image, avoiding redundant Docker builds. For large base images or slow build steps, consider tagging the image explicitly and referencing it via DockerImageName.parse() with the pre-built tag. You can also set .withDeleteOnExit(false) to preserve a built image across JVM restarts, trading storage for startup speed. Always pin image tags in tests — using :latest can cause non-deterministic failures when the upstream image changes between CI runs.

11. Testcontainers Cloud: Running Tests Without Local Docker

Testcontainers Cloud, offered by the Testcontainers team (now part of Docker), addresses a common pain point: not every developer has Docker installed, and many CI environments restrict Docker-in-Docker. Testcontainers Cloud offloads container execution to a managed remote environment while keeping the test code completely unchanged — the same @Container annotations, same @ServiceConnection wiring, same assertions. The cloud environment handles container lifecycle while the JVM running tests sees local ports as usual.

Setup requires a Testcontainers Cloud account and a lightweight agent on the CI runner or developer machine. The agent intercepts Docker API calls and routes container creation to the managed cloud environment over a secure tunnel. Containers start with mapped ports that appear local to the JVM, so no test code changes are needed. The key CI benefit is that GitHub-hosted runners (which have Docker available but limited resources) no longer need to pull multi-GB images themselves — the cloud environment caches popular images like PostgreSQL, Kafka, and Redis.

# .github/workflows/ci.yml — integrate Testcontainers Cloud
steps:
  - name: Setup Testcontainers Cloud
    uses: atomicjar/testcontainers-cloud-setup-action@v1
    with:
      token: ${{ secrets.TC_CLOUD_TOKEN }}

  - name: Run integration tests
    run: mvn verify -Pfailsafe
    # DOCKER_HOST is automatically configured by the agent
    # No changes to test code required

Testcontainers Cloud also provides a Turbo mode that parallelises container operations — starting multiple containers concurrently and streaming logs back in real time. For a test suite that ordinarily waits 30–60 seconds for containers to start before the first test method runs, Turbo mode can reduce this overhead by 50–70% by overlapping container readiness checks. For organizations running hundreds of CI builds per day, this translates to meaningful infrastructure cost reduction.

Security-conscious teams should review which data flows through the Testcontainers Cloud tunnel. Test data including credentials, personal information used as fixtures, and internal service identifiers is transmitted to remote containers. For most CI test scenarios this is acceptable under the same data handling policies as cloud build agents. However, in PCI-DSS, HIPAA, or FedRAMP environments, evaluate whether on-premises Docker or a self-hosted Testcontainers Cloud instance satisfies compliance requirements before adopting the hosted service.

12. Performance Optimization: Reducing Test Suite Execution Time

As a project grows, integration test suites using Testcontainers can become the dominant factor in CI build time. A suite with 50 test classes each independently starting PostgreSQL containers can easily take 20–40 minutes. Four complementary strategies bring this under control: singleton container reuse (covered in Section 4), parallel JVM fork execution, developer-side withReuse(true), and selective profiling to identify which test classes account for the majority of startup time.

<!-- Enable parallel test execution with Maven Surefire -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <!-- Run test classes in parallel across 4 JVM forks -->
        <forkCount>4</forkCount>
        <reuseForks>true</reuseForks>
        <parallel>classes</parallel>
        <threadCount>4</threadCount>
        <systemPropertyVariables>
            <surefire.forkNumber>${surefire.forkNumber}</surefire.forkNumber>
        </systemPropertyVariables>
    </configuration>
</plugin>

When using parallel JVM forks with Testcontainers, each fork gets its own set of Docker containers. The singleton pattern (shared static containers) works within a single JVM fork but not across forks. Keep forkCount between 2–4 to balance parallelism with Docker resource usage — each fork may start its own PostgreSQL container, and 8 simultaneous PostgreSQL containers on a 4-core CI agent will degrade rather than improve throughput. Monitor Docker memory usage using docker stats during test runs to find the right fork count for your CI agent spec.

Container reuse with withReuse(true) is the most impactful optimization for developer inner loops. After the first test run, containers remain running and are reused by subsequent runs, reducing per-run startup overhead from 10–30 seconds to near-zero. Enable this by setting testcontainers.reuse.enable=true in ~/.testcontainers.properties. This setting is machine-local and does not affect CI builds, which should always start clean. Note that reused containers accumulate data between runs — make sure your tests handle pre-existing rows gracefully, typically by using unique test identifiers or truncating tables in a @BeforeEach.

// Enable container reuse for local development
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withReuse(true)   // Reuse across JVM restarts (requires ~/.testcontainers.properties)
    .withDatabaseName("testdb")
    .withUsername("test")
    .withPassword("test");

// ~/.testcontainers.properties  (developer machine only — not committed to source control)
testcontainers.reuse.enable=true
Strategy Time Saved Best Environment Key Caveat
Singleton Container One startup per test class group All environments Tests must not corrupt shared state
withReuse(true) Sub-second re-runs after first run Local development only Stale data between runs
Parallel Forks (forkCount=4) Up to 4× throughput on multi-core CI with 4+ cores, 8+ GB RAM Each fork uses separate Docker containers
Testcontainers Cloud Offloads Docker from CI agent Large teams, resource-limited runners Account required; data leaves machine

13. Testing with WireMock and Testcontainers for External APIs

Most microservices call external APIs — payment gateways, notification services, identity providers, or other internal microservices. Testing these integrations is tricky: hitting real external services in CI creates flakiness and rate-limit errors, while simple in-process mocks do not validate actual HTTP serialization, headers, retry logic, or timeout behaviour. The solution is WireMock running as a Testcontainer, which provides a real HTTP server that you configure with stubbed responses, giving you end-to-end HTTP validation without external network dependencies.

<!-- Add WireMock Testcontainers module -->
<dependency>
    <groupId>org.wiremock.integrations.testcontainers</groupId>
    <artifactId>wiremock-testcontainers-module</artifactId>
    <version>1.0-alpha-13</version>
    <scope>test</scope>
</dependency>
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class PaymentGatewayIntegrationTest {

    @Container
    static WireMockContainer wireMock = new WireMockContainer("wiremock/wiremock:3.3.1")
        .withMappingFromResource("stubs/payment-success.json");

    @DynamicPropertySource
    static void configureWireMock(DynamicPropertyRegistry registry) {
        registry.add("payment.gateway.url", wireMock::getBaseUrl);
    }

    @Test
    void chargeCard_success_returnsConfirmation() {
        // Stub configured in src/test/resources/stubs/payment-success.json
        // OR configure programmatically:
        wireMock.stubFor(WireMock.post("/v1/charges")
            .withRequestBody(matchingJsonPath("$.amount"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("""
                    {"chargeId": "ch_test_123", "status": "succeeded"}
                    """)));

        PaymentResult result = paymentService.charge(new ChargeRequest("4111111111111111", 99.99));

        assertThat(result.status()).isEqualTo("succeeded");
        wireMock.verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
    }

    @Test
    void chargeCard_gateway503_throwsPaymentException() {
        wireMock.stubFor(WireMock.post("/v1/charges")
            .willReturn(aResponse().withStatus(503).withFixedDelay(5_000)));

        assertThatThrownBy(() -> paymentService.charge(new ChargeRequest("4111111111111111", 99.99)))
            .isInstanceOf(PaymentGatewayException.class)
            .hasMessageContaining("gateway unavailable");
    }
}

WireMock stubs can be loaded from JSON mapping files in src/test/resources/wiremock/mappings/ or configured programmatically as shown above. The JSON format matches WireMock standalone server format exactly, meaning stubs developed locally can be committed to version control and reused across teams. This is especially valuable for consumer-driven contract testing: frontend teams can provide WireMock stub files that describe the API responses they depend on, and backend tests can verify the service actually returns those responses.

Combining WireMock containers with real Testcontainers databases gives you full-stack integration tests: real PostgreSQL for persistence, real Kafka for messaging, and WireMock for external HTTP APIs — all running locally and in CI without external dependencies. This combination is significantly more valuable than unit tests alone because it validates HTTP client configuration (RestTemplate/WebClient setup, timeout settings, serialization), Spring retry and circuit breaker integration (Resilience4j wrapping the HTTP call), and the entire request-to-database flow including transactional boundaries and error rollback behaviour.

WireMock also supports response templating using Handlebars expressions, which allows stubs to echo request data back in responses. This is useful for testing scenarios like "return the resource ID I just POSTed" without writing custom stub logic. For chaos engineering tests, WireMock's fault injection — including TCP connection resets, chunked response corruption, and configurable random delays — lets you verify that your retry policies, circuit breakers, and timeout configurations behave correctly under realistic failure conditions that are otherwise difficult to reproduce in tests.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Last updated: April 4, 2026