‹ Back to Blog Software Engineering

Java Spring Boot: Microservices Done Right

April 4, 2026 · 10 min read
Close-up of code on screen

Java is not going anywhere. Despite two decades of "Java is dead" predictions, it remains the dominant language in enterprise software. Spring Boot, the framework that eliminated Java's configuration-over-convention reputation, is the reason. It provides sensible defaults, auto-configuration, and a production-ready foundation for building microservices that scale.

At Pepla, we use Spring Boot for clients with existing Java ecosystems -- banks, insurers, logistics companies, and government agencies where Java is the standard. This article covers the architecture patterns, communication strategies, and operational practices we apply to build microservices that work in production, not just in conference talks.

Spring Boot 3: The Foundation

Spring Boot 3, built on Spring Framework 6, requires Java 17+ and provides first-class support for Jakarta EE 10, GraalVM native images, and the observability APIs that modern microservices need. The starter dependency model remains the framework's most powerful feature: add a starter to your build file, and Spring auto-configures everything with sensible defaults.

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    runtimeOnly("org.postgresql:postgresql")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

That single block gives you an embedded Tomcat server, JPA with Hibernate, Spring Security, health checks with Actuator, bean validation, PostgreSQL connectivity, and a complete test framework. No XML configuration. No application server deployment. Just a JAR you can run with java -jar.

REST Controller Patterns

A well-structured REST controller is thin. It handles HTTP concerns -- request binding, response serialisation, status codes -- and delegates business logic to a service layer. The controller should never contain business rules, database queries, or complex logic:

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

    private final OrderService orderService;

    @GetMapping
    public ResponseEntity<Page<OrderSummaryDto>> getAll(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(required = false) OrderStatus status) {

        var pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
        var orders = orderService.findAll(status, pageable);
        return ResponseEntity.ok(orders);
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderDetailDto> getById(@PathVariable UUID id) {
        return orderService.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<OrderDetailDto> create(
            @Valid @RequestBody CreateOrderRequest request) {

        var order = orderService.create(request);
        var location = URI.create("/api/v1/orders/" + order.id());
        return ResponseEntity.created(location).body(order);
    }
}

Each microservice owns its domain, its data, and its deployment -- services communicate through well-defined APIs and messages, never shared databases.

Spring Data JPA: The Repository Pattern

Spring Data JPA eliminates the boilerplate of data access. Define an interface, extend JpaRepository, and Spring generates the implementation at runtime. For custom queries, use the @Query annotation or method name derivation:

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(nullable = false)
    private String customerName;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(precision = 18, scale = 2)
    private BigDecimal total;

    private Instant createdAt;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderLineItem> lineItems = new ArrayList<>();
}

public interface OrderRepository extends JpaRepository<Order, UUID> {

    Page<Order> findByStatus(OrderStatus status, Pageable pageable);

    @Query("""
        SELECT new com.pepla.dto.OrderSummaryDto(
            o.id, o.customerName, o.total, o.status, o.createdAt, SIZE(o.lineItems)
        )
        FROM Order o
        WHERE (:status IS NULL OR o.status = :status)
        ORDER BY o.createdAt DESC
        """)
    Page<OrderSummaryDto> findSummaries(
            @Param("status") OrderStatus status, Pageable pageable);

    @Query("SELECT o FROM Order o JOIN FETCH o.lineItems WHERE o.id = :id")
    Optional<Order> findByIdWithLineItems(@Param("id") UUID id);
}

Service Layer Architecture

The service layer is where business logic lives. It coordinates between repositories, applies business rules, and manages transactions. Each service method should represent a single business operation:

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;
    private final InventoryClient inventoryClient;
    private final OrderEventPublisher eventPublisher;
    private final OrderMapper mapper;

    public Page<OrderSummaryDto> findAll(OrderStatus status, Pageable pageable) {
        return orderRepository.findSummaries(status, pageable);
    }

    public Optional<OrderDetailDto> findById(UUID id) {
        return orderRepository.findByIdWithLineItems(id)
                .map(mapper::toDetailDto);
    }

    @Transactional
    public OrderDetailDto create(CreateOrderRequest request) {
        // Verify inventory availability
        var availability = inventoryClient.checkAvailability(request.items());
        if (!availability.allAvailable()) {
            throw new InsufficientInventoryException(availability.unavailableItems());
        }

        // Create the order
        var order = mapper.toEntity(request);
        order.setStatus(OrderStatus.PENDING);
        order.setCreatedAt(Instant.now());
        order = orderRepository.save(order);

        // Reserve inventory and publish event
        inventoryClient.reserve(order.getId(), request.items());
        eventPublisher.publishOrderCreated(order);

        return mapper.toDetailDto(order);
    }
}
Network infrastructure cables

Spring Security: Authentication and Authorisation

Spring Security in Spring Boot 3 uses a component-based configuration model. The SecurityFilterChain bean replaces the deprecated WebSecurityConfigurerAdapter:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())))
            .build();
    }
}

Inter-Service Communication

Microservices need to communicate. The two fundamental patterns are synchronous (HTTP/gRPC) and asynchronous (message queues). Choosing correctly is one of the most important architectural decisions in a microservices system.

Synchronous: RestClient

Spring's new RestClient, introduced in Spring Boot 3.2, provides a fluent, synchronous HTTP client that replaces RestTemplate:

@Component
public class InventoryClient {

    private final RestClient restClient;

