Back to Blog
2024-04-12
10 min read

How hipages Scaled Lead Credits: The Actor Model Explained Simply

How hipages eliminated database locks, race conditions, and scaling bottlenecks in our lead credit system—using the Actor Model, a pattern inspired by banks and fintechs.

Actor ModelConcurrencyNestJSTypeScriptArchitectureFintech

How hipages Scaled Lead Credits: The Actor Model Explained Simply

At hipages, we faced a classic scaling challenge: thousands of tradies compete for job leads, and each claim must instantly deduct credits from their account. Under heavy load, our old system struggled—causing slowdowns, errors, and unhappy users.

The root cause? Traditional database locks couldn't keep up with the concurrency demands. We needed a new approach.

The solution: We adopted the Actor Model, a pattern used by banks and fintechs to handle high-volume transactions without locks or race conditions. Here’s how we transformed our system for reliability and scale.

What is Concurrency, and Why is it Hard?

When many users try to update the same data at once (like credits in an account), systems can run into "race conditions"—where the outcome depends on the unpredictable order of operations. This leads to bugs, lost data, or system crashes.

The Problem: Why Database Locks Failed Us

Old approach:

// ❌ Problematic approach: database locks
// Each claim locks the account row, checks balance, deducts credits, saves, unlocks.
// Under load, this caused slowdowns and deadlocks.

Problems:

  • Database lock contention when many tradies claimed leads at once
  • Deadlocks and timeouts during peak hours
  • Poor performance and frustrated users

During busy periods:

  • 40% of transactions timed out
  • Database CPU spiked to 100%
  • Tradies missed out on leads due to system errors

The Actor Model: A Simple Analogy

Imagine a post office:

  • Each mailbox (actor) has its own key and only one person can open it at a time.
  • Letters (messages) are delivered and processed one by one.
  • No two people can change the contents of a mailbox at the same time.

In software, the Actor Model works the same way:

  • Each account is managed by its own "actor" (like a mailbox)
  • All changes happen through messages, processed in order
  • No shared state, no locks, no race conditions

Why do banks and fintechs use it?

  • No locks needed: Each account is managed by a single actor
  • High throughput: Actors process messages concurrently
  • Fault tolerance: Actor failures don't affect others
  • Auditability: Every state change is a message

How We Solved It with the Actor Model

New approach:

  • Each tradie account is managed by a dedicated actor
  • All credit changes are handled by sending messages to that actor
  • Each actor processes one message at a time, so no two changes can conflict

Result: No more locks, no more deadlocks, and the system scales easily.

Key Results

Before (Locks) After (Actor Model)
40% timeouts 99.9% success
Slow at peak Sub-50ms response
Deadlocks Zero deadlocks
Hard to debug Clear audit trail

We redesigned our system with these key components:

// Core actor system interfaces
interface CreditMessage {
  type: 'DEBIT' | 'CREDIT' | 'INQUIRY' | 'RESERVE' | 'RELEASE'
  amount: number
  transactionId: string
  metadata?: any
}

interface CreditActor {
  id: string
  balance: number
  reservedAmount: number
  processMessage(message: CreditMessage): Promise<CreditResponse>
}

interface CreditResponse {
  success: boolean
  newBalance?: number
  error?: string
  transactionId: string
}

The Credit Actor Implementation

Each tradie's credit account is managed by a dedicated actor:

import { Injectable, Logger } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { EventEmitter2 } from '@nestjs/event-emitter'

@Injectable()
export class TradieCreditActor {
  private readonly logger = new Logger(TradieCreditActor.name)
  private messageQueue: CreditMessage[] = []
  private processing = false

  constructor(
    private readonly tradieId: string,
    @InjectRepository(CreditTransaction)
    private readonly transactionRepo: Repository<CreditTransaction>,
    private readonly eventEmitter: EventEmitter2
  ) {}

