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)
| Port | Adapters | Used by |
|---|---|---|
PropertyRepository | PropertyRepositoryPg | all property commands & queries |
RoomTypeRepository | RoomTypeRepositoryPg | room-type commands & publish policy |
RoomRepository | RoomRepositoryPg | room commands & publish policy |
PhotoRepository | PhotoRepositoryPg | photo commands & publish policy |
PolicyRepository | PolicyRepositoryPg | policy commands |
RoomGroupRepository | RoomGroupRepositoryPg | room-group commands |
AmenityRegistry | InMemoryAmenityRegistry | amenity validation |
EventPublisher | EventPublisherPubSub (transactional outbox) | every command emitting events |
FileStoragePort | FileStorageHttpAdapter | photo upload signed URLs |
GeocodePort | GoogleMapsGeocodeAdapter, OpenCageGeocodeAdapter (failover) | geocode fallback |
AIClient | AIClientHttpAdapter | description draft, photo tag, amenity suggest |
ReservationLookupPort | ReservationLookupHttpAdapter | active-reservation count for OOO/archive guards |
Clock | SystemClock, FixedClock (tests) | timestamp generation |
IdGenerator | UlidIdGenerator, SeededIdGenerator (tests) | ppt_…, rmt_…, … |
3. Commands (use cases)
| Command | Aggregate(s) | Triggered by | Outbox events |
|---|---|---|---|
CreateProperty | Property | API POST /properties | property.created.v1 |
UpdatePropertyMetadata | Property | API PATCH /properties/{id} | property.updated.v1 |
SetPropertyAmenities | Property | API PUT /properties/{id}/amenities | property.amenity_set.updated.v1 |
PublishProperty | Property + RoomType + Room + Photo (read) | API POST /properties/{id}/publish | property.published.v1 |
UnpublishProperty | Property | API POST /properties/{id}/unpublish | property.unpublished.v1 |
ArchiveProperty | Property + cascade events | API DELETE /properties/{id} | property.deleted.v1 |
CreateRoomType | RoomType | API POST …/room-types | room_type.created.v1 |
UpdateRoomType | RoomType | API PATCH …/room-types/{rmt} | room_type.updated.v1 |
ArchiveRoomType | RoomType + Room (read) | API DELETE …/room-types/{rmt} | room_type.archived.v1 |
CreateRoom | Room | API POST …/rooms | room.created.v1 |
BulkCreateRooms | Room[] | API POST …/rooms/bulk | room.created.v1 ×N (one per room) |
UpdateRoom | Room | API PATCH …/rooms/{rmu} | room.updated.v1 |
TakeRoomOutOfOrder | Room | API POST …/rooms/{rmu}/take-out-of-order or event consumer | room.taken_out_of_order.v1 |
ReturnRoomToService | Room | API POST …/rooms/{rmu}/return-to-service or event consumer | room.returned_to_service.v1 |
ArchiveRoom | Room | API DELETE …/rooms/{rmu} | room.archived.v1 |
RequestPhotoUpload | (none, intent) | API POST …/photos | none (returns signed URL) |
RegisterPhoto | Photo | callback after upload (POST …/photos/register) | photo.added.v1 |
RemovePhoto | Photo | API DELETE …/photos/{pht} | photo.removed.v1 |
ReorderPhotos | Photo | API PATCH …/photos/order | photo.order_changed.v1 |
UpsertPolicyOverride | PropertyPolicies | API PUT …/policies/overrides/{kind} | policy.updated.v1 |
RemovePolicyOverride | PropertyPolicies | API DELETE …/policies/overrides/{kind} | policy.updated.v1 |
UpsertRoomGroup | RoomGroup | API POST …/room-groups / PATCH … | room_group.changed.v1 |
ArchiveRoomGroup | RoomGroup + 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, bumpsversion. - 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-servicereacts 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-servicearchives allocations).
CreateRoom
- Validates room number uniqueness per property (DB unique + domain pre-check for nicer errors).
- If
roomTypeIdarchived →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> 0→MELMASTOON.PROPERTY.ROOM_OCCUPIED. - Otherwise
Room.takeOutOfOrder(...)and emitroom.taken_out_of_order.v1.
RegisterPhoto (post-upload callback)
- Looks up
MediaRefbystorageKeyfromfile-storage-service; rejects if not present or owner mismatch. - Creates Photo with
status='uploaded'. Photo flips toreadyonmelmastoon.file_storage.media.scanned.v1consumer.
UpsertPolicyOverride
- Validates
valueagainst per-kind zod schema. - Replaces existing override of same
kindoverlapping the new effective range.
4. Queries
| Query | Returns | Cache |
|---|---|---|
GetPropertyById | Property + counts (rooms, room types, photos) | Redis 60 s; bust on update |
ListProperties | Paginated property list (filters: status, country, locale) | Redis 30 s on hot lists |
GetRoomType | RoomType + photo set | Redis 60 s |
ListRoomTypes | List per property | Redis 30 s |
GetRoom | Room + group label + lock binding | Redis 30 s |
ListRooms | Per property, paginated, filter status/floor/group | Redis 30 s |
ListPhotos | Per scope, ordered | Redis 30 s |
GetPolicies | Resolved overrides (this layer; pricing service merges further) | Redis 60 s |
ListRoomGroups | Per property | Redis 60 s |
GeoSearchProperties | Bounding-box / nearby | Redis 30 s on hot bbox keys |
PropertyChangesSince | internal/v1/property/changes for sync | None (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 subscription | Source event | Action |
|---|---|---|
melmastoon.property-service.tenant.v1 | melmastoon.tenant.created.v1 | Pre-warm tenant context (no row created); enables RLS-set app.tenant_id. |
melmastoon.property-service.tenant.v1 | melmastoon.tenant.deleted.v1 | Soft-archive every property in tenant; cascade ArchiveProperty per id; emit property.unpublished.v1 then property.deleted.v1. |
melmastoon.property-service.housekeeping.v1 | melmastoon.housekeeping.room.maintenance_required.v1 | Auto-OOO with reason 'housekeeping'; if room occupied, emit alert MELMASTOON.PROPERTY.ROOM_OCCUPIED to operator inbox. |
melmastoon.property-service.maintenance.v1 | melmastoon.maintenance.work_order.completed.v1 | Auto-RTS if room is OOO with oooReason='maintenance' and tenant policy allows auto-RTS. |
melmastoon.property-service.file_storage.v1 | melmastoon.file_storage.media.scanned.v1 | Flip Photo uploaded → ready on success; on infected → archive Photo and emit photo.removed.v1. |
melmastoon.property-service.lock_integration.v1 | melmastoon.lock_integration.device.paired.v1 | Bind lockDeviceId onto Room. |
melmastoon.property-service.lock_integration.v1 | melmastoon.lock_integration.device.unpaired.v1 | Clear 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
INSERTin the same tx; publisher daemon (EventOutboxPublisher) reads pending rows in commit order, publishes to Pub/Sub, markspublished_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-Keyheader (ULID). - Stored in
idempotency_keyskeyed 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 (orexpectedVersionfor sync push). - Mismatch →
MELMASTOON.GENERAL.PRECONDITION_FAILED. - Bulk endpoints accept per-row
expectedVersionso 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 error | HTTP | Code |
|---|---|---|
PropertyNotFoundError | 404 | MELMASTOON.PROPERTY.NOT_FOUND |
PropertyInactiveError | 409 | MELMASTOON.PROPERTY.INACTIVE |
NoRoomsForPublishError | 409 | MELMASTOON.PROPERTY.NO_ROOMS_FOR_PUBLISH |
MissingGeoForPublishError | 409 | MELMASTOON.PROPERTY.GEO_REQUIRED_FOR_PUBLISH |
RoomOccupiedError | 409 | MELMASTOON.PROPERTY.ROOM_OCCUPIED |
RoomNumberDuplicateError | 409 | MELMASTOON.PROPERTY.ROOM_NUMBER_DUPLICATE |
AmenityUnknownError | 422 | MELMASTOON.PROPERTY.AMENITY_UNKNOWN |
GeoInvalidError | 422 | MELMASTOON.PROPERTY.GEO_INVALID |
LocaleNotEnabledError | 422 | MELMASTOON.PROPERTY.LOCALE_NOT_ENABLED |
CrossTenantReferenceError | 422 | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE |
PreconditionFailedError | 412 | MELMASTOON.GENERAL.PRECONDITION_FAILED |
ValidationError (zod) | 422 | MELMASTOON.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 }