    public InventoryClient(RestClient.Builder builder,
                           @Value("${services.inventory.url}") String baseUrl) {
        this.restClient = builder
                .baseUrl(baseUrl)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }

    public AvailabilityResponse checkAvailability(List<OrderItem> items) {
        return restClient.post()
                .uri("/api/v1/inventory/check")
                .body(new AvailabilityRequest(items))
                .retrieve()
                .body(AvailabilityResponse.class);
    }
}

Asynchronous: RabbitMQ

For operations that do not need an immediate response -- sending notifications, updating analytics, triggering downstream processes -- asynchronous messaging decouples services and improves resilience:

// Publisher
@Component
@RequiredArgsConstructor
public class OrderEventPublisher {

    private final RabbitTemplate rabbitTemplate;
    private final ObjectMapper objectMapper;

    public void publishOrderCreated(Order order) {
        var event = new OrderCreatedEvent(
            order.getId(),
            order.getCustomerName(),
            order.getTotal(),
            Instant.now()
        );
        rabbitTemplate.convertAndSend(
            "orders.exchange",
            "order.created",
            event
        );
    }
}

// Consumer in another service
@Component
@RequiredArgsConstructor
public class OrderNotificationListener {

    private final NotificationService notificationService;

    @RabbitListener(queues = "notifications.order-created")
    public void handleOrderCreated(OrderCreatedEvent event) {
        notificationService.sendOrderConfirmation(
            event.customerName(),
            event.orderId(),
            event.total()
        );
    }
}

Use synchronous calls when you need the response immediately. Use messaging when you need resilience and can tolerate eventual consistency.

Circuit Breaker with Resilience4j

In a microservices architecture, a failing downstream service can cascade failures through the entire system. Circuit breakers prevent this by monitoring failure rates and short-circuiting requests when a service is unhealthy:

@Component
public class InventoryClient {

    private final RestClient restClient;
    private final CircuitBreaker circuitBreaker;

    public InventoryClient(RestClient.Builder builder,
                           CircuitBreakerRegistry registry) {
        this.restClient = builder.baseUrl("http://inventory-service").build();
        this.circuitBreaker = registry.circuitBreaker("inventory", CircuitBreakerConfig.custom()
                .failureRateThreshold(50)
                .waitDurationInOpenState(Duration.ofSeconds(30))
                .slidingWindowSize(10)
                .build());
    }

    public AvailabilityResponse checkAvailability(List<OrderItem> items) {
        return circuitBreaker.executeSupplier(() ->
            restClient.post()
                .uri("/api/v1/inventory/check")
                .body(new AvailabilityRequest(items))
                .retrieve()
                .body(AvailabilityResponse.class)
        );
    }
}

The circuit breaker has three states. Closed (normal operation, requests pass through), Open (failure threshold exceeded, requests fail immediately without calling the downstream service), and Half-Open (after the wait duration, a limited number of test requests are allowed through to check if the service has recovered).

Spring Cloud Gateway

An API gateway is the single entry point for all client requests. Spring Cloud Gateway provides routing, rate limiting, authentication, and request transformation. It runs as a separate Spring Boot service:

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/v1/orders/**
          filters:
            - StripPrefix=0
            - name: CircuitBreaker
              args:
                name: orderServiceCB
                fallbackUri: forward:/fallback/orders
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 50
                redis-rate-limiter.burstCapacity: 100

        - id: inventory-service
          uri: lb://inventory-service
          predicates:
            - Path=/api/v1/inventory/**
Data centre server infrastructure

Docker Containerisation

Every Spring Boot microservice we deploy at Pepla runs in a Docker container. Spring Boot 3 supports building optimised container images directly with Buildpacks, but we prefer explicit Dockerfiles for control over the layering:

# Multi-stage build for minimal image size
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY gradle/ gradle/
COPY build.gradle.kts settings.gradle.kts gradlew ./
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
USER spring
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseZGC", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

The multi-stage build separates the build environment from the runtime. The final image contains only the JRE and the application JAR, keeping the image under 200MB. The ZGC garbage collector provides low-latency performance suitable for microservices with strict response time requirements.

Spring Actuator: Production Observability

Spring Actuator exposes operational endpoints for health checks, metrics, and environment information. In a microservices deployment, these endpoints are consumed by container orchestrators (Kubernetes liveness and readiness probes), monitoring systems (Prometheus), and alerting platforms:

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: when_authorized
      probes:
        enabled: true
  health:
    db:
      enabled: true
    rabbit:
      enabled: true
    diskSpace:
      enabled: true
Microservices are not a goal; they are a trade-off. You gain independent deployability, technology flexibility, and team autonomy. You pay with distributed system complexity, network latency, and operational overhead. At Pepla, we start every project with a well-structured monolith and extract microservices only when the organisational or scaling benefits justify the cost. Most of our Spring Boot projects are modular monoliths that could become microservices -- but have not needed to yet.

Spring Boot remains the best framework in the Java ecosystem for building production services. Its convention-over-configuration philosophy, mature ecosystem, and excellent documentation make it the safe choice for enterprise Java. At Pepla, when a client's technology strategy is built on Java, Spring Boot is how we deliver. The patterns in this article are not theoretical -- they run in production systems that process real transactions, serve real users, and generate real revenue for our clients every day.

Need help with this?

Pepla builds Spring Boot microservices for enterprise Java environments. Let us architect your system.

Get in Touch

Contact Us

Schedule a Meeting

Book a free consultation to discuss your project requirements.

Book a Meeting ›

Let's Connect