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>
This commit is contained in:
barsa 2026-02-24 13:57:43 +09:00
parent 5c329bbe96
commit 912582caf7

View File

@ -0,0 +1,374 @@
# 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:
```typescript
@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()`:
```typescript
@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`:
```typescript
@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
```typescript
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:
- `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
```typescript
// 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()` fallback
- `GetStartedView.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
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