Skip to main content

DOMAIN_MODEL — inventory-service

Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS

Strategic anchors: 02 §5 DDD layering · standards/NAMING

The domain layer is pure TypeScript. It depends only on @ghasi/domain-primitives (branded ids, Money, DateRange, TenantId, PropertyId, RoomTypeId, RoomId, ReservationId, ReservationItemId) and on the standard library. No NestJS, no Drizzle, no Date.now(), no I/O. The domain is what the database, the controllers, the event bus, and the desktop must all bend to — never the other way around.

This document defines value objects, aggregates, the allocation state machine, invariants I1..I18, and the domain events the application layer is allowed to publish through ports.


1. Branded value objects

// Identity
export type TenantId = Brand<string, 'TenantId'>; // 'tnt_…'
export type PropertyId = Brand<string, 'PropertyId'>; // 'ppt_…'
export type RoomTypeId = Brand<string, 'RoomTypeId'>; // 'rty_…'
export type RoomId = Brand<string, 'RoomId'>; // 'rom_…'
export type BedId = Brand<string, 'BedId'>; // 'bed_…' (dorm only)
export type ReservationId = Brand<string, 'ReservationId'>; // 'rsv_…'
export type ReservationItemId = Brand<string, 'ReservationItemId'>;
export type AllocationId = Brand<string, 'AllocationId'>; // 'inv_…'
export type BlockId = Brand<string, 'BlockId'>; // 'blk_…'
export type CalendarId = Brand<string, 'CalendarId'>; // 'cal_…'
export type RoomTypeInventoryId = Brand<string, 'RoomTypeInventoryId'>; // 'rti_…'
export type OverbookingPolicyId = Brand<string, 'OverbookingPolicyId'>; // 'obp_…'

// A property's local calendar date — never a JS Date in the domain
export interface StayDate {
readonly iso: string; // 'YYYY-MM-DD' in the property's timezone
readonly timezone: string; // IANA, e.g. 'Asia/Kabul'
}

// The half-open inclusive-start, exclusive-end stay window
export interface StayWindow {
readonly checkIn: StayDate;
readonly checkOut: StayDate; // > checkIn
readonly nights: ReadonlyArray<StayDate>; // every night to be allocated; length = checkOut - checkIn
}

// Occupancy ask
export interface OccupancyAsk {
readonly adults: number; // ≥ 1
readonly children: number; // ≥ 0
readonly infants: number; // ≥ 0 (ignored for sellability counts)
}

export type AllocationStatus =
| 'held' // tentative; TTL-bound; held by saga
| 'committed' // saga confirmed; survives until release
| 'released' // terminal; row retained for audit
| 'reassigned'; // terminal for THIS allocation; superseded by another

Construction of every branded id goes through domain-primitives factories that validate prefix and ULID body; the domain refuses raw strings.


2. Aggregates

2.1 RoomTypeInventory (the hot row)

export interface RoomTypeInventoryProps {
readonly id: RoomTypeInventoryId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly roomTypeId: RoomTypeId;
readonly stayDate: StayDate;
readonly total: number; // physical rooms of this type that day
readonly held: number; // tentative allocations
readonly committed: number; // confirmed allocations
readonly oosBlocked: number; // OOO/OOS that nullify physical capacity
readonly bedTotal?: number; // dorm only
readonly bedHeld?: number;
readonly bedCommitted?: number;
readonly stopSell: boolean; // operator override
readonly version: number;
}

export class RoomTypeInventory {
// factory
static rehydrate(p: RoomTypeInventoryProps): RoomTypeInventory;
// queries
available(): number; // = total - held - committed - oosBlocked, never negative
bedAvailable(): number; // dorm equivalent
canSell(qty: number): boolean; // !stopSell && available() >= qty
// mutations (return new aggregates; CQRS-friendly)
reserve(qty: number, mode: 'hold' | 'commit'): RoomTypeInventory;
release(qty: number, mode: 'hold' | 'commit'): RoomTypeInventory;
block(qty: number): RoomTypeInventory; // OOO add
unblock(qty: number): RoomTypeInventory; // OOO release
setStopSell(value: boolean): RoomTypeInventory;
}

This aggregate is the target of the advisory lock. Every counter mutation goes through it; the application layer never SETs counters with raw SQL.

2.2 RoomAllocation (the assignment ledger)

