APPLICATION_LOGIC — bff-backoffice-service
Sibling: DOMAIN_MODEL · API_CONTRACTS · SECURITY_MODEL · SYNC_CONTRACT
Cross-cutting: Standards · SERVICE_TEMPLATE · 02 Enterprise Architecture · §8 Sync
The application layer is the heart of the BFF. It is purely orchestration — there is no domain logic, only composition, fanout, projection, single-flight, idempotency, and audit-envelope assembly. Every use case below either composes a view-model or proxies a domain mutation; none mutates a domain aggregate.
1. Layering recap
presentation/ ── controllers, SSE, OpenAPI
application/ ── orchestrators, use cases, view-model composers ◀── this doc
infrastructure/── upstream gateways, Memorystore, Postgres, Pub/Sub
domain/ ── session/projection types only
Use cases are the only entry from controllers; they orchestrate ports defined alongside upstream gateways.
2. Use cases
| ID | Name | HTTP entry | Purpose |
|---|---|---|---|
| UC-01 | OpenSession | first authenticated request | Resolve operator + tenant + property scope; create session |
| UC-02 | RefreshSession | POST /auth/refresh | Device-bound JWT refresh; iam proof-of-possession |
| UC-03 | CloseSession | POST /auth/sign-out | Tear down session; emit closed event |
| UC-04 | ComposeDashboard | GET /dashboard | Per-widget fanout with deadlines + skeletons |
| UC-05 | ComposeWorkbenchView | `GET /today | /arrivals |
| UC-06 | FetchAISuggestions | GET /ai/suggestions | Proxy to orchestrator; mirror to inbox |
| UC-07 | DecideAISuggestion | POST /ai/suggestions/{id}/decide | Record decision; notify orchestrator |
| UC-08 | ListAlerts | GET /alerts | Mirror notification inbox |
| UC-09 | AcknowledgeAlert | POST /alerts/{id}/acknowledge | Ack proxy + audit |
| UC-10 | ReadOperatorPreferences | GET /preferences | Read-through cache |
| UC-11 | WriteOperatorPreferences | PUT /preferences | Proxy to tenant-service + mirror update |
| UC-12 | ReportHeartbeat | POST /devices/{id}/heartbeat | DeviceSyncStatus update + telemetry |
| UC-13 | GetSyncCursor | GET /sync/cursor | Cached cursor read (BFF projection) |
| UC-14 | AdvanceSyncCursor | POST /sync/cursor | Update cache; emit cursor_advanced event |
| UC-15 | NegotiateSyncHandshake | POST /sync/handshake | Mint sync-session-token via sync-service |
| UC-16 | ProxyLockAction | `POST /locks/{rsv}/issue-key | /revoke-key` |
| UC-17 | ProxyDomainMutation | various POST /reservations/{id}/check-in, etc. | Idem-key + audit envelope; thin proxy |
| UC-18 | OpenSseStream | GET /sse/stream | Multiplex ai, alerts, dashboard-refresh-hints, session channels |
| UC-19 | BroadcastForceLogout | inbox event handler | Push forceLogout via SSE on session.revoked |
| UC-20 | ConsumeProjectionEvent | inbox handler | Update DashboardSnapshot / AlertInbox / AISuggestionInbox cache |
3. Ports (interface boundary)
3.1 Upstream gateways (HTTP clients)
interface IamGateway {
refresh(input: { refreshToken: string; devicePoP: DevicePoPProof }): Promise<IamRefreshResult>;
validate(input: { accessToken: string; deviceId: DeviceId }): Promise<IamValidateResult>;
attestStepUp(input: { operatorId: OperatorId; deviceId: DeviceId; scope: StepUpScope }): Promise<MfaAttestationToken>;
}
interface TenantGateway {
resolveOperator(input: { operatorId: OperatorId; tenantId: TenantId }): Promise<OperatorResolution>;
readPreferences(input: { operatorId: OperatorId; tenantId: TenantId }): Promise<OperatorPreferences>;
writePreferences(input: { operatorId: OperatorId; tenantId: TenantId; prefs: OperatorPreferencesPatch; expectedVersion: number }): Promise<OperatorPreferences>;
}
interface ReservationGateway {
arrivals(...): Promise<ArrivalsList>;
departures(...): Promise<DeparturesList>;
inHouse(...): Promise<InHouseList>;
proxyMutation(action: string, body: unknown, idemKey: string, audit: AuditEnvelope): Promise<unknown>;
}
interface InventoryGateway { occupancyKpi(...): Promise<OccupancyKpi>; }
interface PricingGateway { rateSnapshot(...): Promise<RateSnapshot>; }
interface HousekeepingGateway { board(...): Promise<HousekeepingBoard>; summary(...): Promise<HousekeepingSummary>; }
interface MaintenanceGateway { board(...): Promise<MaintenanceBoard>; summary(...): Promise<MaintenanceSummary>; }
interface BillingGateway { folioSummary(...): Promise<FolioSummary>; revenueKpi(...): Promise<RevenueKpi>; }
interface LockGateway { issueKey(input, audit): Promise<LockOpResult>; revokeKey(input, audit): Promise<LockOpResult>; }
interface AiOrchestratorGateway {
fetchSuggestions(input): Promise<AiSuggestionList>;
reportDecision(input): Promise<void>;
}
interface NotificationGateway {
listAlerts(input): Promise<AlertList>;
acknowledgeAlert(input): Promise<void>;
}
interface SyncGateway {
handshake(input): Promise<SyncSessionToken>;
readCursor(input): Promise<SyncCursor>;
reportCursor(input): Promise<void>;
}
interface AnalyticsGateway { kpi(input): Promise<KpiBundle>; }
interface PropertyGateway { propertyMeta(propertyId): Promise<PropertyMeta>; }
3.2 Internal session/projection stores
interface BackofficeSessionStore { open / patch / get / close }
interface DashboardCache { read / writeWithTtl / invalidate }
interface WorkbenchCache { read / writeWithTtl / invalidate }
interface AISuggestionInboxStore { upsert / decide / drop / list }
interface AlertInboxStore { upsert / acknowledge / drop / list }
interface OperatorActivityLedger { append / streamForExport }
interface DeviceSyncStore { upsert / get / patch }
interface OperatorPreferencesCache { read / write }
interface OfflineActionQueueHintStore { upsert / read }
interface LockActionProxyAuditLedger { append / streamForExport }
interface MfaAttestationStore { create / consume / get }
3.3 Telemetry + cache infra
interface TelemetryOutbox { enqueue(subject: string, payload: object, samplingRate?: number): Promise<void>; }
interface IdempotencyStore { lookup(key: string, requestHash: string): Promise<KnownResult|null>; store(key, requestHash, response): Promise<void>; }
interface SingleFlight<T> { do(key: string, factory: () => Promise<T>): Promise<T>; }
interface SseBus { publish(channel: string, deviceId: DeviceId, event: SseEvent): void; subscribe(deviceId, channels): AsyncIterable<SseEvent>; }
4. UC-04 — ComposeDashboard
The flagship orchestration. Per-widget independent fanout; per-widget deadline; partial composition with skeleton placeholders.
class ComposeDashboardUseCase {
constructor(
private readonly cache: DashboardCache,
private readonly singleFlight: SingleFlight<DashboardSnapshot>,
private readonly reservation: ReservationGateway,
private readonly inventory: InventoryGateway,
private readonly billing: BillingGateway,
private readonly housekeeping: HousekeepingGateway,
private readonly maintenance: MaintenanceGateway,
private readonly ai: AiOrchestratorGateway,
private readonly notification: NotificationGateway,
private readonly sync: SyncGateway,
private readonly outbox: TelemetryOutbox,
private readonly clock: Clock,
) {}
async execute(ctx: SessionContext, propertyId: PropertyId): Promise<DashboardSnapshot> {
const cacheKey = `dashboard:${ctx.tenantId}:${propertyId}:${ctx.primaryRole()}`;
const fresh = await this.cache.read(cacheKey);
if (fresh) {
void this.outbox.enqueue('melmastoon.bff.backoffice.dashboard.viewed.v1', toViewedPayload(ctx, propertyId, 'cached'), 0.25);
return fresh;
}
return this.singleFlight.do(cacheKey, async () => {
const composed = await this.composeFanout(ctx, propertyId);
await this.cache.writeWithTtl(cacheKey, composed, 30);
void this.outbox.enqueue('melmastoon.bff.backoffice.dashboard.viewed.v1', toViewedPayload(ctx, propertyId, 'composed', composed.partial), 1.0);
return composed;
});
}
private async composeFanout(ctx: SessionContext, propertyId: PropertyId): Promise<DashboardSnapshot> {
const args = { tenantId: ctx.tenantId, propertyId };
const widgets = await Promise.all([
this.runWidget('today', () => this.reservation.todayPanel(args), 600),
this.runWidget('arrivals', () => this.reservation.arrivals({ ...args, date: 'today' }), 700),
this.runWidget('departures', () => this.reservation.departures({ ...args, date: 'today' }), 700),
this.runWidget('inHouse', () => this.reservation.inHouse(args), 700),
this.runWidget('occupancy', () => this.inventory.occupancyKpi(args), 600),
this.runWidget('revenue', () => this.billing.revenueKpi(args), 600),
this.runWidget('housekeepingSummary', () => this.housekeeping.summary(args), 700),
this.runWidget('maintenanceSummary', () => this.maintenance.summary(args), 700),
this.runWidget('aiInbox', () => this.ai.fetchSuggestions({ ...args, limit: 5 }), 800),
this.runWidget('alerts', () => this.notification.listAlerts({ ...args, limit: 10 }), 600),
this.runWidget('syncStatus', () => this.sync.readCursor({ deviceId: ctx.deviceId }), 200),
]);
return assembleSnapshot(ctx, propertyId, widgets, this.clock.now());
}
private async runWidget<T>(name: string, fn: () => Promise<T>, deadlineMs: number): Promise<{ name: string; status: 'fresh'|'stale'|'unavailable'; payload?: T }> {
try {
const payload = await withDeadline(fn, deadlineMs);
return { name, status: 'fresh', payload };
} catch (err) {
logSoft(err, { widget: name });
return { name, status: err instanceof DeadlineExceededError ? 'unavailable' : 'stale' };
}
}
}
Notes:
- Per-widget deadlines are tighter than the route deadline (5 s) so the user always gets something.
- Single-flight collapses concurrent identical compositions — useful at shift change when many operators open the same dashboard.
- Cache TTL 30 s is short enough to feel live; long enough to absorb the fanout cost of 11 widgets.
5. UC-07 — DecideAISuggestion
class DecideAISuggestionUseCase {
async execute(ctx: SessionContext, suggestionId: string, decision: SuggestionDecision, idemKey: string): Promise<AISuggestionInboxEntry> {
const known = await this.idem.lookup(idemKey, hash({ suggestionId, decision }));
if (known) return known.response as AISuggestionInboxEntry;
const entry = await this.inboxStore.find(ctx.tenantId, suggestionId);
if (!entry) throw new NotFoundError('MELMASTOON.BFF.BACKOFFICE.SUGGESTION_NOT_FOUND');
if (entry.decision) throw new ConflictError('MELMASTOON.BFF.BACKOFFICE.DECISION_ALREADY_RECORDED');
const updated = await this.inboxStore.decide(entry.id, {
...decision,
decidedBy: ctx.operatorId,
decidedDeviceId: ctx.deviceId,
decidedAt: this.clock.nowIso(),
});
void this.ai.reportDecision({
tenantId: ctx.tenantId,
suggestionId,
decision,
operatorId: ctx.operatorId,
attestation: { device: ctx.deviceId, sessionId: ctx.sessionId },
});
void this.outbox.enqueue('melmastoon.bff.backoffice.ai_suggestion.decided.v1', toDecidedPayload(ctx, updated), 1.0);
void this.activity.append({ ...activityBase(ctx), action: 'ai.suggestion.decided', resourceRef: { kind: 'ai_suggestion', id: suggestionId }, outcome: 'success' });
await this.idem.store(idemKey, hash({ suggestionId, decision }), updated);
return updated;
}
}
Decision is recorded first, orchestrator notified async; if the orchestrator is unreachable, the local decision still stands. Reconciliation is handled by orchestrator on its next inbox sync.
6. UC-15 — NegotiateSyncHandshake
class NegotiateSyncHandshakeUseCase {
async execute(ctx: SessionContext, request: SyncHandshakeRequest): Promise<SyncHandshakeResponse> {
if (request.deviceId !== ctx.deviceId) throw new ForbiddenError('MELMASTOON.BFF.BACKOFFICE.DEVICE_MISMATCH');
const cursor = await this.deviceStore.get(ctx.tenantId, ctx.deviceId);
const sessionToken = await this.sync.handshake({
tenantId: ctx.tenantId,
deviceId: ctx.deviceId,
operatorId: ctx.operatorId,
lastKnownCursor: cursor?.lastCursor ?? null,
appVersion: request.appVersion,
capabilities: request.capabilities,
});
await this.deviceStore.upsert({
tenantId: ctx.tenantId,
deviceId: ctx.deviceId,
operatorId: ctx.operatorId,
lastSyncHandshakeAt: this.clock.nowIso(),
appVersion: request.appVersion,
networkProfile: request.networkProfile,
});
void this.outbox.enqueue('melmastoon.bff.backoffice.sync.handshake_completed.v1', toHandshakePayload(ctx, request), 1.0);
return {
syncSessionToken: sessionToken.token,
expiresAt: sessionToken.expiresAt,
pullEndpoint: sessionToken.pullEndpoint, // direct to sync-service; BFF NOT proxied
pushEndpoint: sessionToken.pushEndpoint, // direct to sync-service; BFF NOT proxied
cursor: cursor?.lastCursor ?? sessionToken.initialCursor,
maxBatchSize: sessionToken.maxBatchSize,
};
}
}
After handshake, the desktop talks directly to sync-service for pull/push; the BFF is only on the handshake path, not the bulk transfer path. This keeps the BFF stateless and avoids egress cost amplification.
7. UC-16 — ProxyLockAction
Lock actions are sensitive (physical access). MFA step-up gate enforced.
class ProxyLockActionUseCase {
async execute(ctx: SessionContext, reservationId: string, op: LockOp, body: LockOpRequest, idemKey: string): Promise<LockOpResult> {
if (op === 'revoke' && this.config.requiresMfaForRevoke()) {
const mfaToken = body.mfaAttestationToken;
if (!mfaToken) throw new UnauthorizedError('MELMASTOON.BFF.BACKOFFICE.MFA_REQUIRED');
const attest = await this.mfaStore.consume(ctx.tenantId, ctx.operatorId, ctx.deviceId, mfaToken, 'lock_revoke');
if (!attest) throw new UnauthorizedError('MELMASTOON.BFF.BACKOFFICE.MFA_INVALID_OR_USED');
}
const known = await this.idem.lookup(idemKey, hash({ reservationId, op, body }));
if (known) return known.response as LockOpResult;
const audit = buildAuditEnvelope(ctx, { reservationId, op });
let result: LockOpResult;
let outcome: 'success'|'failure' = 'success';
try {
result = op === 'issue'
? await this.lock.issueKey({ ...body, reservationId }, audit)
: await this.lock.revokeKey({ ...body, reservationId }, audit);
} catch (e) {
outcome = 'failure';
throw e;
} finally {
await this.lockAudit.append({
...audit,
occurredAt: this.clock.nowIso(),
outcome,
upstreamRequestId: ctx.requestId,
upstreamLatencyMs: ctx.elapsedMs(),
});
void this.outbox.enqueue('melmastoon.bff.backoffice.lock.action_proxied.v1', toLockProxyPayload(ctx, reservationId, op, outcome), 1.0);
}
await this.idem.store(idemKey, hash({ reservationId, op, body }), result);
return result;
}
}
8. UC-17 — ProxyDomainMutation
Generic proxy with idempotency + audit envelope. Used for reservations check-in/check-out, folio adjustments, work order updates, etc.
class ProxyDomainMutationUseCase<TReq, TRes> {
async execute(ctx: SessionContext, target: ProxyTarget, body: TReq, idemKey: string): Promise<TRes> {
const known = await this.idem.lookup(idemKey, hash({ target, body }));
if (known) return known.response as TRes;
const audit = buildAuditEnvelope(ctx, { target });
const start = this.clock.now();
try {
const res = await this.gateway.proxy<TReq, TRes>(target, body, idemKey, audit);
await this.activity.append({ ...activityBase(ctx), category: 'mutation_proxy', action: target.action, resourceRef: target.resourceRef, outcome: 'success', latencyMs: this.clock.now() - start });
await this.idem.store(idemKey, hash({ target, body }), res);
return res;
} catch (err) {
await this.activity.append({ ...activityBase(ctx), category: 'mutation_proxy', action: target.action, resourceRef: target.resourceRef, outcome: 'failure', latencyMs: this.clock.now() - start, error: toErrEnvelope(err) });
throw err;
}
}
}
9. UC-18 — OpenSseStream
class OpenSseStreamUseCase {
async execute(ctx: SessionContext, channels: SseChannel[], req: HttpRequest, res: HttpResponse): Promise<void> {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const heartbeat = setInterval(() => res.write(`: keepalive\n\n`), 25000);
try {
for await (const event of this.sseBus.subscribe(ctx.deviceId, channels)) {
res.write(`event: ${event.channel}\nid: ${event.id}\ndata: ${JSON.stringify(event.payload)}\n\n`);
}
} finally {
clearInterval(heartbeat);
}
}
}
Per-device SSE channel multiplex: ai, alerts, dashboard-refresh-hints, session. Idle connections held open with 25 s keep-alive comments; many corporate proxies kill at 30 s.
10. Cross-cutting — Idempotency
All mutating endpoints require X-Idempotency-Key. The desktop generates a ULID per logical action; on retry the same key is reused. The BFF stores (idem_key, request_hash) → response for 24 h. Different bodies under the same key → 412 MELMASTOON.GENERAL.PRECONDITION_FAILED.
11. Cross-cutting — Audit envelope
Every domain-mutation proxy and lock-action proxy carries an AuditEnvelope:
interface AuditEnvelope {
tenantId: TenantId;
operatorId: OperatorId;
deviceId: DeviceId;
sessionId: SessionId;
requestId: string;
traceId: string;
appVersion: string;
appPlatform: 'win32' | 'darwin' | 'linux';
mfaAttestationId?: MfaAttestationId;
emittedAt: ISODateTime;
}
Forwarded as headers (X-Audit-*) and as an in-body _audit block when the upstream contract permits.
12. Cross-cutting — Optimistic concurrency
OperatorPreferences carries a monotonic version. Writes pass expectedVersion; mismatch → 412 + MELMASTOON.BFF.BACKOFFICE.PREFERENCES_CONFLICT. Tenant-service is the source of truth and enforces.
13. Cross-cutting — Deadlines + circuits
| Upstream | Deadline | Retry | Circuit |
|---|---|---|---|
iam-service (refresh) | 600 ms | 0 | open after 3 fails / 15 s |
iam-service (validate) | 200 ms | 0 | open after 5 fails / 15 s |
tenant-service | 500 ms | 1 | open after 5 fails / 15 s |
reservation-service | 800 ms (read) / 1500 ms (mutation) | 1 (mutation with idem-key) | open after 3 fails / 15 s |
inventory-service | 600 ms | 1 | open after 5 fails / 15 s |
pricing-service | 600 ms | 1 | open after 5 fails / 15 s |
housekeeping-service | 700 ms | 1 | open after 5 fails / 15 s |
maintenance-service | 700 ms | 1 | open after 5 fails / 15 s |
billing-service | 600 ms | 1 | open after 5 fails / 15 s |
lock-integration-service | 1200 ms | 0 (sensitive) | open after 3 fails / 15 s |
ai-orchestrator-service | 800 ms | 0 | open after 3 fails / 15 s |
notification-service | 500 ms | 1 | open after 5 fails / 15 s |
sync-service (handshake) | 700 ms | 0 | open after 3 fails / 15 s |
analytics-service | 600 ms | 1 | open after 5 fails / 15 s |
When a circuit is open, dependent widgets render as unavailable in dashboard composition; mutation proxies return 503 + MELMASTOON.BFF.UPSTREAM_UNAVAILABLE.
14. Cross-cutting — Tenant + device guard
Every use case calls enforceContext(ctx):
function enforceContext(ctx: SessionContext) {
if (!ctx.session) throw new UnauthorizedError('MELMASTOON.IAM.SESSION_REQUIRED');
if (ctx.session.expiresAt < now()) throw new UnauthorizedError('MELMASTOON.IAM.SESSION_EXPIRED');
if (ctx.session.deviceId !== ctx.deviceId) throw new ForbiddenError('MELMASTOON.BFF.BACKOFFICE.DEVICE_MISMATCH');
if (ctx.session.tenantId !== ctx.tenantId) throw new ForbiddenError('MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE');
}
15. Cross-cutting — Cache invalidation (inbox)
Cache-invalidation events trigger projection updates:
| Event | Updates |
|---|---|
melmastoon.iam.session.revoked.v1 | Drop BackofficeSession; SSE forceLogout to device |
melmastoon.ai.suggestion.created.v1 | Insert AISuggestionInbox entry; SSE ai.new |
melmastoon.ai.suggestion.invalidated.v1 | Drop entry; SSE ai.removed |
melmastoon.alert.raised.v1 | Insert AlertInbox entry; SSE alerts.new |
melmastoon.alert.resolved.v1 | Drop entry; SSE alerts.removed |
melmastoon.theme.published.v1 | Invalidate keyboard shortcut help bundle |
melmastoon.reservation.checked_in.v1 | Invalidate dashboard:*:propertyId:* (selective) |
melmastoon.housekeeping.task.created.v1 | Invalidate housekeeping board cache |
melmastoon.maintenance.workorder.opened.v1 | Invalidate maintenance board cache |
Inbox events deduplicated by message ID via the standard inbox table.
16. Cross-cutting — Outbox + telemetry
All telemetry events are written to outbox in the same Postgres tx as any side-effecting persistence (idempotency rows, audit ledger rows). The outbox-relay worker drains to Pub/Sub. Sampling rates are read from bff-backoffice-flags Memorystore key.