Skip to main content

Spring Boot Microservices Project: Gateway, Discovery, Config and 3 Services (Complete Example)

A runnable Spring Boot 4.1 microservices project: Spring Cloud Gateway, Eureka, Config Server, product/order/inventory services, a Resilience4j circuit breaker and tracing, with real output.

A Spring Boot microservices project is several small applications that each own one job, with three pieces of infrastructure in front of them: a gateway (one public address), a discovery server (who is running where) and a config server (one place for configuration). This page builds one: a product, an order and an inventory service behind Spring Cloud Gateway, with Eureka, a Config Server, a Resilience4j circuit breaker and distributed tracing, all run for real on Spring Boot 4.1.

Tested on Spring Boot 4.1.1 and Spring Cloud 2025.1.3 with OpenJDK 21.0.12. Spring Cloud 2025.1.3’s own BOM is built against Boot 4.0.8; it ran here on Boot 4.1.1 with no changes. The six services were run as plain jars (scripts/run-all.sh) and every transcript on this page was written by that script. Not run: the Dockerfile and docker-compose.yml (the sandbox had no Docker), and JDK 25. Timings are from one sandbox. Code: the spring-boot-microservices-project repository.
clientgateway:8080product-service:8081order-service:8082inventory-service:8083config-server:8888 (config files)discovery-server:8761 (Eureka)Solid: requests. Dashed: every service reads config at startup and registers with / looks up others in Eureka.

A client only ever talks to the gateway on port 8080. The gateway finds the right service by name in Eureka and forwards the request; the order service in turn calls the product and inventory services by name. Nothing in the code contains a host or port for another service. The two boxes on the right are what makes that possible: the config server hands every service its settings (including the gateway’s routes), and the discovery server is the phone book.

ModulePortJob
config-server8888Serves the files under src/main/resources/config/ to every other service
discovery-server8761Eureka: services register here and look each other up here
gateway8080The only public entry point; routes /api/products, /api/orders, /api/inventory
product-service8081Product catalogue (name and price)
order-service8082Places orders: reads the price, reserves stock behind a circuit breaker
inventory-service8083Stock levels and reservations, with a switch to simulate an outage

1. One build, six modules

A Maven multi-module build with one parent keeps the versions in a single place. The parent imports the Spring Cloud BOM, so no module states a Spring Cloud version (pom.xml):

  <properties>
    <java.version>21</java.version>
    <spring-cloud.version>2025.1.3</spring-cloud.version>
  </properties>

  <modules>
    <module>config-server</module>
    <module>discovery-server</module>
    <module>gateway</module>
    <module>product-service</module>
    <module>inventory-service</module>
    <module>order-service</module>
  </modules>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-dependencies</artifactId>
        <version>${spring-cloud.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

Each module’s own pom lists only its starters. The two Spring Cloud ones you will recognise are spring-cloud-config-server and spring-cloud-starter-netflix-eureka-server; everything else uses the client starters. pom.xml is the busiest: web, validation, the RestClient starter, the load balancer, the circuit breaker, and the config, Eureka and tracing clients.

Boot 4 tracing needs its own module. I first added only io.micrometer:micrometer-tracing-bridge-brave and the log lines had no trace id at all. Boot 4 moved the tracing auto-configuration into spring-boot-micrometer-tracing-brave; with that added (the poms now list both) the ids appeared. I saw this while building and did not capture it as a transcript; the working result is in section 8.

2. Start order: config first, then Eureka, then the rest

Every service reads its configuration from the config server before it starts, so the config server has to be up first. start.sh starts them in that order and waits for each health check:

start config-server 8888
start discovery-server 8761
start product-service 8081
start inventory-service 8083
start order-service 8082
start gateway 8080

The services use spring.config.import: "configserver:..." without optional:, so a service started before the config server fails immediately instead of running with the wrong settings. That is a deliberate choice, and the reason the compose file uses restart: on-failure.

3. The config server: one place for configuration

The server is one annotation (ConfigServerApplication.java):

/** Serves the files under src/main/resources/config to every other service. Start this one first. */
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServerApplication.class, args);
    }
}

Its application.yml uses the native profile, which means “serve files from a folder” instead of from Git (switch to a Git repository for production):

spring:
  application:
    name: config-server
  profiles:
    active: native
  cloud:
    config:
      server:
        native:
          search-locations: classpath:/config
server:
  port: 8888

In that folder, application.yml is served to every service, and order-service.yml only to the order service (order-service.yml):

