| Field | Value |
|---|---|
| TE ID | TE-05 |
| Status | Approved |
| ADR Reference | ADR-0035: Distributed Sagas |
| Satisfies | FS-10 (External B2B Access Request/Approval), FS-12 (Role Promotion Process) |
| Owner | Platform Team |
| Date | 2026-05-15 |
Long-running business processes — such as B2B access approval or role promotion — span multiple bounded contexts and may take minutes or hours. A single distributed transaction is impractical. Instead we need a coordinated sequence of steps where each step can be compensated if a subsequent step fails.
Each bounded context publishes events and reacts to events from other contexts. No central orchestrator — each participant owns its own step and its compensation logic.
FS-10: External B2B Access Request Saga
──────────────────────────────────────
[Requester] [Approval Context] [Identity Context]
│ │ │
│── AccessRequested ────────►│ │
│ │── ApprovalInitiated ──────►│
│ │ │── AccountProvisioned ──►
│ │◄── AccountProvisioned ─────│
│◄── AccessGranted ──────────│ │
On failure at any step → compensation events published backwards
PENDING ──► ACCESS_REQUESTED ──► APPROVAL_INITIATED ──► APPROVED ──► PROVISIONED
│ │ │ │
│ └── REJECTED ◄────────┘ │
│ │
└── CANCELLED ◄─────────────────────────────────────────┘ (compensation)
// domain/sagas/role-promotion.saga.ts
export type SagaStatus =
| 'PENDING'
| 'APPROVAL_REQUESTED'
| 'APPROVED'
| 'REJECTED'
| 'PROVISIONED'
| 'COMPENSATING'
| 'CANCELLED';
export class RolePromotionSaga {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly requestedRole: string,
public status: SagaStatus,
public readonly createdAt: Date,
public updatedAt: Date,
) {}
transitionTo(next: SagaStatus): Result<RolePromotionSaga> {
const allowed: Partial<Record<SagaStatus, SagaStatus[]>> = {
PENDING: ['APPROVAL_REQUESTED'],
APPROVAL_REQUESTED: ['APPROVED', 'REJECTED'],
APPROVED: ['PROVISIONED', 'COMPENSATING'],
COMPENSATING: ['CANCELLED'],
};
if (!allowed[this.status]?.includes(next)) {
return Result.fail(`Invalid transition ${this.status} → ${next}`);
}
return Result.ok(
new RolePromotionSaga(this.id, this.userId, this.requestedRole, next, this.createdAt, new Date()),
);
}
}
// infrastructure/subscribers/role-promotion.subscriber.ts
@Controller()
export class RolePromotionSubscriber {
constructor(
private readonly promotionService: RolePromotionService,
private readonly daprClient: DaprClient,
) {}
@DaprSubscribe({ pubsubName: 'ums-pubsub', topic: 'ums.iga.promotion-request.approved' })
async onApprovalGranted(@Body() event: ApprovalGrantedEvent): Promise<void> {
const result = await this.promotionService.provisionRole(event.sagaId);
if (result.isFailure) {
// publish compensation
await this.daprClient.pubsub.publish('ums-pubsub', 'ums.iga.promotion-request.compensation-requested', {
sagaId: event.sagaId,
reason: result.error,
});
}
}
@DaprSubscribe({ pubsubName: 'ums-pubsub', topic: 'ums.iga.promotion-request.rejected' })
async onApprovalRejected(@Body() event: ApprovalRejectedEvent): Promise<void> {
await this.promotionService.cancelPromotion(event.sagaId, event.reason);
}
}
| Step | Forward Event | Compensation Event | Compensation Handler |
|---|---|---|---|
| Request submitted | ums.iga.promotion-request.submitted |
ums.iga.promotion-request.cancelled |
Delete saga record |
| Approval initiated | ums.iga.promotion-request.approval-initiated |
ums.iga.promotion-request.compensation-requested |
Notify requester |
| Role provisioned | ums.iga.promotion-request.executed |
ums.iga.promotion-request.revoked |
Remove role grant |
# deploy/dapr/components/pubsub.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: ums-pubsub
spec:
type: pubsub.redis
version: v1
metadata:
- name: redisHost
value: redis:6379
- name: enableTLS
value: "false"
Every saga step checks whether the transition has already been applied (optimistic lock on updated_at). If a duplicate event arrives, the handler returns 200 OK without re-applying — Dapr’s at-least-once delivery is safe.
PROVISIONED status end-to-endREJECTED, requester is notifiedAPPROVED triggers COMPENSATING → CANCELLED/sagas/:id| Back to Blueprints Index | Back to Traceability Matrix |