Skip to main content

property-service — Application Logic

Companion: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · SECURITY_MODEL

The application layer hosts use cases (commands), query handlers, event consumers, and policies. It coordinates aggregates and ports; no domain rules live here.

1. Module map

src/application/
├── ports/ # interfaces only (DI tokens)
├── commands/ # write use cases
├── queries/ # read models / handlers
├── consumers/ # inbound event handlers
└── policies/ # cross-aggregate policies (publish, OOO eligibility)

Every command and query has a single handler, a single zod-validated input DTO, and a single output DTO. Errors bubble as typed domain errors and map to HTTP / event responses in presentation.

2. Ports (consumed)

PortAdaptersUsed by
PropertyRepositoryPropertyRepositoryPgall property commands & queries
RoomTypeRepositoryRoomTypeRepositoryPgroom-type commands & publish policy
RoomRepositoryRoomRepositoryPgroom commands & publish policy
PhotoRepositoryPhotoRepositoryPgphoto commands & publish policy
PolicyRepositoryPolicyRepositoryPgpolicy commands
RoomGroupRepositoryRoomGroupRepositoryPgroom-group commands
AmenityRegistryInMemoryAmenityRegistryamenity validation
EventPublisherEventPublisherPubSub (transactional outbox)every command emitting events
FileStoragePortFileStorageHttpAdapterphoto upload signed URLs
GeocodePortGoogleMapsGeocodeAdapter, OpenCageGeocodeAdapter (failover)geocode fallback
AIClientAIClientHttpAdapterdescription draft, photo tag, amenity suggest
ReservationLookupPortReservationLookupHttpAdapteractive-reservation count for OOO/archive guards
ClockSystemClock, FixedClock (tests)timestamp generation
IdGeneratorUlidIdGenerator, SeededIdGenerator (tests)ppt_…, rmt_…, …

3. Commands (use cases)

CommandAggregate(s)Triggered byOutbox events
CreatePropertyPropertyAPI POST /propertiesproperty.created.v1
UpdatePropertyMetadataPropertyAPI PATCH /properties/{id}property.updated.v1
SetPropertyAmenitiesPropertyAPI PUT /properties/{id}/amenitiesproperty.amenity_set.updated.v1
PublishPropertyProperty + RoomType + Room + Photo (read)API POST /properties/{id}/publishproperty.published.v1
UnpublishPropertyPropertyAPI POST /properties/{id}/unpublishproperty.unpublished.v1
ArchivePropertyProperty + cascade eventsAPI DELETE /properties/{id}property.deleted.v1
CreateRoomTypeRoomTypeAPI POST …/room-typesroom_type.created.v1
UpdateRoomTypeRoomTypeAPI PATCH …/room-types/{rmt}room_type.updated.v1
ArchiveRoomTypeRoomType + Room (read)API DELETE …/room-types/{rmt}room_type.archived.v1
CreateRoomRoomAPI POST …/roomsroom.created.v1
BulkCreateRoomsRoom[]API POST …/rooms/bulkroom.created.v1 ×N (one per room)
UpdateRoomRoomAPI PATCH …/rooms/{rmu}room.updated.v1
TakeRoomOutOfOrderRoomAPI POST …/rooms/{rmu}/take-out-of-order or event consumerroom.taken_out_of_order.v1
ReturnRoomToServiceRoomAPI POST …/rooms/{rmu}/return-to-service or event consumerroom.returned_to_service.v1
ArchiveRoomRoomAPI DELETE …/rooms/{rmu}room.archived.v1
RequestPhotoUpload(none, intent)API POST …/photosnone (returns signed URL)
RegisterPhotoPhotocallback after upload (POST …/photos/register)photo.added.v1
RemovePhotoPhotoAPI DELETE …/photos/{pht}photo.removed.v1
ReorderPhotosPhotoAPI PATCH …/photos/orderphoto.order_changed.v1
UpsertPolicyOverridePropertyPoliciesAPI PUT …/policies/overrides/{kind}policy.updated.v1
RemovePolicyOverridePropertyPoliciesAPI DELETE …/policies/overrides/{kind}policy.updated.v1
UpsertRoomGroupRoomGroupAPI POST …/room-groups / PATCH …room_group.changed.v1
ArchiveRoomGroupRoomGroup + Room (read)API DELETE …/room-groups/{rgp}room_group.changed.v1

3.1 Command pseudocode pattern

Every command follows the same skeleton:

