From Monolith to Microservices

Building a Resilient Hotel Booking System with Spring Boot and Resilience4j As a seasoned Java developer, I’ve always been fascinated by the challenge of building systems that are not just functional but also scalable, resilient, and maintainable. Over the years, I’ve worked on various projects, but none have been as transformative as my recent journey into microservices with the Hotel Reservation System—a project that pushed me to embrace 3 resilience patterns (Circuit Breaker + Retry) for hotel bookings, with polyglot persistence (PostgreSQL + MongoDB). In this article, I’ll share the technical and personal lessons learned while transitioning from a monolithic mindset to a microservices architecture, focusing on the challenges, triumphs, and practical insights for Java developers looking to make a similar leap. Introduction: A Java Developer’s Quest for Resilience With experience in Java, I’ve built everything from enterprise monoliths to RESTful APIs. But as cloud-native architectures gained traction, I felt the urge to dive into microservices—not just to stay relevant but to explore how patterns like Circuit Breaker, Retry, and Specification could elevate system design. My motivation was clear: create a hotel booking platform that could handle real-world failures gracefully, using modern tools like Spring Boot 3.2.4, Maven, PostgreSQL, MongoDB, Resilience4j, and Swagger/OpenAPI. The Hotel Reservation System became my playground—a set of three microservices (hotel-rooms-service, hotel-reservations-service, and hotel-payments-service) designed to manage room availability, reservations, and payments. This project wasn’t just about coding; it was about mastering design patterns, embracing polyglot persistence, and deploying with Docker. Along the way, I faced significant challenges, learned invaluable lessons, and even had a few humbling moments that reshaped my approach to development. The Challenges: Navigating the Microservices Maze Transitioning to microservices wasn’t a walk in the park. Here are the key obstacles I encountered and the lessons they taught me: Challenge 1: Understanding Microservices Boundaries Coming from a monolithic background, I initially struggled to define clear boundaries for each microservice. Should payments handle reservation validation? Should rooms manage availability checks? The complexity of distributed systems felt overwhelming. Learning: I adopted Domain-Driven Design (DDD) principles to carve out bounded contexts. For example, hotel-rooms-service focused solely on room inventory and availability, using the Specification Pattern to filter rooms dynamically: public List findAvailableRooms(Integer guestCount, Double maxPrice) { Specification spec = Specification.where(RoomSpecifications.isAvailable()) .and(RoomSpecifications.hasGuestCapacity(guestCount)) .and(RoomSpecifications.hasMaxPrice(maxPrice)); return roomRepository.findAll(spec); } This pattern encapsulated filtering logic, making it reusable and testable—a cornerstone of clean code in microservices. Challenge 2: Resilience in a Distributed World Failures in microservices are inevitable—network glitches, database timeouts, or third-party service outages. My early attempts at payment processing were brittle, crashing on transient errors. Learning: I embraced 3 resilience patterns (Circuit Breaker + Retry) for hotel bookings using Resilience4j. The payment service became my proving ground: @CircuitBreaker(name = "paymentService", fallbackMethod = "processPaymentFallback") @Retry(name = "paymentService", fallbackMethod = "processPaymentFallback") public Payment processPayment(String reservationId, double amount) { logger.info("Processing payment for reservationId: {}", reservationId); if (Math.random() > 0.7) { throw new RuntimeException("Payment service failed"); } Payment payment = new Payment(UUID.randomUUID().toString(), reservationId, amount, "COMPLETED"); return paymentRepository.save(payment); } public Payment processPaymentFallback(String reservationId, double amount, Throwable t) { logger.warn("Fallback triggered for reservationId: {} due to: {}", reservationId, t.getMessage()); return new Payment(UUID.randomUUID().toString(), reservationId, amount, "PENDING"); } CircuitBreaker: Prevents cascading failures by opening the circuit after 5 failures (50% of 10 calls), redirecting to the fallback. Retry: Attempts up to 3 retries with exponential backoff (500ms, 1000ms, 2000ms) before giving up. Configuration (in application.properties): resilience4j.circuitbreaker.instances.paymentService.slidingWindowSize=10 resilience4j.circuitbreaker.instances.paymentService.failureRateThreshold=50 resilience4j.retry.instances.paymentService.maxAttempts=3 resilience4j.retry.instances.paymentService.waitDuration=500 resilience4j.retry.instances.paymentService.enableE

