← Back to blog

Designing Frontend Architecture for Transaction-Heavy Systems

8 min read
ArchitectureFintechState ManagementDistributed Systems

Designing Frontend Architecture for Transaction-Heavy Systems

Building frontend systems for payment flows is fundamentally different from building social feeds or dashboards. When money moves, the UI isn't just a view—it's a critical participant in distributed transaction coordination.

After years building transaction-heavy frontends, I've learned that the architectural decisions you make upfront determine whether you ship with confidence or wake up to production fires.

Context: Why UI State Explodes in Payment Flows

A typical checkout flow coordinates:

  • Payment method validation (client + server)
  • Fraud detection (async, variable latency)
  • Inventory reservation (distributed lock)
  • Payment authorization (external service)
  • Order creation (multi-step transaction)
  • Notification dispatch (async, best-effort)

Each step has multiple failure modes. Each failure mode needs different UI states. The state space grows exponentially.

I've debugged production incidents where:

  • Payment succeeded but UI showed failure
  • User refreshed mid-transaction, paid twice
  • Concurrent requests caused inventory oversell
  • Partial failure left system in unknown state
  • Retry logic created duplicate charges

These aren't edge cases. In transaction systems, they're Tuesday.

Why This Matters in Production

In fintech, frontend bugs have financial consequences:

  • False negative → User pays twice, support refunds
  • False positive → Lost sale, damaged trust
  • Race condition → Duplicate charges, compliance issues
  • Wrong state display → Support tickets, manual reconciliation

One production bug in a payment flow can cost mid-six-figures before you roll back. The frontend is a liability until proven otherwise.

Architectural Decisions That Matter

1. Model State as a Finite State Machine

Don't use boolean flags. Don't use string enums. Use a proper state machine:

type PaymentFlowState = 
  | { phase: 'collecting-details'; data: Partial<PaymentData> }
  | { phase: 'validating'; data: PaymentData }
  | { phase: 'authorizing'; data: PaymentData; authId: string }
  | { phase: 'processing'; data: PaymentData; authId: string; orderId: string }
  | { phase: 'succeeded'; orderId: string; confirmationCode: string }
  | { phase: 'failed'; reason: FailureReason; retryable: boolean; data: PaymentData }
  | { phase: 'timeout'; lastKnownState: string; transactionId: string }
  | { phase: 'unknown'; transactionId: string }

type PaymentFlowAction = 
  | { type: 'SUBMIT'; data: PaymentData }
  | { type: 'AUTHORIZE_SUCCESS'; authId: string }
  | { type: 'AUTHORIZE_FAILURE'; reason: string }
  | { type: 'PROCESS_SUCCESS'; orderId: string; confirmationCode: string }
  | { type: 'PROCESS_FAILURE'; reason: string; retryable: boolean }
  | { type: 'TIMEOUT' }
  | { type: 'RETRY' }

function paymentReducer(
  state: PaymentFlowState, 
  action: PaymentFlowAction
): PaymentFlowState {
  switch (state.phase) {
    case 'collecting-details':
      if (action.type === 'SUBMIT') {
        return { phase: 'validating', data: action.data }
      }
      return state
      
    case 'validating':
      // Only valid transitions from validating
      return state
      
    // ... explicit transitions only
  }
}

Why this matters:

  • Invalid state transitions become TypeScript errors
  • Every possible state is explicitly handled
  • Debugging shows exact state machine position
  • No implicit state in multiple variables

In production, this prevents:

  • Users clicking "Pay" twice creating duplicate charges
  • Back button breaking transaction state
  • Network retry causing inconsistent state
  • Refresh mid-flow leaving orphaned transactions

2. Separate Request State from Business State

A common mistake I see:

// ❌ Conflating request state with business state
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [order, setOrder] = useState<Order | null>(null)

What breaks:

  • Can't distinguish "loading first time" from "retrying"
  • Can't show partial data during revalidation
  • Can't handle concurrent requests
  • Error state doesn't indicate recoverability

Better approach:

// ✅ Separate request metadata from business data
interface TransactionState<T> {
  // Business state
  data: T | null
  
  // Request state
  request: {
    status: 'idle' | 'pending' | 'success' | 'error'
    requestId: string
    timestamp: number
    attempt: number
  }
  
  // Error state
  error: {
    type: 'network' | 'validation' | 'business' | 'timeout'
    message: string
    retryable: boolean
    retryAfter?: number
  } | null
  
  // Transaction metadata
  transaction: {
    id: string
    initiatedAt: number
    lastUpdated: number
  }
}

This enables:

  • Showing stale data while revalidating
  • Retry logic based on error type
  • Timeout handling with last known state
  • Request deduplication via requestId
  • Metrics on attempt counts

3. Implement Idempotency at UI Layer

Backend idempotency isn't enough. The UI needs its own:

