Skip to main content

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

TermDefinitionSample sentence
PropertyPhysical site sellable to guests within a tenant."Tenant tnt_… published 3 properties in Kabul."
RoomTypeCatalog category with bed config and max occupancy."RoomType rmt_KING_DLX has bed config king and max occupancy 2."
RoomPhysical room with a unique number per property."Room rmu_… (number 301) is OOO until maintenance closes ticket MNT-…."
AmenityCanonical tag selectable per property and per room type."Property ppt_… advertises halal_kitchen and prayer_room."
PhotoOrdered media reference uploaded to file-storage-service."Photo pht_… is position 0 (cover) for property ppt_…."
Policy overrideProperty-level overrides of tenant defaults."Policy override sets check_in_time = 14:00 for ppt_…."
Room groupLogical grouping (floor, wing, building) used for routing."Floor 3 rgp_… contains rooms 301–320."
PublishMake property visible to booking and search surfaces."Cannot publish: no rooms."
OOO / RTSOut-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

IDStatementEnforced where
INV-PRP-001slug is unique per tenant and matches ^[a-z0-9-]{3,80}$.Property.create, DB UNIQUE (tenant_id, slug).
INV-PRP-002name includes a value for defaultLocale.Property.create, rename.
INV-PRP-003A published property has ≥ 1 active Room.Property.publish (uses rooms arg).
INV-PRP-004A published property has ≥ 1 ready Photo.Property.publish (uses readyPhotos).
INV-PRP-005A published property has geo set.Property.publish.
INV-PRP-006A published property has description.values[defaultLocale].Property.publish.
INV-PRP-007enabledLocales ⊆ tenant allowedLocales.Property.create, update.
INV-PRP-008timezone is a valid IANA zone.constructor (Intl.DateTimeFormat check).
INV-PRP-009geo.lat ∈ [-90,90], geo.lng ∈ [-180,180].setGeo.
INV-PRP-010tenantId 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

IDStatement
INV-RMT-001code unique per propertyId, normalized uppercase, ^[A-Z][A-Z0-9_]{1,15}$.
INV-RMT-002maxOccupancy ≥ 1.
INV-RMT-003amenities codes ∈ canonical registry; deduplicated.
INV-RMT-004name and description carry the property's defaultLocale.
INV-RMT-005archive() 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

IDStatement
INV-RMU-001number unique per propertyId. Normalized whitespace, max 16 chars.
INV-RMU-002OOO with activeReservations > 0 raises RoomOccupiedError (MELMASTOON.PROPERTY.ROOM_OCCUPIED).
INV-RMU-003archive() blocked when activeReservations > 0 (MELMASTOON.PROPERTY.ROOM_HAS_ACTIVE_RESERVATIONS).
INV-RMU-004Status transitions: active ↔ out_of_order, active → out_of_service, * → archived. No other transitions.
INV-RMU-005roomTypeId references a non-archived RoomType in the same property.
INV-RMU-006lockDeviceId 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

IDStatement
INV-PHT-001order contiguous within scope (no gaps after reorder).
INV-PHT-002status='ready' only after file_storage.media.scanned.v1 arrives.
INV-PHT-003tags deduplicated; AI-tagged photos require aiProvenance.
INV-PHT-004A 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

IDStatement
INV-POL-001At most one active override per kind at a given instant.
INV-POL-002value validates against the per-kind schema.
INV-POL-003effectiveTo > 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

IDStatement
INV-RGP-001(propertyId, kind, ordinal) unique when ordinal set.
INV-RGP-002Archive 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.

EventAggregatePayload (top-level)
melmastoon.property.created.v1Property{ id, tenantId, slug, name, defaultLocale, status:'draft', address, timezone }
melmastoon.property.updated.v1Property{ id, changedFields, current }
melmastoon.property.published.v1Property{ id, publishedAt, geo, defaultLocale }
melmastoon.property.unpublished.v1Property{ id, unpublishedAt, reason }
melmastoon.property.deleted.v1Property{ id, archivedAt, cascade:{rooms:n, roomTypes:n, photos:n} }
melmastoon.property.room_type.created.v1RoomType{ id, propertyId, code, bedConfig, maxOccupancy }
melmastoon.property.room_type.updated.v1RoomType{ id, propertyId, changedFields }
melmastoon.property.room_type.archived.v1RoomType{ id, propertyId, archivedAt }
melmastoon.property.room.created.v1Room{ id, propertyId, roomTypeId, number, floor }
melmastoon.property.room.updated.v1Room{ id, propertyId, changedFields }
melmastoon.property.room.taken_out_of_order.v1Room{ id, propertyId, reason, until }
melmastoon.property.room.returned_to_service.v1Room{ id, propertyId, at }
melmastoon.property.room.archived.v1Room{ id, propertyId, archivedAt }
melmastoon.property.amenity_set.updated.v1Property{ propertyId, added, removed, current }
melmastoon.property.policy.updated.v1Policy{ propertyId, changedKinds, current }
melmastoon.property.photo.added.v1Photo{ photoId, scope, mediaKey, order }
melmastoon.property.photo.removed.v1Photo{ photoId, scope }
melmastoon.property.photo.order_changed.v1Photo{ scope, order:[ {photoId, position} ] }
melmastoon.property.room_group.changed.v1RoomGroup{ id, propertyId, kind, ordinal, archived }

12. Domain Errors

ErrorCodeHTTPNotes
PropertyNotFoundErrorMELMASTOON.PROPERTY.NOT_FOUND404also returned for cross-tenant access
PropertyInactiveErrorMELMASTOON.PROPERTY.INACTIVE409property unpublished/archived
NoRoomsForPublishErrorMELMASTOON.PROPERTY.NO_ROOMS_FOR_PUBLISH409INV-PRP-003
NoReadyPhotoForPublishErrorMELMASTOON.PROPERTY.NO_PHOTO_FOR_PUBLISH409INV-PRP-004
MissingGeoForPublishErrorMELMASTOON.PROPERTY.GEO_REQUIRED_FOR_PUBLISH409INV-PRP-005
RoomNumberDuplicateErrorMELMASTOON.PROPERTY.ROOM_NUMBER_DUPLICATE409INV-RMU-001
RoomOccupiedErrorMELMASTOON.PROPERTY.ROOM_OCCUPIED409INV-RMU-002
RoomHasActiveReservationsErrorMELMASTOON.PROPERTY.ROOM_HAS_ACTIVE_RESERVATIONS409INV-RMU-003
InvalidStatusTransitionErrorMELMASTOON.PROPERTY.INVALID_STATE_TRANSITION409INV-RMU-004
AmenityUnknownErrorMELMASTOON.PROPERTY.AMENITY_UNKNOWN422unknown code
GeoInvalidErrorMELMASTOON.PROPERTY.GEO_INVALID422INV-PRP-009
LocaleNotEnabledErrorMELMASTOON.PROPERTY.LOCALE_NOT_ENABLED422INV-PRP-007
CrossTenantReferenceErrorMELMASTOON.GENERAL.CROSS_TENANT_REFERENCE422aggregate construction guard
PreconditionFailedErrorMELMASTOON.GENERAL.PRECONDITION_FAILED412optimistic-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.