The JVM has been running production microservices longer than the word “microservices” has been fashionable. Netflix, LinkedIn, Uber, and thousands of less famous shops all ship Java services at scale — and the ecosystem around that has matured into a fairly opinionated stack. This article walks through what building Java microservices actually looks like: the frameworks to pick, the patterns you’ll use every day, and working code for a small system of three services.
Why Java for microservices?
A few reasons that still hold up in 2026:
- Mature ecosystem. Spring Boot, Micronaut, Quarkus, Helidon, Vert.x — every communication style, every cloud, every database has a well-worn Java library.
- Tooling and observability. JVM profilers, heap dumps, flight recorder, OpenTelemetry auto-instrumentation — unmatched.
- Concurrency story. Virtual threads (Project Loom, stable since JDK 21) make I/O-bound services cheap to write and cheap to run.
- Talent pool. You can hire Java developers in every market in the world.
- Performance. Modern JVMs with G1/ZGC and GraalVM native images handle almost any workload.
The usual counter-argument — “Java is heavy” — has largely been answered: GraalVM native compilation produces services that start in tens of milliseconds and use tens of MB of RAM.
The modern Java microservices stack
Here’s what a pragmatic 2026 stack looks like:
| Concern | Typical choice |
|---|---|
| Framework | Spring Boot 3.x, Quarkus, or Micronaut |
| JDK | 21 LTS (virtual threads) or 25 |
| Build | Gradle (Kotlin DSL) or Maven |
| HTTP/JSON | Framework default (Jackson) |
| Synchronous RPC | REST or gRPC |
| Async messaging | Kafka, RabbitMQ, or NATS |
| Persistence | PostgreSQL + Spring Data JPA / jOOQ / MyBatis |
| Migrations | Flyway or Liquibase |
| Service discovery | Kubernetes DNS (most cases) or Consul |
| Config | Spring Cloud Config, Kubernetes ConfigMaps, Vault |
| Resilience | Resilience4j |
| Observability | Micrometer + OpenTelemetry + Prometheus + Loki |
| Containerization | Jib, Buildpacks, or a plain Dockerfile |
| Orchestration | Kubernetes |
Spring Boot is still the default — it has the largest community and the most examples. Quarkus and Micronaut are faster to start and smaller in memory (important for serverless), but Spring has closed most of the gap with AOT compilation.
A worked example: three services
Let’s build a minimal order-processing system:
┌──────────┐ REST ┌──────────┐ Kafka ┌──────────┐
│ Orders │ ────────▶ │ Payments │ ────────▶ │ Shipping │
│ Service │ │ Service │ events │ Service │
└──────────┘ └──────────┘ └──────────┘
│ │ │
Orders DB Payments DB Shipments DB- Orders accepts new orders over REST and calls Payments synchronously.
- Payments charges and publishes a
PaymentSettledevent to Kafka. - Shipping consumes the event and schedules delivery.
Orders Service — REST endpoint + synchronous call
OrderController.java:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest req) {
Order order = orderService.placeOrder(req);
return ResponseEntity
.created(URI.create("/orders/" + order.getId()))
.body(OrderResponse.from(order));
}
@GetMapping("/{id}")
public OrderResponse get(@PathVariable UUID id) {
return OrderResponse.from(orderService.findById(id));
}
}OrderService.java — note the synchronous call to Payments, wrapped in a circuit breaker:
@Service
public class OrderService {
private final OrderRepository repo;
private final PaymentsClient payments;
public OrderService(OrderRepository repo, PaymentsClient payments) {
this.repo = repo;
this.payments = payments;
}
@Transactional
public Order placeOrder(CreateOrderRequest req) {
Order order = new Order(UUID.randomUUID(), req.customerId(), req.items(), OrderStatus.PENDING);
repo.save(order);
PaymentResult result = payments.charge(order.getId(), order.totalAmount());
order.setStatus(result.success() ? OrderStatus.PAID : OrderStatus.FAILED);
return repo.save(order);
}
public Order findById(UUID id) {
return repo.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}PaymentsClient.java — Spring’s declarative HTTP client with Resilience4j:
@HttpExchange("/payments")
public interface PaymentsClient {
@CircuitBreaker(name = "payments", fallbackMethod = "chargeFallback")
@Retry(name = "payments")
@PostExchange
PaymentResult charge(@RequestParam UUID orderId, @RequestParam BigDecimal amount);
default PaymentResult chargeFallback(UUID orderId, BigDecimal amount, Throwable t) {
return new PaymentResult(false, "payments-unavailable");
}
}Payments Service — DB + event publishing
PaymentService.java:
@Service
public class PaymentService {
private final PaymentRepository repo;
private final KafkaTemplate<String, PaymentSettled> kafka;
public PaymentService(PaymentRepository repo, KafkaTemplate<String, PaymentSettled> kafka) {
this.repo = repo;
this.kafka = kafka;
}
@Transactional
public PaymentResult charge(UUID orderId, BigDecimal amount) {
Payment payment = new Payment(UUID.randomUUID(), orderId, amount, PaymentStatus.SETTLED);
repo.save(payment);
kafka.send("payments.settled", orderId.toString(),
new PaymentSettled(payment.getId(), orderId, amount, Instant.now()));
return new PaymentResult(true, payment.getId().toString());
}
}A subtle but important point: the DB write and the Kafka publish are not atomic. If the process crashes between them, you have a paid-but-not-published payment. Two common fixes:
- Transactional outbox — write the event to an
outboxtable in the same transaction as the payment. A separate poller reads the outbox and publishes to Kafka. - Change Data Capture — use Debezium to stream DB changes straight to Kafka.
Use one. Never assume “it’ll probably be fine.”
Shipping Service — Kafka consumer
ShippingListener.java:
@Component
public class ShippingListener {
private final ShipmentService shipmentService;
public ShippingListener(ShipmentService shipmentService) {
this.shipmentService = shipmentService;
}
@KafkaListener(topics = "payments.settled", groupId = "shipping")
public void onPaymentSettled(PaymentSettled event) {
shipmentService.schedule(event.orderId());
}
}ShipmentService.java — idempotent by design, because Kafka delivers at least once:
@Service
public class ShipmentService {
private final ShipmentRepository repo;
public ShipmentService(ShipmentRepository repo) {
this.repo = repo;
}
@Transactional
public void schedule(UUID orderId) {
if (repo.existsByOrderId(orderId)) {
return;
}
repo.save(new Shipment(UUID.randomUUID(), orderId, ShipmentStatus.SCHEDULED, Instant.now()));
}
}The existsByOrderId check is the idempotency guard. Without it, retrying a duplicate event schedules the same shipment twice.
Patterns you’ll reach for often
API Gateway with Spring Cloud Gateway
A minimal gateway that routes to services by path:
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
.route("orders", r -> r.path("/api/orders/**")
.filters(f -> f.stripPrefix(1).circuitBreaker(c -> c.setName("orders")))
.uri("lb://orders-service"))
.route("payments", r -> r.path("/api/payments/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://payments-service"))
.build();
}
}lb://orders-service uses client-side load balancing; service names resolve via the service registry (Eureka, Consul, or Kubernetes).
Resilience4j — circuit breaker + retry + timeout
Configure in application.yml:
resilience4j:
circuitbreaker:
instances:
payments:
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
sliding-window-size: 20
minimum-number-of-calls: 10
retry:
instances:
payments:
max-attempts: 3
wait-duration: 500ms
retry-exceptions:
- java.io.IOException
- org.springframework.web.client.ResourceAccessException
timelimiter:
instances:
payments:
timeout-duration: 2sThe pattern is always the same: short timeout, limited retries on idempotent calls only, circuit breaker to fail fast when the dependency is down, fallback to a degraded response.
Observability with Micrometer + OpenTelemetry
Add the starter, and every service gets a /actuator/prometheus endpoint plus W3C trace context propagation out of the box:
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>Custom metric from business code:
@Service
public class PaymentService {
private final Counter settledCounter;
public PaymentService(MeterRegistry registry) {
this.settledCounter = Counter.builder("payments.settled")
.description("Number of settled payments")
.register(registry);
}
public void charge(...) {
...
settledCounter.increment();
}
}Virtual threads (JDK 21+)
Flip one line in application.yml and every blocking I/O call runs on a virtual thread instead of a platform thread:
spring:
threads:
virtual:
enabled: trueFor I/O-bound services (the common case), this typically means 10–100× more concurrent requests on the same hardware, without rewriting code in a reactive style.
Testing microservices
Three test layers you actually need:
Unit tests — plain JUnit, mock the collaborators, no Spring context.
Slice tests — load only the part of the context you need:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService orderService;
@Test
void createsOrder() throws Exception {
when(orderService.placeOrder(any()))
.thenReturn(new Order(UUID.randomUUID(), "c-1", List.of(), OrderStatus.PAID));
mvc.perform(post("/orders")
.contentType(APPLICATION_JSON)
.content("""
{"customerId":"c-1","items":[{"sku":"A","qty":1}]}
"""))
.andExpect(status().isCreated());
}
}Integration tests with Testcontainers — real Postgres, real Kafka, no mocking of infra:
@SpringBootTest
@Testcontainers
class PaymentServiceIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", postgres::getJdbcUrl);
r.add("spring.datasource.username", postgres::getUsername);
r.add("spring.datasource.password", postgres::getPassword);
r.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired PaymentService service;
@Test
void persistsAndPublishes() {
PaymentResult r = service.charge(UUID.randomUUID(), new BigDecimal("19.99"));
assertThat(r.success()).isTrue();
// + assert Kafka consumer sees the event
}
}Skip end-to-end tests that span all services until you’ve squeezed everything you can from integration tests. They’re slow, flaky, and catch far less than you’d hope.
Packaging and deployment
The modern approach is a layered container image built without a Dockerfile:
./gradlew bootBuildImage --imageName=myrepo/orders:1.4.2Spring Boot’s buildpack produces an OCI image with the JDK, dependencies, and application classes in separate layers, so rebuilds push only what changed.
For even smaller footprints, compile to a GraalVM native image:
./gradlew nativeCompileA typical Spring Boot service goes from ~300 MB / ~2s startup / ~400 MB RAM to ~80 MB / ~40 ms startup / ~80 MB RAM. Trade-off: longer build times and some reflection-heavy libraries need native hints.
Kubernetes Deployment for the Orders service:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders
spec:
replicas: 3
selector:
matchLabels: { app: orders }
template:
metadata:
labels: { app: orders }
spec:
containers:
- name: orders
image: myrepo/orders:1.4.2
ports: [{ containerPort: 8080 }]
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
resources:
requests: { cpu: 200m, memory: 512Mi }
limits: { cpu: 1, memory: 1Gi }
env:
- name: SPRING_DATASOURCE_URL
valueFrom: { secretKeyRef: { name: orders-db, key: url } }Separate readiness and liveness endpoints matter: readiness failing takes the pod out of the load balancer; liveness failing restarts it. Never wire them to the same check.
What goes wrong in practice
The production pain you will eventually feel:
- Shared databases. Someone reaches into another service’s DB “just this once.” Now you have a distributed monolith with worse latency. Enforce it at the network level — services only see their own DB.
- Chatty services. Orders calls Payments, which calls Accounts, which calls Orders for context. Each hop adds latency, each call can fail. Flatten with events or denormalize data.
- Version drift. Service A upgrades a Kafka event schema; service B crashes on unknown fields. Use a schema registry (Confluent, Apicurio) and evolve schemas with forward/backward compatibility rules.
- N+1 service calls. A UI needs data from five services, so the gateway makes five sequential calls. Either use GraphQL federation, a BFF (backend-for-frontend), or parallel calls with
CompletableFuture. - No idempotency. Retries silently create duplicates. Every write endpoint should accept an idempotency key; every event handler should be idempotent.
- Tight coupling via shared libraries. A core library everyone depends on becomes a change-bottleneck. Keep shared code minimal and versioned independently.
A checklist before your first Java microservice
- JDK 21+ with virtual threads enabled
- Framework chosen (Spring Boot is the safe default)
- Database per service, migrations with Flyway
- Synchronous calls behind Resilience4j (timeout, retry, circuit breaker)
- Async communication via Kafka with a schema registry
- Transactional outbox or CDC for write-then-publish
- Idempotency keys on all state-changing endpoints
- Micrometer + OpenTelemetry wired up
- Readiness + liveness probes distinct
- Integration tests with Testcontainers
- Images built with Jib or Spring Boot buildpacks
Closing thought
Java gives you the deepest, best-tested toolbox for microservices on the planet. The flip side is that the defaults have accumulated — a naive Spring Boot service ships with dozens of auto-configurations you didn’t ask for. Pick a framework, pick a small set of patterns (gateway, resilience, outbox, idempotent handlers), and say no to everything else until you need it. The teams that ship reliable Java microservices aren’t the ones using the most features — they’re the ones using the fewest that solve their problem.