Apr 13, 2025 - 17:42
 0
From Monolith to Microservices

Building a Resilient Hotel Booking System with Spring Boot and Resilience4j

As a seasoned Java developer, I’ve always been fascinated by the challenge of building systems that are not just functional but also scalable, resilient, and maintainable. Over the years, I’ve worked on various projects, but none have been as transformative as my recent journey into microservices with the Hotel Reservation System—a project that pushed me to embrace 3 resilience patterns (Circuit Breaker + Retry) for hotel bookings, with polyglot persistence (PostgreSQL + MongoDB). In this article, I’ll share the technical and personal lessons learned while transitioning from a monolithic mindset to a microservices architecture, focusing on the challenges, triumphs, and practical insights for Java developers looking to make a similar leap.

Introduction: A Java Developer’s Quest for Resilience

With experience in Java, I’ve built everything from enterprise monoliths to RESTful APIs. But as cloud-native architectures gained traction, I felt the urge to dive into microservices—not just to stay relevant but to explore how patterns like Circuit Breaker, Retry, and Specification could elevate system design. My motivation was clear: create a hotel booking platform that could handle real-world failures gracefully, using modern tools like Spring Boot 3.2.4, Maven, PostgreSQL, MongoDB, Resilience4j, and Swagger/OpenAPI.

The Hotel Reservation System became my playground—a set of three microservices (hotel-rooms-service, hotel-reservations-service, and hotel-payments-service) designed to manage room availability, reservations, and payments. This project wasn’t just about coding; it was about mastering design patterns, embracing polyglot persistence, and deploying with Docker. Along the way, I faced significant challenges, learned invaluable lessons, and even had a few humbling moments that reshaped my approach to development.

The Challenges: Navigating the Microservices Maze

Transitioning to microservices wasn’t a walk in the park. Here are the key obstacles I encountered and the lessons they taught me:

Challenge 1: Understanding Microservices Boundaries

Coming from a monolithic background, I initially struggled to define clear boundaries for each microservice. Should payments handle reservation validation? Should rooms manage availability checks? The complexity of distributed systems felt overwhelming.

Learning: I adopted Domain-Driven Design (DDD) principles to carve out bounded contexts. For example, hotel-rooms-service focused solely on room inventory and availability, using the Specification Pattern to filter rooms dynamically:

public List<Room> findAvailableRooms(Integer guestCount, Double maxPrice) {
    Specification<Room> spec = Specification.where(RoomSpecifications.isAvailable())
        .and(RoomSpecifications.hasGuestCapacity(guestCount))
        .and(RoomSpecifications.hasMaxPrice(maxPrice));
    return roomRepository.findAll(spec);
}

This pattern encapsulated filtering logic, making it reusable and testable—a cornerstone of clean code in microservices.

Challenge 2: Resilience in a Distributed World

Failures in microservices are inevitable—network glitches, database timeouts, or third-party service outages. My early attempts at payment processing were brittle, crashing on transient errors.

Learning: I embraced 3 resilience patterns (Circuit Breaker + Retry) for hotel bookings using Resilience4j. The payment service became my proving ground:

@CircuitBreaker(name = "paymentService", fallbackMethod = "processPaymentFallback")
@Retry(name = "paymentService", fallbackMethod = "processPaymentFallback")
public Payment processPayment(String reservationId, double amount) {
    logger.info("Processing payment for reservationId: {}", reservationId);
    if (Math.random() > 0.7) {
        throw new RuntimeException("Payment service failed");
    }
    Payment payment = new Payment(UUID.randomUUID().toString(), reservationId, amount, "COMPLETED");
    return paymentRepository.save(payment);
}

