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, committed → held) raise IllegalAllocationTransition from the aggregate; the controller maps to MELMASTOON.INVENTORY.ILLEGAL_TRANSITION.
4. Invariants
| # | Invariant | Enforcement |
|---|---|---|
| I1 | Every aggregate has a tenantId; mutations refuse a different tenant context | aggregate constructors + TenantId brand |
| I2 | available() ≥ 0 for every RoomTypeInventory row | aggregate reserve + DB CHECK (held + committed + oos_blocked ≤ total + overbooking_cap) |
| I3 | A RoomAllocation for a specific roomId cannot overlap (in nights) another active RoomAllocation for the same roomId | aggregate factory + DB EXCLUDE USING gist on room_allocations (per tenant_id, room_id, status IN ('held','committed')) |
| I4 | A held allocation must have heldUntil > now() at insertion time | aggregate factory |
| I5 | A committed allocation must have committedAt set and heldUntil cleared | aggregate commit() |
| I6 | A released allocation has releasedAt and releaseReasonCode set; immutable thereafter | aggregate factory; row-level revoke of UPDATE in DB role |
| I7 | RoomTypeInventory.stayDate must be within the property's calendar horizon | use case guard + DB CHECK (stay_date < (now() + '540 days')::date) (soft; nightly job extends) |
| I8 | A group_member allocation must succeed only if every sibling member also succeeds in the same transaction | application layer atomic batch + per-night locks taken in canonical order |
| I9 | A bed allocation (bedId set) requires the room type to declare bedTotal > 0 | aggregate factory |
| I10 | stopSell=true rejects new holds (released/committed unaffected) | aggregate canSell() |
| I11 | An InventoryBlock for a roomId cancels any available count contribution from that room for the block window | recompute path |
| I12 | A new InventoryBlock overlapping committed allocations triggers reaccommodation_required.v1 and never silently releases the allocation | application layer CreateBlockUseCase |
| I13 | OverbookingPolicy.cap cannot be negative; enabled=true requires non-empty alertRoutes[] | aggregate factory |
| I14 | Advisory lock acquisition is per-night and per (tenant_id, property_id, room_type_id, stay_date); held only for the transaction lifetime | application layer AllocationLockManager; CI test asserts no lock outlives transaction |
| I15 | Releasing a non-existent or already-released allocation is idempotent (no-op) | aggregate release() returns same instance + 200 OK |
| I16 | AvailabilityCalendar.summary matches SUM(RoomTypeInventory) for that day | recompute on every commit; nightly reconciliation job alerts on drift |
| I17 | Calendar extension never deletes future rows; only inserts forward | extender job |
| I18 | Cross-tenant lock collisions are impossible: lock key is keyed on tenant_id | hashing 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 class | Code | HTTP | Notes |
|---|---|---|---|
InsufficientAvailability | MELMASTOON.INVENTORY.INSUFFICIENT_AVAILABILITY | 409 | Allocation rejected: counters cannot accommodate; carries requested, available, roomTypeId, stayDates[] |
IllegalAllocationTransition | MELMASTOON.INVENTORY.ILLEGAL_TRANSITION | 409 | E.g., committing a released allocation |
AllocationNotFound | MELMASTOON.INVENTORY.ALLOCATION_NOT_FOUND | 404 | |
AllocationStaleVersion | MELMASTOON.INVENTORY.STALE_VERSION | 409 | OCC mismatch |
LockAcquisitionTimeout | MELMASTOON.INVENTORY.LOCK_TIMEOUT | 503 | Advisory lock not granted within budget; client retries |
BlockOverlapsCommittedAllocation | MELMASTOON.INVENTORY.BLOCK_REQUIRES_REACCOMMODATION | 202 | Block is created and the response includes affected reservation ids |
OverbookingExceedsCap | MELMASTOON.INVENTORY.OVERBOOKING_CAP_EXCEEDED | 409 | Allocation refused even with policy on |
CalendarHorizonExhausted | MELMASTOON.INVENTORY.HORIZON_EXHAUSTED | 422 | Stay date is beyond extended horizon |
StopSellActive | MELMASTOON.INVENTORY.STOP_SELL_ACTIVE | 409 | Operator-set stop-sell |
RoomNotInType | MELMASTOON.INVENTORY.ROOM_NOT_IN_TYPE | 422 | Specific-room hold for a roomId that doesn't belong to the requested roomTypeId |
7. Domain services (pure TS, no I/O)
| Service | Responsibility |
|---|---|
RoomPicker | Given 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. |
OverbookingDecider | Given a policy and the candidate counters, decide allow / allow_with_alert / reject. |
ReaccommodationFinder | Given a new InventoryBlock, return the set of allocations whose stay overlaps the block window for the affected room. |
CalendarHorizonExtender | Compute the next set of RoomTypeInventory rows to insert for a property given current horizon and total per room type. |
GroupAtomicHoldPlanner | Given 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
- DDL and partitioning: DATA_MODEL
- Use cases that drive these aggregates: APPLICATION_LOGIC
- Events surface and registry: EVENT_SCHEMAS
- Reservation orchestration counterpart: reservation-service DOMAIN_MODEL
- Naming rules used throughout: standards/NAMING