APPLICATION_LOGIC — reservation-service
Sibling: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · SECURITY_MODEL
Strategic anchors: 02 §2 Application Architecture · 02 §7 Event Backbone · 05 API Design
The application layer is the orchestration tier between the framework-free domain layer and the infrastructure adapters. It exposes use cases (commands), queries, and ports (interfaces). Use cases are the only entry points for state mutation; queries are read-only. All cross-service interactions go through ports — never through inline fetch or vendor SDKs.
1. Ports (interfaces only — under src/application/ports/)
// reservation.repository.port.ts
export interface ReservationRepository {
findById(id: ReservationId, tx?: UnitOfWork): Promise<Reservation | null>;
findByCode(tenantId: TenantId, code: string, tx?: UnitOfWork): Promise<Reservation | null>;
findActiveByGuest(tenantId: TenantId, guestId: GuestId): Promise<Reservation[]>;
findArrivals(tenantId: TenantId, propertyId: PropertyId, localDate: ISODate): Promise<Reservation[]>;
findInHouse(tenantId: TenantId, propertyId: PropertyId): Promise<Reservation[]>;
findExpiredHolds(now: ISODate, batchSize: number): Promise<Reservation[]>;
save(r: Reservation, tx: UnitOfWork): Promise<void>; // OCC-checked
}
// pricing.client.port.ts
export interface PricingClient {
getQuote(req: QuoteRequest): Promise<Quote>; // returns priced items + fxSnapshot
applyDateChange(req: DateChangeRequest): Promise<PriceDelta>;
}
// inventory.client.port.ts
export interface InventoryClient {
hold(req: HoldRequest): Promise<{ allocations: AllocationId[]; expiresAt: ISODate }>;
commit(req: CommitRequest): Promise<void>; // idempotent by reservationId
release(req: ReleaseRequest): Promise<void>;
reallocate(req: ReallocateRequest): Promise<void>;
}
// payment.client.port.ts
export interface PaymentClient {
createIntent(req: CreateIntentRequest): Promise<PaymentIntent>;
confirmCashOnArrival(req: { reservationId: ReservationId; amount: Money }): Promise<PaymentIntent>;
refund(req: RefundRequest): Promise<RefundResult>;
}
// lock.client.port.ts
export interface LockClient {
issueOnCheckIn(req: IssueRequest): Promise<{ deferred: true } | { issued: true; credentialId: KeyCredentialId }>;
updateOnDateChange(req: UpdateRequest): Promise<void>;
revokeOnCheckOut(req: RevokeRequest): Promise<void>;
}
// notification.client.port.ts
export interface NotificationClient {
sendConfirmation(req: ConfirmationRequest): Promise<void>;
sendCancellation(req: CancellationRequest): Promise<void>;
sendCheckInInstructions(req: CheckInRequest): Promise<void>;
sendKeyDelivery(req: KeyDeliveryRequest): Promise<void>;
}
// ai.client.port.ts (consumes ai-orchestrator-service)
export interface AIClient {
detectAnomalies(req: AnomalyDetectionRequest): Promise<AnomalyVerdict>;
parseSpecialRequest(req: { freeText: string; locale: BCP47 }): Promise<{ tags: SpecialRequestTag[]; structured?: Record<string, unknown> }>;
transliterateName(req: { name: string; sourceScript: string; targetScript: 'latin' }): Promise<{ given: string; family: string; confidence: number }>;
suggestUpsell(req: UpsellRequest): Promise<UpsellSuggestion[]>;
}
// supporting infra ports
export interface EventPublisher { publish(envelope: EventEnvelope, tx: UnitOfWork): Promise<void>; }
export interface Clock { now(): ISODate; }
export interface IdGenerator { reservationId(): ReservationId; bookingId(): BookingId; ulid(): string; }
export interface IdentityResolver { resolve(req: AuthenticatedRequest): ActorRef; }
export interface UnitOfWork { /* opaque tx handle */ }
2. Use cases (commands)
Each use case is a class …UseCase with a single execute(cmd) method. Inputs are DTOs; outputs are domain aggregates or value objects. The service ships these 14 use cases.
2.1 RequestQuoteUseCase
- Input:
{ tenantId, propertyId, stayWindow, items: [{ roomTypeId, occupants }], ratePlanIdHint?, channel, locale } - Flow: call
PricingClient.getQuote→ buildQuoteprojection (no aggregate persisted yet) → emitmelmastoon.reservation.quote.created.v1(with TTL) → return. - Idempotency: quote requests are idempotent on
Idempotency-Key; same key returns the same quote within its TTL. - Errors:
MELMASTOON.PRICING.RATE_PLAN_UNAVAILABLE,MELMASTOON.PRICING.NO_INVENTORY_VISIBLE.
2.2 HoldReservationUseCase
- Input:
{ quoteId, primaryGuest, additionalGuests, specialRequests, paymentMethodHint } - Flow:
- Resolve quote; reject if expired (
QUOTE_EXPIRED). - Construct
Reservationinquotedstate, transition toheld. InventoryClient.hold(...)— synchronous call returning allocation IDs andexpiresAt.- Set
Reservation.hold; create initialPaymentIntentviaPaymentClient.createIntent. - Persist (UOW) and append
melmastoon.reservation.held.v1to outbox. - Set
pendingSagaStep = await_paymentwith deadline =hold.expiresAt.
- Resolve quote; reject if expired (
- Output:
{ reservation, paymentIntentClientSecret, holdExpiresAt }. - Compensation if step 3 fails: abort; return
MELMASTOON.RESERVATION.HOLD_LIMIT_EXCEEDEDor upstream error.
2.3 ConfirmReservationUseCase
- Input (event-driven): consumed
melmastoon.payment.transaction.captured.v1ORcash_on_arrivalconfirm command. - Flow:
- Load reservation by
reservationId(from event metadata). - Verify hold not expired and saga step
await_paymentmatches. - Apply
Reservation.confirm(actor, fxSnapshot, captureRef). - Lock the FX snapshot.
- Persist + emit
melmastoon.reservation.confirmed.v1. - If stay starts today, fire-and-forget
LockClient.issueOnCheckIn(deferred for future stays — issued atStartCheckIn). - Fire
NotificationClient.sendConfirmation.
- Load reservation by
- Idempotency: keyed on
reservationId + 'confirm' + version; replays are no-ops. - Compensation hooks: none on success; on failure, the inbox keeps the message un-acked and the saga retries with backoff.
2.4 CancelReservationUseCase
- Input:
{ reservationId, actor, reason, policyOverride?: { actorRole: 'gm'|'owner'; justification: string } } - Flow:
- Load reservation; check
pendingSagaStep == null(else 409). - Snapshot the active cancellation policy (from
tenant-service). - Run
CancellationPolicyEvaluator→{ refundEligibilityMicro, penaltyMicro }. - Apply
Reservation.cancel(actor, ruleApplied). - Persist + emit
melmastoon.reservation.cancelled.v1. - Side-effects (async via events): inventory release, payment refund, key revoke, notification.
- Load reservation; check
2.5 ModifyReservationUseCase
A discriminated union by type — each sub-type runs its own mini-saga:
| Sub-type | Sub-saga steps |
|---|---|
date_change | re-quote (PricingClient) → reallocate inventory → if confirmed, update key (LockClient) → diff payment (charge or refund delta) → emit dates_changed.v1 |
room_change | reallocate (release old, hold new in same tx) → if checked-in, lock revoke + reissue → emit modified.v1 |
room_added | hold extra inventory → repurpose existing intent or create new → emit modified.v1 |
room_removed | release inventory for removed item → refund delta if any → emit modified.v1 |
guest_count_change | revalidate occupancy (I9) → re-quote if rate depends on occupants → emit modified.v1 |
rate_change | re-quote → recalc totals → diff payment → emit modified.v1 |
guest_profile_update | mutate primary or additional guest fields → emit modified.v1 |
special_request_added | append SpecialRequest → emit special_request.added.v1 |
channel_attribution_corrected | mutate channel; admin-only → emit modified.v1 (audit-flagged) |
- Common guards:
pendingSagaStep == null, OCC, I11 (no retroactive date moves onchecked_in). - All sub-types produce a
ReservationModificationaudit row with before/after snapshots.
2.6 CheckInUseCase — composed of StartCheckInUseCase and CompleteCheckInUseCase
- Start: transition
confirmed → check_in_started, kick offLockClient.issueOnCheckIn, request folio open viabilling-service(saga), emitcheck_in_started.v1. - Complete: triggered when
lock.key.issued.v1lands (or when staff setsrequiresManualKey=true); transition tochecked_in; emitchecked_in.v1. - Edge cases:
- Early check-in: requires
earlyCheckInAllowed=trueon rate plan or front-desk override (audit:policyOverride). - Block if room not in
cleanstate (read fromhousekeeping-serviceprojection); raiseROOM_NOT_READY. - Walk-in path bypasses
confirmed → check_in_started; see §2.10.
- Early check-in: requires
2.7 CheckOutUseCase — composed of StartCheckOutUseCase and CompleteCheckOutUseCase
- Start: transition
checked_in → checkout_started, askbilling-serviceto settle the folio (saga), kick offLockClient.revokeOnCheckOut. - Complete: when folio settles and key revoke acks; transition to
checked_out; emitchecked_out.v1. - Side-effects emitted (consumers act): housekeeping turnover, analytics snapshot, post-stay notification.
- Folio dispute: if folio reports
requires_dispute_resolution, the reservation stays incheckout_starteduntil resolved.
2.8 RecordNoShowUseCase
- Input:
{ reservationId, actor }(manual) or auto via cron checkingnow > stay.start + property.noShowGraceHours. - Flow: Apply policy → apply penalty (event to
billing-service) → transitionconfirmed → no_show→ emitno_show.v1→ release inventory.
2.9 RecordEarlyCheckoutUseCase
- Input:
{ reservationId, effectiveCheckOutDate, actor } - Flow: Apply early-checkout refund policy → transition
checked_in → checked_outwithearly_checkout=true→ emitearly_checkout.v1andchecked_out.v1.
2.10 WalkInBookingUseCase
- Input:
{ tenantId, propertyId, stayWindow, items, primaryGuest, paymentMethod, actorStaffId, deferKyc?: boolean } - Flow: synchronous combined create+check-in:
- Quote (sync) + hold (sync) + payment (sync; cash typically) + confirm + start check-in + complete check-in → all in one operator transaction.
- If
deferKyc=true(phone-by-staff),Guest.documentRefis null but flagged for KYC follow-up; emitswalk_in_kyc_deferredaudit entry.
- Idempotency:
Idempotency-Keyper operator session; replays return the same reservation.
2.11 MergeReservationsUseCase
- Input:
{ survivingReservationId, mergedReservationId, actor } - Guards: same
tenantId, sameprimaryGuest.id, contiguous or overlapping stay windows. - Flow: absorb items + modifications + special requests → mark
mergedReservationcancelled with reasonmerged_into:rsv_…→ emitmodified.v1on survivor,cancelled.v1on absorbed.
2.12 SplitGroupUseCase
- Input:
{ originalReservationId, splits: [{ items: [...], primaryGuest }], actor } - Guards:
originalReservation.status == confirmed,pendingSagaStep == null, all items accounted for. - Flow: create N new reservations via
WalkInBookingUseCase-like fast path skipping payment (folio is split bybilling-service); mark originalcancelledwith reasonsplit_into:[…].
2.13 AddSpecialRequestUseCase
- Input:
{ reservationId, freeText?, tags?, locale, actor } - Flow: if
freeTextprovided and notags, callAIClient.parseSpecialRequest; persist; emitspecial_request.added.v1.
2.14 ListGuestStaysQueryHandler
- Input:
{ tenantId, guestId | email | phoneE164, includeFuture?: boolean, includeCancelled?: boolean, page, pageSize } - Output: paginated list of reservations for that guest within the tenant. Read-only; uses repository's
findActiveByGuestplus index lookups by email/phone.
3. Saga participation
reservation-service is the orchestrator for two sagas. State lives on the Reservation aggregate's pendingSagaStep field plus the modification audit; no separate saga store.
3.1 Booking saga (forward)
| Step | Trigger | Compensation if it fails |
|---|---|---|
| 1. Hold inventory | HoldReservationUseCase | abort use case; return error |
| 2. Create payment intent | HoldReservationUseCase | release inventory hold |
| 3. Capture payment | external webhook → payment.transaction.captured.v1 | release inventory; cancel reservation; emit cancelled.v1 |
| 4. Confirm reservation | ConfirmReservationUseCase | none (idempotent) |
| 5. Issue key (today's stays) | lock.issueOnCheckIn | mark requiresManualKey=true; alert front desk; reservation remains confirmed |
| 6. Send confirmation | notification.sendConfirmation | retry; escalate after N attempts |
3.2 Cancellation saga
| Step | Trigger | Notes |
|---|---|---|
| 1. Cancel reservation | CancelReservationUseCase | apply policy snapshot |
| 2. Release inventory | inventory-service consumes cancelled.v1 | idempotent |
| 3. Refund payment | payment-gateway-service consumes; or skipped if cash_on_arrival not yet captured | partial refund per policy |
| 4. Revoke key | lock-integration-service consumes; only if previously issued | idempotent |
| 5. Send cancellation notice | notification-service consumes | locale-aware |
3.3 Date-change sub-saga (most error-prone)
The ModifyReservationUseCase date_change path is a mini-saga because it touches inventory + pricing + lock + payment in one logical operation but cannot be a single transaction across services. Steps with compensation:
1. Quote new dates (pricing-service) ── on fail: return error to caller
2. Reallocate inventory (release+hold in atomic call)
── on fail: pricing quote auto-expires
3. Compute price delta
4. Charge or refund delta (payment-gateway-service)
── on fail: revert inventory reallocation
5. If confirmed: update key (lock-integration-service)
── on fail: mark requiresManualKey; reservation still updates
6. Persist Reservation with new stayWindow + modification audit
7. Emit dates_changed.v1
If the caller cancels mid-flight (rare), the use case raises MELMASTOON.RESERVATION.SAGA_IN_FLIGHT until the in-flight step completes or times out.
4. Hold-expiry worker (separate Cloud Run job)
The hold-expiry worker runs as a Cloud Run job triggered by Cloud Scheduler every 30 seconds (configurable). Its only use case:
class ExpireHoldsUseCase {
async execute(now: ISODate): Promise<{ expired: number }> {
const batch = await this.repo.findExpiredHolds(now, batchSize=100);
for (const r of batch) {
try { r.expireHold(this.actorSystem('hold-expiry')); }
catch (e) { /* log + skip; idempotent */ continue; }
await this.uow.run(async tx => {
await this.repo.save(r, tx);
await this.events.publish(buildHoldExpiredEvent(r), tx);
});
}
return { expired: batch.length };
}
}
The worker is at-least-once; the use case is idempotent because the state-machine refuses a second expireHold() call on an already-expired_hold aggregate.
5. Concurrency & OCC
- Every aggregate save uses optimistic concurrency on
Reservation.version. The repository implementation doesUPDATE ... WHERE id = $1 AND version = $expected; if affected_rows == 0 throw STALE_VERSION. - Concurrency hot spots:
- Front-desk concurrent check-in attempts (two staff members on different terminals) — first wins, second sees STALE_VERSION; the BFF reloads and retries automatically once.
- Saga + operator modification race —
pendingSagaStepfield gates operator commands until the saga step lands or times out. - Group partial cancel — each item cancel bumps version; operator UI must reload before subsequent cancels.
6. Authorization checks (cross-reference SECURITY_MODEL)
Each use case starts with an authorization check using the resolved actor:
| Use case | Required role(s) (RBAC) | Required attributes (ABAC) |
|---|---|---|
RequestQuote, HoldReservation | guest, anonymous_booking, staff_front_desk, gm, owner | tenantId match |
ConfirmReservation (event-driven) | system | event signed by payment-gateway-service IAM principal |
CancelReservation | guest (own), staff_front_desk, gm, owner | tenantId match; guest may only cancel own; staff_front_desk requires propertyId access |
Modify* | staff_front_desk, gm, owner | propertyId access |
CheckIn, CheckOut, WalkIn, RecordNoShow, RecordEarlyCheckout | staff_front_desk, gm, owner | propertyId access |
Merge, Split, policyOverride actions | gm, owner | propertyId access; gm cannot merge across properties |
AddSpecialRequest | guest (own), staff_front_desk, gm, owner | tenantId match |
ListGuestStays | guest (self), staff_front_desk, gm, owner, finance | tenantId match |
7. Mappers (DTO ↔ domain)
Mappers live in src/application/mappers/:
ReservationMapper.toDto(reservation): ReservationResponseDtoReservationMapper.toEvent(reservation, eventType): EventPayloadReservationMapper.fromCreateCommand(cmd): Reservation(factory; called inside the use case)
DTOs never escape the application layer; controllers receive plain shapes ready for serialization.
8. Anti-patterns explicitly forbidden in this layer
- No direct vendor SDK imports anywhere in
src/application/— all external calls go through ports. - No NestJS decorators in use cases — use cases are framework-free; the
@Injectablewrapping is a thin adapter in the composition root. - No saga state stored in another service — only
Reservation.pendingSagaStepis authoritative. - No silent compensations — every compensation emits an audit row in
ReservationModificationor a dedicated event so the trail is explainable. - No retries that swallow errors — retries are bounded; on exhaustion the use case raises a typed error; the caller decides.