Back to Projects

Hotel Inventory Arbitrage Platform

An automated hotel inventory arbitrage system that monitors supplier stock levels, auto-books rooms at optimal thresholds, and manages the full lifecycle from acquisition through resale or cancellation — achieving zero-risk arbitrage.

Role: Solo Developer (Full Ownership)
Period: 2024 - 2025

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

React 18TypeScriptViteTailwind CSSRecharts

Backend

Java 21Spring Boot 3.4Spring Cloud (OpenFeign)Spring Data JPAFlywayJWT + HttpOnly Cookie auth

Infrastructure

DockerKubernetes / GKEArgoCDAsync Slack / LINE notifier

Database / Messaging

MySQL 8 (Cloud SQL Micro)Redis (distributed lock)

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.

System architecture diagram

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: cancelBeforeDays buffer 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.

TriggerEvaluatorFactory (Actual Production Code)
@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?"

Trigger Evaluation (Actual Production Code)
@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:

Saga Compensation Pattern
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

Preventing Duplicate Bookings
// 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

Cancellation Threshold Calculation
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.

Hotel Inventory AdminMock Data
Open in new tab
Dashboard, Inventory Conditions, and Inventory List — all CRUD and chart interactions run against an in-memory mock, no backend required.

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