shop:
  order:
    currency: EUR
    max-quantity: 10

The first thing to prove is that the value really travels. Asking the config server what it would give the order service, and then asking the order service what it is using, through the gateway (output: 01-config-server.txt):

--- order-service: which files were used and the values ---
name: order-service  profiles: ['default']
source: order-service.yml
    shop.order.currency = EUR
    shop.order.max-quantity = 10
source: application.yml
    management.endpoints.web.exposure.include = health,info,circuitbreakers
    management.tracing.sampling.probability = 1.0

--- the same value as the order service itself reports it (GET /api/orders/config through the gateway) ---
$ curl -X GET localhost:8080/api/orders/config
HTTP 200
{"currency":"EUR","maxQuantity":10}

The more specific file (order-service.yml) is listed first and wins over the shared application.yml. The order service binds shop.order.* into a record, OrderProperties.java. A change to currency needs only a restart of the order service, not a rebuild. For the Git-backed setup and refresh without restart, see Spring Cloud 2026.0 preview: config notifications.

4. Eureka: the phone book

DiscoveryServerApplication.java is again one annotation, @EnableEurekaServer. Its application.yml tells it not to register with itself:

spring:
  application:
    name: discovery-server
server:
  port: 8761
eureka:
  instance:
    hostname: ${EUREKA_HOSTNAME:localhost}
  client:
    # This server is the registry; it must not try to register with itself or fetch from itself.
    register-with-eureka: false
    fetch-registry: false
  server:
    # Dev setting: a registry with one client would otherwise enter "self-preservation" and never evict dead instances.
    enable-self-preservation: false

Each client declares where Eureka is and registers under a readable instance id. This is application.yml, and the other services differ only in name and port:

spring:
  application:
    name: order-service
  config:
    # Fail fast if the config server is not up: a service with the wrong config is worse than no service.
    import: "configserver:${CONFIG_URL:http://localhost:8888}"
server:
  port: 8082