export interface RoomAllocationProps {
readonly id: AllocationId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly roomTypeId: RoomTypeId;
readonly roomId?: RoomId; // null for pure-type holds (dorm bed)
readonly bedId?: BedId; // dorm assignment
readonly reservationId: ReservationId;
readonly reservationItemId: ReservationItemId;
readonly stayWindow: StayWindow;
readonly status: AllocationStatus;
readonly mode: 'auto_pick' | 'specific_room' | 'group_member';
readonly heldUntil?: string; // RFC3339; only for status='held'
readonly assignmentSource: 'system' | 'staff' | 'guest_request';
readonly notes?: string; // operator note (not guest-facing)
readonly version: number;
readonly createdAt: string;
readonly committedAt?: string;
readonly releasedAt?: string;
readonly releaseReasonCode?: ReleaseReasonCode;
}

export type ReleaseReasonCode =
| 'reservation_cancelled'
| 'reservation_dates_changed'
| 'reservation_no_show'
| 'hold_expired'
| 'saga_compensation'
| 'block_cascade_reaccommodation'
| 'staff_manual_release';

State transitions are governed by §3.

2.3 InventoryBlock (OOO/OOS)

export type BlockReason = 'ooo' | 'oos' | 'maintenance' | 'event' | 'other';

export interface InventoryBlockProps {
readonly id: BlockId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly roomId?: RoomId; // either room-specific
readonly roomTypeId?: RoomTypeId; // or type-wide (rare)
readonly stayWindow: StayWindow;
readonly reason: BlockReason;
readonly reasonText?: string;
readonly source: { kind: 'staff' | 'system'; eventId?: string; actorId?: string };
readonly status: 'active' | 'released';
readonly version: number;
readonly createdAt: string;
readonly releasedAt?: string;
}

2.4 AvailabilityCalendar (day-bucket aggregate)

export interface AvailabilityCalendarProps {
readonly id: CalendarId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly stayDate: StayDate;
readonly version: number;
readonly summary: {
readonly totalRooms: number;
readonly availableRooms: number;
readonly heldRooms: number;
readonly committedRooms: number;
readonly blockedRooms: number;
};
readonly stopSell: boolean;
readonly lastModifiedAt: string;
}

This aggregate is mostly a read projection — it's recomputed from RoomTypeInventory + InventoryBlock rows on write. The grid view in backoffice reads it.

2.5 OverbookingPolicy

export interface OverbookingPolicyProps {
readonly id: OverbookingPolicyId;
readonly tenantId: TenantId;
readonly enabled: boolean;
readonly cap: number; // max additional held + committed beyond `total`
readonly roomTypeIds: ReadonlyArray<RoomTypeId>; // empty = all
readonly alertRoutes: ReadonlyArray<string>; // 'pagerduty:gm', 'email:owner', 'slack:#ops'
readonly effectiveFrom: string; // RFC3339
readonly effectiveUntil?: string;
readonly version: number;
}

Default policy is enabled=false. When enabled, every allocation that would exceed total increments committed past total (allowed up to cap) and the application layer publishes inventory.overbooking_alert.v1.


3. State machine — RoomAllocation

┌─────────┐
place hold │ held │ release(hold_expired | reservation_cancelled
────────────►│ │ | saga_compensation | dates_changed)
└────┬────┘ ─────────────────────────────────────────► released

reservation │ commit
.confirmed.v1 ▼
┌──────────┐ release(reservation_cancelled
│committed │ | reservation_no_show
│ │ | dates_changed | block_cascade)
└────┬─────┘ ────────────────────────────────────► released

reaccommodate │ reassign

┌──────────┐
│reassigned│ (terminal for THIS row;
└──────────┘ a successor RoomAllocation row exists)

Illegal transitions (e.g., released → anything, committedheld) raise IllegalAllocationTransition from the aggregate; the controller maps to MELMASTOON.INVENTORY.ILLEGAL_TRANSITION.


4. Invariants

#InvariantEnforcement
I1Every aggregate has a tenantId; mutations refuse a different tenant contextaggregate constructors + TenantId brand
I2available() ≥ 0 for every RoomTypeInventory rowaggregate reserve + DB CHECK (held + committed + oos_blocked ≤ total + overbooking_cap)
I3A RoomAllocation for a specific roomId cannot overlap (in nights) another active RoomAllocation for the same roomIdaggregate factory + DB EXCLUDE USING gist on room_allocations (per tenant_id, room_id, status IN ('held','committed'))
I4A held allocation must have heldUntil > now() at insertion timeaggregate factory
I5A committed allocation must have committedAt set and heldUntil clearedaggregate commit()
I6A released allocation has releasedAt and releaseReasonCode set; immutable thereafteraggregate factory; row-level revoke of UPDATE in DB role
I7RoomTypeInventory.stayDate must be within the property's calendar horizonuse case guard + DB CHECK (stay_date < (now() + '540 days')::date) (soft; nightly job extends)
I8A group_member allocation must succeed only if every sibling member also succeeds in the same transactionapplication layer atomic batch + per-night locks taken in canonical order
I9A bed allocation (bedId set) requires the room type to declare bedTotal > 0aggregate factory
I10stopSell=true rejects new holds (released/committed unaffected)aggregate canSell()
I11An InventoryBlock for a roomId cancels any available count contribution from that room for the block windowrecompute path
I12A new InventoryBlock overlapping committed allocations triggers reaccommodation_required.v1 and never silently releases the allocationapplication layer CreateBlockUseCase
I13OverbookingPolicy.cap cannot be negative; enabled=true requires non-empty alertRoutes[]aggregate factory
I14Advisory lock acquisition is per-night and per (tenant_id, property_id, room_type_id, stay_date); held only for the transaction lifetimeapplication layer AllocationLockManager; CI test asserts no lock outlives transaction
I15Releasing a non-existent or already-released allocation is idempotent (no-op)aggregate release() returns same instance + 200 OK
I16AvailabilityCalendar.summary matches SUM(RoomTypeInventory) for that dayrecompute on every commit; nightly reconciliation job alerts on drift
I17Calendar extension never deletes future rows; only inserts forwardextender job
I18Cross-tenant lock collisions are impossible: lock key is keyed on tenant_idhashing function in DATA_MODEL §6

