property-service — Domain Model
Companion: SERVICE_OVERVIEW · DATA_MODEL · EVENT_SCHEMAS · API_CONTRACTS · 06 Data Models · Naming
The domain layer is pure TypeScript — no I/O, no framework imports. All side effects flow through ports defined in application/ports/*.
1. Ubiquitous Language
| Term | Definition | Sample sentence |
|---|---|---|
| Property | Physical site sellable to guests within a tenant. | "Tenant tnt_… published 3 properties in Kabul." |
| RoomType | Catalog category with bed config and max occupancy. | "RoomType rmt_KING_DLX has bed config king and max occupancy 2." |
| Room | Physical room with a unique number per property. | "Room rmu_… (number 301) is OOO until maintenance closes ticket MNT-…." |
| Amenity | Canonical tag selectable per property and per room type. | "Property ppt_… advertises halal_kitchen and prayer_room." |
| Photo | Ordered media reference uploaded to file-storage-service. | "Photo pht_… is position 0 (cover) for property ppt_…." |
| Policy override | Property-level overrides of tenant defaults. | "Policy override sets check_in_time = 14:00 for ppt_…." |
| Room group | Logical grouping (floor, wing, building) used for routing. | "Floor 3 rgp_… contains rooms 301–320." |
| Publish | Make property visible to booking and search surfaces. | "Cannot publish: no rooms." |
| OOO / RTS | Out-of-Order then Returned-to-Service — short-term decommission. | "Room 412 RTS after WO MNT-… closed." |
2. Branded IDs
import type { Brand } from '@ghasi/contracts-melmastoon';
export type TenantId = Brand<string, 'TenantId'>; // 'tnt_<ULID>'
export type PropertyId = Brand<string, 'PropertyId'>; // 'ppt_<ULID>'
export type RoomTypeId = Brand<string, 'RoomTypeId'>; // 'rmt_<ULID>'
export type RoomId = Brand<string, 'RoomId'>; // 'rmu_<ULID>'
export type AmenityId = Brand<string, 'AmenityId'>; // 'amn_<ULID>'
export type PhotoId = Brand<string, 'PhotoId'>; // 'pht_<ULID>'
export type PolicyId = Brand<string, 'PolicyId'>; // 'pol_<ULID>'
export type RoomGroupId = Brand<string, 'RoomGroupId'>; // 'rgp_<ULID>'
export const PropertyId = {
parse(raw: string): PropertyId {
if (!/^ppt_[0-7][0-9A-HJKMNP-TV-Z]{25}$/i.test(raw)) {
throw new InvalidIdError('PropertyId', raw);
}
return raw as PropertyId;
},
generate(clock: Clock): PropertyId {
return `ppt_${ulid(clock.now())}` as PropertyId;
},
};
Constructors for RoomTypeId, RoomId, AmenityId, PhotoId, PolicyId, RoomGroupId follow the same pattern.
3. Value Objects
export type Locale = 'en' | 'ps' | 'fa' | 'tg' | 'ar' | 'ur' | 'ru';
export type ISODate = Brand<string, 'ISODate'>;
export type ISO4217 = Brand<string, 'ISO4217'>;
export interface I18nString {
// BCP-47 → text. At least one entry required.
values: Partial<Record<Locale, string>>;
default: Locale;
}
export interface AddressLine {
line1: string;
line2?: string;
nativeScriptLine1?: string; // Pashto/Dari/Persian/Tajik script
nativeScriptLine2?: string;
city: string;
district?: string;
region?: string;
countryIso2: string; // ISO 3166-1 alpha-2
postalCode?: string;
}
export interface GeoPoint {
lat: number; // -90..90
lng: number; // -180..180
accuracyMeters?: number;
source: 'manual' | 'geocoded' | 'gps';
}
export type BedKind =
| 'king' | 'queen' | 'double' | 'single' | 'twin' | 'bunk'
| 'sofa_bed' | 'futon' | 'crib' | 'floor_mattress';
export interface BedConfig {
primary: BedKind;
extras?: BedKind[];
description?: I18nString;
}
export interface MediaRef {
storageKey: string; // bucket-relative
contentType: string;
bytes: number;
width?: number;
height?: number;
}
4. Aggregate: Property
export type PropertyStatus = 'draft' | 'published' | 'unpublished' | 'archived';
export interface PropertyProps {
id: PropertyId;
tenantId: TenantId;
slug: string; // unique within tenant; `[a-z0-9-]{3,80}`
name: I18nString;
description: I18nString;
address: AddressLine;
geo?: GeoPoint; // required at publish
timezone: string; // IANA, e.g. 'Asia/Kabul'
starRating?: 1 | 2 | 3 | 4 | 5;
status: PropertyStatus;
enabledLocales: Locale[]; // intersect with tenant's allowed locales
defaultLocale: Locale;
acceptedTermsAt?: ISODate;
publishedAt?: ISODate;
unpublishedAt?: ISODate;
archivedAt?: ISODate;
createdAt: ISODate;
updatedAt: ISODate;
version: number; // optimistic concurrency
}
export class Property {
// domain methods only — repositories call .toSnapshot()
publish(input: { clock: Clock; rooms: ReadonlyArray<Room>; readyPhotos: ReadonlyArray<Photo>; }): DomainEvent[];
unpublish(reason: UnpublishReason, clock: Clock): DomainEvent[];
rename(name: I18nString, clock: Clock): DomainEvent[];
setGeo(geo: GeoPoint, clock: Clock): DomainEvent[];
setAddress(address: AddressLine, clock: Clock): DomainEvent[];
archive(clock: Clock): DomainEvent[];
}
4.1 Invariants
| ID | Statement | Enforced where |
|---|---|---|
| INV-PRP-001 | slug is unique per tenant and matches ^[a-z0-9-]{3,80}$. | Property.create, DB UNIQUE (tenant_id, slug). |
| INV-PRP-002 | name includes a value for defaultLocale. | Property.create, rename. |
| INV-PRP-003 | A published property has ≥ 1 active Room. | Property.publish (uses rooms arg). |
| INV-PRP-004 | A published property has ≥ 1 ready Photo. | Property.publish (uses readyPhotos). |
| INV-PRP-005 | A published property has geo set. | Property.publish. |
| INV-PRP-006 | A published property has description.values[defaultLocale]. | Property.publish. |
| INV-PRP-007 | enabledLocales ⊆ tenant allowedLocales. | Property.create, update. |
| INV-PRP-008 | timezone is a valid IANA zone. | constructor (Intl.DateTimeFormat check). |
| INV-PRP-009 | geo.lat ∈ [-90,90], geo.lng ∈ [-180,180]. | setGeo. |
| INV-PRP-010 | tenantId is immutable after creation. | constructor + repository. |
5. Aggregate: RoomType
export interface RoomTypeProps {
id: RoomTypeId;
tenantId: TenantId;
propertyId: PropertyId;
code: string; // 'KING','TWIN','SUITE','FAMILY','DORM_M','DORM_F','APT'
name: I18nString;
description: I18nString;
bedConfig: BedConfig;
maxOccupancy: number; // ≥ 1
amenities: AmenityCode[]; // unique set
photos: PhotoId[]; // ordered
basePriceHintMicro?: bigint; // hint only; pricing-service is authoritative
baseCurrency?: ISO4217;
archivedAt?: ISODate;
createdAt: ISODate;
updatedAt: ISODate;
version: number;
}
Invariants
| ID | Statement |
|---|---|
| INV-RMT-001 | code unique per propertyId, normalized uppercase, ^[A-Z][A-Z0-9_]{1,15}$. |
| INV-RMT-002 | maxOccupancy ≥ 1. |
| INV-RMT-003 | amenities codes ∈ canonical registry; deduplicated. |
| INV-RMT-004 | name and description carry the property's defaultLocale. |
| INV-RMT-005 | archive() blocked when ≥ 1 active Room references this type. |
6. Aggregate: Room
export type RoomStatus = 'active' | 'out_of_order' | 'out_of_service' | 'archived';
export interface AccessibilityFlags {
wheelchair: boolean;
rollInShower: boolean;
hearingAids: boolean;
visualAids: boolean;
}
export interface RoomProps {
id: RoomId;
tenantId: TenantId;
propertyId: PropertyId;
roomTypeId: RoomTypeId;
number: string; // human-friendly, unique per property
floor?: number;
roomGroupId?: RoomGroupId;
status: RoomStatus;
features: string[]; // free-form ('balcony','sea_view','kitchenette')
accessibility: AccessibilityFlags;
lockDeviceId?: string; // bound by lock-integration-service event
oooReason?: 'housekeeping' | 'maintenance' | 'manual' | 'incident';
oooUntil?: ISODate;
archivedAt?: ISODate;
createdAt: ISODate;
updatedAt: ISODate;
version: number;
}
export class Room {
takeOutOfOrder(input: { reason: Room['oooReason']; until?: ISODate; activeReservations: number; clock: Clock; }): DomainEvent[];
returnToService(clock: Clock): DomainEvent[];
rename(number: string, clock: Clock): DomainEvent[];
reassignType(roomTypeId: RoomTypeId, clock: Clock, activeReservations: number): DomainEvent[];
archive(clock: Clock, activeReservations: number): DomainEvent[];
}
Invariants
| ID | Statement |
|---|---|
| INV-RMU-001 | number unique per propertyId. Normalized whitespace, max 16 chars. |
| INV-RMU-002 | OOO with activeReservations > 0 raises RoomOccupiedError (MELMASTOON.PROPERTY.ROOM_OCCUPIED). |
| INV-RMU-003 | archive() blocked when activeReservations > 0 (MELMASTOON.PROPERTY.ROOM_HAS_ACTIVE_RESERVATIONS). |
| INV-RMU-004 | Status transitions: active ↔ out_of_order, active → out_of_service, * → archived. No other transitions. |
| INV-RMU-005 | roomTypeId references a non-archived RoomType in the same property. |
| INV-RMU-006 | lockDeviceId is set only via the lock_integration.device.paired.v1 consumer; not via REST. |
7. Aggregate: Photo (per property and per room type)
export interface PhotoProps {
id: PhotoId;
tenantId: TenantId;
scope: { kind: 'property'; propertyId: PropertyId } | { kind: 'room_type'; roomTypeId: RoomTypeId };
media: MediaRef;
status: 'uploaded' | 'ready' | 'archived';
order: number; // 0-based, contiguous within scope
altText: I18nString;
tags: string[]; // subset of canonical AmenityCode + free
aiProvenance?: AIProvenance; // when tags suggested by AI
createdAt: ISODate;
updatedAt: ISODate;
version: number;
}
Invariants
| ID | Statement |
|---|---|
| INV-PHT-001 | order contiguous within scope (no gaps after reorder). |
| INV-PHT-002 | status='ready' only after file_storage.media.scanned.v1 arrives. |
| INV-PHT-003 | tags deduplicated; AI-tagged photos require aiProvenance. |
| INV-PHT-004 | A published property requires ≥ 1 ready photo at scope property. |
8. Aggregate: PropertyPolicies
export type PolicyKind =
| 'check_in_time' | 'check_out_time' | 'cancellation' | 'child' | 'smoking'
| 'pets' | 'deposit' | 'id_required' | 'quiet_hours' | 'breakfast_included'
| 'prayer_room_access' | 'women_only_floor';
export interface PolicyOverride {
kind: PolicyKind;
value: unknown; // shape per kind, validated by zod schema in domain
effectiveFrom?: ISODate;
effectiveTo?: ISODate;
}
export interface PropertyPoliciesProps {
id: PolicyId;
tenantId: TenantId;
propertyId: PropertyId;
overrides: PolicyOverride[]; // unique by kind+effective range
updatedAt: ISODate;
version: number;
}
Invariants
| ID | Statement |
|---|---|
| INV-POL-001 | At most one active override per kind at a given instant. |
| INV-POL-002 | value validates against the per-kind schema. |
| INV-POL-003 | effectiveTo > effectiveFrom. |
9. Aggregate: RoomGroup
export interface RoomGroupProps {
id: RoomGroupId;
tenantId: TenantId;
propertyId: PropertyId;
kind: 'floor' | 'wing' | 'building';
label: I18nString;
ordinal?: number; // floor number / wing index
archivedAt?: ISODate;
createdAt: ISODate;
updatedAt: ISODate;
version: number;
}
Invariants
| ID | Statement |
|---|---|
| INV-RGP-001 | (propertyId, kind, ordinal) unique when ordinal set. |
| INV-RGP-002 | Archive blocked while rooms reference this group. |
10. Reference: AmenityCode registry (excerpt)
export const AMENITY_CODES = [
// global
'wifi', 'parking', 'pool', 'breakfast', 'restaurant', 'spa', 'gym',
'air_conditioning', 'heating', 'elevator', 'reception_24h', 'safe',
'laundry', 'family_room', 'non_smoking',
// regionally meaningful
'halal_kitchen', 'prayer_room', 'women_only_floor', 'generator_backup',
'hot_water_24h', 'hot_water_scheduled', 'male_dorm', 'female_dorm',
'bus_pickup', 'borderless_payment', 'mahram_only', 'tea_service',
] as const;
export type AmenityCode = (typeof AMENITY_CODES)[number];
Codes outside the registry are rejected with MELMASTOON.PROPERTY.AMENITY_UNKNOWN. The registry is part of the published language and changes only by ADR.
11. Domain Events
All events follow Naming: melmastoon.<service>.<aggregate>.<verb_past>.v<n>. Envelope adds eventId, occurredAt, tenantId, aggregateId, version, traceId, causationId, correlationId. See EVENT_SCHEMAS for full payloads.
| Event | Aggregate | Payload (top-level) |
|---|---|---|
melmastoon.property.created.v1 | Property | { id, tenantId, slug, name, defaultLocale, status:'draft', address, timezone } |
melmastoon.property.updated.v1 | Property | { id, changedFields, current } |
melmastoon.property.published.v1 | Property | { id, publishedAt, geo, defaultLocale } |
melmastoon.property.unpublished.v1 | Property | { id, unpublishedAt, reason } |
melmastoon.property.deleted.v1 | Property | { id, archivedAt, cascade:{rooms:n, roomTypes:n, photos:n} } |
melmastoon.property.room_type.created.v1 | RoomType | { id, propertyId, code, bedConfig, maxOccupancy } |
melmastoon.property.room_type.updated.v1 | RoomType | { id, propertyId, changedFields } |
melmastoon.property.room_type.archived.v1 | RoomType | { id, propertyId, archivedAt } |
melmastoon.property.room.created.v1 | Room | { id, propertyId, roomTypeId, number, floor } |
melmastoon.property.room.updated.v1 | Room | { id, propertyId, changedFields } |
melmastoon.property.room.taken_out_of_order.v1 | Room | { id, propertyId, reason, until } |
melmastoon.property.room.returned_to_service.v1 | Room | { id, propertyId, at } |
melmastoon.property.room.archived.v1 | Room | { id, propertyId, archivedAt } |
melmastoon.property.amenity_set.updated.v1 | Property | { propertyId, added, removed, current } |
melmastoon.property.policy.updated.v1 | Policy | { propertyId, changedKinds, current } |
melmastoon.property.photo.added.v1 | Photo | { photoId, scope, mediaKey, order } |
melmastoon.property.photo.removed.v1 | Photo | { photoId, scope } |
melmastoon.property.photo.order_changed.v1 | Photo | { scope, order:[ {photoId, position} ] } |
melmastoon.property.room_group.changed.v1 | RoomGroup | { id, propertyId, kind, ordinal, archived } |
12. Domain Errors
| Error | Code | HTTP | Notes |
|---|---|---|---|
PropertyNotFoundError | MELMASTOON.PROPERTY.NOT_FOUND | 404 | also returned for cross-tenant access |
PropertyInactiveError | MELMASTOON.PROPERTY.INACTIVE | 409 | property unpublished/archived |
NoRoomsForPublishError | MELMASTOON.PROPERTY.NO_ROOMS_FOR_PUBLISH | 409 | INV-PRP-003 |
NoReadyPhotoForPublishError | MELMASTOON.PROPERTY.NO_PHOTO_FOR_PUBLISH | 409 | INV-PRP-004 |
MissingGeoForPublishError | MELMASTOON.PROPERTY.GEO_REQUIRED_FOR_PUBLISH | 409 | INV-PRP-005 |
RoomNumberDuplicateError | MELMASTOON.PROPERTY.ROOM_NUMBER_DUPLICATE | 409 | INV-RMU-001 |
RoomOccupiedError | MELMASTOON.PROPERTY.ROOM_OCCUPIED | 409 | INV-RMU-002 |
RoomHasActiveReservationsError | MELMASTOON.PROPERTY.ROOM_HAS_ACTIVE_RESERVATIONS | 409 | INV-RMU-003 |
InvalidStatusTransitionError | MELMASTOON.PROPERTY.INVALID_STATE_TRANSITION | 409 | INV-RMU-004 |
AmenityUnknownError | MELMASTOON.PROPERTY.AMENITY_UNKNOWN | 422 | unknown code |
GeoInvalidError | MELMASTOON.PROPERTY.GEO_INVALID | 422 | INV-PRP-009 |
LocaleNotEnabledError | MELMASTOON.PROPERTY.LOCALE_NOT_ENABLED | 422 | INV-PRP-007 |
CrossTenantReferenceError | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE | 422 | aggregate construction guard |
PreconditionFailedError | MELMASTOON.GENERAL.PRECONDITION_FAILED | 412 | optimistic-concurrency mismatch |
13. Aggregate Diagram (ASCII)
Property (root)
├── PropertyPolicies (1:1)
├── Photo[] (scope='property')
├── RoomGroup[] (floors / wings)
└── RoomType[]
├── BedConfig (VO)
├── Photo[] (scope='room_type')
└── Room[]
├── AccessibilityFlags (VO)
└── (lockDeviceId set by event)
Each aggregate root is loaded and persisted in its own transaction; cross-aggregate references use IDs only. Multi-aggregate writes (e.g., publish, which inspects rooms + photos) read repositories then perform a single write on Property and emit events through the outbox.