export class PublishPropertyHandler implements CommandHandler<PublishPropertyInput, PropertyDto> {
constructor(
private readonly properties: PropertyRepository,
private readonly roomTypes: RoomTypeRepository,
private readonly rooms: RoomRepository,
private readonly photos: PhotoRepository,
private readonly publisher: EventPublisher,
private readonly clock: Clock,
private readonly tenant: TenantContext, // SET LOCAL app.tenant_id
) {}

async execute(input: PublishPropertyInput): Promise<PropertyDto> {
return this.tenant.withTenant(input.tenantId, async (tx) => {
const property = await this.properties.findById(input.id, tx);
if (!property) throw new PropertyNotFoundError(input.id);

const [activeRooms, readyPhotos] = await Promise.all([
this.rooms.listActive(input.id, tx),
this.photos.listReadyForProperty(input.id, tx),
]);

const events = property.publish({
clock: this.clock,
rooms: activeRooms,
readyPhotos,
});

await this.properties.save(property, tx);
await this.publisher.append(events, tx); // outbox in same tx
return PropertyDto.from(property);
});
}
}

3.2 Detailed semantics

PublishProperty

  • Loads property, active rooms, ready property-scope photos.
  • Property.publish(...) enforces INV-PRP-003..006 + INV-PRP-009.
  • Sets status=published, publishedAt=now, bumps version.
  • Emits property.published.v1.
  • Writes outbox row in the same transaction; publisher daemon flushes to Pub/Sub.

UnpublishProperty

  • Requires reason ∈ { 'tenant_request', 'compliance', 'incident', 'cascade_tenant_deleted' }.
  • Sets status=unpublished, unpublishedAt=now.
  • search-aggregation-service reacts to remove from index.

ArchiveProperty

  • Soft-delete: sets archivedAt, status=archived.
  • Emits cascade summary ({rooms:N, roomTypes:M, photos:K}); other services react via their own consumers (e.g., inventory-service archives allocations).

CreateRoom

  • Validates room number uniqueness per property (DB unique + domain pre-check for nicer errors).
  • If roomTypeId archived → MELMASTOON.PROPERTY.ROOM_TYPE_INVALID.

BulkCreateRooms

  • Accepts up to 200 rows in one call.
  • All-or-nothing transaction; first violation rolls back the whole batch with errors[] listing failures.

TakeRoomOutOfOrder

  • Calls ReservationLookupPort.activeReservationsForRoom(roomId, atLeastUntil). If > 0MELMASTOON.PROPERTY.ROOM_OCCUPIED.
  • Otherwise Room.takeOutOfOrder(...) and emit room.taken_out_of_order.v1.

RegisterPhoto (post-upload callback)

  • Looks up MediaRef by storageKey from file-storage-service; rejects if not present or owner mismatch.
  • Creates Photo with status='uploaded'. Photo flips to ready on melmastoon.file_storage.media.scanned.v1 consumer.

UpsertPolicyOverride

  • Validates value against per-kind zod schema.
  • Replaces existing override of same kind overlapping the new effective range.

4. Queries

QueryReturnsCache
GetPropertyByIdProperty + counts (rooms, room types, photos)Redis 60 s; bust on update
ListPropertiesPaginated property list (filters: status, country, locale)Redis 30 s on hot lists
GetRoomTypeRoomType + photo setRedis 60 s
ListRoomTypesList per propertyRedis 30 s
GetRoomRoom + group label + lock bindingRedis 30 s
ListRoomsPer property, paginated, filter status/floor/groupRedis 30 s
ListPhotosPer scope, orderedRedis 30 s
GetPoliciesResolved overrides (this layer; pricing service merges further)Redis 60 s
ListRoomGroupsPer propertyRedis 60 s
GeoSearchPropertiesBounding-box / nearbyRedis 30 s on hot bbox keys
PropertyChangesSinceinternal/v1/property/changes for syncNone (deterministic)

Cache-busting uses event consumers in the same service that listen to its own property.* events on a separate subscription.

5. Event consumers (inbound)

Consumer subscriptionSource eventAction
melmastoon.property-service.tenant.v1melmastoon.tenant.created.v1Pre-warm tenant context (no row created); enables RLS-set app.tenant_id.
melmastoon.property-service.tenant.v1melmastoon.tenant.deleted.v1Soft-archive every property in tenant; cascade ArchiveProperty per id; emit property.unpublished.v1 then property.deleted.v1.
melmastoon.property-service.housekeeping.v1melmastoon.housekeeping.room.maintenance_required.v1Auto-OOO with reason 'housekeeping'; if room occupied, emit alert MELMASTOON.PROPERTY.ROOM_OCCUPIED to operator inbox.
melmastoon.property-service.maintenance.v1melmastoon.maintenance.work_order.completed.v1Auto-RTS if room is OOO with oooReason='maintenance' and tenant policy allows auto-RTS.
melmastoon.property-service.file_storage.v1melmastoon.file_storage.media.scanned.v1Flip Photo uploaded → ready on success; on infected → archive Photo and emit photo.removed.v1.
melmastoon.property-service.lock_integration.v1melmastoon.lock_integration.device.paired.v1Bind lockDeviceId onto Room.
melmastoon.property-service.lock_integration.v1melmastoon.lock_integration.device.unpaired.v1Clear lockDeviceId on Room.

