Example: User Onboarding
A saga example for user onboarding — from account creation to first billing.
The Problem
Onboarding a new user involves four coordinated steps across multiple services:
1. create-account → Create the user account in the system
2. send-welcome-email → Send welcome email and verify address
3. provision-workspace → Create the user's workspace and initial resources
4. charge-first-billing → Charge the first subscription billing
The tricky part is compensation:
| Failure at step | What must be undone |
|---|---|
send-welcome-email fails | Delete the created account |
provision-workspace fails | Delete account (email is RETRIABLE, always succeeds) |
charge-first-billing fails | Deprovision workspace + delete account |
send-welcome-email is a RETRIABLE step — emails are idempotent (sending twice just creates a duplicate), and we want the user to always receive the welcome message. The billing step is COMPENSABLE — if charging fails, we clean up everything.
Context
public record OnboardingContext(
// Input data
String email,
String name,
String planId,
String paymentMethodId,
String referralCode,
// Step outputs
String userId,
String workspaceId,
String billingSubscriptionId
) {
public static OnboardingContext create(SignupRequest request) {
return new OnboardingContext(
request.getEmail(),
request.getName(),
request.getPlanId(),
request.getPaymentMethodId(),
request.getReferralCode(),
null, null, null
);
}
public OnboardingContext withUserId(String id) {
return new OnboardingContext(email, name, planId, paymentMethodId,
referralCode, id, workspaceId, billingSubscriptionId);
}
public OnboardingContext withWorkspaceId(String id) {
return new OnboardingContext(email, name, planId, paymentMethodId,
referralCode, userId, id, billingSubscriptionId);
}
public OnboardingContext withBillingSubscriptionId(String id) {
return new OnboardingContext(email, name, planId, paymentMethodId,
referralCode, userId, workspaceId, id);
}
}
Saga Definition
@Saga(
value = "user-onboarding",
description = "Complete user onboarding with automatic rollback on failure"
)
public class UserOnboardingSaga implements SagaDefinition<OnboardingContext> {
private final AccountService accountService;
private final EmailService emailService;
private final WorkspaceService workspaceService;
private final BillingService billingService;
public UserOnboardingSaga(
AccountService accountService,
EmailService emailService,
WorkspaceService workspaceService,
BillingService billingService
) {
this.accountService = accountService;
this.emailService = emailService;
this.workspaceService = workspaceService;
this.billingService = billingService;
}
@Override
public void define(SagaBuilder<OnboardingContext> builder) {
builder
// Step 1: Create account (COMPENSABLE)
.step("create-account")
.invoke(this::createAccount)
.compensate(this::deleteAccount)
.timeout(Duration.ofSeconds(10))
// Step 2: Send welcome email (RETRIABLE — emails are idempotent)
.step("send-welcome-email")
.invoke(this::sendWelcomeEmail)
.retry(infinite()
.initialDelay(Duration.ofSeconds(30))
.maxDelay(Duration.ofMinutes(30)))
// Step 3: Provision workspace (COMPENSABLE)
.step("provision-workspace")
.invoke(this::provisionWorkspace)
.compensate(this::deprovisionWorkspace)
.timeout(Duration.ofSeconds(30))
.retry(exponential(3, Duration.ofSeconds(5)))
// Step 4: Charge first billing (COMPENSABLE)
.step("charge-first-billing")
.invoke(this::chargeFirstBilling)
.compensate(this::refundFirstBilling)
.retry(exponential(3, Duration.ofSeconds(2)))
.timeout(Duration.ofSeconds(20))
.build();
}
// ═══════════════════════════════════════════════════════════════
// INVOKE METHODS
// ═══════════════════════════════════════════════════════════════
private OnboardingContext createAccount(OnboardingContext ctx) {
log.info("Creating account for: {}", ctx.email());
User user = accountService.create(
ctx.email(),
ctx.name(),
ctx.referralCode()
);
return ctx.withUserId(user.getId());
}
private OnboardingContext sendWelcomeEmail(OnboardingContext ctx) {
log.info("Sending welcome email to: {}", ctx.email());
emailService.sendWelcome(
ctx.userId(),
ctx.email(),
ctx.name()
);
return ctx;
}
private OnboardingContext provisionWorkspace(OnboardingContext ctx) {
log.info("Provisioning workspace for user: {}", ctx.userId());
Workspace workspace = workspaceService.provision(
ctx.userId(),
ctx.planId()
);
return ctx.withWorkspaceId(workspace.getId());
}
private OnboardingContext chargeFirstBilling(OnboardingContext ctx) {
log.info("Charging first billing for user: {}, plan: {}",
ctx.userId(), ctx.planId());
Subscription subscription = billingService.createSubscription(
ctx.userId(),
ctx.paymentMethodId(),
ctx.planId(),
ctx.workspaceId()
);
return ctx.withBillingSubscriptionId(subscription.getId());
}
// ═══════════════════════════════════════════════════════════════
// COMPENSATE METHODS
// ═══════════════════════════════════════════════════════════════
private void deleteAccount(OnboardingContext ctx) {
log.info("Compensating: deleting account for user: {}", ctx.userId());
if (ctx.userId() != null) {
accountService.delete(ctx.userId());
}
}
private void deprovisionWorkspace(OnboardingContext ctx) {
log.info("Compensating: deprovisioning workspace: {}", ctx.workspaceId());
if (ctx.workspaceId() != null) {
workspaceService.deprovision(ctx.workspaceId());
}
}
private void refundFirstBilling(OnboardingContext ctx) {
log.info("Compensating: cancelling subscription: {}",
ctx.billingSubscriptionId());
if (ctx.billingSubscriptionId() != null) {
billingService.cancelSubscription(
ctx.billingSubscriptionId(),
"onboarding_failed"
);
}
}
}
What Failure Looks Like
If workspace provisioning fails after the welcome email was sent:
{
"name": "user-onboarding",
"status": { "type": "COMPENSATED" },
"steps": [
{
"name": "create-account",
"status": { "type": "COMPENSATED" },
"durationMs": 120
},
{
"name": "send-welcome-email",
"status": { "type": "COMPLETED" },
"durationMs": 890,
"attempt": 2
},
{
"name": "provision-workspace",
"status": { "type": "FAILED" },
"attempt": 3,
"maxAttempts": 3,
"lastError": "Workspace quota exceeded for region us-east-1"
}
]
}
The account was deleted automatically. The billing step never ran. The welcome email was sent (it's idempotent, and the user shouldn't be in a broken half-created state with no notification). The user can try signing up again from scratch.
The welcome email step has no compensation because emails are idempotent in effect — sending it twice is acceptable. What's not acceptable is having a half-created account with no email sent at all. That's why it's RETRIABLE rather than COMPENSABLE.