Skip to main content

APPLICATION_LOGIC — inventory-service

Sibling: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL

Strategic anchors: 02 §5 DDD layering · 04 §7 Booking saga

The application layer is the only layer allowed to coordinate ports (DB, Pub/Sub, lock manager, time clock). It enumerates use cases, declares ports, owns saga participation, hosts the OCC + advisory-lock dance, and runs the hold-expiry sweeper and the calendar-extender background jobs.


1. Ports (interfaces; implementations live in src/infrastructure/)

export interface RoomTypeInventoryRepo {
loadForUpdate(p: { tenantId: TenantId; propertyId: PropertyId; roomTypeId: RoomTypeId; nights: ReadonlyArray<StayDate> }): Promise<ReadonlyArray<RoomTypeInventory>>;
loadRange(q: AvailabilityQuery): Promise<ReadonlyArray<RoomTypeInventory>>;
save(rows: ReadonlyArray<RoomTypeInventory>): Promise<void>;
}

export interface RoomAllocationRepo {
byId(id: AllocationId): Promise<RoomAllocation | null>;
byReservationItem(id: ReservationItemId): Promise<RoomAllocation | null>;
insert(a: RoomAllocation): Promise<void>;
update(a: RoomAllocation): Promise<void>; // OCC via version
}

export interface InventoryBlockRepo {
byId(id: BlockId): Promise<InventoryBlock | null>;
insert(b: InventoryBlock): Promise<void>;
update(b: InventoryBlock): Promise<void>;
list(q: { tenantId: TenantId; propertyId: PropertyId; range: StayWindow }): Promise<ReadonlyArray<InventoryBlock>>;
}

export interface OverbookingPolicyRepo {
byTenant(t: TenantId): Promise<OverbookingPolicy | null>;
upsert(p: OverbookingPolicy): Promise<void>;
}

export interface AdvisoryLockManager {
// Acquires per-night locks in canonical order; returns release callback;
// throws LockAcquisitionTimeout if not granted within budget (default 750 ms).
acquireForAllocation(p: { tenantId: TenantId; propertyId: PropertyId; roomTypeId: RoomTypeId; nights: ReadonlyArray<StayDate>; budgetMs?: number }): Promise<() => Promise<void>>;
}

export interface OutboxPublisher {
publish(event: DomainEvent, ctx: EventEnvelopeContext): Promise<void>;
}

export interface PropertyClient {
// pulled from property-service; cached in Memorystore with TTL 60 s
roomsByType(p: PropertyId, t: RoomTypeId): Promise<ReadonlyArray<RoomId>>;
isRoomInType(r: RoomId, t: RoomTypeId): Promise<boolean>;
}

export interface HousekeepingClient {
// hint only; never blocks allocation
readyHint(p: PropertyId, rooms: ReadonlyArray<RoomId>, date: StayDate): Promise<ReadonlySet<RoomId>>;
}

export interface Clock { now(): string; }

The DB is accessed only via RoomTypeInventoryRepo, RoomAllocationRepo, InventoryBlockRepo, OverbookingPolicyRepo. The advisory lock is a separate port to keep its lifecycle explicit and testable.


2. Use cases

2.1 SearchAvailabilityUseCase

Read-only. Loads RoomTypeInventory rows for the requested (propertyIds × roomTypeIds × stayDates), filters by occupancy, returns sellable counts. Idempotent. Sampled telemetry → inventory.availability.queried.v1 at 1% (configurable per tenant).

StepAction
1Validate query: at most 50 properties, ≤ 90 nights span, occupancy ≥ 1 adult
2Try Memorystore cache (key avail:<tenant>:<propIds-hash>:<dates-hash>); if hit, return
3Else RoomTypeInventoryRepo.loadRange (partition-pruned)
4For each row, compute available() and canSell(qty=1)
5Cache result for 30 s with cache-stampede guard
6Sample emit (1%) availability.queried.v1 to outbox

2.2 PlaceHoldAllocationUseCase — saga step on reservation.held.v1

