Java Microservices Architecture: Spring Boot, Docker & Kubernetes Guide

Microservices architecture breaks a monolithic application into small, independently deployable services, each owning a specific business capability. In the Java ecosystem, Spring Boot is the dominant framework for building microservices, with Docker and Kubernetes providing the containerisation and orchestration layer.

This guide covers microservices concepts, how to build them with Spring Boot, how to handle inter-service communication, and the key challenges you will face in production.

Microservices vs Monolith

A monolith packages all application functionality into a single deployable unit. This is simple to develop and deploy initially, but as the system grows:

  • One change requires deploying the entire application
  • A single slow module degrades the whole system
  • Technology choices are locked in for the whole codebase
  • Teams step on each other's changes

Microservices address these by splitting along business domain lines. An e-commerce platform might have: order-service, inventory-service, payment-service, notification-service — each deployed, scaled, and updated independently.

Start with a monolith if your team is small or the domain is unclear. Premature microservices add enormous operational complexity. Break services out as genuine scaling or deployment conflicts emerge.

Building a Microservice with Spring Boot

Each microservice is a standalone Spring Boot application. Typical dependencies:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <!-- Service discovery -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
</dependencies>

A minimal order service:

@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<Order> createOrder(@RequestBody @Valid CreateOrderRequest req) {
        Order order = orderService.create(req);
        return ResponseEntity.status(201).body(order);
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable UUID id) {
        return orderService.findById(id);
    }
}

Service Discovery with Eureka

In a microservices environment, services start and stop dynamically. Service discovery lets services find each other without hardcoded URLs.

Set up a Eureka server:

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(DiscoveryServerApplication.class, args);
    }
}

Each service registers itself by adding to application.yml:

spring:
  application:
    name: order-service
eureka:
  client:
    service-url:
      defaultZone: http://discovery-server:8761/eureka/

Inter-Service Communication

Services communicate in two ways:

Synchronous (REST / gRPC)

Use Spring's WebClient for non-blocking HTTP calls between services:

@Service
public class InventoryClient {

    private final WebClient webClient;

    public InventoryClient(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("http://inventory-service").build();
    }

    public boolean checkStock(String sku, int quantity) {
        return Boolean.TRUE.equals(
            webClient.get()
                .uri("/inventory/{sku}/available?qty={qty}", sku, quantity)
                .retrieve()
                .bodyToMono(Boolean.class)
                .block()
        );
    }
}

Asynchronous (Message Queues)

For operations where you do not need an immediate response, use Kafka or RabbitMQ. The order service publishes an OrderCreatedEvent; the notification service consumes it and sends an email — fully decoupled.

// Publisher (order-service)
@Service
public class OrderEventPublisher {
    private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;

    public void publish(OrderCreatedEvent event) {
        kafkaTemplate.send("order-events", event.orderId().toString(), event);
    }
}

// Consumer (notification-service)
@KafkaListener(topics = "order-events", groupId = "notification-group")
public void onOrderCreated(OrderCreatedEvent event) {
    emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}

API Gateway Pattern

Clients should not call individual microservices directly — they should talk to an API gateway. The gateway handles routing, authentication, rate limiting, and SSL termination in one place.

Spring Cloud Gateway setup:

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
          filters:
            - StripPrefix=1
        - id: inventory-service
          uri: lb://inventory-service
          predicates:
            - Path=/api/inventory/**

The lb:// prefix tells the gateway to use the Eureka load balancer to resolve the service address.

Containerising with Docker

Each microservice gets its own Dockerfile:

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/order-service-1.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Use a multi-stage build to keep the image small and not ship build tools:

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /build/target/order-service-*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Deploying to Kubernetes

A Kubernetes Deployment and Service for the order microservice:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-service
          image: techoral/order-service:1.0
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
    - port: 80
      targetPort: 8080

Key Challenges in Production

  • Distributed tracing: A single user request may touch 5 services. Use Spring Cloud Sleuth + Zipkin or OpenTelemetry to trace calls across services.
  • Cascading failures: If inventory-service is slow, calls from order-service pile up and bring it down too. Use the Circuit Breaker pattern (Resilience4j) to fail fast.
  • Data consistency: Each service owns its database. Cross-service transactions require the Saga pattern — a sequence of local transactions with compensating rollbacks.
  • Configuration management: Use Spring Cloud Config Server or Kubernetes ConfigMaps to manage configuration centrally across all services.

Conclusion

Java microservices with Spring Boot, Docker, and Kubernetes give you independent deployability, scalability per service, and clear ownership boundaries. The cost is operational complexity — service discovery, distributed tracing, and data consistency all require deliberate solutions. Start simple, break services out only when you hit real pain points, and invest in observability from day one.

Related: CQRS Pattern in Java | Java Design Patterns