  async sendMessage(message: CreditMessage): Promise<CreditResponse> {
    return new Promise((resolve) => {
      // Add response callback to message
      const messageWithCallback = {
        ...message,
        resolve
      }
      
      this.messageQueue.push(messageWithCallback)
      this.processQueue()
    })
  }

  private async processQueue(): Promise<void> {
    if (this.processing || this.messageQueue.length === 0) {
      return
    }

    this.processing = true

    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift()!
      
      try {
        const response = await this.handleMessage(message)
        message.resolve(response)
      } catch (error) {
        this.logger.error(`Error processing message for tradie ${this.tradieId}`, error)
        message.resolve({
          success: false,
          error: error.message,
          transactionId: message.transactionId
        })
      }
    }

    this.processing = false
  }

  private async handleMessage(message: CreditMessage): Promise<CreditResponse> {
    const currentState = await this.getCurrentState()
    
    switch (message.type) {
      case 'DEBIT':
        return await this.processDebit(message, currentState)
      case 'CREDIT':
        return await this.processCredit(message, currentState)
      case 'RESERVE':
        return await this.processReservation(message, currentState)
      case 'RELEASE':
        return await this.processRelease(message, currentState)
      case 'INQUIRY':
        return await this.processInquiry(currentState)
      default:
        throw new Error(`Unknown message type: ${message.type}`)
    }
  }

  private async processDebit(
    message: CreditMessage, 
    currentState: CreditState
  ): Promise<CreditResponse> {
    const availableBalance = currentState.balance - currentState.reservedAmount
    
    if (availableBalance < message.amount) {
      // Log insufficient funds attempt
      await this.logTransaction({
        tradieId: this.tradieId,
        type: 'DEBIT_FAILED',
        amount: message.amount,
        transactionId: message.transactionId,
        reason: 'INSUFFICIENT_FUNDS',
        balanceBefore: currentState.balance,
        balanceAfter: currentState.balance
      })
      
      return {
        success: false,
        error: 'Insufficient credits',
        transactionId: message.transactionId
      }
    }

    // Process the debit
    const newBalance = currentState.balance - message.amount
    
    await this.logTransaction({
      tradieId: this.tradieId,
      type: 'DEBIT',
      amount: message.amount,
      transactionId: message.transactionId,
      balanceBefore: currentState.balance,
      balanceAfter: newBalance,
      metadata: message.metadata
    })

    // Emit event for other systems
    this.eventEmitter.emit('credit.debited', {
      tradieId: this.tradieId,
      amount: message.amount,
      newBalance,
      transactionId: message.transactionId,
      metadata: message.metadata
    })

    return {
      success: true,
      newBalance,
      transactionId: message.transactionId
    }
  }

  private async processReservation(
    message: CreditMessage, 
    currentState: CreditState
  ): Promise<CreditResponse> {
    const availableBalance = currentState.balance - currentState.reservedAmount
    
    if (availableBalance < message.amount) {
      return {
        success: false,
        error: 'Insufficient credits for reservation',
        transactionId: message.transactionId
      }
    }

    // Reserve the amount
    const newReservedAmount = currentState.reservedAmount + message.amount
    
    await this.logTransaction({
      tradieId: this.tradieId,
      type: 'RESERVE',
      amount: message.amount,
      transactionId: message.transactionId,
      balanceBefore: currentState.balance,
      balanceAfter: currentState.balance,
      reservedBefore: currentState.reservedAmount,
      reservedAfter: newReservedAmount,
      metadata: message.metadata
    })

    return {
      success: true,
      newBalance: currentState.balance,
      transactionId: message.transactionId
    }
  }

  private async getCurrentState(): Promise<CreditState> {
    // Get current balance from the latest transaction
    const latestTransaction = await this.transactionRepo.findOne({
      where: { tradieId: this.tradieId },
      order: { createdAt: 'DESC' }
    })

    if (!latestTransaction) {
      return { balance: 0, reservedAmount: 0 }
    }

    // Calculate reserved amount from active reservations
    const activeReservations = await this.transactionRepo
      .createQueryBuilder('t')
      .where('t.tradieId = :tradieId', { tradieId: this.tradieId })
      .andWhere('t.type = :type', { type: 'RESERVE' })
      .andWhere('t.status = :status', { status: 'ACTIVE' })
      .getMany()

    const reservedAmount = activeReservations.reduce(
      (sum, reservation) => sum + reservation.amount, 
      0
    )

    return {
      balance: latestTransaction.balanceAfter,
      reservedAmount
    }
  }

  private async logTransaction(transaction: Partial<CreditTransaction>): Promise<void> {
    const creditTransaction = this.transactionRepo.create({
      ...transaction,
      createdAt: new Date(),
      status: 'COMPLETED'
    })

    await this.transactionRepo.save(creditTransaction)
  }
}

