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).
| Step | Action |
|---|---|
| 1 | Validate query: at most 50 properties, ≤ 90 nights span, occupancy ≥ 1 adult |
| 2 | Try Memorystore cache (key avail:<tenant>:<propIds-hash>:<dates-hash>); if hit, return |
| 3 | Else RoomTypeInventoryRepo.loadRange (partition-pruned) |
| 4 | For each row, compute available() and canSell(qty=1) |
| 5 | Cache result for 30 s with cache-stampede guard |
| 6 | Sample emit (1%) availability.queried.v1 to outbox |
2.2 PlaceHoldAllocationUseCase — saga step on reservation.held.v1
| Step | Action |
|---|---|
| 1 | Inbox dedupe by event_id; idempotent if seen |
| 2 | Begin DB transaction |
| 3 | Acquire advisory locks for every night × room-type in the request, in canonical order (sorted by (propertyId, roomTypeId, stayDate)) — total budget 750 ms |
| 4 | RoomTypeInventoryRepo.loadForUpdate for those rows |
| 5 | For each requested item: OverbookingDecider.decide against OverbookingPolicy; if reject, accumulate InsufficientAvailability |
| 6 | If any rejection, abort transaction, emit inventory.allocation.failed.v1 with per-item reasons, return |
| 7 | Else roomTypeInventory.reserve(qty, 'hold') for each row, persist via save |
| 8 | If mode='auto_pick', run RoomPicker with housekeepingClient.readyHint to choose a roomId; if mode='specific_room', validate propertyClient.isRoomInType |
| 9 | Insert RoomAllocation rows (status='held', heldUntil = now() + ttl) |
| 10 | Outbox: allocation.confirmed.v1 (status=held), room.assigned.v1 (if room picked) |
| 11 | Commit transaction → release advisory locks via the callback in the defer block |
2.3 CommitAllocationUseCase — saga step on reservation.confirmed.v1
| Step | Action |
|---|---|
| 1 | Inbox dedupe |
| 2 | Load all RoomAllocation rows for the reservation; idempotent if all already committed |
| 3 | For each held row: allocation.commit(now) (status → committed, committedAt set, heldUntil cleared) |
| 4 | RoomTypeInventoryRepo.loadForUpdate for the relevant rows; flip held → committed counters |
| 5 | Outbox: 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).
| Step | Action |
|---|---|
| 1 | Inbox dedupe / idempotent |
| 2 | Load allocation; if already released, no-op (return 200 with same state) |
| 3 | Compute counter delta — held vs committed |
| 4 | Acquire advisory locks for the affected nights |
| 5 | RoomTypeInventoryRepo.loadForUpdate and release(qty, mode) per row |
| 6 | allocation.release(reasonCode) → terminal released |
| 7 | Outbox: allocation.released.v1 |
| 8 | Cache invalidation event to search-aggregation-service (via the same outbox event) |
2.5 CreateInventoryBlockUseCase — operator-driven or property.room.taken_out_of_order.v1
| Step | Action |
|---|---|
| 1 | Validate window (≤ 365 days), reason, source actor |
| 2 | ReaccommodationFinder.find against existing committed allocations |
| 3 | Insert InventoryBlock row; recompute RoomTypeInventory.oosBlocked for affected days |
| 4 | If affectedAllocations.length > 0: emit inventory.reaccommodation_required.v1 with the list; do not auto-release the allocations (reservation-service runs the sub-saga) |
| 5 | Emit inventory.block.created.v1 |
| 6 | Response (sync): 202 Accepted with affected reservation ids |
2.6 ReleaseInventoryBlockUseCase
Reverse of 2.5; recompute oos_blocked; emit inventory.block.released.v1.
2.7 GroupAtomicHoldUseCase — POST /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 UpdateOverbookingPolicyUseCase — PUT /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 consumed | Allocation effect | Compensation if subsequent step fails |
|---|---|---|
reservation.held.v1 | Place tentative allocation | allocation.released.v1 on hold_expired/cancelled/payment_failed (handled via 2.4) |
reservation.confirmed.v1 | Commit allocation | allocation.released.v1 on cancelled (post-confirm) |
reservation.cancelled.v1 | Release | none — terminal |
reservation.dates_changed.v1 | Release 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.v1 | Release per tenant.no_show.policy (default release after grace) | none |
reservation.hold_expired.v1 | Release tentative allocation | none |
property.room.taken_out_of_order.v1 | Create block + emit reaccommodation_required | none |
property.room.returned_to_service.v1 | Release block | none |
property.room.created.v1 | Extend calendar lane | none |
tenant.settings.changed.v1 | Refresh policies, TTL defaults | none |
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
RoomAllocationandOverbookingPolicyviaversion.RoomTypeInventorywrites 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 acceptsIdempotency-Key; replay returns the recorded response.
5. Background jobs
| Job | Schedule | Purpose | Idempotency |
|---|---|---|---|
expire-holds | every 30 s (Cloud Scheduler) | release expired tentative allocations | sweeper writes are no-ops on already-released rows |
extend-calendar-horizon | nightly 02:00 UTC (per region) | maintain 540-day horizon | UNIQUE constraint on (tenant, prop, room_type, date) |
reconcile-calendar-summary | nightly 03:00 UTC | recompute AvailabilityCalendar.summary | full recompute; idempotent by definition |
cache-warmer | 06:00 local property tz | preheat Memorystore for the next 7 days for top-traffic properties | TTL-bounded |
6. Authorization (RBAC + ABAC)
| Action | Roles allowed | Attribute checks |
|---|---|---|
| Search availability | guest, anonymous_booking, staff_*, gm, owner, system | tenantId 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/:id | gm, owner only | written justification mandatory; audit |
| Create block | staff_front_desk, gm, owner | property access |
| Release block | staff_front_desk, gm, owner | property access |
| Update overbooking policy | gm, owner only | tenant match |
| Walk-in allocate | staff_front_desk, gm, owner | property 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 error | HTTP | Code |
|---|---|---|
InsufficientAvailability | 409 | MELMASTOON.INVENTORY.INSUFFICIENT_AVAILABILITY |
IllegalAllocationTransition | 409 | MELMASTOON.INVENTORY.ILLEGAL_TRANSITION |
AllocationNotFound | 404 | MELMASTOON.INVENTORY.ALLOCATION_NOT_FOUND |
AllocationStaleVersion | 409 | MELMASTOON.INVENTORY.STALE_VERSION |
LockAcquisitionTimeout | 503 | MELMASTOON.INVENTORY.LOCK_TIMEOUT (Retry-After: 1) |
BlockOverlapsCommittedAllocation | 202 | MELMASTOON.INVENTORY.BLOCK_REQUIRES_REACCOMMODATION |
OverbookingExceedsCap | 409 | MELMASTOON.INVENTORY.OVERBOOKING_CAP_EXCEEDED |
CalendarHorizonExhausted | 422 | MELMASTOON.INVENTORY.HORIZON_EXHAUSTED |
StopSellActive | 409 | MELMASTOON.INVENTORY.STOP_SELL_ACTIVE |
RoomNotInType | 422 | MELMASTOON.INVENTORY.ROOM_NOT_IN_TYPE |
9. Cross-references
- Domain types and invariants: DOMAIN_MODEL
- DDL + advisory-lock function: DATA_MODEL
- REST surface: API_CONTRACTS
- Event surface: EVENT_SCHEMAS
- Saga overview: 04 §7
- Reservation orchestration: reservation-service APPLICATION_LOGIC