StepAction
1Inbox dedupe by event_id; idempotent if seen
2Begin DB transaction
3Acquire advisory locks for every night × room-type in the request, in canonical order (sorted by (propertyId, roomTypeId, stayDate)) — total budget 750 ms
4RoomTypeInventoryRepo.loadForUpdate for those rows
5For each requested item: OverbookingDecider.decide against OverbookingPolicy; if reject, accumulate InsufficientAvailability
6If any rejection, abort transaction, emit inventory.allocation.failed.v1 with per-item reasons, return
7Else roomTypeInventory.reserve(qty, 'hold') for each row, persist via save
8If mode='auto_pick', run RoomPicker with housekeepingClient.readyHint to choose a roomId; if mode='specific_room', validate propertyClient.isRoomInType
9Insert RoomAllocation rows (status='held', heldUntil = now() + ttl)
10Outbox: allocation.confirmed.v1 (status=held), room.assigned.v1 (if room picked)
11Commit transaction → release advisory locks via the callback in the defer block

2.3 CommitAllocationUseCase — saga step on reservation.confirmed.v1

StepAction
1Inbox dedupe
2Load all RoomAllocation rows for the reservation; idempotent if all already committed
3For each held row: allocation.commit(now) (status → committed, committedAt set, heldUntil cleared)
4RoomTypeInventoryRepo.loadForUpdate for the relevant rows; flip held → committed counters
5Outbox: allocation.confirmed.v1 (status=committed) per allocation

2.4 ReleaseAllocationUseCase — multi-trigger

Triggered by reservation.cancelled.v1, reservation.dates_changed.v1 (release-then-reallocate), reservation.no_show.v1 (per tenant.no_show.policy), reservation.hold_expired.v1, sweeper, and DELETE /allocations/:id (operator manual release).

StepAction
1Inbox dedupe / idempotent
2Load allocation; if already released, no-op (return 200 with same state)
3Compute counter delta — held vs committed
4Acquire advisory locks for the affected nights
5RoomTypeInventoryRepo.loadForUpdate and release(qty, mode) per row
6allocation.release(reasonCode) → terminal released
7Outbox: allocation.released.v1
8Cache invalidation event to search-aggregation-service (via the same outbox event)

2.5 CreateInventoryBlockUseCase — operator-driven or property.room.taken_out_of_order.v1

StepAction
1Validate window (≤ 365 days), reason, source actor
2ReaccommodationFinder.find against existing committed allocations
3Insert InventoryBlock row; recompute RoomTypeInventory.oosBlocked for affected days
4If affectedAllocations.length > 0: emit inventory.reaccommodation_required.v1 with the list; do not auto-release the allocations (reservation-service runs the sub-saga)
5Emit inventory.block.created.v1
6Response (sync): 202 Accepted with affected reservation ids

2.6 ReleaseInventoryBlockUseCase

Reverse of 2.5; recompute oos_blocked; emit inventory.block.released.v1.

2.7 GroupAtomicHoldUseCasePOST /groups/holds

Allocates N rooms in a single transaction. Uses GroupAtomicHoldPlanner to compute the canonical lock acquisition order across all nights × room types, acquires every lock, validates every counter, and either inserts all RoomAllocation rows or rolls back. Partial-fill is impossible.

2.8 WalkInAllocateUseCase — sync REST POST /allocations for walk-in

Synchronous fast path. Same internal mechanics as 2.2 but driven by the REST request from bff-backoffice-service (operator). Emits allocation.confirmed.v1 (status=committed) directly without a hold step (walk-in implies on-prem, payment captured or cash-pending).

2.9 UpdateOverbookingPolicyUseCasePUT /properties/:propertyId/policies/overbooking

Restricted to gm and owner roles. Persists the policy; emits tenant.policy.changed.v1 (cross-service convention) so other services can audit.

2.10 ExtendCalendarHorizonUseCase — daily Cloud Run job

For every active property, ensure RoomTypeInventory rows exist for the next 540 days. Inserts missing rows from the property's room-type catalog. Idempotent (UNIQUE on (tenant_id, property_id, room_type_id, stay_date)).

2.11 ExtendNewRoomCalendarUseCase — on property.room.created.v1

When a new physical room is added, increment RoomTypeInventory.total for every future date in the horizon for that room's type. No effect on past dates.

2.12 ExpireHoldsUseCase — sweeper job (every 30 s)

Scans room_allocations WHERE status='held' AND held_until < now() FOR UPDATE SKIP LOCKED LIMIT 200. For each, runs ReleaseAllocationUseCase with reasonCode='hold_expired'. Idempotent if a concurrent cancelled event already released the row.

2.13 ReconcileCalendarSummaryUseCase — nightly recompute

Recompute AvailabilityCalendar.summary from RoomTypeInventory + InventoryBlock for the next 30 days; alert on drift > 0 (Invariant I16).