Distributed Actor Management with Dapr

Critical Challenge: Multiple Pods, Single Actor Per Account

Our initial implementation had a serious flaw: what happens when multiple Kubernetes pods each create their own CreditActorManager? Each pod could instantiate its own actor for the same tradie account, breaking the fundamental Actor Model guarantee of single-actor-per-account.

The Solution: Dapr Actors

We use Dapr Actors to ensure only one actor instance exists per tradie account across the entire cluster:

import { DaprServer, ActorId, ActorRuntime } from '@dapr/dapr'

// Dapr ensures only one instance of this actor exists cluster-wide
export class TradieCreditActor {
  private balance: number = 0
  private reservedAmount: number = 0
  private readonly actorId: ActorId

  constructor(actorId: ActorId) {
    this.actorId = actorId
  }

  // Dapr automatically routes messages to the correct actor instance
  async debitCredits(amount: number, transactionId: string): Promise<CreditResponse> {
    // Load current state from Dapr state store
    await this.loadState()
    
    const availableBalance = this.balance - this.reservedAmount
    
    if (availableBalance < amount) {
      return {
        success: false,
        error: 'Insufficient credits',
        transactionId
      }
    }

    // Update balance
    this.balance -= amount
    
    // Persist state to Dapr state store
    await this.saveState()
    
    // Log transaction
    await this.logTransaction({
      type: 'DEBIT',
      amount,
      transactionId,
      balanceBefore: this.balance + amount,
      balanceAfter: this.balance
    })

    return {
      success: true,
      newBalance: this.balance,
      transactionId
    }
  }

  async reserveCredits(amount: number, transactionId: string): Promise<CreditResponse> {
    await this.loadState()
    
    const availableBalance = this.balance - this.reservedAmount
    
    if (availableBalance < amount) {
      return {
        success: false,
        error: 'Insufficient credits for reservation',
        transactionId
      }
    }

    this.reservedAmount += amount
    await this.saveState()
    
    // Set timer to auto-release reservation after 5 minutes
    await this.setReservationTimer(transactionId, amount)
    
    return {
      success: true,
      newBalance: this.balance,
      transactionId
    }
  }

  async releaseReservation(amount: number, transactionId: string): Promise<void> {
    await this.loadState()
    this.reservedAmount = Math.max(0, this.reservedAmount - amount)
    await this.saveState()
    
    // Cancel the auto-release timer
    await this.cancelReservationTimer(transactionId)
  }

  // Dapr actor state management
  private async loadState(): Promise<void> {
    const state = await this.getActorState()
    this.balance = state.balance || 0
    this.reservedAmount = state.reservedAmount || 0
  }

  private async saveState(): Promise<void> {
    await this.setActorState({
      balance: this.balance,
      reservedAmount: this.reservedAmount,
      lastUpdated: new Date().toISOString()
    })
  }

  // Dapr actor timers for automatic reservation cleanup
  private async setReservationTimer(transactionId: string, amount: number): Promise<void> {
    await this.registerActorTimer(
      `reservation_${transactionId}`,
      'releaseExpiredReservation',
      { amount, transactionId },
      '5m' // 5 minutes
    )
  }