5. Domain events

The domain layer raises typed events; application-layer adapters serialize to the canonical envelope and publish via the outbox. Subjects follow NAMING.

export type DomainEvent =
| AllocationRequested
| AllocationConfirmed
| AllocationFailed
| AllocationReleased
| RoomAssigned
| RoomReassigned
| InventoryBlockCreated
| InventoryBlockReleased
| OverbookingAlertRaised
| ReaccommodationRequired
| AvailabilityQueriedSample;

export interface AllocationConfirmed {
readonly kind: 'inventory.allocation.confirmed.v1';
readonly allocationId: AllocationId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly roomTypeId: RoomTypeId;
readonly roomId?: RoomId;
readonly bedId?: BedId;
readonly reservationId: ReservationId;
readonly reservationItemId: ReservationItemId;
readonly stayWindow: StayWindow;
readonly status: 'held' | 'committed';
readonly committedAt?: string;
readonly causationEventId?: string;
}

Full schemas (TS + JSON Schema) are in EVENT_SCHEMAS.


6. Domain errors

Error classCodeHTTPNotes
InsufficientAvailabilityMELMASTOON.INVENTORY.INSUFFICIENT_AVAILABILITY409Allocation rejected: counters cannot accommodate; carries requested, available, roomTypeId, stayDates[]
IllegalAllocationTransitionMELMASTOON.INVENTORY.ILLEGAL_TRANSITION409E.g., committing a released allocation
AllocationNotFoundMELMASTOON.INVENTORY.ALLOCATION_NOT_FOUND404
AllocationStaleVersionMELMASTOON.INVENTORY.STALE_VERSION409OCC mismatch
LockAcquisitionTimeoutMELMASTOON.INVENTORY.LOCK_TIMEOUT503Advisory lock not granted within budget; client retries
BlockOverlapsCommittedAllocationMELMASTOON.INVENTORY.BLOCK_REQUIRES_REACCOMMODATION202Block is created and the response includes affected reservation ids
OverbookingExceedsCapMELMASTOON.INVENTORY.OVERBOOKING_CAP_EXCEEDED409Allocation refused even with policy on
CalendarHorizonExhaustedMELMASTOON.INVENTORY.HORIZON_EXHAUSTED422Stay date is beyond extended horizon
StopSellActiveMELMASTOON.INVENTORY.STOP_SELL_ACTIVE409Operator-set stop-sell
RoomNotInTypeMELMASTOON.INVENTORY.ROOM_NOT_IN_TYPE422Specific-room hold for a roomId that doesn't belong to the requested roomTypeId

7. Domain services (pure TS, no I/O)

ServiceResponsibility
RoomPickerGiven a RoomTypeInventory row plus roomTypeId, choose a roomId for an auto-pick allocation. Inputs include housekeeping hints (passed as plain DTO), prior assignments, and a rotation strategy (sequential, least_used, housekeeping_ready_first) configured per tenant.
OverbookingDeciderGiven a policy and the candidate counters, decide allow / allow_with_alert / reject.
ReaccommodationFinderGiven a new InventoryBlock, return the set of allocations whose stay overlaps the block window for the affected room.
CalendarHorizonExtenderCompute the next set of RoomTypeInventory rows to insert for a property given current horizon and total per room type.
GroupAtomicHoldPlannerGiven N requested room-type+occupancy items and a candidate set of nights, produce the canonical advisory-lock acquisition order to avoid deadlock.

These services are framework-free and unit-tested with property-based tests where shape allows (e.g., RoomPicker against a generator of room states).


8. Cross-references