eureka:
  client:
    service-url:
      defaultZone: ${EUREKA_URL:http://localhost:8761/eureka}
  instance:
    hostname: ${EUREKA_HOSTNAME:localhost}
    # Register the service name + port as the instance id so two copies of one service do not collide.
    instance-id: ${spring.application.name}:${server.port}

The ${CONFIG_URL:...} and ${EUREKA_URL:...} placeholders default to localhost and are overridden by environment variables in Docker, with no code change. After the system is up, Eureka lists all four registered applications (output: 02-eureka-registry.txt):

GATEWAY            instance=gateway:8080           status=UP
INVENTORY-SERVICE  instance=inventory-service:8083 status=UP
ORDER-SERVICE      instance=order-service:8082     status=UP
PRODUCT-SERVICE    instance=product-service:8081   status=UP
The registry is eventually consistent. A service appears in Eureka a few seconds after it starts, and every client keeps its own copy that it refreshes on a timer. Section 10 shows what that does to the first requests after startup.

5. The gateway: one door, routes from the config server

The gateway application itself is empty (GatewayApplication.java). The routes are configuration, served by the config server, in gateway.yml:

# Routes live here, not in the gateway jar: change a route, restart the gateway, no rebuild.
spring:
  cloud:
    gateway:
      server:
        webmvc:
          routes:
            - id: products
              uri: lb://product-service
              predicates:
                - Path=/api/products/**
              filters:
                - StripPrefix=1
            - id: orders
              uri: lb://order-service
              predicates:
                - Path=/api/orders/**
              filters:
                - StripPrefix=1
            - id: inventory
              uri: lb://inventory-service
              predicates:
                - Path=/api/inventory/**
              filters:
                - StripPrefix=1

lb://product-service means “ask Eureka for an instance of product-service and pick one”. StripPrefix=1 removes /api before forwarding, so the product service only knows /products. The gateway here is the WebMVC variant (spring-cloud-starter-gateway-server-webmvc), which runs on the same servlet stack as the rest of the system. If you are migrating an older gateway, see Zuul to Spring Cloud Gateway.

Now use the system. Products, stock and every kind of order, all through port 8080 (output: 03-gateway-requests.txt):

$ curl -X GET localhost:8080/api/products
HTTP 200
[{"id":1,"name":"Mechanical keyboard","price":89.90},{"id":2,"name":"USB-C dock","price":129.00},{"id":3,"name":"27-inch monitor","price":249.50}]

$ curl -X GET localhost:8080/api/products/2
HTTP 200
{"id":2,"name":"USB-C dock","price":129.00}

$ curl -X GET localhost:8080/api/inventory/3
HTTP 200
{"productId":3,"quantity":3,"lowStock":true}
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":2}'
HTTP 201
{"id":1,"productId":1,"productName":"Mechanical keyboard","quantity":2,"total":179.80,"currency":"EUR","status":"CONFIRMED"}

$ curl -X POST localhost:8080/api/orders -d '{"productId":3,"quantity":4}'
HTTP 409
{"timestamp":"2026-10-21T17:07:49.685Z","status":409,"error":"Conflict","path":"/orders"}

$ curl -X POST localhost:8080/api/orders -d '{"productId":2,"quantity":11}'
HTTP 400
{"timestamp":"2026-10-21T17:07:49.721Z","status":400,"error":"Bad Request","path":"/orders"}

$ curl -X POST localhost:8080/api/orders -d '{"productId":99,"quantity":1}'
HTTP 404
{"timestamp":"2026-10-21T17:07:49.763Z","status":404,"error":"Not Found","path":"/orders"}

Look at the four order results (same file, 03-gateway-requests.txt). A good order returns 201 and CONFIRMED (the total is the product price times the quantity, in the currency from the config server). Asking for more than is in stock gives 409; more than the configured maximum gives 400; an unknown product gives 404. Those last three come back with Boot’s default error body, which hides the message; add a ProblemDetail handler as in global exception handling for a real API.

6. Calling another service by name

The order service never writes a host name. It builds a RestClient whose base URL is just the service name (InventoryClient.java), and a load-balanced builder replaces that name with a real address from Eureka:

@Configuration
class ClientConfig {

    /**
     * "http://product-service/..." is not a DNS name: @LoadBalanced makes the client ask Eureka for a real
     * host and port. The ObservationRegistry is what makes the client add the trace headers to the request.
     */
    @Bean
    @LoadBalanced
    RestClient.Builder loadBalancedRestClientBuilder(ObservationRegistry observationRegistry) {
        return RestClient.builder().observationRegistry(observationRegistry);
    }

    /**
     * The Eureka client also asks for a RestClient.Builder. If the only one is the load-balanced one, Eureka's own
     * calls to the registry go through the load balancer, which asks Eureka for "localhost": a circular reference
     * and "No instances available for localhost". A plain @Primary builder keeps Eureka off the balanced one.
     */
    @Bean
    @Primary
    @ConditionalOnProperty(name = "shop.demo.primary-builder", havingValue = "true", matchIfMissing = true)   // false only in scripts/gotchas.sh
    RestClient.Builder plainRestClientBuilder(ObservationRegistry observationRegistry) {
        return RestClient.builder().observationRegistry(observationRegistry);
    }
}

Two builders, on purpose. That is the single most expensive trap in this project, so here it is in full.

Trap: a load-balanced RestClient.Builder that Eureka picks up. The Eureka client also asks Spring for a RestClient.Builder to talk to the registry. If the only one is @LoadBalanced, Eureka’s own calls to localhost:8761 go through the load balancer, which asks Eureka for an instance called localhost. The service starts, logs a wall of warnings and never registers. The @Primary plain builder above keeps Eureka off the balanced one.

Reproduced by scripts/gotchas.sh, which starts a second order service with --shop.demo.primary-builder=false (output: 06-eureka-lb-trap.txt):

--- the log lines that give it away ---
org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'scopedTarget.eurekaClient': Requested bean is currently in creation: Is there an unresolvable circular reference or an as
c.n.d.s.t.d.RedirectingEurekaHttpClient  : Request execution error. endpoint=DefaultEndpoint{ serviceUrl='http://localhost:8761/eureka/}, exception=No instances available for localhost
com.netflix.discovery.DiscoveryClient    : DiscoveryClient_ORDER-SERVICE/order-service:8092 - registration failed Cannot execute request on any known server

--- is instance order-service:8092 in Eureka? ---
order-service:8092 registered: False
Note that the service itself starts and the health check is green: only the missing registration tells you.

7. The circuit breaker: when inventory is down, keep taking orders

If the inventory service is slow or down, every order that waits for it ties up a thread and fails. A circuit breaker counts recent failures; past a threshold it stops calling the sick service for a while and answers immediately with a fallback. Here the fallback is a business decision: take the order and mark it PENDING_INVENTORY. The configuration is in ClientConfig.java:

    @Bean
    Customizer<Resilience4JCircuitBreakerFactory> inventoryBreaker() {
        return factory -> factory.configure(builder -> builder
                .circuitBreakerConfig(CircuitBreakerConfig.custom()
                        .slidingWindowSize(4)
                        .minimumNumberOfCalls(4)
                        .failureRateThreshold(50)
                        .waitDurationInOpenState(Duration.ofSeconds(5))
                        .permittedNumberOfCallsInHalfOpenState(1)
                        .build())
                .timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(3)).build()),
                "inventory");
    }