  private async cancelReservationTimer(transactionId: string): Promise<void> {
    await this.unregisterActorTimer(`reservation_${transactionId}`)
  }

  // Called by Dapr when reservation timer expires
  async releaseExpiredReservation(data: { amount: number, transactionId: string }): Promise<void> {
    await this.loadState()
    this.reservedAmount = Math.max(0, this.reservedAmount - data.amount)
    await this.saveState()
    
    console.log(`Auto-released expired reservation: ${data.transactionId}`)
  }
}

Dapr Actor Registration and Client

// Register the actor with Dapr runtime
const daprServer = new DaprServer({
  serverHost: '127.0.0.1',
  serverPort: '50001'
})

// Register actor type
await ActorRuntime.registerActor(TradieCreditActor)

// Start Dapr server
await daprServer.start()

Distributed Actor Manager

The Actor Manager now uses Dapr to ensure distributed actor management:

@Injectable()
export class CreditActorManager {
  private readonly logger = new Logger(CreditActorManager.name)
  private readonly daprClient: DaprClient

  constructor(
    @InjectRepository(CreditTransaction)
    private readonly transactionRepo: Repository<CreditTransaction>,
    private readonly eventEmitter: EventEmitter2
  ) {
    this.daprClient = new DaprClient({
      daprHost: '127.0.0.1',
      daprPort: '3500'
    })
  }

  // No local actor map - Dapr handles actor location and lifecycle
  private async getActorProxy(tradieId: string): Promise<ActorProxy> {
    return this.daprClient.actor.getActorProxy(
      'TradieCreditActor',  // Actor type
      tradieId              // Actor ID (unique per tradie)
    )
  }

  async claimLead(
    tradieId: string, 
    leadId: string, 
    creditCost: number
  ): Promise<LeadClaimResult> {
    const transactionId = this.generateTransactionId()
    
    try {
      // Get Dapr actor proxy - Dapr ensures we get the right instance
      const actorProxy = await this.getActorProxy(tradieId)
      
      // Step 1: Reserve credits via Dapr actor
      const reservationResult = await actorProxy.invoke(
        'reserveCredits', 
        creditCost, 
        transactionId
      )

      if (!reservationResult.success) {
        return {
          success: false,
          error: reservationResult.error,
          transactionId
        }
      }

      // Step 2: Attempt to claim the lead
      const leadClaimResult = await this.attemptLeadClaim(leadId, tradieId, transactionId)

      if (leadClaimResult.success) {
        // Step 3a: Convert reservation to debit
        await actorProxy.invoke('releaseReservation', creditCost, transactionId)
        await actorProxy.invoke('debitCredits', creditCost, `${transactionId}_debit`)
        
        return {
          success: true,
          leadClaim: leadClaimResult.leadClaim,
          transactionId
        }
      } else {
        // Step 3b: Release the reservation
        await actorProxy.invoke('releaseReservation', creditCost, transactionId)
        
        return {
          success: false,
          error: leadClaimResult.error,
          transactionId
        }
      }
    } catch (error) {
      this.logger.error(`Lead claim failed for tradie ${tradieId}`, error)
      
      // Ensure reservation is released on any error
      try {
        const actorProxy = await this.getActorProxy(tradieId)
        await actorProxy.invoke('releaseReservation', creditCost, transactionId)
      } catch (cleanupError) {
        this.logger.error(`Failed to cleanup reservation`, cleanupError)
      }
      
      return {
        success: false,
        error: 'System error during lead claim',
        transactionId
      }
    }
  }

