Skip to main content

    Lesson 47 • Expert

    Microservices Architecture

    By the end you'll be able to split a system into well-bounded Spring Boot services, choose REST or messaging for each call, wire them together with discovery and a gateway, and keep the whole thing standing up when one service fails.

    Before You Start

    You should know REST APIs (HTTP endpoints), Deployment (Docker & JAR packaging), and Networking (HTTP clients). Comfort with JSON processing helps too, since services talk to each other in JSON.

    What You'll Learn in This Lesson

    • Draw service boundaries around business capabilities
    • Choose REST vs messaging (Kafka/RabbitMQ) per call
    • Use service discovery (Eureka) so callers never hard-code addresses
    • Front your services with an API gateway and a config server
    • Add resilience with Resilience4j circuit breakers and retries
    • Trace a request across services and dodge the distributed monolith

    🍽️ A Real-World Analogy

    A monolith is a Swiss Army knife — every tool in one body. Brilliant for camping, but you can't hand the scissors to one person and the blade to another, and replacing one tool means replacing the whole knife.

    Microservices are a professional kitchen: separate stations for grill, pastry, and salads. Each station has its own ingredients (its own database), its own staff (its own team), and can be staffed up independently when it gets busy (independent scaling). A head chef calling out orders is the API gateway; a notice board everyone reads is your message broker; and a seating plan that always knows which station is open is service discovery.

    💡 Key insight: microservices trade code complexity for operational complexity. Don't adopt them until your team can run a distributed system — networking, tracing, and eventual consistency all become your problem.

    1️⃣ Service Boundaries: Split by Capability, Not by Table

    A microservice is a small, independently deployable program that owns one business capability end to end — its API and its data. The hardest part isn't the code; it's deciding where one service ends and the next begins. Draw the lines around things the business does (authenticate a user, place an order, charge a card), not around database tables.

    Get a boundary wrong and two services will constantly need each other's data, and you'll be back to a monolith with network calls in the middle. The test of a good boundary: a service can change its internals, schema, and even its language without anyone else noticing.

    AspectMonolithMicroservices
    DeploymentSingle unit, all-or-nothingIndependent per service
    ScalingScale the entire appScale only the hot services
    DebuggingOne stack traceDistributed tracing required
    DataShared DB (easy joins)Database per service (eventual consistency)
    Best forStartups, MVPs, small teamsLarge orgs, independent scaling

    2️⃣ Service Discovery with Eureka

    In a cluster, instances come and go and their addresses change all the time — hard-coding host:port breaks constantly. Service discovery fixes this: each service registers itself with a registry (Spring Cloud's Eureka) on startup, and callers ask the registry to resolve a name to a live address. Callers say "give me an order-service" instead of an IP.

    The worked example below builds a tiny in-memory version of that registry so you can see the shape of the data Eureka keeps — a logical name mapped to the instances running behind it. Read it, then run it.

    Worked example: an in-memory service registry
    import java.util.List;
    
    public class Main {
        // A service entry as Eureka would hold it: a logical name plus the
        // actual instances running behind it. Discovery means: "given a name,
        // give me a live address" — so callers never hard-code host:port.
        record ServiceInstance(String name, int port, int instances, String role) {}
    
        public static void main(String[] args) {
            List<ServiceInstance> registry = List.of(
                new ServiceInstance("api-gateway",      8080, 2, "Routes + rate limits"),
                new ServiceInstance("user-service",     8081, 3, "Auth + profiles"),
                new ServiceInstance("order-service",    8082, 4, "Order processing"),
                new ServiceInstance("payment-service",  8083, 2, "Payment handling"),
                new ServiceInstance("notification-svc", 8084, 1, "Email/push alerts"),
                new ServiceInstance("eureka-server",    8761, 1, "Service registry")
            );
    
            System.out.println("SERVICE REGISTRY:");
            for (ServiceInstance s : registry) {
                // %-18s left-pads the name so the columns line up.
                System.out.printf("  %-18s :%d  x%d  %s%n",
                    s.name(), s.port(), s.instances(), s.role());
            }
    
            // Sum every instance — this is what the gateway load-balances across.
            int total = registry.stream().mapToInt(ServiceInstance::instances).sum();
            System.out.println("  Total instances: " + total);  // 2+3+4+2+1+1 = 13
        }
    }
    Output
    SERVICE REGISTRY:
      api-gateway        :8080  x2  Routes + rate limits
      user-service       :8081  x3  Auth + profiles
      order-service      :8082  x4  Order processing
      payment-service    :8083  x2  Payment handling
      notification-svc   :8084  x1  Email/push alerts
      eureka-server      :8761  x1  Service registry
      Total instances: 13
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    3️⃣ REST vs Messaging (Kafka / RabbitMQ)

    Services talk in two fundamentally different ways. Synchronous REST (or gRPC) is a phone call: you dial, you wait for the reply, and you're coupled to the other side being up right now. Use it when you need the answer to continue — "is this user authorised?".

    Asynchronous messaging through a broker like Kafka or RabbitMQ is posting a notice on a board: you publish an event ("OrderPlaced") and walk away; anyone interested reacts in their own time. It decouples services, so a slow or down subscriber never blocks the producer, and you can add new subscribers later without touching the producer. The example models both with a tiny event bus.

    Worked example: REST (sync) vs an event bus (async)
    import java.util.ArrayList;
    import java.util.List;
    import java.util.function.Consumer;
    
    public class Main {
        // Synchronous REST: the caller WAITS for a reply and is coupled to the
        // callee being up right now. Good for "I need the answer to continue".
        static String chargeCardOverRest(double amount) {
            return "charged " + amount + " (caller blocked until this returned)";
        }
    
        // Asynchronous messaging (Kafka/RabbitMQ): the producer publishes an
        // EVENT and moves on. Subscribers react in their own time. Good for
        // "tell whoever cares that this happened" — loose coupling.
        static class EventBus {
            private final List<Consumer<String>> subscribers = new ArrayList<>();
            void subscribe(Consumer<String> handler) { subscribers.add(handler); }
            void publish(String event) {
                System.out.println("  published: " + event);
                for (Consumer<String> handler : subscribers) handler.accept(event);
            }
        }
    
        public static void main(String[] args) {
            // 1) Synchronous call — order-service blocks for the result.
            System.out.println("REST (sync): " + chargeCardOverRest(49.99));
    
            // 2) Asynchronous fan-out — one event, two independent reactions.
            EventBus bus = new EventBus();
            bus.subscribe(e -> System.out.println("    email-service handled: " + e));
            bus.subscribe(e -> System.out.println("    analytics-service handled: " + e));
    
            System.out.println("Messaging (async):");
            bus.publish("OrderPlaced{id=1001}");   // producer does not wait
            System.out.println("order-service moved on without waiting");
        }
    }
    Output
    REST (sync): charged 49.99 (caller blocked until this returned)
    Messaging (async):
      published: OrderPlaced{id=1001}
        email-service handled: OrderPlaced{id=1001}
        analytics-service handled: OrderPlaced{id=1001}
    order-service moved on without waiting
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    4️⃣ The API Gateway and Config Server

    You don't want browsers calling twenty internal services directly. An API gateway (Spring Cloud Gateway) is the single front door: it routes each incoming path to the right service, and centralises cross-cutting concerns — authentication, rate limiting, TLS, and CORS — so each service doesn't reimplement them.

    A config server (Spring Cloud Config) solves the opposite end: instead of every service shipping its own copy of database URLs and feature flags, they fetch configuration from one central place (often a Git repo) at startup. Change a value once and every service can pick it up — no rebuild required.

    🎯 Your Turn #1: own each capability
    import java.util.List;
    import java.util.Map;
    
    public class Main {
        public static void main(String[] args) {
            // 🎯 YOUR TURN — fill in the blanks marked with ___
    
            // Draw service BOUNDARIES around business capabilities, not tables.
            // Each service owns its data and exposes a small API. Map a few
            // features to the service that should own them.
    
            // 1) Which service owns logging in and storing profiles?
            String authFeature = ___;        // 👉 replace ___ with "user-service"
    
            // 2) Which service owns the shopping cart and checkout?
            String orderFeature = ___;       // 👉 replace ___ with "order-service"
    
            // 3) Which service owns charging the card?
            String payFeature = ___;         // 👉 replace ___ with "payment-service"
    
            Map<String, String> ownership = Map.of(
                "login + profile", authFeature,
                "cart + checkout", orderFeature,
                "charge card",     payFeature
            );
    
            // Print each capability next to the service that owns it.
            List<String> order = List.of("login + profile", "cart + checkout", "charge card");
            for (String capability : order) {
                System.out.println(capability + " -> " + ownership.get(capability));
            }
    
            // ✅ Expected output:
            // login + profile -> user-service
            // cart + checkout -> order-service
            // charge card -> payment-service
        }
    }
    Output
    login + profile -> user-service
    cart + checkout -> order-service
    charge card -> payment-service
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    5️⃣ Resilience: Circuit Breakers and Retries (Resilience4j)

    In a distributed system, everything fails eventually — networks drop packets, services crash, databases time out. Resilience patterns make your system degrade gracefully instead of one failure cascading into all of them.

    The most important is the circuit breaker. Like the breaker in your fuse box, it watches the failure rate and trips open once failures cross a threshold, so it stops calling a service that's clearly down and returns a fallback instead. After a cooldown it goes HALF_OPEN and tries a single request to see if the service recovered. A retry is the complementary tool: re-attempt a single call a few times to ride out a brief blip. The example builds a breaker by hand so the state machine is visible.

    Worked example: a circuit breaker from scratch
    import java.util.function.Supplier;
    
    public class Main {
        // A minimal Circuit Breaker — the core idea behind Resilience4j.
        static class CircuitBreaker {
            enum State { CLOSED, OPEN, HALF_OPEN }   // states it cycles through
    
            private final String name;
            private final int threshold;
            private State state = State.CLOSED;      // start healthy
            private int failures = 0;
    
            CircuitBreaker(String name, int threshold) {
                this.name = name;
                this.threshold = threshold;
            }
    
            // Run the call unless the breaker is OPEN; on too many failures, trip.
            <T> T call(Supplier<T> action, T fallback) {
                if (state == State.OPEN) {
                    System.out.println("  " + name + ": OPEN -> returning fallback");
                    return fallback;                 // fail fast, do not even try
                }
                try {
                    T result = action.get();
                    failures = 0;                    // success resets the counter
                    state = State.CLOSED;
                    return result;
                } catch (RuntimeException e) {
                    failures++;
                    if (failures >= threshold) {
                        state = State.OPEN;          // trip the breaker
                        System.out.println("  " + name + ": OPEN (threshold hit: " + failures + " failures)");
                    } else {
                        System.out.println("  " + name + ": failure " + failures + "/" + threshold);
                    }
                    return fallback;                 // degrade gracefully
                }
            }
        }
    
        static int callCount = 0;
        // An unstable downstream service: fails the first 4 calls, then recovers.
        static String callPaymentService() {
            callCount++;
            if (callCount <= 4) throw new RuntimeException("Connection timeout");
            return "Payment processed!";
        }
    
        public static void main(String[] args) {
            CircuitBreaker cb = new CircuitBreaker("PaymentService", 3);
            for (int i = 1; i <= 7; i++) {
                String result = cb.call(Main::callPaymentService, "FALLBACK_RESULT");
                System.out.println("Call " + i + " -> " + result);
            }
        }
    }
    Output
      PaymentService: failure 1/3
    Call 1 -> FALLBACK_RESULT
      PaymentService: failure 2/3
    Call 2 -> FALLBACK_RESULT
      PaymentService: OPEN (threshold hit: 3 failures)
    Call 3 -> FALLBACK_RESULT
      PaymentService: OPEN -> returning fallback
    Call 4 -> FALLBACK_RESULT
      PaymentService: OPEN -> returning fallback
    Call 5 -> FALLBACK_RESULT
      PaymentService: OPEN -> returning fallback
    Call 6 -> FALLBACK_RESULT
      PaymentService: OPEN -> returning fallback
    Call 7 -> FALLBACK_RESULT
    This is real code — run it for free atonecompiler.com/javaor in your own editor.
    In real Spring Boot: declare it with Resilience4j annotations
    // In a real Spring Boot service you declare resilience with annotations
    // from Resilience4j, instead of hand-rolling a circuit breaker.
    import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
    import io.github.resilience4j.retry.annotation.Retry;
    import io.github.resilience4j.bulkhead.annotation.Bulkhead;
    import org.springframework.stereotype.Service;
    
    @Service
    public class PaymentGateway {
    
        @CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
        @Retry(name = "paymentService")               // retry transient failures
        @Bulkhead(name = "paymentService")            // cap concurrent calls
        public Payment processPayment(Order order) {
            return paymentClient.charge(order);       // a remote call that may fail
        }
    
        // Fallback must share the signature plus the thrown exception.
        public Payment paymentFallback(Order order, Throwable t) {
            log.warn("Payment failed, queuing for retry: {}", t.getMessage());
            return Payment.pending(order);            // graceful degradation
        }
    }
    This is a Spring Boot service class — it needs the Spring Boot and Resilience4j dependencies and a running application context, so it isn't a standalone snippet you can paste into an online runner. Use it as a template in a real Spring Boot project.
    🎯 Your Turn #2: a retry loop
    public class Main {
        static int attempts = 0;
        // A flaky call that succeeds only on the 3rd try.
        static String callInventory() {
            attempts++;
            if (attempts < 3) throw new RuntimeException("503 Service Unavailable");
            return "in stock";
        }
    
        public static void main(String[] args) {
            // 🎯 YOUR TURN — fill in the blanks marked with ___
    
            int maxRetries = ___;            // 👉 replace ___ with 3
            String result = "GAVE UP";
    
            for (int attempt = 1; attempt <= maxRetries; attempt++) {
                try {
                    result = callInventory();
                    System.out.println("Attempt " + attempt + ": success -> " + result);
                    break;                   // 👉 stop retrying once it works
                } catch (RuntimeException e) {
                    // Print the failed attempt and keep looping until maxRetries.
                    System.out.println("Attempt " + attempt + ": failed (" + e.getMessage() + ")");
                }
            }
    
            System.out.println("Final: " + result);
    
            // ✅ Expected output:
            // Attempt 1: failed (503 Service Unavailable)
            // Attempt 2: failed (503 Service Unavailable)
            // Attempt 3: success -> in stock
            // Final: in stock
        }
    }
    Output
    Attempt 1: failed (503 Service Unavailable)
    Attempt 2: failed (503 Service Unavailable)
    Attempt 3: success -> in stock
    Final: in stock
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    6️⃣ Distributed Tracing: Following One Request

    In a monolith, a failure gives you one stack trace. In microservices, a single user click might touch the gateway, then order-service, then payment-service, then a Kafka consumer — and a stack trace in any one of them tells you nothing about the others. Distributed tracing stitches the whole journey together.

    The gateway tags the incoming request with a trace ID, and every service passes that ID along (usually as a header) so each hop becomes a span under the same trace. Tools like Micrometer Tracing with Zipkin collect those spans and draw a timeline of the full request, so you can see exactly which hop was slow or threw. Without it, debugging a distributed system is guesswork.

    Mini-Challenge: write it yourself

    The support is gone now — only an outline remains. Build a tiny API gateway router that maps a request path to the service that owns it. The comments give the brief and the expected output; you write the logic.

    🎯 Mini-Challenge: an API gateway router
    public class Main {
        public static void main(String[] args) {
            // 🎯 MINI-CHALLENGE: an API gateway router
            // The gateway maps an incoming path prefix to the service that owns it.
            //
            // 1. Make a Map<String,String> "routes" with these entries:
            //      "/users"   -> "user-service:8081"
            //      "/orders"  -> "order-service:8082"
            //      "/payments"-> "payment-service:8083"
            // 2. For each request path below, find the matching route by its prefix
            //    and print:  path + " -> " + target
            //      paths: "/users/42", "/orders/1001", "/payments/charge", "/admin"
            // 3. If no prefix matches, print:  path + " -> 404 (no route)"
            //
            // ✅ Expected output:
            // /users/42 -> user-service:8081
            // /orders/1001 -> order-service:8082
            // /payments/charge -> payment-service:8083
            // /admin -> 404 (no route)
    
            // your code here
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    Common Errors (and the fix)

    • The distributed monolith — services that must be deployed together, share a database, and call each other in long synchronous chains. You get a monolith's rigidity plus network latency. Fix: give each service its own data, talk through versioned APIs and events, and verify each service can deploy alone.
    • Sharing one database between services — a schema change for one service silently breaks another, and you lose true independence. Fix: database-per-service; coordinate cross-service changes with events or sagas instead of SQL joins.
    • No resilience on remote calls — one slow service makes every caller hang, and the failure cascades across the system. Fix: wrap external calls with a Resilience4j @CircuitBreaker (plus @Retry for transient blips) and always provide a fallback.
    • Chatty calls — Service A calls B in a loop, firing dozens of tiny synchronous requests per user action. Each hop adds latency and a new failure point. Fix: batch requests, fetch what you need in one call, or push the work onto an async event instead.
    • Hard-coded service addresseshttp://10.0.3.7:8082 breaks the moment that instance moves. Fix: register with discovery (Eureka) and resolve services by name with a client-side load balancer.

    📋 Quick Reference

    ConcernToolPurpose
    DiscoveryEureka / ConsulResolve a service name to a live instance
    GatewaySpring Cloud GatewayRouting, rate limiting, auth — one front door
    ConfigSpring Cloud ConfigCentralised, shared configuration
    Sync callsREST / gRPCCaller needs the answer now
    Async eventsKafka / RabbitMQAnnounce that something happened
    ResilienceResilience4jCircuit breaker, retry, bulkhead, fallback
    TracingMicrometer + ZipkinFollow one request across services

    Frequently Asked Questions

    When should I actually choose microservices over a monolith?

    Choose microservices when you have independent scaling needs (one part of the system is far hotter than the rest), multiple teams that keep blocking each other in one codebase, or parts that must deploy on different schedules. If none of those apply, a well-structured 'modular monolith' is simpler, faster, and cheaper. Microservices trade code complexity for operational complexity — you take on networking, distributed tracing, and eventual consistency, so only adopt them when the organisational pain justifies that cost.

    REST or messaging — how do I pick for a call between two services?

    Use synchronous REST (or gRPC) when the caller needs the answer to continue right now, such as 'is this user authorised?'. Use asynchronous messaging (Kafka or RabbitMQ) when you are announcing that something happened and other services can react in their own time, such as 'OrderPlaced'. Messaging decouples services so a slow or down subscriber doesn't block the producer, and it lets you add new subscribers later without changing the producer.

    What does service discovery with Eureka actually solve?

    In a cluster, service instances come and go and their host:port changes — hard-coding addresses breaks constantly. Each service registers itself with Eureka on startup ('I am order-service at 10.0.3.7:8082') and asks Eureka to resolve names when it needs to call someone. Callers then say 'give me an order-service instance' instead of an address, and a client-side load balancer spreads requests across the live instances.

    Why does each microservice need its own database?

    A database shared across services secretly couples them: a schema change for one service can break another, and you can no longer deploy, scale, or reason about a service in isolation — which is the whole point of microservices. Giving each service its own database (database-per-service) keeps ownership clear. The cost is that you lose cross-service SQL joins and must accept eventual consistency, coordinating changes through events or sagas instead.

    What is a 'distributed monolith' and how do I avoid it?

    A distributed monolith is the worst of both worlds: services split apart on paper but so tightly coupled that you must deploy them all together, they share a database, and they make long synchronous call chains. You get a monolith's rigidity plus the network latency and failure modes of a distributed system. Avoid it by giving services their own data, communicating through versioned APIs and events, and confirming each service can be deployed on its own without the others.

    What is the difference between a circuit breaker and a retry?

    Retry re-attempts a single failed call a few times, ideal for brief transient blips like a momentary network hiccup. A circuit breaker watches the failure rate across many calls and, once it crosses a threshold, 'trips open' to stop calling a service that is clearly down — failing fast and returning a fallback instead of piling on. They complement each other: retry handles the occasional flake, the breaker protects you from hammering a service that has genuinely fallen over.

    🎉 Lesson Complete!

    You can now draw service boundaries around business capabilities, choose REST or messaging per call, wire services together with Eureka discovery, a Spring Cloud Gateway, and a config server, keep them resilient with Resilience4j circuit breakers and retries, trace a request across hops, and spot the distributed-monolith trap before you fall into it.

    Next up: CLI Tools — building professional command-line applications with Java and picocli.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.