Skip to main content

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 stepWhat must be undone
send-welcome-email failsDelete the created account
provision-workspace failsDelete account (email is RETRIABLE, always succeeds)
charge-first-billing failsDeprovision 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.

Note on welcome email

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.