housekeeping-service — DOMAIN_MODEL
Pure domain layer. No I/O, no framework imports, no ORM types. Everything below lives in
src/domain/**of the service repo. All TypeScript here is illustrative of the canonical types — exact import paths follow the monorepo's@melmastoon/housekeeping-domainpackage.
This file declares the value objects, aggregates, domain events, invariants, and domain errors that make housekeeping-service a Core bounded context. Application orchestration is in APPLICATION_LOGIC.md; persistence in DATA_MODEL.md.
1. Value objects
1.1 Identifiers
type Brand<T, B> = T & { readonly __brand: B };
export type TenantId = Brand<string, "TenantId">; // tnt_<ulid>
export type PropertyId = Brand<string, "PropertyId">; // prp_<ulid>
export type RoomId = Brand<string, "RoomId">; // rom_<ulid>
export type ReservationId = Brand<string, "ReservationId">; // rsv_<ulid>
export type StaffId = Brand<string, "StaffId">; // stf_<ulid>
export type HousekeepingTaskId = Brand<string, "HousekeepingTaskId">; // hkt_<ulid>
export type ChecklistId = Brand<string, "ChecklistId">; // chl_<ulid>
export type InspectionId = Brand<string, "InspectionId">; // ins_<ulid>
export type LinenInventoryId = Brand<string, "LinenInventoryId">; // lin_<ulid>
export type LostAndFoundId = Brand<string, "LostAndFoundId">; // laf_<ulid>
export type RoomBlockId = Brand<string, "RoomBlockId">; // blk_<ulid>
export type ShiftAssignmentId = Brand<string, "ShiftAssignmentId">; // sft_<ulid>
All IDs are ULIDs with a 4-letter prefix (NAMING §6). Constructors validate format and reject mismatched prefixes (assertId(value, "hkt_")).
1.2 Enumerations
export enum TaskKind {
Turnover = "turnover", // post-checkout cleaning
MidStayClean = "mid_stay_clean", // daily / on-request during stay
DeepClean = "deep_clean", // periodic / between long stays
PostMaintenance = "post_maintenance", // after a maintenance work order
PostRenovation = "post_renovation", // after capex work
Inspection = "inspection", // standalone inspection round
}
export enum TaskStatus {
Pending = "pending",
Assigned = "assigned",
InProgress = "in_progress",
Paused = "paused",
Completed = "completed",
Failed = "failed",
Cancelled = "cancelled",
RequiresMaintenance = "requires_maintenance", // terminal: handed off to maintenance-service
}
export enum TaskPriority { Low = "low", Normal = "normal", High = "high", Urgent = "urgent" }
export enum RoomStatusValue {
Clean = "clean",
Dirty = "dirty",
Cleaning = "cleaning",
Cleaned = "cleaned", // post-clean, awaiting inspection (if required)
Inspected = "inspected", // inspection passed
Ready = "ready", // available for next guest
OutOfOrder = "out_of_order", // transient (maintenance in progress)
OutOfService = "out_of_service", // longer-term (echoed from property-service)
}
export enum PauseReason {
Break = "break",
AwaitingLinen = "awaiting_linen",
AwaitingMaintenance = "awaiting_maintenance",
StaffSwap = "staff_swap",
GuestPresent = "guest_present",
Other = "other",
}
export enum FailureReason {
RoomNotVacated = "room_not_vacated",
AccessDenied = "access_denied",
Sickness = "sickness",
EquipmentMissing = "equipment_missing",
Other = "other",
}
export enum LostItemStatus { Recorded = "recorded", Matched = "matched", Disposed = "disposed", Returned = "returned" }
1.3 Small VOs
export class Locale {
private constructor(public readonly value: "ps-AF" | "fa-AF" | "fa-IR" | "tg-TJ" | "en") {}
static of(v: string): Locale { /* validate or throw DomainError(MELMASTOON.HOUSEKEEPING.INVALID_LOCALE) */ }
}
export class TimeWindow {
constructor(public readonly fromUtc: Date, public readonly toUtc: Date) {
if (toUtc <= fromUtc) throw DomainError.of("MELMASTOON.HOUSEKEEPING.INVALID_TIME_WINDOW");
}
durationMinutes(): number { return Math.round((+this.toUtc - +this.fromUtc) / 60000); }
}
export class LinenCount {
constructor(public readonly issued: number, public readonly returned: number) {
if (issued < 0 || returned < 0) throw DomainError.of("MELMASTOON.HOUSEKEEPING.INVALID_LINEN_COUNT");
}
delta(): number { return this.issued - this.returned; }
}
export class ChecklistItemResult {
constructor(
public readonly itemKey: string,
public readonly checked: boolean,
public readonly note?: string,
public readonly photoMediaId?: string,
) {}
}
2. Aggregate: HousekeepingTask
The root aggregate. Every state transition produces a domain event recorded on the aggregate's pending-events buffer; the application layer flushes them through the outbox.
2.1 State machine
┌─────────┐ assign ┌──────────┐ start ┌──────────────┐
create ────▶ │ Pending │ ──────────▶ │ Assigned │ ─────────▶ │ InProgress │
└─────────┘ └──────────┘ └──────┬───────┘
│ cancel │ reassign │ pause / resume
▼ ▼ ▼
┌─────────────┐ ┌──────────┐ ┌──────────┐
│ Cancelled │ │ Assigned │ ◀───────── │ Paused │
└─────────────┘ └──────────┘ └──────────┘
│ complete / fail / requires_maint
▼
┌─────────┐ ┌────────┐ ┌──────────────────────┐
│Completed│ │ Failed │ │ RequiresMaintenance │
└─────────┘ └────────┘ └──────────────────────┘
2.2 Type
export class HousekeepingTask {
readonly id: HousekeepingTaskId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly roomId: RoomId;
readonly kind: TaskKind;
status: TaskStatus;
priority: TaskPriority;
assigneeStaffId?: StaffId;
checklistId?: ChecklistId;
checklistVersion?: number;
reservationId?: ReservationId; // turnover and mid-stay tasks reference the originating reservation
scheduledFor?: Date;
startedAt?: Date;
pausedAt?: Date;
pauseReason?: PauseReason;
completedAt?: Date;
failureReason?: FailureReason;
failureNote?: string;
outcomeChecklistResults?: ChecklistItemResult[];
linen?: LinenCount;
durationMinutes?: number;
localeHint: Locale;
pendingEvents: DomainEvent[] = [];
// …timestamps, version (for optimistic concurrency)…
}
2.3 Behaviours (selected)
class HousekeepingTask {
static create(input: CreateTaskInput): HousekeepingTask { /* emits HousekeepingTaskCreatedV1 */ }
assign(staffId: StaffId, by: ActorRef): void {
this.guard(this.status === TaskStatus.Pending || this.status === TaskStatus.Assigned,
"MELMASTOON.HOUSEKEEPING.INVALID_TRANSITION");
const previous = this.assigneeStaffId;
this.assigneeStaffId = staffId;
this.status = TaskStatus.Assigned;
this.pendingEvents.push(previous
? new TaskReassignedV1(this.id, previous, staffId, by)
: new TaskAssignedV1(this.id, staffId, by));
}
start(by: ActorRef, at: Date): void { /* InProgress; emits TaskStartedV1 */ }
pause(reason: PauseReason, by: ActorRef, at: Date): void { /* Paused; emits TaskPausedV1 */ }
resume(by: ActorRef, at: Date): void { /* InProgress; emits TaskResumedV1 */ }
complete(input: CompleteTaskInput, by: ActorRef): void { /* Completed; emits TaskCompletedV1 + RoomStatusChangedV1 (cleaned) */ }
fail(reason: FailureReason, note: string | undefined, by: ActorRef): void { /* Failed; emits TaskFailedV1 */ }
cancel(reason: string, by: ActorRef): void { /* Cancelled; emits TaskCancelledV1 */ }
escalate(toStaffOrRole: ActorRef, by: ActorRef): void { /* emits TaskEscalatedV1; priority bump */ }
requireMaintenance(issue: MaintenanceIssue, by: ActorRef): void {
/* If light → pause(awaiting_maintenance); if blocking → terminal RequiresMaintenance.
Always emits RoomMaintenanceRequiredV1. */
}
bumpPriority(to: TaskPriority, by: ActorRef): void { /* emits TaskPriorityBumpedV1 */ }
}
2.4 Invariants
statustransitions follow the diagram above. Any other transition throwsMELMASTOON.HOUSEKEEPING.INVALID_TRANSITION.assigneeStaffIdmust reference a staff member with an active housekeeping shift covering "now" (verified by the application layer viaStaffShiftPort).checklistIdandchecklistVersionare bound at assign time and become immutable for the rest of the task's life. Updating the checklist template never mutates an in-flight task.RequiresMaintenanceis terminal; the room flips toout_of_orderand ownership of "what to do next" hands off tomaintenance-service. After maintenance completes, a new task is created (we do not resurrect the old one).completerequires every checklist item withmandatory=trueto be present inoutcomeChecklistResultswithchecked=true, otherwiseMELMASTOON.HOUSEKEEPING.CHECKLIST_INCOMPLETE.linen.returnedmay be <linen.issued(loss); we record but do not block.bumpPrioritytoUrgentfor a non-InProgress, non-Pausedtask auto-firesescalateif it stays unworked for 5 minutes (enforced by a domain timer in the application layer; seeAPPLICATION_LOGIC.md§6).
3. Aggregate: RoomStatus
A singleton per (tenant, property, room). State is one of RoomStatusValue. Holds lastReason, lastTaskId, lastFlippedAt, and lastFlippedBy.
3.1 Allowed transitions
| From → To | Trigger |
|---|---|
dirty → cleaning | task start |
cleaning → cleaned | task complete (no inspection required) |
cleaning → cleaned | task complete (inspection required → inspected after pass) |
cleaned → inspected | inspection pass |
cleaned → dirty | inspection fail (re-clean) |
inspected → ready | publish to front desk (automatic) |
* → out_of_order | requireMaintenance or RoomBlock(maintenance) |
out_of_order → dirty | maintenance.work_order.completed.v1 |
* → out_of_service | echo from property-service (manual long-term block) |
ready → dirty | reservation checkout (or manual flip) |
* → clean (manual) | supervisor override (audit-flagged, requires reason) |
Invariants:
- The transition table is exhaustive. Any other transition throws
MELMASTOON.HOUSEKEEPING.ROOM_STATE_CONFLICT. - A room cannot enter
cleaningwithout anassignedtask pointing at it. - A room cannot enter
readywhile aRoomBlockexists (cleaning, maintenance, oos). - Manual flips require
actor.role ∈ { housekeeping_supervisor, property_manager, owner }and includereason(free text, persisted onroom_status_audit).
4. Aggregate: CleaningChecklist
Versioned, append-only.
export class CleaningChecklist {
readonly id: ChecklistId;
readonly tenantId: TenantId;
readonly kind: TaskKind; // template binds to a task kind
readonly version: number; // monotonically increasing
readonly items: ChecklistItem[];
readonly publishedAt: Date;
readonly publishedBy: ActorRef;
}
export interface ChecklistItem {
key: string; // stable identifier across versions
labelI18n: Record<string, string>;
mandatory: boolean;
requiresPhoto: boolean;
}
Invariants: once publishedAt is set, the aggregate is immutable. New versions are new aggregate instances. Item keys are stable across versions; new items get fresh keys. Removed items keep their key reserved (to keep historical results readable).
5. Aggregate: Inspection
export class Inspection {
readonly id: InspectionId;
readonly tenantId: TenantId;
readonly taskId: HousekeepingTaskId;
readonly inspectorStaffId: StaffId;
readonly checklistId: ChecklistId;
readonly checklistVersion: number;
readonly results: ChecklistItemResult[];
readonly outcome: "pass" | "fail";
readonly note?: string;
readonly performedAt: Date;
}
Invariant: outcome=pass requires all mandatory items checked=true. Otherwise MELMASTOON.HOUSEKEEPING.INSPECTION_INCOMPLETE.
6. Aggregate: LinenInventory
export class LinenInventory {
readonly id: LinenInventoryId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly line: string; // e.g., "towel-large", "sheet-double"
onHand: number;
lowWatermark: number;
// …issued/returned roll-ups updated transactionally with task linen ops…
}
Invariant: onHand >= 0. Crossing below lowWatermark emits LinenLowStockAlertV1 exactly once per crossing (debounce at application layer).
7. Aggregate: LostAndFound
Lifecycle: recorded → matched → returned or recorded → disposed. Retention defaults to 30 days; configurable per tenant.
8. Aggregate: StaffShiftAssignment
A read-side projection updated by staff.shift.started.v1 / .ended.v1. Carries capacityMinutes, activeTaskCount, and languages. Used by the router and the staffing-gap detector.
9. Aggregate: RoomBlock
Composite ownership with maintenance-service. We own rows where reason ∈ { cleaning, inspection, oos_echo }; maintenance owns reason = maintenance. Both project into a single read view under room_blocks_view.
10. Domain events (canonical names → TS classes)
| Event | TS class |
|---|---|
melmastoon.housekeeping.task.created.v1 | HousekeepingTaskCreatedV1 |
.assigned.v1 | TaskAssignedV1 |
.reassigned.v1 | TaskReassignedV1 |
.started.v1 | TaskStartedV1 |
.paused.v1 | TaskPausedV1 |
.resumed.v1 | TaskResumedV1 |
.completed.v1 | TaskCompletedV1 |
.failed.v1 | TaskFailedV1 |
.cancelled.v1 | TaskCancelledV1 |
.escalated.v1 | TaskEscalatedV1 |
melmastoon.housekeeping.room.status_changed.v1 | RoomStatusChangedV1 |
melmastoon.housekeeping.room.maintenance_required.v1 | RoomMaintenanceRequiredV1 |
melmastoon.housekeeping.inspection.passed.v1 | InspectionPassedV1 |
melmastoon.housekeeping.inspection.failed.v1 | InspectionFailedV1 |
melmastoon.housekeeping.checklist.template_updated.v1 | ChecklistTemplateUpdatedV1 |
melmastoon.housekeeping.lost_item.recorded.v1 | LostItemRecordedV1 |
melmastoon.housekeeping.lost_item.matched.v1 | LostItemMatchedV1 |
melmastoon.housekeeping.lost_item.disposed.v1 | LostItemDisposedV1 |
melmastoon.housekeeping.linen.low_stock_alert.v1 | LinenLowStockAlertV1 |
melmastoon.housekeeping.shift.staffing_gap_detected.v1 | ShiftStaffingGapDetectedV1 |
JSON wire schemas in EVENT_SCHEMAS.md.
11. Domain errors
All domain errors are subclasses of DomainError and carry an error code. The presentation layer (shared/http/error-mapper.ts) maps them to RFC 7807. Catalogue lives in docs/standards/ERROR_CODES.md under the HOUSEKEEPING domain.
| Code | Meaning |
|---|---|
MELMASTOON.HOUSEKEEPING.TASK_NOT_FOUND | Unknown task ID |
MELMASTOON.HOUSEKEEPING.ROOM_STATE_CONFLICT | Illegal room-status transition |
MELMASTOON.HOUSEKEEPING.STAFF_UNAVAILABLE | Assignee has no active shift |
MELMASTOON.HOUSEKEEPING.TASK_ALREADY_COMPLETED | Re-completion attempt |
MELMASTOON.HOUSEKEEPING.SCHEDULE_OVERFLOW | Too many tasks for shift capacity |
MELMASTOON.HOUSEKEEPING.INVALID_TRANSITION | Task lifecycle illegal transition |
MELMASTOON.HOUSEKEEPING.CHECKLIST_INCOMPLETE | Missing mandatory checklist items at completion |
MELMASTOON.HOUSEKEEPING.INSPECTION_INCOMPLETE | Inspection passed without all mandatory items checked |
MELMASTOON.HOUSEKEEPING.LINEN_OUT_OF_STOCK | Issuing more linen than onHand |
MELMASTOON.HOUSEKEEPING.ROOM_BLOCKED | Action blocked by an active RoomBlock |
MELMASTOON.HOUSEKEEPING.INVALID_LOCALE | Locale.of rejected the value |
MELMASTOON.HOUSEKEEPING.INVALID_TIME_WINDOW | TimeWindow constructor rejected the input |
MELMASTOON.HOUSEKEEPING.INVALID_LINEN_COUNT | Negative linen count |
MELMASTOON.HOUSEKEEPING.CHECKLIST_FROZEN | Attempt to mutate a published checklist version |
MELMASTOON.HOUSEKEEPING.LOST_ITEM_NOT_FOUND | Unknown lost-item ID |
MELMASTOON.HOUSEKEEPING.LOST_ITEM_ALREADY_DISPOSED | Action on already-disposed item |
12. Concurrency
- Every aggregate row has a
versioncolumn; repositories perform optimistic-concurrencyUPDATE … WHERE id = $1 AND version = $2. - On version mismatch the application layer throws
ConcurrencyConflictError, which the presentation layer maps to HTTP409 ConflictwithMELMASTOON.SHARED.CONCURRENCY_CONFLICT. RoomStatusflips serialise per(tenant, property, room)via row-levelSELECT … FOR UPDATEto keep multi-actor flips race-free.
13. Folder layout (domain only)
src/domain/
housekeeping-task/
housekeeping-task.aggregate.ts
housekeeping-task.events.ts
housekeeping-task.errors.ts
room-status/
room-status.aggregate.ts
room-status.events.ts
cleaning-checklist/
inspection/
linen-inventory/
lost-and-found/
staff-shift-assignment/ (read-side projection)
room-block/
shared/
ids.ts (branded ID factories)
enums.ts
value-objects.ts
domain-error.ts
actor.ts