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

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 singledocker-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:
-
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.
-
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.
-
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/
- Use multi-stage builds to keep images lean. Alpine-based images like
-
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.
-
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
-
Learn from Mistakes:
- Don’t fear errors like
image not found
ornon-resolvable POM
. They’re opportunities to deepen your understanding. - Keep a debugging log to track what worked and what didn’t.
- Don’t fear errors like
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.