// ✅ Client-side idempotency key management
function useIdempotentMutation<TData, TResult>(
  mutationFn: (data: TData, idempotencyKey: string) => Promise<TResult>
) {
  const [state, setState] = useState<MutationState<TResult>>({
    status: 'idle',
    idempotencyKey: null,
  })
  
  const execute = useCallback(async (data: TData) => {
    // Check if mutation already in flight
    if (state.status === 'pending') {
      return state.promise // Return existing promise
    }
    
    // Generate or reuse idempotency key
    const idempotencyKey = state.idempotencyKey || crypto.randomUUID()
    
    // Store key for retries
    sessionStorage.setItem(
      `idempotency-${mutationFn.name}`,
      idempotencyKey
    )
    
    const promise = mutationFn(data, idempotencyKey)
    
    setState({
      status: 'pending',
      idempotencyKey,
      promise,
    })
    
    try {
      const result = await promise
      setState({ status: 'success', result, idempotencyKey })
      
      // Clear stored key on success
      sessionStorage.removeItem(`idempotency-${mutationFn.name}`)
      
      return result
    } catch (error) {
      setState({ status: 'error', error, idempotencyKey })
      throw error
    }
  }, [state, mutationFn])
  
  return { ...state, execute }
}

This prevents:

  • Duplicate submissions from double-clicks
  • Retry creating duplicate charges
  • Browser refresh duplicating transaction
  • Back button causing re-submission

4. Design for Partial Failures

In distributed systems, partial failures are normal:

// ✅ Handle every combination of success/failure
type CheckoutResult =
  | { status: 'complete'; orderId: string }
  | { status: 'payment-failed'; reason: string }
  | { status: 'payment-succeeded-order-failed'; paymentId: string }
  | { status: 'order-created-notification-failed'; orderId: string }
  | { status: 'unknown'; transactionId: string }

async function processCheckout(data: CheckoutData): Promise<CheckoutResult> {
  const transactionId = crypto.randomUUID()
  
  try {
    // Step 1: Authorize payment
    const payment = await authorizePayment(data.payment, transactionId)
    
    try {
      // Step 2: Create order
      const order = await createOrder(data.order, payment.id)
      
      try {
        // Step 3: Send notification (best-effort)
        await sendNotification(order.id)
        return { status: 'complete', orderId: order.id }
      } catch {
        // Notification failed but order succeeded
        return { status: 'order-created-notification-failed', orderId: order.id }
      }
    } catch (error) {
      // Order creation failed but payment succeeded
      // Need to void payment or queue for refund
      return { 
        status: 'payment-succeeded-order-failed', 
        paymentId: payment.id 
      }
    }
  } catch (error) {
    return { status: 'payment-failed', reason: error.message }
  }
}

UI handles each case explicitly:

function CheckoutConfirmation({ result }: Props) {
  switch (result.status) {
    case 'complete':
      return <SuccessView orderId={result.orderId} />
      
    case 'payment-failed':
      return <RetryPaymentView reason={result.reason} />
      
    case 'payment-succeeded-order-failed':
      return (
        <PartialSuccessView 
          message="Payment processed but order failed. Support will contact you."
          paymentId={result.paymentId}
        />
      )
      
    case 'order-created-notification-failed':
      return (
        <SuccessWithWarningView 
          orderId={result.orderId}
          warning="Order confirmed but email may be delayed"
        />
      )
      
    case 'unknown':
      return (
        <UnknownStateView 
          transactionId={result.transactionId}
          supportAction="Contact support with this transaction ID"
        />
      )
  }
}

5. Build Observability Into State

Every state change should be observable:

// ✅ Instrumented state machine
function useTransactionFlow(onStateChange?: (state: TransactionState) => void) {
  const [state, setState] = useState<TransactionState>(initialState)
  
  const transition = useCallback((action: TransactionAction) => {
    const nextState = transactionReducer(state, action)
    
    // Log every transition
    console.log('[Transaction]', {
      from: state.phase,
      to: nextState.phase,
      action: action.type,
      timestamp: Date.now(),
    })
    
    // Track in analytics
    trackEvent('transaction_state_change', {
      fromState: state.phase,
      toState: nextState.phase,
      transactionId: state.transactionId,
      duration: Date.now() - state.timestamp,
    })
    
    // Call external observer
    onStateChange?.(nextState)
    
    setState(nextState)
  }, [state, onStateChange])
  
  return [state, transition] as const
}

In production, this enables:

  • Replaying user's exact state journey
  • Identifying where users get stuck
  • Measuring time in each state
  • A/B testing different flows
  • Debugging specific transaction IDs

Tradeoffs

Explicit state machines:

  • ✅ Impossible states become impossible
  • ❌ More boilerplate
  • ❌ Harder to change flow

Idempotency keys:

  • ✅ Prevents duplicates
  • ❌ State management complexity
  • ❌ Need cleanup strategy

Partial failure handling:

  • ✅ Graceful degradation
  • ❌ More UI states to design
  • ❌ Complex recovery logic

Heavy instrumentation:

  • ✅ Fast debugging
  • ❌ Performance overhead
  • ❌ Privacy considerations

What I'd Do Differently Today

  1. Start with state machine diagram. Draw every state and transition before writing code.

  2. Build instrumentation first. Ship observability before features.

  3. Test partial failures explicitly. Use tools like Chaos Monkey to simulate backend failures.

  4. Design error states with support in mind. Every error should give support enough info to help.

  5. Document money movement separately. Map UI states to actual financial transactions.

The frontend in a transaction system isn't just UI. It's distributed transaction coordination, state management, error recovery, and financial risk management.

Most engineers treat it like a view layer. Staff engineers treat it like a distributed system.

When building payment flows, think like a distributed systems engineer who happens to write React.

The money depends on it.