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);
}
}
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/**
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.




