Skip to main content

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 → build Quote projection (no aggregate persisted yet) → emit melmastoon.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:
    1. Resolve quote; reject if expired (QUOTE_EXPIRED).
    2. Construct Reservation in quoted state, transition to held.
    3. InventoryClient.hold(...) — synchronous call returning allocation IDs and expiresAt.
    4. Set Reservation.hold; create initial PaymentIntent via PaymentClient.createIntent.
    5. Persist (UOW) and append melmastoon.reservation.held.v1 to outbox.
    6. Set pendingSagaStep = await_payment with deadline = hold.expiresAt.
  • Output: { reservation, paymentIntentClientSecret, holdExpiresAt }.
  • Compensation if step 3 fails: abort; return MELMASTOON.RESERVATION.HOLD_LIMIT_EXCEEDED or upstream error.

2.3 ConfirmReservationUseCase

  • Input (event-driven): consumed melmastoon.payment.transaction.captured.v1 OR cash_on_arrival confirm command.
  • Flow:
    1. Load reservation by reservationId (from event metadata).
    2. Verify hold not expired and saga step await_payment matches.
    3. Apply Reservation.confirm(actor, fxSnapshot, captureRef).
    4. Lock the FX snapshot.
    5. Persist + emit melmastoon.reservation.confirmed.v1.
    6. If stay starts today, fire-and-forget LockClient.issueOnCheckIn (deferred for future stays — issued at StartCheckIn).
    7. Fire NotificationClient.sendConfirmation.
  • 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:
    1. Load reservation; check pendingSagaStep == null (else 409).
    2. Snapshot the active cancellation policy (from tenant-service).
    3. Run CancellationPolicyEvaluator{ refundEligibilityMicro, penaltyMicro }.
    4. Apply Reservation.cancel(actor, ruleApplied).
    5. Persist + emit melmastoon.reservation.cancelled.v1.
    6. Side-effects (async via events): inventory release, payment refund, key revoke, notification.

2.5 ModifyReservationUseCase

A discriminated union by type — each sub-type runs its own mini-saga:

Sub-typeSub-saga steps
date_changere-quote (PricingClient) → reallocate inventory → if confirmed, update key (LockClient) → diff payment (charge or refund delta) → emit dates_changed.v1
room_changereallocate (release old, hold new in same tx) → if checked-in, lock revoke + reissue → emit modified.v1
room_addedhold extra inventory → repurpose existing intent or create new → emit modified.v1
room_removedrelease inventory for removed item → refund delta if any → emit modified.v1
guest_count_changerevalidate occupancy (I9) → re-quote if rate depends on occupants → emit modified.v1
rate_changere-quote → recalc totals → diff payment → emit modified.v1
guest_profile_updatemutate primary or additional guest fields → emit modified.v1
special_request_addedappend SpecialRequest → emit special_request.added.v1
channel_attribution_correctedmutate channel; admin-only → emit modified.v1 (audit-flagged)
  • Common guards: pendingSagaStep == null, OCC, I11 (no retroactive date moves on checked_in).
  • All sub-types produce a ReservationModification audit row with before/after snapshots.

2.6 CheckInUseCase — composed of StartCheckInUseCase and CompleteCheckInUseCase

  • Start: transition confirmed → check_in_started, kick off LockClient.issueOnCheckIn, request folio open via billing-service (saga), emit check_in_started.v1.
  • Complete: triggered when lock.key.issued.v1 lands (or when staff sets requiresManualKey=true); transition to checked_in; emit checked_in.v1.
  • Edge cases:
    • Early check-in: requires earlyCheckInAllowed=true on rate plan or front-desk override (audit: policyOverride).
    • Block if room not in clean state (read from housekeeping-service projection); raise ROOM_NOT_READY.
    • Walk-in path bypasses confirmed → check_in_started; see §2.10.

2.7 CheckOutUseCase — composed of StartCheckOutUseCase and CompleteCheckOutUseCase

  • Start: transition checked_in → checkout_started, ask billing-service to settle the folio (saga), kick off LockClient.revokeOnCheckOut.
  • Complete: when folio settles and key revoke acks; transition to checked_out; emit checked_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 in checkout_started until resolved.