  private async attemptLeadClaim(
    leadId: string, 
    tradieId: string, 
    transactionId: string
  ): Promise<LeadClaimAttemptResult> {
    // This is where we check if the lead is still available
    // and create the claim record atomically
    try {
      const leadClaim = await this.dataSource.transaction(async (manager) => {
        const lead = await manager.findOne(Lead, {
          where: { id: leadId, status: 'AVAILABLE' }
        })

        if (!lead) {
          throw new Error('Lead no longer available')
        }

        // Check if tradie already claimed this lead
        const existingClaim = await manager.findOne(LeadClaim, {
          where: { leadId, tradieId }
        })

        if (existingClaim) {
          throw new Error('Lead already claimed by this tradie')
        }

        // Create the claim
        const claim = manager.create(LeadClaim, {
          leadId,
          tradieId,
          transactionId,
          claimedAt: new Date(),
          status: 'ACTIVE'
        })

        await manager.save(claim)

        // Update lead status if needed (e.g., if it's now fully claimed)
        const claimCount = await manager.count(LeadClaim, {
          where: { leadId, status: 'ACTIVE' }
        })

        if (claimCount >= lead.maxClaims) {
          lead.status = 'CLAIMED'
          await manager.save(lead)
        }

        return claim
      })

      return { success: true, leadClaim }
    } catch (error) {
      return { success: false, error: error.message }
    }
  }