Read it as: look at the last 4 calls; if at least 4 have happened and 50% failed, open; stay open 5 seconds; then let one call through (half-open) and close again if it succeeds. The call itself is in InventoryClient.java:

    enum Reservation { RESERVED, OUT_OF_STOCK, UNAVAILABLE }

    Reservation reserve(long productId, int quantity) {
        return breakers.create("inventory").run(
                () -> {
                    try {
                        client.post().uri("/inventory/{id}/reserve", productId)
                                .body(Map.of("quantity", quantity))
                                .retrieve()
                                .toBodilessEntity();
                        return Reservation.RESERVED;
                    } catch (HttpClientErrorException.Conflict e) {
                        return Reservation.OUT_OF_STOCK;
                    }
                },
                failure -> {
                    log.warn("inventory call did not complete ({}); falling back", failure.getClass().getSimpleName());
                    return Reservation.UNAVAILABLE;
                });
    }
}
Trap: the fallback gets every exception. The 409 “not enough stock” is a normal answer, not a sick dependency. I first configured Resilience4j to ignore that exception, and the order still came back PENDING_INVENTORY, because Spring Cloud’s fallback function receives every exception, ignored or not. The fix in the code above is to translate the 409 into OUT_OF_STOCK inside the protected call, so it never counts as a failure and never reaches the fallback. I saw the wrong result while building; the corrected behaviour is the 409 in section 5’s output.

Now watch the breaker work. The script switches the inventory service to failing, orders, then checks how many calls actually reached it (output: 05-circuit-breaker.txt):

$ curl -X GET localhost:8080/api/orders/circuit
HTTP 200
{"state":"CLOSED","failedCalls":0,"notPermittedCalls":0}

$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
HTTP 201
{"id":3,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"CONFIRMED"}
$ curl -X POST localhost:8080/api/inventory/chaos?down=true
HTTP 200
{"down":true}

$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
HTTP 201
{"id":4,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}

$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
HTTP 201
{"id":5,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}

$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
HTTP 201
{"id":6,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}

$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
HTTP 201
{"id":7,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}

$ curl -X GET localhost:8080/api/orders/circuit
HTTP 200
{"state":"OPEN","failedCalls":2,"notPermittedCalls":2}

(From 05-circuit-breaker.txt.) Orders 4 and 5 failed against the sick service and fell back. That made 2 failures in the last 4 calls (the two earlier calls were healthy), so the breaker opened after the second failed order. Orders 6 and 7 were rejected without calling inventory at all: notPermittedCalls is 2. Every order still returned 201. The next block proves the open circuit really stops the calls:

$ curl -X GET localhost:8080/api/inventory/stats
HTTP 200
{"reserveCalls":8}

$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
HTTP 201
{"id":8,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}

$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
HTTP 201
{"id":9,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}

$ curl -X GET localhost:8080/api/inventory/stats
HTTP 200
{"reserveCalls":8}

$ curl -X GET localhost:8080/api/orders/circuit
HTTP 200
{"state":"OPEN","failedCalls":2,"notPermittedCalls":4}

(Same file, 05-circuit-breaker.txt.) reserveCalls is 8 before and 8 after two more orders: the inventory service was never contacted. Finally the service recovers and the breaker probes it:

$ curl -X POST localhost:8080/api/inventory/chaos?down=false
HTTP 200
{"down":false}

$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
HTTP 201
{"id":10,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"CONFIRMED"}

$ curl -X GET localhost:8080/api/orders/circuit
HTTP 200
{"state":"CLOSED","failedCalls":0,"notPermittedCalls":0}

After the 5-second wait the next order was the half-open probe; it succeeded, so the circuit is CLOSED again and the order is CONFIRMED. For the retry and concurrency-limit features built into Spring Framework 7 and how they compare, see Retryable, ConcurrencyLimit and Resilience4j.

8. Tracing: one id across four services

