Assist_Design/docs/plans/2026-02-24-get-started-refactoring-design.md
barsa 912582caf7 docs: add get-started flow refactoring design
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>
2026-02-24 13:57:43 +09:00

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 — GetStartedSessionService stays as-is
  • Existing shared helpers — SignupWhmcsService, SignupUserCreationService, SignupAccountResolverService are 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 AuthOrchestrator and auth.module.ts
  • AuthOrchestrator keeps 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:

  • CRITICAL failure with network error → TRANSIENT
  • CRITICAL failure with conflict → PERMANENT
  • DEGRADABLE failure → 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 session
  • isNewOrSfUnmapped: only NEW_CUSTOMER and SF_UNMAPPED enter complete-account
  • isWhmcsUnmapped: only WHMCS_UNMAPPED enters migrate-account

Actions — side effects on transitions:

  • setSessionToken, setAccountStatus, setPrefill — store API response data
  • clearError, setError — error state management
  • resetMachine — clean state for retry

Actors — async API calls invoked by states:

  • sendCodeServicegetStartedApi.sendVerificationCode()
  • verifyCodeServicegetStartedApi.verifyCode()
  • completeAccountServicegetStartedApi.completeAccount()
  • migrateAccountServicegetStartedApi.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.tsxgetSafeRedirect() fallback
  • GetStartedView.tsxhandleRedirectParam() 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

  1. Fix immediate bugs (dashboard 404 + WHMCS 403)
  2. Remove legacy SignupWorkflowService
  3. Create shared step services
  4. Split god class into per-path workflows + coordinator
  5. Wire DistributedTransactionService for rollback in each workflow
  6. Add error classification to coordinator responses
  7. Add XState machine + hook
  8. Migrate frontend components from Zustand to XState
  9. Delete old Zustand store

New Dependencies

  • xstate + @xstate/react — frontend state machine
  • No new backend dependencies — uses existing infrastructure