public Payment processPaymentFallback(String reservationId, double amount, Throwable t) {
    logger.warn("Fallback triggered for reservationId: {} due to: {}", reservationId, t.getMessage());
    return new Payment(UUID.randomUUID().toString(), reservationId, amount, "PENDING");
}
  • CircuitBreaker: Prevents cascading failures by opening the circuit after 5 failures (50% of 10 calls), redirecting to the fallback.
  • Retry: Attempts up to 3 retries with exponential backoff (500ms, 1000ms, 2000ms) before giving up.
  • Configuration (in application.properties):
  resilience4j.circuitbreaker.instances.paymentService.slidingWindowSize=10
  resilience4j.circuitbreaker.instances.paymentService.failureRateThreshold=50
  resilience4j.retry.instances.paymentService.maxAttempts=3
  resilience4j.retry.instances.paymentService.waitDuration=500
  resilience4j.retry.instances.paymentService.enableExponentialBackoff=true

This combination made the payment service robust, handling transient failures gracefully while maintaining user trust with a "PENDING" status.

Challenge 3: Polyglot Persistence

Using PostgreSQL for rooms and reservations and MongoDB for payments introduced complexity. I underestimated the differences in data modeling and connection management.

Learning: Polyglot persistence requires careful planning. For example, hotel-rooms-service used JPA for structured room data:

@Entity
public class Room {
    @Id
    private String id;
    private String type;
    private int guestCapacity;
    private double price;
    private boolean available;
    // Getters and setters
}

Meanwhile, hotel-payments-service leveraged MongoDB’s flexibility:

@Document(collection = "payments")
public class Payment {
    @Id
    private String id;
    private String reservationId;
    private double amount;
    private String status;
    // Getters and setters
}

I learned to align database choices with domain needs—PostgreSQL for relational integrity, MongoDB for high-write scenarios like payments.

Challenge 4: Dockerizing a Multi-Module Project

Deploying with Docker revealed my naivety about multi-module Maven projects. My first Dockerfile attempts failed spectacularly, with errors like:

ERROR: failed to solve: maven:3.9.6-openjdk-17: not found

and later:

[FATAL] Non-resolvable parent POM for org.xsoto.springcloud.msvc:hotel-payments-service:0.0.1-SNAPSHOT

Learning: Multi-stage Docker builds and proper context management are critical. Here’s the final Dockerfile for hotel-payments-service:

# Stage 1: Build the application
FROM maven:3.9.6-eclipse-temurin-17 AS builder
WORKDIR /app
COPY ../pom.xml ./pom.xml
COPY ./pom.xml ./hotel-payments-service/pom.xml
RUN mvn dependency:go-offline -B
COPY src ./hotel-payments-service/src
RUN mvn clean package -pl hotel-payments-service -am -DskipTests

# Stage 2: Create the runtime image
FROM openjdk:17-jdk-alpine
WORKDIR /app
COPY --from=builder /app/hotel-payments-service/target/hotel-payments-service-1.0-SNAPSHOT.jar app.jar
EXPOSE 8083
ENV SPRING_DATA_MONGODB_URI=mongodb://mongo:27017/payments_db
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

The key was including the parent pom.xml and building from the root directory to resolve dependencies.

A Personal Anecdote: The Docker Debacle

Let me share a moment that humbled me. Early in the Dockerization process, I was confident I could whip up a Dockerfile in minutes. I started with maven:3.8.6-openjdk-17-slim, assuming it existed. Spoiler: it didn’t. The build failed with image not found. Undeterred, I switched to maven:3.9.6-openjdk-17—another dead end. Then came the real kicker: the Non-resolvable parent POM error. I’d overlooked the multi-module structure, naively copying only the module’s pom.xml.

Hours of debugging later, I realized my mistake: Docker’s build context needed the parent pom.xml, and I had to verify image tags on Docker Hub. It was a classic case of overconfidence meeting reality. But that frustration led to a breakthrough—learning to use multi-stage builds, .dockerignore, and docker-compose effectively. That moment taught me to respect the nuances of containerization and double-check assumptions.

Practical Experience: Bringing It All Together

