Covers splitting the god class into per-path workflow services, compensating transactions via DistributedTransactionService, error classification, XState frontend state machine, and legacy signup flow removal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
16 KiB
Get-Started Flow Refactoring Design
Problem
The GetStartedWorkflowService is a 1,300-line god class with 18 injected dependencies handling 5 distinct workflows. It swallows infrastructure errors as "not found," has no compensating transactions for partial failures, returns generic error messages for all failure types, and the frontend has an implicit state machine with no transition guards. A separate legacy SignupWorkflowService duplicates signup logic.
Decisions
- Architecture: Hybrid approach — per-path workflow services composing shared step functions
- Frontend state: XState replaces Zustand for enforced state transitions with guards
- Legacy flow: Remove
SignupWorkflowService; get-started becomes the canonical signup path - Testing: Deferred to a follow-up pass
Scope
What Changes
| Area | Before | After |
|---|---|---|
| BFF Orchestration | 1 god class (1,300 lines, 18 deps) | 1 thin coordinator + 5 focused workflows + 6 shared steps |
| Compensating Transactions | Silent partial failures, orphaned accounts | DistributedTransactionService with per-step rollback |
| Error Handling | Generic "creation failed" for all errors | Classified errors (retriable / permanent / transient) surfaced to frontend |
| Frontend State | Zustand store with implicit transitions | XState machine with guards, typed events, enforced transitions |
| Legacy Signup | Two parallel signup paths | One canonical path (get-started) |
| Dashboard Route | 404 on /account/dashboard |
Correct redirect to /account |
| WHMCS Discovery | 403 swallowed as "not found" | Infrastructure errors propagated |
What Doesn't Change
- Controller API contract — same endpoints, same request/response shapes
- Domain schemas —
packages/domain/get-started/stays the same - Session service —
GetStartedSessionServicestays as-is - Existing shared helpers —
SignupWhmcsService,SignupUserCreationService,SignupAccountResolverServiceare kept and reused
1. Backend — Splitting the God Class
New File Structure
apps/bff/src/modules/auth/infra/workflows/
├── get-started-coordinator.service.ts # Thin dispatcher (~80 lines)
├── verification-workflow.service.ts # send-code + verify-code (~200 lines)
├── guest-eligibility-workflow.service.ts # Guest eligibility check (~180 lines)
├── new-customer-signup-workflow.service.ts # NEW_CUSTOMER path (~200 lines)
├── sf-completion-workflow.service.ts # SF_UNMAPPED path (~180 lines)
├── whmcs-migration-workflow.service.ts # WHMCS_UNMAPPED path (~200 lines)
├── steps/ # Shared step functions
│ ├── resolve-salesforce-account.step.ts # Find or create SF account
│ ├── create-whmcs-client.step.ts # Create WHMCS client + rollback
│ ├── create-portal-user.step.ts # Create user + mapping + rollback
│ ├── update-salesforce-flags.step.ts # Update SF portal status
│ ├── generate-auth-result.step.ts # Auth tokens + audit log
│ └── create-eligibility-case.step.ts # SF eligibility case creation
├── signup/ # Existing helpers (keep as-is)
│ ├── signup-whmcs.service.ts
│ ├── signup-user-creation.service.ts
│ ├── signup-account-resolver.service.ts
│ └── signup-validation.service.ts
└── [DELETED] get-started-workflow.service.ts # The god class — removed
Coordinator (Thin Dispatcher)
The coordinator is a thin routing layer. It holds references to each workflow service and delegates based on the endpoint or session state:
@Injectable()
export class GetStartedCoordinator {
constructor(
private readonly verification: VerificationWorkflowService,
private readonly guestEligibility: GuestEligibilityWorkflowService,
private readonly newCustomerSignup: NewCustomerSignupWorkflowService,
private readonly sfCompletion: SfCompletionWorkflowService,
private readonly whmcsMigration: WhmcsMigrationWorkflowService,
) {}
sendCode(req, fingerprint?) → this.verification.sendCode(req, fingerprint)
verifyCode(req, fingerprint?) → this.verification.verifyCode(req, fingerprint)
guestEligibility(req, fp?) → this.guestEligibility.execute(req, fp)
completeAccount(req) → dispatches to sfCompletion or newCustomerSignup
based on session.accountStatus
signupWithEligibility(req) → this.newCustomerSignup.execute(req)
migrateWhmcsAccount(req) → this.whmcsMigration.execute(req)
}
The controller calls the coordinator — same API contract, no frontend changes.
Shared Steps
Each step is an injectable service with execute() and optional rollback():
@Injectable()
export class ResolveSalesforceAccountStep {
async execute(params: { email, firstName, lastName, source }): Promise<{ sfAccountId: string }>
// No rollback — SF accounts are reusable
}
@Injectable()
export class CreateWhmcsClientStep {
async execute(params: { email, password, ... }): Promise<{ whmcsClientId: number }>
async rollback(whmcsClientId: number): Promise<void> // marks for cleanup
}
@Injectable()
export class CreatePortalUserStep {
async execute(params: { email, passwordHash, sfAccountId, whmcsClientId }): Promise<{ userId: string }>
async rollback(userId: string): Promise<void> // deletes user + mapping
}
@Injectable()
export class UpdateSalesforceFlagsStep {
async execute(params: { sfAccountId, status, source, whmcsClientId? }): Promise<void>
// No rollback — retry-safe
}
@Injectable()
export class GenerateAuthResultStep {
async execute(params: { userId }): Promise<AuthResultInternal>
// No rollback — stateless
}
@Injectable()
export class CreateEligibilityCaseStep {
async execute(params: { sfAccountId, address, email }): Promise<{ caseId: string }>
// No rollback — cases are idempotent
}
Per-Path Workflow Example (NewCustomerSignupWorkflow)
Each workflow composes shared steps via DistributedTransactionService:
@Injectable()
export class NewCustomerSignupWorkflowService {
async execute(request: SignupWithEligibilityRequest): Promise<SignupResponse> {
// 1. Acquire session + email lock
const session = await this.sessionService.acquireAndMarkAsUsed(
request.sessionToken, 'signup'
);
return this.lockService.withLock(
`get-started:${session.email}`,
{ ttlMs: 60_000 },
async () => {
// 2. Run transaction with compensating rollbacks
const result = await this.txService.execute({
transactionId: `signup-${session.id}`,
steps: [
{
id: 'resolve-sf',
execute: () => this.sfStep.execute({ ... }),
critical: true,
},
{
id: 'create-eligibility-case',
execute: (prev) => this.caseStep.execute({
sfAccountId: prev['resolve-sf'].sfAccountId,
}),
critical: false, // degradable
},
{
id: 'create-whmcs',
execute: () => this.whmcsStep.execute({ ... }),
rollback: (result) => this.whmcsStep.rollback(result.whmcsClientId),
critical: true,
},
{
id: 'create-portal-user',
execute: (prev) => this.portalUserStep.execute({ ... }),
rollback: (result) => this.portalUserStep.rollback(result.userId),
critical: true,
},
{
id: 'update-sf-flags',
execute: () => this.sfFlagsStep.execute({ ... }),
critical: false, // degradable
},
],
});
// 3. Generate auth result
await this.sessionService.invalidate(session.id);
return this.authResultStep.execute({
userId: result.stepResults['create-portal-user'].userId,
});
}
);
}
}
Legacy Cleanup
- Delete
SignupWorkflowService(the legacy signup path) - Remove its references from
AuthOrchestratorandauth.module.ts AuthOrchestratorkeeps login, password reset, WHMCS linking
2. Compensating Transactions & Error Classification
Rollback Strategy Per Step
| Step | Rollback Strategy | Criticality |
|---|---|---|
| Resolve SF Account | No rollback — SF accounts are reusable | CRITICAL |
| Create Eligibility Case | No rollback — idempotent, CS can see partial state | DEGRADABLE |
| Create WHMCS Client | SignupWhmcsService.markClientForCleanup() — flags for manual review |
CRITICAL |
| Create Portal User | Delete user + mapping in Prisma transaction | CRITICAL |
| Update SF Flags | No rollback — retry-safe, non-critical | DEGRADABLE |
| Generate Auth Result | No rollback — stateless token generation | CRITICAL |
On failure of a CRITICAL step, DistributedTransactionService rolls back all completed steps in reverse order. DEGRADABLE steps log warnings but don't block the workflow.
Error Classification
enum ErrorCategory {
RETRIABLE, // Network timeout, rate limit → "Try again in a moment"
PERMANENT, // Email already registered, invalid data → specific message
TRANSIENT, // SF down, WHMCS unavailable → "We're having issues, try later"
}
Each workflow catches errors and classifies them before returning to the controller. The frontend receives { success: false, errorCategory, message } instead of a generic "Account creation failed."
The safeOperation criticality levels map naturally:
CRITICALfailure with network error →TRANSIENTCRITICALfailure with conflict →PERMANENTDEGRADABLEfailure → logged, workflow continues
3. Frontend — XState State Machine
State Machine
┌──────────┐
│ idle │
└────┬─────┘
│ START
┌────▼─────┐
┌─────│ email │
│ └────┬─────┘
│ │ SEND_CODE (success)
│ ┌────▼──────────┐
│ │ verification │
│ └────┬──────────┘
│ │ VERIFY_CODE (success)
│ ┌────▼──────────────┐
│ │ account-status │─── PORTAL_EXISTS ──▶ login-redirect
│ └───┬──────┬────────┘
│ │ │
│ NEW_CUSTOMER │ WHMCS_UNMAPPED
│ or SF_UNMAPPED
│ │ │
│ ┌──────▼───┐ ┌─────▼────────────┐
│ │ complete │ │ migrate-account │
│ │ account │ │ │
│ └──────┬────┘ └─────┬─────────────┘
│ │ │
│ │ COMPLETE │ MIGRATE
│ │ │
│ ┌───▼─────────────▼───┐
│ │ success │
│ └─────────────────────┘
│
└── RESET (from any state)
XState Features Used
Guards — validate transitions:
hasSessionToken: blocks complete/migrate without verified sessionisNewOrSfUnmapped: only NEW_CUSTOMER and SF_UNMAPPED enter complete-accountisWhmcsUnmapped: only WHMCS_UNMAPPED enters migrate-account
Actions — side effects on transitions:
setSessionToken,setAccountStatus,setPrefill— store API response dataclearError,setError— error state managementresetMachine— clean state for retry
Actors — async API calls invoked by states:
sendCodeService→getStartedApi.sendVerificationCode()verifyCodeService→getStartedApi.verifyCode()completeAccountService→getStartedApi.completeAccount()migrateAccountService→getStartedApi.migrateWhmcsAccount()
Each API-calling state has substates: idle, loading, error — replacing manual loading/error flags.
File Structure
apps/portal/src/features/get-started/
├── machines/
│ ├── get-started.machine.ts # XState machine definition
│ ├── get-started.types.ts # Context, events, guards types
│ └── get-started.actors.ts # API service actors
├── hooks/
│ └── useGetStartedMachine.ts # React hook wrapping the machine
├── stores/
│ └── [DELETED] get-started.store.ts # Replaced by XState
├── components/
│ └── GetStartedForm/
│ └── GetStartedForm.tsx # Uses useGetStartedMachine()
Component Integration
// Before (Zustand):
const { step, sendVerificationCode, loading, error } = useGetStartedStore();
// After (XState):
const { state, send } = useGetStartedMachine();
// state.value === "email" | "verification" | ...
// state.context.sessionToken, state.context.accountStatus, etc.
// send({ type: "SEND_CODE", email })
Handoff Handling
GetStartedView reads sessionStorage for handoffs, then sends events to the machine:
- Verified handoff →
send({ type: "RESTORE_VERIFIED_SESSION", sessionToken, accountStatus, prefill }) - Unverified handoff →
send({ type: "SET_EMAIL", email, handoffToken })
The machine has explicit transitions for these; invalid handoff states are rejected by guards.
4. Immediate Bug Fixes
Dashboard 404
Change redirect fallback from /account/dashboard to /account in:
SuccessStep.tsx—getSafeRedirect()fallbackGetStartedView.tsx—handleRedirectParam()fallback
WHMCS 403 Error Swallowing
In WhmcsAccountDiscoveryService, findClientByEmail() and findUserByEmail() catch all errors and return null. A 403 (auth/permissions failure) becomes indistinguishable from "user not found," risking duplicate account creation.
Fix: Only return null for genuine not-found errors (NotFoundException, messages containing "not found"). Re-throw all other errors (auth failures, network issues, timeouts).
Execution Order
- Fix immediate bugs (dashboard 404 + WHMCS 403)
- Remove legacy
SignupWorkflowService - Create shared step services
- Split god class into per-path workflows + coordinator
- Wire
DistributedTransactionServicefor rollback in each workflow - Add error classification to coordinator responses
- Add XState machine + hook
- Migrate frontend components from Zustand to XState
- Delete old Zustand store
New Dependencies
xstate+@xstate/react— frontend state machine- No new backend dependencies — uses existing infrastructure