  private generateTransactionId(): string {
    return `txn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
  }
}

Integration with the Lead Claiming API

Here's how the Actor system integrates with our NestJS API:

@Controller('api/leads')
@UseGuards(AuthGuard, RBACGuard)
export class LeadController {
  constructor(
    private readonly creditActorManager: CreditActorManager,
    private readonly leadService: LeadService,
    private readonly logger: Logger
  ) {}

  @Post(':leadId/claim')
  @ApiOperation({ summary: 'Claim a lead using credits' })
  @ApiResponse({ status: 200, description: 'Lead claimed successfully' })
  @ApiResponse({ status: 400, description: 'Insufficient credits or lead unavailable' })
  async claimLead(
    @Param('leadId') leadId: string,
    @Request() req: AuthenticatedRequest
  ): Promise<LeadClaimResponse> {
    const tradieId = req.user.id
    
    try {
      // Get lead details and credit cost
      const lead = await this.leadService.getLeadById(leadId)
      if (!lead) {
        throw new NotFoundException('Lead not found')
      }

      if (lead.status !== 'AVAILABLE') {
        throw new BadRequestException('Lead is no longer available')
      }

      const creditCost = await this.leadService.calculateCreditCost(lead, tradieId)

      // Use actor system to claim the lead
      const result = await this.creditActorManager.claimLead(
        tradieId,
        leadId,
        creditCost
      )

      if (result.success) {
        // Emit event for notifications, analytics, etc.
        this.eventEmitter.emit('lead.claimed', {
          leadId,
          tradieId,
          creditCost,
          transactionId: result.transactionId
        })

        return {
          success: true,
          message: 'Lead claimed successfully',
          leadClaim: result.leadClaim,
          creditsUsed: creditCost,
          transactionId: result.transactionId
        }
      } else {
        return {
          success: false,
          error: result.error,
          transactionId: result.transactionId
        }
      }
    } catch (error) {
      this.logger.error(`Lead claim failed for tradie ${tradieId}`, error)
      throw error
    }
  }

  @Get('credits/balance')
  @ApiOperation({ summary: 'Get current credit balance' })
  async getCreditBalance(
    @Request() req: AuthenticatedRequest
  ): Promise<CreditBalanceResponse> {
    const tradieId = req.user.id
    
    const actor = this.creditActorManager.getActor(tradieId)
    const inquiry = await actor.sendMessage({
      type: 'INQUIRY',
      amount: 0,
      transactionId: `inquiry_${Date.now()}`
    })

    return {
      balance: inquiry.newBalance || 0,
      availableBalance: inquiry.availableBalance || 0
    }
  }
}

Dapr Deployment Configuration

# Kubernetes deployment with Dapr sidecar
apiVersion: apps/v1
kind: Deployment
metadata:
  name: credit-service
spec:
  replicas: 3  # Multiple pods, but Dapr ensures single actor per account
  template:
    metadata:
      annotations:
        dapr.io/enabled: "true"
        dapr.io/app-id: "credit-service"
        dapr.io/app-port: "3000"
        dapr.io/config: "dapr-config"
    spec:
      containers:
      - name: credit-service
        image: credit-service:latest
        ports:
        - containerPort: 3000
---
# Dapr configuration for actor placement
apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: dapr-config
spec:
  features:
  - name: Actor.Placement.ActorDeactivationScanInterval
    enabled: true
  - name: Actor.Placement.ActorIdleTimeout
    enabled: true

Why Dapr Actors Solve the Distribution Problem

Before Dapr (Problematic):

  • Pod A creates TradieCreditActor for tradie "123"
  • Pod B also creates TradieCreditActor for tradie "123"
  • Both actors process messages independently
  • Race conditions and inconsistent state

With Dapr Actors:

  • Dapr Placement Service ensures only one actor instance per ID across the cluster
  • Actor "123" lives on exactly one pod at any time
  • All messages for tradie "123" are routed to that single actor
  • If the pod fails, Dapr recreates the actor on another pod with preserved state

Key Benefits:

  1. Guaranteed Uniqueness: Only one actor per tradie ID cluster-wide
  2. Automatic Failover: Actors move between pods seamlessly
  3. State Persistence: Actor state survives pod restarts
  4. Load Distribution: Dapr distributes actors across available pods
  5. Automatic Cleanup: Idle actors are deactivated to save resources

Advanced Features

Credit Expiration with Dapr Actor Timers

With Dapr, we use actor timers for automatic cleanup instead of cron jobs:

// Inside TradieCreditActor class
async reserveCredits(amount: number, transactionId: string): Promise<CreditResponse> {
  // ... reservation logic ...
  
  // Set Dapr actor timer for automatic cleanup
  await this.registerActorTimer(
    `reservation_${transactionId}`,     // Timer name
    'releaseExpiredReservation',        // Method to call
    { amount, transactionId },          // Data to pass
    '5m'                               // Duration (5 minutes)
  )
  
  return { success: true, newBalance: this.balance, transactionId }
}

// This method is called by Dapr when the timer fires
async releaseExpiredReservation(data: { amount: number, transactionId: string }): Promise<void> {
  await this.loadState()
  
  // Only release if reservation still exists
  if (this.reservedAmount >= data.amount) {
    this.reservedAmount -= data.amount
    await this.saveState()
    
    console.log(`Auto-released expired reservation: ${data.transactionId}`)
  }
}

// Cancel timer when reservation is manually released
async releaseReservation(amount: number, transactionId: string): Promise<void> {
  await this.loadState()
  this.reservedAmount = Math.max(0, this.reservedAmount - amount)
  await this.saveState()
  
  // Cancel the auto-release timer
  await this.unregisterActorTimer(`reservation_${transactionId}`)
}

Benefits of Dapr Actor Timers:

  • Per-actor timers: Each actor manages its own cleanup timers
  • Fault tolerant: Timers survive pod restarts and actor migrations
  • No external dependencies: No need for separate cron jobs or schedulers
  • Precise timing: Timers are tied to specific reservations, not global cleanup

Monitoring and Metrics

We added comprehensive monitoring to track actor performance:

@Injectable()
export class CreditMetricsService {
  constructor(
    @InjectMetric('credit_transactions_total') 
    private readonly transactionCounter: Counter<string>,
    @InjectMetric('credit_processing_duration') 
    private readonly processingHistogram: Histogram<string>,
    @InjectMetric('actor_queue_size') 
    private readonly queueSizeGauge: Gauge<string>
  ) {}

  recordTransaction(type: string, success: boolean, duration: number): void {
    this.transactionCounter.inc({
      type,
      status: success ? 'success' : 'failure'
    })
    
    this.processingHistogram.observe({ type }, duration)
  }

  updateQueueSize(actorId: string, size: number): void {
    this.queueSizeGauge.set({ actor_id: actorId }, size)
  }
}

Installing Dapr for Actor Support

# Install Dapr CLI
curl -fsSL https://raw.githubusercontent.com/dapr/cli/master/install/install.sh | /bin/bash

# Initialize Dapr in Kubernetes
dapr init -k

# Install Dapr Node.js SDK
npm install @dapr/dapr

Comparison: Local vs Distributed Actors

Aspect Local Actors (In-Memory) Dapr Actors (Distributed)
Actor Uniqueness ❌ Per-pod only ✅ Cluster-wide guaranteed
Failover ❌ Lost on pod restart ✅ Automatic migration
State Persistence ❌ In-memory only ✅ Durable state store
Load Distribution ❌ Manual sharding ✅ Automatic placement
Scaling ❌ Complex coordination ✅ Seamless horizontal scaling
Development Complexity ✅ Simple to start ⚠️ Requires Dapr setup
Operational Overhead ✅ Minimal ⚠️ Dapr infrastructure

Results and Benefits

Performance Improvements

After implementing Dapr Actors:

  • 99.9% success rate for lead claims (up from 60%)
  • Sub-50ms response times even during peak load
  • Zero database deadlocks related to credit management
  • 10x throughput improvement during busy periods
  • 100% actor uniqueness guaranteed across all pods
  • Zero state inconsistencies from duplicate actors

Operational Benefits

  • Simplified debugging: Each transaction has a clear audit trail
  • Better monitoring: Actor-level metrics provide detailed insights
  • Easier scaling: Dapr automatically distributes actors across pods
  • Fault isolation: Actor failures don't cascade to other accounts
  • Automatic failover: Actors seamlessly move between healthy pods
  • Persistent state: Actor state survives pod restarts and deployments

Business Impact

  • Increased tradie satisfaction: Reliable lead claiming experience
  • Higher conversion rates: More successful transactions during peak times
  • Reduced support tickets: Fewer credit-related issues
  • Better cash flow: More predictable credit usage patterns

Should You Use the Actor Model?

Use it if you have:

  • High concurrency
  • Financial or credit-like transactions
  • Need for audit trails

Not needed for:

  • Simple CRUD apps
  • Low-traffic systems

Best Practices and Lessons Learned

1. Message Design

Keep messages simple and idempotent:

// ✅ Good: Simple, clear message
interface DebitMessage {
  type: 'DEBIT'
  amount: number
  transactionId: string
  reason: string
}

// ❌ Bad: Complex message with side effects
interface ComplexMessage {
  type: 'DEBIT_AND_NOTIFY_AND_LOG'
  amount: number
  shouldSendEmail: boolean
  logLevel: string
  // ... too many responsibilities
}

2. Error Handling

Always handle failures gracefully:

private async handleMessage(message: CreditMessage): Promise<CreditResponse> {
  try {
    return await this.processMessage(message)
  } catch (error) {
    // Log error but don't crash the actor
    this.logger.error(`Message processing failed`, error)
    
    return {
      success: false,
      error: 'Processing failed',
      transactionId: message.transactionId
    }
  }
}

3. State Persistence

Ensure actor state is always recoverable:

async restoreActorState(tradieId: string): Promise<CreditState> {
  // Rebuild state from transaction log
  const transactions = await this.transactionRepo.find({
    where: { tradieId },
    order: { createdAt: 'ASC' }
  })
  
  return this.calculateStateFromTransactions(transactions)
}

When to Use the Actor Model

The Actor Model is ideal when you have:

  • High concurrency requirements
  • Complex state management
  • Need for audit trails
  • Distributed system architecture
  • Financial or credit-like transactions

It's not suitable for:

  • Simple CRUD operations
  • Low-concurrency applications
  • Systems requiring immediate consistency across actors

Conclusion

By switching to the Actor Model, hipages turned a scaling bottleneck into a competitive advantage. If you’re struggling with concurrency, consider this proven approach—used by banks and fintechs for decades.


Want to discuss concurrency patterns or actor systems? Connect with me on LinkedIn or email me.