All consumers are idempotent: the inbox table records (eventId, subscription) with processedAt; duplicate deliveries are no-ops. Inbox row is written in the same transaction as the side effect.

6. Policies (cross-aggregate)

6.1 PublishEligibilityPolicy

Inputs: property, active rooms, ready property-scope photos.

Returns: list of PublishViolation (empty when eligible). Used by API to expose a dry-run GET /properties/{id}/publish/preview before the transition.

export interface PublishViolation {
code: 'NO_ROOMS' | 'NO_PHOTO' | 'GEO_MISSING' | 'NAME_LOCALE_MISSING' | 'DESCRIPTION_LOCALE_MISSING' | 'TERMS_NOT_ACCEPTED';
detail: string;
}

6.2 OOOEligibilityPolicy

Inputs: room, planned until, current active reservation count.

Returns: { ok: true } | { ok: false; reason: 'room_occupied' | 'reservation_overlaps_window' }. Reservation lookup uses time-window when until is set.

6.3 RoomTypeArchivePolicy

Blocks archive if any non-archived Room references the type.

6.4 RoomGroupArchivePolicy

Blocks archive if any non-archived Room references the group.

7. Transactions and consistency

  • One PostgreSQL transaction per command.
  • SET LOCAL app.tenant_id = '<tenantId>' is the first statement (enforces RLS).
  • Outbox row is INSERT in the same tx; publisher daemon (EventOutboxPublisher) reads pending rows in commit order, publishes to Pub/Sub, marks published_at.
  • Cross-aggregate writes (e.g., publish reading rooms + photos) are read-then-write-one-aggregate; never write multiple aggregates in one tx.
  • Cache invalidation runs after the tx commits and after the outbox row is INSERTed; failure to invalidate is logged and re-attempted by a TTL-bounded reconciliation job.

8. Idempotency

  • Every write endpoint requires Idempotency-Key header (ULID).
  • Stored in idempotency_keys keyed by (tenant_id, route, key, body_hash) for 24 h.
  • Replay returns the original 2xx response.
  • Mismatched body for same key → MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED.

9. Concurrency

  • Optimistic concurrency: every write requires If-Match: <version> header (or expectedVersion for sync push).
  • Mismatch → MELMASTOON.GENERAL.PRECONDITION_FAILED.
  • Bulk endpoints accept per-row expectedVersion so partial conflicts can be reported.

10. Outbox & Inbox tables

interface OutboxRow {
id: string; // ULID
tenant_id: string;
aggregate_id: string;
aggregate_type: 'property' | 'room_type' | 'room' | 'photo' | 'policy' | 'room_group';
event_type: string; // melmastoon.property…
schema_version: number;
payload: unknown;
occurred_at: string;
trace_id: string;
causation_id?: string;
correlation_id?: string;
published_at?: string;
}

interface InboxRow {
event_id: string;
subscription: string; // melmastoon.property-service.tenant.v1
processed_at: string;
result: 'ok' | 'rejected' | 'noop';
}

11. Error mapping (application → HTTP)

Domain errorHTTPCode
PropertyNotFoundError404MELMASTOON.PROPERTY.NOT_FOUND
PropertyInactiveError409MELMASTOON.PROPERTY.INACTIVE
NoRoomsForPublishError409MELMASTOON.PROPERTY.NO_ROOMS_FOR_PUBLISH
MissingGeoForPublishError409MELMASTOON.PROPERTY.GEO_REQUIRED_FOR_PUBLISH
RoomOccupiedError409MELMASTOON.PROPERTY.ROOM_OCCUPIED
RoomNumberDuplicateError409MELMASTOON.PROPERTY.ROOM_NUMBER_DUPLICATE
AmenityUnknownError422MELMASTOON.PROPERTY.AMENITY_UNKNOWN
GeoInvalidError422MELMASTOON.PROPERTY.GEO_INVALID
LocaleNotEnabledError422MELMASTOON.PROPERTY.LOCALE_NOT_ENABLED
CrossTenantReferenceError422MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE
PreconditionFailedError412MELMASTOON.GENERAL.PRECONDITION_FAILED
ValidationError (zod)422MELMASTOON.GENERAL.VALIDATION_FAILED

12. Sequence: publish a property

operator → API: POST /properties/{id}/publish (Idempotency-Key, If-Match)
API → app: PublishPropertyCommand
app → repos: load Property, active Rooms, ready Photos (1 tx, RLS set)
app → domain: Property.publish({rooms, photos, clock})
domain → app: events [PropertyPublished]
app → outbox: INSERT row (same tx)
app → DB: COMMIT
publisher daemon → Pub/Sub: melmastoon.property.published.v1
search-aggregation-service ← event: rebuild index entry
bff-tenant-booking-service ← event: bust cache
operator ← API: 200 PropertyDto{ status: 'published', publishedAt }