With the tracing module on the classpath, Boot puts a trace id into the logging context, and the instrumented clients (the gateway’s forwarding, the RestClient because it was given the ObservationRegistry) add it to outgoing requests as a header. The gateway starts a trace, or continues one the caller sent, and each service logs the same id. The log lines look like [order-service] [thread] [traceId-spanId]. This is a single order sent to the gateway, with every log line that carries its trace id, gathered from four services (output: 04-trace-propagation.txt):

--- POST /api/orders sent to the gateway; trace id 6ad8f16556352b627323a47cb0510c20 ---
gateway | 6ad8f16556352b627323a47cb0510c20-7323a47cb0510c20 | gateway received POST /api/orders
order-service | 6ad8f16556352b627323a47cb0510c20-fe6e6271c03b82ea | placing order for product 2 x1
product-service | 6ad8f16556352b627323a47cb0510c20-a01e14c8e2434acc | looking up product 2
inventory-service | 6ad8f16556352b627323a47cb0510c20-419570d38f840942 | reserved 1 of product 2, 7 left
order-service | 6ad8f16556352b627323a47cb0510c20-fe6e6271c03b82ea | order 2 is CONFIRMED

The gateway’s span id equals the last 16 characters of the trace id: the gateway created this trace. Order, product and inventory logged the same trace id with their own span ids, so a single search for that id in your log system finds the whole request. The inventory call happens inside the circuit breaker, which runs it on another thread, and the id still arrived. The second case shows a caller that already has a trace:

--- a caller that already has a trace id (W3C traceparent header): the same id comes out the other end ---
gateway | 4bf92f3577b34da6a3ce929d0e0e4736-91a3561198164d6c | gateway received GET /api/products/1
product-service | 4bf92f3577b34da6a3ce929d0e0e4736-bce2498c85b72fc7 | looking up product 1

A request carrying a W3C traceparent header keeps its trace id through the gateway into the product service. The gateway line in these transcripts comes from RequestLogFilter.java, a one-line filter. Sending these traces to a backend (Zipkin, Tempo, an OpenTelemetry collector) is an exporter dependency plus an endpoint property; I did not run an exporter, so that part is untested here. The metrics side is in monitoring Spring Boot microservices with Prometheus and Grafana.

Sampling. The shared config sets management.tracing.sampling.probability: 1.0 (every request) so the demo shows every trace. In production, sample a fraction.

9. Every service has the same shape

The product and inventory services are deliberately small: a controller with in-memory data. This is the inventory side that the circuit-breaker demo switches off (InventoryController.java):

    @PostMapping("/inventory/{productId}/reserve")
    Map<String, Object> reserve(@PathVariable long productId, @RequestBody ReserveRequest request) {
        reserveCalls.incrementAndGet();
        if (down.get()) {
            log.warn("simulating an outage: refusing to reserve product {}", productId);
            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "inventory is down");
        }
        AtomicInteger s = stock.get(productId);
        if (s == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No stock record for product " + productId);
        }
        int remaining = s.addAndGet(-request.quantity());
        if (remaining < 0) {
            s.addAndGet(request.quantity());
            throw new ResponseStatusException(HttpStatus.CONFLICT, "Only " + s.get() + " of product " + productId + " in stock");
        }
        log.info("reserved {} of product {}, {} left", request.quantity(), productId, remaining);
        return view(productId, remaining);
    }

Nothing in it knows it is part of a distributed system. The only microservice-specific parts are the dependencies in its pom and the four lines of application.yml. That is the point: a service is a normal Spring Boot application plus a few client starters.

10. What happens in the first 30 seconds

This one is not in most tutorials. The script waits until the system is usable before sending requests, and it records what it saw while waiting (output: 00-startup-race.txt):

