Skip to main content

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

IDNameHTTP entryPurpose
UC-01OpenSessionfirst authenticated requestResolve operator + tenant + property scope; create session
UC-02RefreshSessionPOST /auth/refreshDevice-bound JWT refresh; iam proof-of-possession
UC-03CloseSessionPOST /auth/sign-outTear down session; emit closed event
UC-04ComposeDashboardGET /dashboardPer-widget fanout with deadlines + skeletons
UC-05ComposeWorkbenchView`GET /today/arrivals
UC-06FetchAISuggestionsGET /ai/suggestionsProxy to orchestrator; mirror to inbox
UC-07DecideAISuggestionPOST /ai/suggestions/{id}/decideRecord decision; notify orchestrator
UC-08ListAlertsGET /alertsMirror notification inbox
UC-09AcknowledgeAlertPOST /alerts/{id}/acknowledgeAck proxy + audit
UC-10ReadOperatorPreferencesGET /preferencesRead-through cache
UC-11WriteOperatorPreferencesPUT /preferencesProxy to tenant-service + mirror update
UC-12ReportHeartbeatPOST /devices/{id}/heartbeatDeviceSyncStatus update + telemetry
UC-13GetSyncCursorGET /sync/cursorCached cursor read (BFF projection)
UC-14AdvanceSyncCursorPOST /sync/cursorUpdate cache; emit cursor_advanced event
UC-15NegotiateSyncHandshakePOST /sync/handshakeMint sync-session-token via sync-service
UC-16ProxyLockAction`POST /locks/{rsv}/issue-key/revoke-key`
UC-17ProxyDomainMutationvarious POST /reservations/{id}/check-in, etc.Idem-key + audit envelope; thin proxy
UC-18OpenSseStreamGET /sse/streamMultiplex ai, alerts, dashboard-refresh-hints, session channels
UC-19BroadcastForceLogoutinbox event handlerPush forceLogout via SSE on session.revoked
UC-20ConsumeProjectionEventinbox handlerUpdate 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

UpstreamDeadlineRetryCircuit
iam-service (refresh)600 ms0open after 3 fails / 15 s
iam-service (validate)200 ms0open after 5 fails / 15 s
tenant-service500 ms1open after 5 fails / 15 s
reservation-service800 ms (read) / 1500 ms (mutation)1 (mutation with idem-key)open after 3 fails / 15 s
inventory-service600 ms1open after 5 fails / 15 s
pricing-service600 ms1open after 5 fails / 15 s
housekeeping-service700 ms1open after 5 fails / 15 s
maintenance-service700 ms1open after 5 fails / 15 s
billing-service600 ms1open after 5 fails / 15 s
lock-integration-service1200 ms0 (sensitive)open after 3 fails / 15 s
ai-orchestrator-service800 ms0open after 3 fails / 15 s
notification-service500 ms1open after 5 fails / 15 s
sync-service (handshake)700 ms0open after 3 fails / 15 s
analytics-service600 ms1open 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:

EventUpdates
melmastoon.iam.session.revoked.v1Drop BackofficeSession; SSE forceLogout to device
melmastoon.ai.suggestion.created.v1Insert AISuggestionInbox entry; SSE ai.new
melmastoon.ai.suggestion.invalidated.v1Drop entry; SSE ai.removed
melmastoon.alert.raised.v1Insert AlertInbox entry; SSE alerts.new
melmastoon.alert.resolved.v1Drop entry; SSE alerts.removed
melmastoon.theme.published.v1Invalidate keyboard shortcut help bundle
melmastoon.reservation.checked_in.v1Invalidate dashboard:*:propertyId:* (selective)
melmastoon.housekeeping.task.created.v1Invalidate housekeeping board cache
melmastoon.maintenance.workorder.opened.v1Invalidate 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.