3. Saga participation matrix

Reservation event consumedAllocation effectCompensation if subsequent step fails
reservation.held.v1Place tentative allocationallocation.released.v1 on hold_expired/cancelled/payment_failed (handled via 2.4)
reservation.confirmed.v1Commit allocationallocation.released.v1 on cancelled (post-confirm)
reservation.cancelled.v1Releasenone — terminal
reservation.dates_changed.v1Release old + place new (atomic two-phase: hold new under lock, then release old; if new fails, old retained)none — atomic by construction
reservation.no_show.v1Release per tenant.no_show.policy (default release after grace)none
reservation.hold_expired.v1Release tentative allocationnone
property.room.taken_out_of_order.v1Create block + emit reaccommodation_requirednone
property.room.returned_to_service.v1Release blocknone
property.room.created.v1Extend calendar lanenone
tenant.settings.changed.v1Refresh policies, TTL defaultsnone

4. Concurrency model

  • Advisory locks keyed per (tenant_id, property_id, room_type_id, stay_date). Acquired in canonical order to prevent deadlocks. Released by transaction commit/rollback (pg_advisory_xact_lock).
  • Optimistic concurrency on RoomAllocation and OverbookingPolicy via version. RoomTypeInventory writes happen under the advisory lock and re-read fresh; OCC is the safety net.
  • Idempotency: every inbox handler dedupes by event_id (04 §6). Every REST mutation accepts Idempotency-Key; replay returns the recorded response.

5. Background jobs

JobSchedulePurposeIdempotency
expire-holdsevery 30 s (Cloud Scheduler)release expired tentative allocationssweeper writes are no-ops on already-released rows
extend-calendar-horizonnightly 02:00 UTC (per region)maintain 540-day horizonUNIQUE constraint on (tenant, prop, room_type, date)
reconcile-calendar-summarynightly 03:00 UTCrecompute AvailabilityCalendar.summaryfull recompute; idempotent by definition
cache-warmer06:00 local property tzpreheat Memorystore for the next 7 days for top-traffic propertiesTTL-bounded

6. Authorization (RBAC + ABAC)

ActionRoles allowedAttribute checks
Search availabilityguest, anonymous_booking, staff_*, gm, owner, systemtenantId match (or consumer for meta)
Place hold (event-driven)system (Pub/Sub principal must be reservation-service)tenant on envelope = aggregate tenant
Commit allocation (event-driven)system (reservation-service)same
Release allocation (event-driven)system (reservation-service or scheduler)same
Manual DELETE /allocations/:idgm, owner onlywritten justification mandatory; audit
Create blockstaff_front_desk, gm, ownerproperty access
Release blockstaff_front_desk, gm, ownerproperty access
Update overbooking policygm, owner onlytenant match
Walk-in allocatestaff_front_desk, gm, ownerproperty access

7. Outbound interactions

  • property-service (REST, cached): rooms-by-type, room-in-type validation. 60 s Memorystore TTL.
  • housekeeping-service (REST, hint): ready-room hint for auto-pick. Soft-fail (treat as no hint).
  • No synchronous calls to pricing-service, payment-gateway-service, lock-integration-service. Coupling to those services is purely event-driven.

8. Error mapping

Domain errorHTTPCode
InsufficientAvailability409MELMASTOON.INVENTORY.INSUFFICIENT_AVAILABILITY
IllegalAllocationTransition409MELMASTOON.INVENTORY.ILLEGAL_TRANSITION
AllocationNotFound404MELMASTOON.INVENTORY.ALLOCATION_NOT_FOUND
AllocationStaleVersion409MELMASTOON.INVENTORY.STALE_VERSION
LockAcquisitionTimeout503MELMASTOON.INVENTORY.LOCK_TIMEOUT (Retry-After: 1)
BlockOverlapsCommittedAllocation202MELMASTOON.INVENTORY.BLOCK_REQUIRES_REACCOMMODATION
OverbookingExceedsCap409MELMASTOON.INVENTORY.OVERBOOKING_CAP_EXCEEDED
CalendarHorizonExhausted422MELMASTOON.INVENTORY.HORIZON_EXHAUSTED
StopSellActive409MELMASTOON.INVENTORY.STOP_SELL_ACTIVE
RoomNotInType422MELMASTOON.INVENTORY.ROOM_NOT_IN_TYPE

9. Cross-references