MICROSERVICES
with Java & Spring Boot
A Practical, In-Depth Guide — Concepts, Architecture Diagrams,
and Working Java Code
From Monolith to Microservices · Service Discovery · API Gateway ·
Resilience · Sagas · Messaging · Observability
Table of Contents
1 What Are Microservices? Monolith vs. Microservices
2 Core Design Concepts (Bounded Contexts, Database-per-Service)
3 Building a Microservice with Spring Boot (full Java code)
4 Inter-Service Communication (REST, Feign, async messaging)
5 Service Discovery with Eureka (concepts + Java code)
6 API Gateway with Spring Cloud Gateway (Java code)
7 Centralized Configuration with Spring Cloud Config
8 Resilience: Circuit Breakers, Retries, Timeouts (Resilience4j)
9 Distributed Transactions: The Saga Pattern (Java code)
10 Event-Driven Communication with Kafka (Java code)
11 Containerizing Services with Docker & Docker Compose
12 Observability: Logging, Tracing, Metrics
13 Security Basics: JWT & OAuth2 Between Services
14 Best Practices, Common Pitfalls, and Where to Go Next
Microservices with Java & Spring Boot — A Practical Deep Dive 1
CHAPTER 1
What Are Microservices?
• Microservices are an architectural style where an application is built as a collection of small,
independently deployable services, each owning a single business capability (e.g. Orders, Payments,
Inventory) and its own data.
• Each service can be developed, deployed, scaled, and even rewritten independently — as long as it
honors its API contract with the rest of the system.
• This is the opposite of a monolith, where all business logic lives in a single codebase and is deployed as
one unit, sharing one database.
1.1 Monolith vs. Microservices
Figure 1.1 — A monolith bundles every module into one deployable and one database. Microservices split those modules into
independent services, each with its own datastore.
Why teams move away from monoliths
• Deployment coupling — shipping a one-line fix to Payments means redeploying the entire application.
• Scaling waste — if only Order-processing is under heavy load, you still have to scale the whole monolith,
including parts that don't need it.
• Blast radius — a memory leak in one module can crash the entire process, taking every feature down
with it.
• Technology lock-in — the whole application is stuck on one language/framework version.
Microservices with Java & Spring Boot — A Practical Deep Dive 2
• Team friction — multiple teams committing to the same codebase leads to merge conflicts and slow
releases.
Trade-offs microservices introduce
• Distributed systems are harder to debug — a single user request may hop across five services.
• Network calls can fail in ways in-process calls can't (partial failure, latency, timeouts).
• Data consistency across services requires new patterns (sagas, eventual consistency) since you no longer
have a single ACID transaction spanning everything.
• You now need infrastructure: service discovery, API gateways, centralized logging, distributed tracing.
KEY TAKEAWAY: Microservices are not free performance or free scalability — they trade one set of
problems (coupling, slow releases) for another (operational complexity). Adopt them when the coupling
pain is real, not by default.
1.2 The Reference Architecture Used in This Guide
Every concept in this guide fits into the architecture below. Keep coming back to this diagram as a map: a client
talks only to the API Gateway, which routes to the right service using the Discovery Server; services pick up
their settings from a Config Server, talk to each other synchronously (REST/Feign) or asynchronously (a
message broker), and everything emits logs/traces/metrics for observability.
Figure 1.2 — Reference architecture: the components this guide builds, piece by piece.
Microservices with Java & Spring Boot — A Practical Deep Dive 3
CHAPTER 2
Core Design Concepts
2.1 Bounded Contexts (Domain-Driven Design)
The hardest part of microservices isn't the framework code — it's drawing the right service boundaries.
Domain-Driven Design's bounded context is the standard tool: identify sub-domains of the business (Ordering,
Payments, Inventory, Shipping) where the same word can mean different things in different contexts, and give
each sub-domain its own service and its own model.
• A “Product” in the Catalog service has descriptions, images, categories.
• The same “Product” in the Inventory service is mostly a SKU and a stock count.
• Trying to share one “Product” model across both services is a common beginner mistake — it re-creates
monolith coupling with extra network hops.
2.2 Database-per-Service
Each service owns its data exclusively. No other service is allowed to read or write another service's tables
directly — all access goes through that service's API or through events it publishes. This is what actually makes
services independently deployable: you can change Order's schema without coordinating with any other team.
Figure 2.1 — Each service owns its own datastore; other services never touch it directly.
How do you join data across services, then?
Microservices with Java & Spring Boot — A Practical Deep Dive 4
• API composition — the caller (often the gateway or a BFF) calls two services and merges results in
memory. Fine for simple reads.
• CQRS + materialized views — a service subscribes to another service's events and keeps a local,
denormalized read-only copy of the data it needs, updated asynchronously.
• You give up cross-service JOINs and cross-service ACID transactions. In exchange you get independent
scaling and deployment. This is the central trade-off of the whole architecture.
2.3 Twelve-Factor Principles That Matter Most
• Config in the environment, not hardcoded — same artifact runs in dev/staging/prod.
• Stateless processes — session state goes in a shared store (Redis, DB), not in memory, so any instance
can handle any request.
• Own your dependencies explicitly (Maven/Gradle) — no relying on what happens to be on the host.
• Logs as event streams — write to stdout, let the platform (Docker/K8s) collect and route them.
Microservices with Java & Spring Boot — A Practical Deep Dive 5
CHAPTER 3
Building a Microservice with Spring Boot
Let's build a real Order Service — the same one referenced throughout this guide. It exposes a REST API,
persists to its own PostgreSQL database via Spring Data JPA, and follows a clean layered structure: Controller
→ Service → Repository → Entity.
3.1 Project Dependencies ([Link])
[Link] — core dependencies
<dependencies>
<dependency>
<groupId>[Link]</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>[Link]</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>[Link]</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>[Link]</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>[Link]</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
</dependencies>
3.2 The Entity
[Link] — JPA entity
Microservices with Java & Spring Boot — A Practical Deep Dive 6
package [Link];
import [Link].*;
import [Link];
import [Link];
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = [Link])
private String id;
@Column(nullable = false)
private String customerId;
@Column(nullable = false)
private String productId;
private int quantity;
@Column(nullable = false)
private BigDecimal totalPrice;
@Enumerated([Link])
private OrderStatus status;
private Instant createdAt = [Link]();
// constructors, getters, and setters omitted for brevity
public enum OrderStatus {
PENDING, CONFIRMED, CANCELLED
}
}
3.3 The Repository
[Link]
package [Link];
import [Link];
import [Link];
import [Link];
public interface OrderRepository extends JpaRepository<Order, String> {
List<Order> findByCustomerId(String customerId);
}
3.4 The Service Layer
Microservices with Java & Spring Boot — A Practical Deep Dive 7
[Link] — business logic
package [Link];
import [Link];
import [Link];
import [Link];
import [Link];
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
[Link] = orderRepository;
}
public Order createOrder(Order order) {
[Link]([Link]);
return [Link](order);
}
public Order getOrder(String id) {
return [Link](id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
public List<Order> getOrdersForCustomer(String customerId) {
return [Link](customerId);
}
public Order confirmOrder(String id) {
Order order = getOrder(id);
[Link]([Link]);
return [Link](order);
}
}
3.5 The REST Controller
[Link] — REST API
Microservices with Java & Spring Boot — A Practical Deep Dive 8
package [Link];
import [Link];
import [Link];
import [Link];
import [Link];
import [Link];
import [Link].*;
import [Link];
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
[Link] = orderService;
}
@PostMapping
public ResponseEntity<Order> create(@Valid @RequestBody Order order) {
Order created = [Link](order);
return [Link]([Link]).body(created);
}
@GetMapping("/{id}")
public ResponseEntity<Order> get(@PathVariable String id) {
return [Link]([Link](id));
}
@GetMapping
public ResponseEntity<List<Order>> byCustomer(@RequestParam String customerId) {
return [Link]([Link](customerId));
}
@PutMapping("/{id}/confirm")
public ResponseEntity<Order> confirm(@PathVariable String id) {
return [Link]([Link](id));
}
}
NOTE: Notice the controller never touches the repository directly, and the service never touches HTTP.
Keeping these layers separate is what lets you unit-test business logic without spinning up a web server.
3.6 Application Configuration
[Link] — order-service
Microservices with Java & Spring Boot — A Practical Deep Dive 9
server:
port: 8081
spring:
application:
name: order-service
datasource:
url: jdbc:postgresql://localhost:5432/orders_db
username: orders_user
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
eureka:
client:
service-url:
defaultZone: [Link]
Microservices with Java & Spring Boot — A Practical Deep Dive 10
CHAPTER 4
Inter-Service Communication
Services rarely work alone. When Order Service needs to check whether Payment succeeded, it has two broad
options: call Payment Service synchronously (and wait for a response) or communicate asynchronously
through events. Each has real trade-offs.
4.1 Synchronous REST Calls with OpenFeign
Spring Cloud OpenFeign lets you call another service as if it were a local Java interface — no manual
HttpClient boilerplate. Combined with Eureka, Feign resolves payment-service to a live instance address
automatically.
[Link] — declarative HTTP client
package [Link];
import [Link];
import [Link].*;
@FeignClient(name = "payment-service")
public interface PaymentClient {
@PostMapping("/api/payments")
PaymentResponse charge(@RequestBody PaymentRequest request);
}
[Link] — calling another service
// Using the client inside OrderService
private final PaymentClient paymentClient;
public Order createOrder(Order order) {
[Link]([Link]);
Order saved = [Link](order);
PaymentResponse response = [Link](
new PaymentRequest([Link](), [Link]()));
if ([Link]()) {
[Link]([Link]);
}
return [Link](saved);
}
WATCH OUT: Synchronous calls create a runtime dependency: if Payment Service is slow or down,
Order Service's thread blocks too, and the failure can cascade. This is exactly why Chapter 8
(Resilience) exists.
Microservices with Java & Spring Boot — A Practical Deep Dive 11
4.2 Asynchronous Messaging
Instead of calling Payment Service directly, Order Service can publish an OrderCreated event to a message
broker. Payment Service subscribes to it, processes the charge in its own time, and publishes
PaymentCompleted or PaymentFailed back. Neither service needs the other to be online at the same instant
— this is the basis of the Saga pattern in Chapter 9 and the Kafka example in Chapter 10.
Sync vs. Async — how to choose
Synchronous (REST/Feign) Asynchronous (events)
Latency Caller waits for response Caller continues immediately
Coupling Caller must know callee's address Publisher doesn't know subscribers
Failure handling Needs timeouts/circuit breakers Needs idempotent consumers, retries
Best for Queries needing an immediate answer Workflows, notifications, data replication
Microservices with Java & Spring Boot — A Practical Deep Dive 12
CHAPTER 5
Service Discovery with Eureka
In a monolith you never had to ask “where is the Payment module running?” — it was in the same process. In
microservices, instances come and go (scaling up/down, restarts, new deployments), and their IP addresses
change constantly. Service discovery solves this: services register themselves with a registry, and other
services ask the registry for a current address instead of hardcoding one.
Figure 5.1 — Both services register with Eureka and send heartbeats. Order Service asks Eureka for Payment Service's
location, then calls it directly (client-side discovery).
5.1 Running the Eureka Server
[Link]
package [Link];
import [Link];
import [Link];
import [Link];
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
public static void main(String[] args) {
[Link]([Link], args);
}
}
Microservices with Java & Spring Boot — A Practical Deep Dive 13
[Link] — discovery-server
server:
port: 8761
eureka:
client:
register-with-eureka: false # the registry doesn't register with itself
fetch-registry: false
server:
enable-self-preservation: false # fine for dev; keep it on in prod
5.2 Registering a Client Service
Any service becomes discoverable just by adding the Eureka client dependency and pointing it at the registry —
no extra annotation is required in modern Spring Cloud versions, but it's common to add
@EnableDiscoveryClient for clarity:
[Link]
@SpringBootApplication
@EnableDiscoveryClient
public class OrderServiceApplication {
public static void main(String[] args) {
[Link]([Link], args);
}
}
NOTE: Once registered, you can call other services by their logical name (payment-service) instead of an
IP:port. Feign (Chapter 4) and Spring Cloud LoadBalancer resolve that name to a real, currently-healthy
instance — and spread requests across multiple instances automatically.
Microservices with Java & Spring Boot — A Practical Deep Dive 14
CHAPTER 6
API Gateway with Spring Cloud Gateway
Clients shouldn't need to know about ten different service addresses, or handle CORS, auth, and rate limiting
ten different ways. An API Gateway is the single entry point: it routes each incoming request to the right
backend service, and is the natural place for cross-cutting concerns.
6.1 What a Gateway Centralizes
• Routing — /api/orders/** → order-service, /api/payments/** → payment-service.
• Authentication — validate a JWT once, before it ever reaches a backend service.
• Rate limiting & throttling — protect backend services from being overwhelmed.
• Request/response transformation, logging, and CORS handling in one place.
6.2 Gateway Configuration
[Link] — api-gateway
server:
port: 8080
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service # 'lb' = load-balanced via Eureka
predicates:
- Path=/api/orders/**
- id: payment-service
uri: lb://payment-service
predicates:
- Path=/api/payments/**
filters:
- name: CircuitBreaker
args:
name: paymentCB
fallbackUri: forward:/fallback/payments
eureka:
client:
service-url:
defaultZone: [Link]
6.3 A Programmatic Route (alternative to YAML)
Microservices with Java & Spring Boot — A Practical Deep Dive 15
[Link]
@Configuration
public class GatewayRoutes {
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return [Link]()
.route("order-service", r -> [Link]("/api/orders/**")
.uri("lb://order-service"))
.route("payment-service", r -> [Link]("/api/payments/**")
.filters(f -> [Link](c -> c
.setName("paymentCB")
.setFallbackUri("forward:/fallback/payments")))
.uri("lb://payment-service"))
.build();
}
}
6.4 A Global Auth Filter
[Link] — runs for every request
@Component
public class JwtAuthFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String authHeader = [Link]()
.getHeaders().getFirst([Link]);
if (authHeader == null || ) {
[Link]().setStatusCode([Link]);
return [Link]().setComplete();
}
// token validated here (signature, expiry, issuer) - see Chapter 13
return [Link](exchange);
}
@Override
public int getOrder() {
return -1; // run before routing filters
}
}
Microservices with Java & Spring Boot — A Practical Deep Dive 16
CHAPTER 7
Centralized Configuration
With ten services, keeping database URLs, feature flags, and credentials consistent across every
[Link] gets unmanageable — especially when a value needs to change at runtime. Spring Cloud
Config Server serves configuration from a central Git repository, and services can even refresh their config
without a restart.
7.1 The Config Server
[Link]
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
[Link]([Link], args);
}
}
[Link] — config-server
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: [Link]
default-label: main
7.2 A Client Pulling Its Config
order-service/[Link]
spring:
application:
name: order-service
config:
import: optional:configserver:[Link]
The Config Server looks for a file named [Link] in the Git repo and serves it to any service that
identifies itself as order-service. Add @RefreshScope to a bean and it picks up new values on a POST
/actuator/refresh — no redeploy needed.
Microservices with Java & Spring Boot — A Practical Deep Dive 17
CHAPTER 8
Resilience: Circuit Breakers, Retries, Timeouts
In a distributed system, remote calls fail in ways local calls never do: the network hiccups, a downstream
service is overloaded, or it's just slow. Without protection, a struggling Payment Service can make Order
Service pile up blocked threads waiting on it, which then makes Order Service slow for everyone — a
cascading failure. Resilience4j gives you the standard patterns to contain this.
Figure 8.1 — A circuit breaker trips OPEN after too many failures (failing fast instead of waiting on a doomed call), tests recovery
in HALF-OPEN, and only fully resumes once trial calls succeed.
8.1 Circuit Breaker + Retry + Timeout in Code
[Link]
Microservices with Java & Spring Boot — A Practical Deep Dive 18
@Service
public class PaymentGatewayService {
private final PaymentClient paymentClient;
@CircuitBreaker(name = "paymentCB", fallbackMethod = "fallbackCharge")
@Retry(name = "paymentRetry")
@TimeLimiter(name = "paymentTimeout")
public CompletableFuture<PaymentResponse> charge(PaymentRequest request) {
return [Link](() -> [Link](request));
}
// Signature must match: same args + a Throwable at the end
private CompletableFuture<PaymentResponse> fallbackCharge(
PaymentRequest request, Throwable t) {
return [Link](
[Link]([Link]()));
}
}
[Link] — resilience4j config
resilience4j:
circuitbreaker:
instances:
paymentCB:
sliding-window-size: 20
failure-rate-threshold: 50 # % failures that trips the breaker
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 5
retry:
instances:
paymentRetry:
max-attempts: 3
wait-duration: 500ms
timelimiter:
instances:
paymentTimeout:
timeout-duration: 2s
HOW THEY COMPOSE: Order matters conceptually even though annotations are declarative: a call
times out, that counts as a failure for Retry, and repeated failures are what eventually trips the Circuit
Breaker. Always give the fallback a sane default — “fail fast with a clear message” beats “hang the user's
request for 30 seconds.”
Microservices with Java & Spring Boot — A Practical Deep Dive 19
CHAPTER 9
Distributed Transactions: The Saga Pattern
Since each service has its own database, you can't wrap “create order, charge payment, reserve stock” in one
ACID transaction the way you could in a monolith. A saga is a sequence of local transactions, each in a
different service, coordinated through events — with an explicit compensating action to undo previous steps if
a later step fails.
Figure 9.1 — Choreography saga: each service reacts to the previous service's event. If Payment fails, it publishes
PaymentFailed, and Order Service compensates by cancelling the order.
9.1 Choreography vs. Orchestration
• Choreography (shown above) — each service listens for events and decides what to do next. No central
coordinator; simple for a few steps, harder to follow as steps grow.
• Orchestration — a dedicated saga orchestrator service explicitly tells each participant what to do next
and calls the compensations in order. More visible and testable, but adds a component.
9.2 A Choreography Saga in Java
[Link] — saga participant
Microservices with Java & Spring Boot — A Practical Deep Dive 20
// Order Service: publishes the first event
@Service
public class OrderService {
private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
public Order createOrder(Order order) {
[Link]([Link]);
Order saved = [Link](order);
[Link]("order-events",
new OrderCreatedEvent([Link](), [Link]()));
return saved;
}
@KafkaListener(topics = "payment-events")
public void onPaymentEvent(PaymentEvent event) {
Order order = [Link]([Link]()).orElseThrow();
if ([Link]()) {
[Link]([Link]);
} else {
[Link]([Link]); // compensating action
}
[Link](order);
}
}
[Link] — saga participant
// Payment Service: reacts to OrderCreated, publishes its own outcome
@Service
public class PaymentSagaListener {
private final KafkaTemplate<String, PaymentEvent> kafkaTemplate;
private final PaymentRepository paymentRepository;
@KafkaListener(topics = "order-events")
public void onOrderCreated(OrderCreatedEvent event) {
boolean approved = attemptCharge([Link](), [Link]());
[Link](new Payment([Link](), approved));
[Link]("payment-events",
new PaymentEvent([Link](), approved));
}
}
CRITICAL RULE: Every saga participant must be idempotent — the same event might be delivered
twice (at-least-once delivery is the norm for message brokers). Use a unique event/order ID and check
“have I already processed this?” before acting.
Microservices with Java & Spring Boot — A Practical Deep Dive 21
CHAPTER 10
Event-Driven Communication with Kafka
Apache Kafka is the most common backbone for asynchronous, event-driven microservices: producers publish
immutable events to a topic, and any number of consumers can read them independently, at their own pace,
without the producer knowing who's listening.
10.1 Dependencies and Config
[Link]
<dependency>
<groupId>[Link]</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
[Link] — kafka
spring:
kafka:
bootstrap-servers: localhost:9092
consumer:
group-id: order-service-group
auto-offset-reset: earliest
producer:
key-serializer: [Link]
value-serializer: [Link]
[Link]-deserializer: [Link]
10.2 Producing an Event
Topic + event definition
@Configuration
public class KafkaTopicConfig {
@Bean
public NewTopic orderEventsTopic() {
return [Link]("order-events")
.partitions(3)
.replicas(1)
.build();
}
}
public record OrderCreatedEvent(String orderId, BigDecimal amount, Instant occurredAt) {}
Microservices with Java & Spring Boot — A Practical Deep Dive 22
10.3 Consuming an Event
[Link]
@Component
public class InventoryEventListener {
private final InventoryService inventoryService;
@KafkaListener(topics = "order-events", groupId = "inventory-service-group")
public void handle(OrderCreatedEvent event) {
boolean reserved = [Link]([Link]());
// publish InventoryReserved / InventoryFailed for the saga to react to
}
}
NOTE: Design topics around business facts that already happened (“OrderCreated”), not commands
(“CreateOrder”). This keeps producers decoupled from what consumers do with the event — new
consumers can be added later with zero changes to the producer.
Microservices with Java & Spring Boot — A Practical Deep Dive 23
CHAPTER 11
Containerizing Services with Docker
Each microservice should build into its own container image, so it runs identically on your laptop, in CI, and in
production. Docker Compose is the easiest way to run the whole system — gateway, discovery, config server,
every service, and their databases — together during development.
11.1 Dockerfile for a Spring Boot Service
Dockerfile — order-service (multi-stage build)
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY .mvn/ .mvn
COPY mvnw [Link] ./
RUN ./mvnw dependency:go-offline
COPY src ./src
RUN ./mvnw package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar [Link]
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "[Link]"]
11.2 [Link] — the Whole System
[Link]
Microservices with Java & Spring Boot — A Practical Deep Dive 24
version: "3.9"
services:
discovery-server:
build: ./discovery-server
ports: ["8761:8761"]
config-server:
build: ./config-server
ports: ["8888:8888"]
depends_on: [discovery-server]
orders-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders_db
POSTGRES_PASSWORD: secret
order-service:
build: ./order-service
ports: ["8081:8081"]
depends_on: [discovery-server, config-server, orders-db]
environment:
DB_PASSWORD: secret
payment-service:
build: ./payment-service
ports: ["8082:8082"]
depends_on: [discovery-server, config-server]
api-gateway:
build: ./api-gateway
ports: ["8080:8080"]
depends_on: [discovery-server, order-service, payment-service]
With this file, docker compose up --build starts the entire architecture from Chapter 1's reference diagram in one
command — exactly what makes microservices practical to develop locally.
Microservices with Java & Spring Boot — A Practical Deep Dive 25
CHAPTER 12
Observability: Logging, Tracing, Metrics
A single user action can now touch five services. When something goes wrong, you can't just “read the stack
trace” — you need to reconstruct the whole journey. Three pillars make this possible.
Centralized logging
Every service logs structured JSON to stdout; a shipper (Filebeat/Fluentd) forwards it to a central store (the
ELK stack: Elasticsearch, Logstash, Kibana) where you can search across all services at once.
Distributed tracing
Each incoming request gets a unique trace ID that's propagated through every downstream call (HTTP
headers, Kafka message headers). Tools like Zipkin or Jaeger stitch the spans back together into one timeline,
showing exactly which service was slow.
Custom trace span example
// Micrometer Tracing (Spring Boot 3) - mostly automatic, but you can add custom spans:
@Service
public class OrderService {
private final Tracer tracer;
public Order createOrder(Order order) {
Span span = [Link]().name("validate-order").start();
try ([Link] ws = [Link](span)) {
validate(order);
} finally {
[Link]();
}
return [Link](order);
}
}
Metrics
Spring Boot Actuator + Micrometer expose metrics (request rates, latencies, error counts, JVM memory) at
/actuator/prometheus; Prometheus scrapes them and Grafana turns them into dashboards and alerts.
[Link] — metrics
<dependency>
<groupId>[Link]</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>[Link]</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Microservices with Java & Spring Boot — A Practical Deep Dive 26
CHAPTER 13
Security Basics: JWT & OAuth2
You don't want every service re-implementing username/password checks. The standard approach: an
Authorization Server (Keycloak, Okta, or Spring Authorization Server) issues signed JWTs after login; the
gateway validates the token once; each downstream service trusts the token's claims and just checks
scopes/roles.
13.1 Validating a JWT in a Resource Service
[Link] — order-service
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers([Link], "/api/orders").hasAuthority("SCOPE_orders.write
")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> [Link]([Link]()));
return [Link]();
}
}
[Link] — JWT issuer
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: [Link]
DON'T FORGET: Service-to-service calls (Order → Payment) need their own credentials too — typically
the OAuth2 client_credentials grant, so Payment Service can tell “this call came from Order Service
itself” apart from “this call is on behalf of user X.”
Microservices with Java & Spring Boot — A Practical Deep Dive 27
CHAPTER 14
Best Practices, Pitfalls, and Next Steps
14.1 Best Practices
• Design around business capabilities, not technical layers — avoid a “Controller service” and a separate
“Database service.”
• Keep services small enough for one team to own, but not so small that every feature needs five
services to talk to each other (“nanoservices”).
• Version your APIs (e.g. /api/v1/orders) so you can evolve a service without breaking every consumer at
once.
• Make every consumer idempotent — assume every event or request might arrive more than once.
• Automate everything: CI/CD per service, contract tests between services, and infrastructure as code.
• Start with a “modular monolith” if you're unsure of your boundaries — clean module separation inside
one deployable is much cheaper to fix than wrong network boundaries between ten services.
14.2 Common Pitfalls
• The “distributed monolith” — services that must all be deployed together because they share a
database or make long synchronous call chains. You've paid the operational cost of microservices without
getting any of the independence benefits.
• Chatty services — one user request fanning out into 15 synchronous calls. Push toward async events or
precomputed read models instead.
• No circuit breakers — one slow dependency takes down the whole call chain (see Chapter 8).
• Shared libraries that couple deployments — a shared “common-models” JAR that every service
depends on quietly re-creates the monolith's coupling; every change forces a wave of redeployments.
• Skipping observability until it hurts — in production, debugging a distributed system without tracing is
extremely painful. Build it in from the start (Chapter 12).
14.3 Where to Go From Here
• Build the Order/Payment/Inventory example from this guide end-to-end, running it all via Docker
Compose.
• Add Kubernetes on top of Docker for orchestration, health-check-based restarts, and rolling deployments.
• Explore contract testing (Spring Cloud Contract or Pact) so services can verify compatibility without a full
integration environment.
Microservices with Java & Spring Boot — A Practical Deep Dive 28
• Read up on the Strangler Fig pattern if you're migrating an existing monolith piece by piece rather than
starting from scratch.
FINAL THOUGHT: The biggest skill in microservices isn't Spring annotations — it's judgment about
where to draw service boundaries and when the added operational complexity is actually worth it for your
team and problem. Use this guide's code as a working skeleton for your own project, and let real pain
points (not fashion) guide where you split things further.
Microservices with Java & Spring Boot — A Practical Deep Dive 29