2.8 RecordNoShowUseCase

  • Input: { reservationId, actor } (manual) or auto via cron checking now > stay.start + property.noShowGraceHours.
  • Flow: Apply policy → apply penalty (event to billing-service) → transition confirmed → no_show → emit no_show.v1 → release inventory.

2.9 RecordEarlyCheckoutUseCase

  • Input: { reservationId, effectiveCheckOutDate, actor }
  • Flow: Apply early-checkout refund policy → transition checked_in → checked_out with early_checkout=true → emit early_checkout.v1 and checked_out.v1.

2.10 WalkInBookingUseCase

  • Input: { tenantId, propertyId, stayWindow, items, primaryGuest, paymentMethod, actorStaffId, deferKyc?: boolean }
  • Flow: synchronous combined create+check-in:
    1. Quote (sync) + hold (sync) + payment (sync; cash typically) + confirm + start check-in + complete check-in → all in one operator transaction.
    2. If deferKyc=true (phone-by-staff), Guest.documentRef is null but flagged for KYC follow-up; emits walk_in_kyc_deferred audit entry.
  • Idempotency: Idempotency-Key per operator session; replays return the same reservation.

2.11 MergeReservationsUseCase

  • Input: { survivingReservationId, mergedReservationId, actor }
  • Guards: same tenantId, same primaryGuest.id, contiguous or overlapping stay windows.
  • Flow: absorb items + modifications + special requests → mark mergedReservation cancelled with reason merged_into:rsv_… → emit modified.v1 on survivor, cancelled.v1 on 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 by billing-service); mark original cancelled with reason split_into:[…].

2.13 AddSpecialRequestUseCase

  • Input: { reservationId, freeText?, tags?, locale, actor }
  • Flow: if freeText provided and no tags, call AIClient.parseSpecialRequest; persist; emit special_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 findActiveByGuest plus 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)

StepTriggerCompensation if it fails
1. Hold inventoryHoldReservationUseCaseabort use case; return error
2. Create payment intentHoldReservationUseCaserelease inventory hold
3. Capture paymentexternal webhook → payment.transaction.captured.v1release inventory; cancel reservation; emit cancelled.v1
4. Confirm reservationConfirmReservationUseCasenone (idempotent)
5. Issue key (today's stays)lock.issueOnCheckInmark requiresManualKey=true; alert front desk; reservation remains confirmed
6. Send confirmationnotification.sendConfirmationretry; escalate after N attempts

3.2 Cancellation saga

StepTriggerNotes
1. Cancel reservationCancelReservationUseCaseapply policy snapshot
2. Release inventoryinventory-service consumes cancelled.v1idempotent
3. Refund paymentpayment-gateway-service consumes; or skipped if cash_on_arrival not yet capturedpartial refund per policy
4. Revoke keylock-integration-service consumes; only if previously issuedidempotent
5. Send cancellation noticenotification-service consumeslocale-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 does UPDATE ... 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 racependingSagaStep field 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 caseRequired role(s) (RBAC)Required attributes (ABAC)
RequestQuote, HoldReservationguest, anonymous_booking, staff_front_desk, gm, ownertenantId match
ConfirmReservation (event-driven)systemevent signed by payment-gateway-service IAM principal
CancelReservationguest (own), staff_front_desk, gm, ownertenantId match; guest may only cancel own; staff_front_desk requires propertyId access
Modify*staff_front_desk, gm, ownerpropertyId access
CheckIn, CheckOut, WalkIn, RecordNoShow, RecordEarlyCheckoutstaff_front_desk, gm, ownerpropertyId access
Merge, Split, policyOverride actionsgm, ownerpropertyId access; gm cannot merge across properties
AddSpecialRequestguest (own), staff_front_desk, gm, ownertenantId match
ListGuestStaysguest (self), staff_front_desk, gm, owner, financetenantId match

7. Mappers (DTO ↔ domain)

Mappers live in src/application/mappers/:

  • ReservationMapper.toDto(reservation): ReservationResponseDto
  • ReservationMapper.toEvent(reservation, eventType): EventPayload
  • ReservationMapper.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 @Injectable wrapping is a thin adapter in the composition root.
  • No saga state stored in another service — only Reservation.pendingSagaStep is authoritative.
  • No silent compensations — every compensation emits an audit row in ReservationModification or 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.