/api/products/1      t+ 1s HTTP 200 {"id":1,"name":"Mechanical keyboard","price":89.90}
/api/inventory/1     t+ 0s HTTP 200 {"productId":1,"quantity":20,"lowStock":false}
/api/orders/ready    t+ 0s HTTP 500 {"timestamp":"2026-10-21T17:07:18.322Z","status":500,"error":"Internal Server Error","path
/api/orders/ready    t+ 2s HTTP 500 {"timestamp":"2026-10-21T17:07:20.364Z","status":500,"error":"Internal Server Error","path
/api/orders/ready    t+ 4s HTTP 500 {"timestamp":"2026-10-21T17:07:22.396Z","status":500,"error":"Internal Server Error","path
/api/orders/ready    t+28s HTTP 500 {"timestamp":"2026-10-21T17:07:46.709Z","status":500,"error":"Internal Server Error","path
/api/orders/ready    t+30s HTTP 200 {"ready":true}

Every health check was green and the gateway could already reach product and inventory, but /api/orders/ready, which asks the order service to call both of its dependencies, failed with 500 for 28 seconds (the middle lines of the transcript are the same 500) and answered 200 at t+30. Load balancers and the gateway hold their own copy of the Eureka registry and refresh it about every 30 seconds, so a service that registered a moment after its callers started is invisible to them until the next refresh. In production this is the reason for readiness checks and retries on startup, and for not sending traffic the moment a process reports “UP”. In a script it is the reason to wait on a request that exercises the real path, not on a health endpoint.

11. Docker Compose (not run)

The repository has one Dockerfile for all services and a docker-compose.yml that starts them on one network. I could not run Docker in the sandbox, so neither file has been tested; they follow the same pattern as the jar run. The only differences inside a compose network are three environment variables, because services reach each other by service name:

  restart: on-failure          # a service that starts before the config server is up exits and is retried
  environment: &env
    CONFIG_URL: http://config-server:8888
    EUREKA_URL: http://discovery-server:8761/eureka

Each service also sets EUREKA_HOSTNAME to its own service name, so Eureka advertises an address the other containers can resolve. The compose file uses restart: on-failure so a service that starts before the config server is retried. For packaging a single service as an efficient image, see Dockerizing Spring Boot 4; for local development with compose, Docker Compose support.

Run it yourself

./scripts/run-all.sh      # build, start six services, run the scenarios, stop everything

Needs JDK 21+, Maven 3.9+, curl and python3 (no Docker). It regenerates every file under output/: run-all.sh, start.sh, demo.sh, gotchas.sh. Allow a few minutes: six JVMs plus the 30-second registry wait.

Should you build this? Not by default. Six deployables mean six pipelines, a network between every call and failures that did not exist before: the breaker, the 30-second race and the load-balancer trap in this one small project are examples. If one team owns everything, a modular monolith is cheaper; modular monolith vs microservices is the decision framework. What is deliberately missing here: authentication between services (see token relay and mTLS), distributed transactions (see the saga pattern), real databases, and a Kubernetes deployment, where Kubernetes service discovery usually replaces Eureka.

Common interview questions

What is an API gateway and why use one?

A single entry point that routes requests to services, so clients know one address and cross-cutting concerns (auth, rate limits, logging) live in one place.

What does Eureka do?

It is a service registry: services register their address and clients look services up by name, so nothing hard-codes hosts and ports.

What is a circuit breaker?

A wrapper around a remote call that stops calling a failing service after a failure threshold, answers with a fallback, and probes again after a wait (closed, open, half-open).

What is the Config Server for?

It serves configuration to all services from one place, so settings change without rebuilding the services.

How do you trace a request across services?

Propagate a trace id in a request header from the entry point to every call; each service logs it, so one id finds the whole request.

What is the difference between client-side and server-side load balancing?

In client-side load balancing the caller picks an instance from the registry (Spring Cloud LoadBalancer); in server-side a separate proxy picks it.

Common mistakes

  • Starting services in the wrong order. Config server first, then Eureka, then the rest (section 2).
  • One @LoadBalanced RestClient.Builder and nothing else. Eureka picks it up and never registers (section 6).
  • Sending traffic the moment the process is UP. The registry copies need up to 30 seconds (section 10).
  • Counting business answers as failures in the circuit breaker. Turn the 409 into a result inside the protected call (section 7).
  • Only the Micrometer bridge on the classpath in Boot 4. Add spring-boot-micrometer-tracing-brave or no trace id appears (section 1).
  • Hard-coding service URLs. Use the service name and the load-balanced client.
  • A breaker with no fallback decision. Decide what the business does when the dependency is down before you write the fallback.

FAQ

How do I create a microservices project in Spring Boot?

Create one Spring Boot application per service, add a gateway, a Eureka server and a config server, and have services call each other by name through a load-balanced client.

Which Spring Cloud version works with Spring Boot 4?

Spring Cloud 2025.1.x. 2025.1.3 ran on Boot 4.1.1 in this project, although its BOM is built against Boot 4.0.8.

Do I still need Eureka on Kubernetes?

Usually not: Kubernetes services already provide discovery. Eureka is for running outside it.

How do I handle a service being down in Spring Boot?

Wrap the call in a circuit breaker with a fallback (Resilience4j through Spring Cloud CircuitBreaker) and decide what the business does when the dependency is unavailable.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.