Overview
An automated inventory arbitrage platform for the hotel industry. It periodically polls supplier stock levels, automatically books rooms when inventory matches a configured strategy, and manages the full lifecycle through resale or safe cancellation.
Business Model: Monitor supplier inventory → auto-book when a condition triggers → resell on the main booking site at a higher price → auto-cancel before the free cancellation deadline if unsold. The free cancellation window is the key risk control — every booking is either sold for profit or returned at zero cost.
Problem Solved: The arbitrage opportunity exists only for a short window when stock becomes scarce. Human operators cannot realistically watch inventory across many properties, date ranges, and occupancy combinations at the pace required to act. This system replaces that watch with a rule-based engine plus careful correctness guarantees — so the business can trust it to act unattended on a live booking funnel.
Scope note: This is a low-volume, high-correctness system. Traffic is modest (a few hundred active bookings at a time, ~10 active watch conditions). The engineering challenge is not scale — it is making sure each automated booking is safe, deduplicated, auditable, and reliably cancelled if unsold.
My Role
As the sole developer, I designed and built every layer of the system:
- Defined the domain model using Hexagonal Architecture (Ports & Adapters)
- Designed the Strategy Pattern for pluggable trigger evaluation logic
- Implemented the 4-stage Saga compensation flow for booking reliability
- Built the auto-cancellation scheduler with configurable safety margins
- Implemented Redis distributed locking to prevent duplicate bookings
- Designed the JWT authentication system with account lockout and password expiry
- Set up Kubernetes deployment, monitoring, and Slack alerting
Tech Stack
Frontend
Backend
Infrastructure
Database / Messaging
Architecture
The system follows Hexagonal Architecture (Ports & Adapters), ensuring the domain layer has zero external dependencies:
Domain Layer: Pure business logic — trigger evaluation strategies, booking rules, cancellation policies. No framework annotations, no HTTP concerns, no database coupling. This layer can be unit tested without any infrastructure.
Application Layer: Orchestrates use cases by wiring domain services to ports. Manages transactions and coordinates the saga flow.
Adapter Layer (Inbound): REST controllers, scheduled job triggers, admin dashboard API.
Adapter Layer (Outbound): Hotel supplier API client (OpenFeign), database repositories (Spring Data JPA), Redis lock adapter, Slack notification adapter.
This separation means swapping a supplier API or changing the database engine requires zero changes to business logic.
Key Challenges
1. Multi-Strategy Trigger Evaluation
Different properties require different booking strategies. Some should trigger when a specific room type drops below a threshold; others should trigger when the entire property has only one room left. The system needed to support multiple strategies without coupling them to each other or to the core booking flow.
2. Reliable Multi-Step Booking
Booking a room requires four sequential API calls to the supplier system (APPLICATION, ITINERARY, SAVE, COMPLETE). Any step can fail, and a partial booking (e.g., application created but itinerary not saved) leaves the system in an inconsistent state that requires cleanup.
3. Preventing Duplicate Bookings
Multiple monitoring cycles can detect the same low-stock condition simultaneously. Without coordination, the system could book the same room twice — violating booking limits and wasting budget.
4. Safe Auto-Cancellation
The system must cancel unsold bookings before the free cancellation deadline. But cutting it too close risks missing the deadline due to API latency or downtime. Too early, and we lose potential resale time.
Solutions & Design Decisions
Strategy Pattern with Factory + EnumMap Dispatch
Each trigger strategy implements a TriggerEvaluator interface with a shouldTrigger() method. A TriggerEvaluatorFactory collects all evaluators via Spring's dependency injection and indexes them into an EnumMap<ConditionStrategy, TriggerEvaluator> at startup. Lookup is O(1). A NO_OP fallback evaluator safely handles unknown strategies without throwing exceptions.
Current strategies:
- PerRoomThresholdEvaluator: Triggers when
maxStockInPeriod >= threshold AND stock <= target(confirms the room was popular enough to warrant booking, and current stock is low enough to act) - PropertyTotalRemainOneEvaluator: Triggers when total property stock equals exactly 1 (last room standing — highest urgency)
Adding a new strategy requires implementing one interface and registering the class as a Spring component. Zero changes to existing code.
Saga Compensation for 4-Stage Booking
The booking flow is modeled as a saga with compensating actions. Each stage (APPLICATION, ITINERARY, SAVE, COMPLETE) is executed sequentially. If stage 2 (ITINERARY) or stage 3 (SAVE) fails, the system automatically executes a compensating cancellation against the supplier API to clean up the partial booking. The resultJson field stores the full booking snapshot at each successful stage, ensuring data consistency for downstream resale logic.
Redis Distributed Lock (Lua CAS Script)
Before initiating a booking, the system acquires a Redis distributed lock keyed on property + checkIn + checkOut + room. The lock uses a Lua compare-and-set script for atomicity — no race conditions between the check and the lock acquisition. This prevents duplicate bookings even when multiple monitoring cycles overlap.
Dual Booking Limits
Two independent limits prevent overcommitment: maxDailyBookings caps how many bookings the system makes per day (protects budget), and maxHoldingQuantity caps total active holdings (prevents inventory concentration risk). Both are configurable per reserve condition.
Auto-Cancellation with Safety Margin
The cancelBeforeDays parameter defines how many days before the free cancellation deadline the system should act. For example, if free cancellation expires on March 15 and cancelBeforeDays = 2, the system cancels on March 13. This buffer absorbs API latency, downtime, and timezone edge cases.
Results & Impact
Automation
- Fully automated stock monitoring, booking, and cancellation pipeline
- Zero manual intervention required for the standard lifecycle
- Configurable strategies per property — no code changes needed for new business rules
Reliability
- Saga compensation ensures no orphaned partial bookings
- Redis distributed locks eliminate duplicate booking risk
- Dual booking limits prevent budget overcommitment
Risk Management
- Zero financial risk through free cancellation guarantee
- Auto-cancellation safety margin protects against missed deadlines
- Full booking snapshot stored in resultJson for audit trail and resale consistency
Architecture Quality
- Domain layer has zero external dependencies — fully unit-testable
- New trigger strategies added without modifying existing code (Open/Closed Principle)
- Supplier API changes isolated to adapter layer — no domain impact
Learnings
Hexagonal Architecture Pays Off in Maintenance
The upfront investment in separating domain logic from infrastructure concerns seemed excessive for a solo project. But when the supplier changed their API response format, I only touched one adapter class. The domain layer, booking logic, and trigger strategies were completely unaffected. For systems with external API dependencies, this architecture is worth the initial overhead.
The NO_OP Fallback Prevents Silent Failures
Early versions threw exceptions for unknown strategy types, which crashed the entire monitoring cycle. The NO_OP evaluator (returns false, logs a warning) is a much better default — it safely skips unknown strategies while alerting the operator via logs. Defensive defaults beat fail-fast for background automation systems.
Saga Compensation is Non-Negotiable for Multi-Step External Calls
During development, I discovered the supplier API occasionally fails on stage 3 (SAVE) after successfully completing stages 1 and 2. Without compensating cancellation, these orphaned bookings accumulated silently. The saga pattern caught this on the first occurrence in production — the system cleaned up automatically and notified via Slack.
maxStockInPeriod as a Business Invariant
Storing the historical peak stock level (maintained automatically via @PrePersist/@PreUpdate) was a key design decision. It lets the system distinguish between "this room always had low stock" (not worth booking) and "this room had high stock that suddenly dropped" (strong demand signal, worth booking). This single field eliminated a significant class of false-positive triggers.
Deep Dive: Domain-Driven Design Decisions
Why Hexagonal Architecture for a Solo Project?
Hexagonal Architecture is often associated with large teams, but its real value is dependency isolation. This system depends on an external supplier API that can change without notice. By keeping the domain layer free of external dependencies, API changes require touching exactly one adapter class — not refactoring business logic. For any system with unstable external dependencies, the architecture pays for itself on the first breaking change.
Design Decisions at a Glance
- Domain layer: Pure Java POJOs — no framework annotations, no DB coupling
- Booking flow: 4-stage saga with compensating cancellation on partial failure
- Trigger strategies: 2 today (
PER_ROOM_THRESHOLD,PROPERTY_TOTAL_REMAIN_ONE), pluggable via Spring DI - Deduplication: Redis distributed lock keyed on property + date + room, atomic via Lua CAS
- Cancellation safety:
cancelBeforeDaysbuffer absorbs API latency / timezone / downtime - Financial risk: Zero — every booking is inside the free-cancellation window
- Infrastructure: Cloud SQL Micro tier · Redis · K8s CronJobs · ArgoCD · async Slack/LINE notifier
Strategy Pattern: TriggerEvaluatorFactory
The factory uses Spring's dependency injection to discover all TriggerEvaluator implementations at startup, then indexes them by their ConditionStrategy enum value into an EnumMap for O(1) lookup.
@Component public class TriggerEvaluatorFactory { private final Map<ConditionStrategy, TriggerEvaluator> cache; // Spring injects ALL TriggerEvaluator beans automatically public TriggerEvaluatorFactory(List<TriggerEvaluator> evaluators) { Map<ConditionStrategy, TriggerEvaluator> tmp = new EnumMap<>(ConditionStrategy.class); evaluators.forEach(ev -> { ConditionStrategy key = ev.supports(); if (tmp.putIfAbsent(key, ev) != null) { log.warn("Duplicate TriggerEvaluator for strategy={}", key); } }); this.cache = tmp; } // NO_OP fallback: unknown strategies return false safely private static final TriggerEvaluator NO_OP_EVALUATOR = new TriggerEvaluator() { @Override public boolean shouldTrigger(HotelStockEntity s, ReserveConditionEntity c) { return false; } @Override public ConditionStrategy supports() { return null; } }; public TriggerEvaluator get(ConditionStrategy strategy) { return cache.getOrDefault(strategy, NO_OP_EVALUATOR); } }
Why EnumMap Instead of HashMap?
EnumMap uses a flat array indexed by enum ordinal — no hashing, no collisions, no boxing. For a small, fixed set of strategy keys, it is faster and more memory-efficient than HashMap. It also provides compile-time type safety: you cannot accidentally put a non-ConditionStrategy key into the map.
PerRoomThresholdEvaluator Logic
This evaluator answers the question: "Was this room popular enough to warrant booking, and is current stock low enough to act?"
@Component public class PerRoomThresholdEvaluator implements TriggerEvaluator { @Override public boolean shouldTrigger(HotelStockEntity stock, ReserveConditionEntity cond) { return stock.getMaxStockInPeriod() != null && stock.getMaxStockInPeriod() >= cond.getStockThreshold() && stock.getStock() <= cond.getStockTarget(); } @Override public ConditionStrategy supports() { return ConditionStrategy.PER_ROOM_THRESHOLD; } }
The maxStockInPeriod Insight
Without maxStockInPeriod, the system would book rooms that always had low stock (unpopular properties). The field records the historical peak — if a room once had 10 available but now has 1, that signals genuine demand. If it never had more than 2, low stock is the normal state and booking carries higher resale risk. This single business invariant eliminated the majority of false-positive triggers.
Saga Compensation Flow
The 4-stage booking process with automatic compensation for partial failures:
public BookingResult executeBooking(BookingRequest request) { String applicationNo = null; try { // Stage 1: APPLICATION — create the booking application applicationNo = supplierApi.createApplication(request); // Stage 2: ITINERARY — set travel details supplierApi.setItinerary(applicationNo, request.getItinerary()); // Stage 3: SAVE — persist the booking supplierApi.saveBooking(applicationNo); // Stage 4: COMPLETE — finalize and confirm var result = supplierApi.completeBooking(applicationNo); // Store full snapshot for resale data consistency bookingRecord.setResultJson(result.toJson()); return BookingResult.success(result); } catch (Exception e) { // Compensating action: cancel the partial booking if (applicationNo != null) { compensate(applicationNo); } return BookingResult.failure(e); } } private void compensate(String applicationNo) { try { supplierApi.cancelBooking(applicationNo); slackNotifier.warn("Saga compensated: " + applicationNo); } catch (Exception e) { // Compensation failure is critical — requires manual intervention slackNotifier.alert("COMPENSATION FAILED: " + applicationNo); } }
Why Compensate Instead of Two-Phase Commit?
The supplier API does not support distributed transactions. Each API call is an independent HTTP request with its own commit semantics. Two-phase commit is impossible across service boundaries without protocol support. Saga compensation is the pragmatic alternative — it accepts that partial states will occur and defines cleanup actions for each one.
Redis Distributed Lock
// Lua CAS script: atomic check-and-set prevents race conditions // KEYS[1] = lock key, ARGV[1] = lock value, ARGV[2] = TTL private static final String LOCK_SCRIPT = """ if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then redis.call('pexpire', KEYS[1], ARGV[2]) return 1 end return 0 """; public boolean acquireLock(String property, LocalDate checkIn, String roomType) { String lockKey = String.format("stock-lock:%s:%s:%s", property, checkIn, roomType); Long result = redis.execute(LOCK_SCRIPT, List.of(lockKey), UUID.randomUUID().toString(), String.valueOf(LOCK_TTL_MS)); return result != null && result == 1; }
Auto-Cancellation Safety Margin
public void cancelBookings() { LocalDate today = LocalDate.now(); List<BookingRecordEntity> bookings = repository .findByStatusAndResaleApplicationNumberIsNull(BookingStatus.CONFIRMED); for (BookingRecordEntity booking : bookings) { // cancelBeforeDays provides a safety buffer // e.g., freeCancellation = March 15, cancelBeforeDays = 2 // threshold = March 13 — cancel on or after this date LocalDate threshold = booking.getFreeCancellationDate() .minusDays(booking.getCancelBeforeDays()); if (!today.isBefore(threshold)) { cancelBooking(booking); // Still within free cancellation window } } }
Zero-Risk Guarantee
The entire business model rests on one invariant: every booking must be either resold or cancelled before the free cancellation deadline. The cancelBeforeDays safety margin ensures the system always acts before the deadline, absorbing API latency, timezone differences, and unexpected downtime. In production, this invariant has never been violated.
Authentication & Security
The admin dashboard uses JWT with HttpOnly cookies and includes enterprise-grade security controls:
- Auth Method: JWT (HttpOnly Cookie)
- Lockout Policy: 3 fails (15-min lock)
- Password Expiry: Enforced (configurable)
- Notifications: Slack (@Async thread pool)
Why HttpOnly Cookies Over Bearer Tokens?
Storing JWTs in localStorage or sessionStorage exposes them to XSS attacks — any injected script can steal the token. HttpOnly cookies are inaccessible to JavaScript entirely. Combined with SameSite and Secure flags, this eliminates the most common JWT theft vectors with zero impact on the developer experience.
Live Demo
Below is a fully interactive mock of the inventory admin UI. All data shown is fictional for demonstration purposes.
What You Can Try
- Dashboard — overview of total / active / cancelled / sold bookings, plus monthly and yearly trend charts
- Inventory Conditions — the list of active watch rules; open any row to see the full strategy config (property IDs, date range, stock thresholds, booking limits, cancellation safety margin)
- Inventory List — every auto-booked record grouped by property, with its status (CONFIRMED / CANCELLED / SOLD) and the one-click "cancel" action