Pular para o conteúdo principal

Exemplo: Onboarding de Usuário

Um exemplo de saga para onboarding de usuários — da criação da conta até o primeiro faturamento.


O Problema

Fazer o onboarding de um novo usuário envolve quatro etapas coordenadas entre múltiplos serviços:

1. create-account → Criar a conta do usuário no sistema
2. send-welcome-email → Enviar email de boas-vindas e verificar endereço
3. provision-workspace → Criar o workspace do usuário e recursos iniciais
4. charge-first-billing → Cobrar o primeiro faturamento da assinatura

A parte complicada é a compensação:

Falha na etapaO que deve ser desfeito
send-welcome-email falhaDeletar a conta criada
provision-workspace falhaDeletar conta (email é RETRIABLE, sempre tem sucesso)
charge-first-billing falhaDesprovisionar workspace + deletar conta

send-welcome-email é um step RETRIABLE — emails são idempotentes (enviar duas vezes apenas cria uma duplicata), e queremos que o usuário sempre receba a mensagem de boas-vindas. O step de faturamento é COMPENSABLE — se a cobrança falhar, limpamos tudo.


Context

public record OnboardingContext(
// Dados de entrada
String email,
String name,
String planId,
String paymentMethodId,
String referralCode,

// Outputs dos steps
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);
}
}

Definição da Saga

@Saga(
value = "user-onboarding",
description = "Onboarding completo de usuário com rollback automático em caso de falha"
)
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: Criar conta (COMPENSABLE)
.step("create-account")
.invoke(this::createAccount)
.compensate(this::deleteAccount)
.timeout(Duration.ofSeconds(10))

// Step 2: Enviar email de boas-vindas (RETRIABLE — emails são idempotentes)
.step("send-welcome-email")
.invoke(this::sendWelcomeEmail)
.retry(infinite()
.initialDelay(Duration.ofSeconds(30))
.maxDelay(Duration.ofMinutes(30)))

// Step 3: Provisionar workspace (COMPENSABLE)
.step("provision-workspace")
.invoke(this::provisionWorkspace)
.compensate(this::deprovisionWorkspace)
.timeout(Duration.ofSeconds(30))
.retry(exponential(3, Duration.ofSeconds(5)))

// Step 4: Cobrar primeiro faturamento (COMPENSABLE)
.step("charge-first-billing")
.invoke(this::chargeFirstBilling)
.compensate(this::refundFirstBilling)
.retry(exponential(3, Duration.ofSeconds(2)))
.timeout(Duration.ofSeconds(20))

.build();
}

// ═══════════════════════════════════════════════════════════════
// MÉTODOS INVOKE
// ═══════════════════════════════════════════════════════════════

private OnboardingContext createAccount(OnboardingContext ctx) {
log.info("Criando conta para: {}", ctx.email());

User user = accountService.create(
ctx.email(),
ctx.name(),
ctx.referralCode()
);

return ctx.withUserId(user.getId());
}

private OnboardingContext sendWelcomeEmail(OnboardingContext ctx) {
log.info("Enviando email de boas-vindas para: {}", ctx.email());

emailService.sendWelcome(
ctx.userId(),
ctx.email(),
ctx.name()
);

return ctx;
}

private OnboardingContext provisionWorkspace(OnboardingContext ctx) {
log.info("Provisionando workspace para usuário: {}", ctx.userId());

Workspace workspace = workspaceService.provision(
ctx.userId(),
ctx.planId()
);

return ctx.withWorkspaceId(workspace.getId());
}

private OnboardingContext chargeFirstBilling(OnboardingContext ctx) {
log.info("Cobrando primeiro faturamento para usuário: {}, plano: {}",
ctx.userId(), ctx.planId());

Subscription subscription = billingService.createSubscription(
ctx.userId(),
ctx.paymentMethodId(),
ctx.planId(),
ctx.workspaceId()
);

return ctx.withBillingSubscriptionId(subscription.getId());
}

// ═══════════════════════════════════════════════════════════════
// MÉTODOS COMPENSATE
// ═══════════════════════════════════════════════════════════════

private void deleteAccount(OnboardingContext ctx) {
log.info("Compensando: deletando conta do usuário: {}", ctx.userId());

if (ctx.userId() != null) {
accountService.delete(ctx.userId());
}
}

private void deprovisionWorkspace(OnboardingContext ctx) {
log.info("Compensando: desprovisionando workspace: {}", ctx.workspaceId());

if (ctx.workspaceId() != null) {
workspaceService.deprovision(ctx.workspaceId());
}
}

private void refundFirstBilling(OnboardingContext ctx) {
log.info("Compensando: cancelando assinatura: {}",
ctx.billingSubscriptionId());

if (ctx.billingSubscriptionId() != null) {
billingService.cancelSubscription(
ctx.billingSubscriptionId(),
"onboarding_failed"
);
}
}
}

Como Fica uma Falha

Se o provisionamento do workspace falhar após o email de boas-vindas ter sido enviado:

{
"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": "Cota de workspace excedida para a região us-east-1"
}
]
}

A conta foi deletada automaticamente. O step de faturamento nunca executou. O email de boas-vindas foi enviado (é idempotente, e o usuário não deveria ficar em um estado quebrado de conta semi-criada sem notificação). O usuário pode tentar se cadastrar novamente do zero.

Nota sobre o email de boas-vindas

O step de email de boas-vindas não tem compensação porque emails são idempotentes em efeito — enviar duas vezes é aceitável. O que não é aceitável é ter uma conta semi-criada sem email enviado. Por isso é RETRIABLE ao invés de COMPENSABLE.


Relacionados