Building the Hotel Reservation System required integrating Spring Boot 3.2.4, Maven, PostgreSQL, MongoDB, Resilience4j, and Swagger/OpenAPI. Here’s how it came together:

  • Microservices Setup:

    • hotel-rooms-service (port 8081, PostgreSQL): Manages room inventory with the Specification Pattern.
    • hotel-reservations-service (port 8082, PostgreSQL): Handles bookings with a Factory Pattern for object creation.
    • hotel-payments-service (port 8083, MongoDB): Processes payments with CircuitBreaker + Retry.
  • Docker Compose:

    A single docker-compose.yml orchestrated everything:

  version: '3.8'
  services:
    postgres:
      image: postgres:15-alpine
      environment:
        POSTGRES_USER: postgres
        POSTGRES_PASSWORD: password
      ports:
        - "5432:5432"
    mongo:
      image: mongo:6
      ports:
        - "27017:27017"
    hotel-rooms-service:
      build:
        context: .
        dockerfile: hotel-rooms-service/Dockerfile
      ports:
        - "8081:8081"
      environment:
        SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/rooms_db
    hotel-reservations-service:
      build:
        context: .
        dockerfile: hotel-reservations-service/Dockerfile
      ports:
        - "8082:8082"
      environment:
        SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/reservations_db
    hotel-payments-service:
      build:
        context: .
        dockerfile: hotel-payments-service/Dockerfile
      ports:
        - "8083:8083"
      environment:
        SPRING_DATA_MONGODB_URI: mongodb://mongo:27017/payments_db
  • Swagger/OpenAPI:
    Each service exposed a Swagger UI (e.g., http://localhost:8083/swagger-ui.html), making API testing a breeze.

  • Testing:
    Comprehensive tests validated resilience patterns:

  @Test
  void testProcessPayment_FallbackTriggered() {
      String reservationId = "res1";
      double amount = 150.0;
      circuitBreaker.transitionToOpenState();
      Payment result = paymentService.processPayment(reservationId, amount);
      assertEquals("PENDING", result.getStatus());
  }

Tips for Java Developers Transitioning to Microservices

For fellow Java developers eyeing a similar transition, here are practical tips to make your journey smoother:

  1. Master Design Patterns:

    • Study Circuit Breaker, Retry, and Specification. They’re not just buzzwords—they solve real problems. Start with Resilience4j’s documentation for hands-on examples.
    • Example: Tune Retry parameters carefully to avoid overwhelming downstream services.
  2. Embrace Polyglot Persistence:

    • Choose databases based on domain needs. For our hotel system, PostgreSQL ensured relational integrity for rooms, while MongoDB handled high-write payment data.
    • Tip: Use Spring Data’s abstractions to simplify repository code across databases.
  3. Get Comfortable with Docker:

    • Use multi-stage builds to keep images lean. Alpine-based images like openjdk:17-jdk-alpine saved me ~200 MB per service.
    • Always verify image tags on Docker Hub before building.
    • Example: .dockerignore is your friend:
     target/
     *.jar
     *.log
     .idea/
    
  4. Test Relentlessly:

    • Write tests for resilience patterns. Mock failures to ensure fallbacks work as expected.
    • Use tools like JaCoCo for code coverage to impress potential employers.
  5. Automate Early:

    • Set up a CI/CD pipeline (e.g., GitHub Actions) to build and test Docker images automatically. This saves time and catches errors early.
    • Example: A simple workflow to build and push images:
     name: Build Docker Images
     on: [push]
     jobs:
       build:
         runs-on: ubuntu-latest
         steps:
           - uses: actions/checkout@v3
           - run: docker-compose build
    
  6. Learn from Mistakes:

    • Don’t fear errors like image not found or non-resolvable POM. They’re opportunities to deepen your understanding.
    • Keep a debugging log to track what worked and what didn’t.

Conclusion: A Journey Worth Taking

Transitioning to microservices with the Hotel Reservation System was a game-changer. It taught me to build resilient, scalable systems using 3 resilience patterns (Circuit Breaker + Retry) for hotel bookings, with polyglot persistence (PostgreSQL + MongoDB). The challenges—defining boundaries, mastering resilience, and conquering Docker—were steep but rewarding. Each error, from missing image tags to POM resolution woes, sharpened my skills and mindset.

To my fellow Java developers: dive into microservices and design patterns. Experiment with Spring Boot, Resilience4j, and Docker. The road may be bumpy, but the ability to craft systems that thrive under pressure is worth every moment. Start small, test rigorously, and automate fearlessly. Your next project could be the one that defines your career.