Part 2 - Your First Akka.NET Actor: Building a Calculator Step-by-Step

Originally published on Dev-Tinker | Reading time: 10 minutes | Part 2 of 16 in the Complete Akka.NET Series



Part 1 - What is the Actor Model? A Complete Guide for C# Developers

Build a Calculator That Never Blocks Your UI Thread

In Week 1, we discovered how the Actor Model solves traditional concurrency nightmares. Now it's time to get your hands dirty and build something practical. Today, we're creating a sophisticated calculator actor that demonstrates professional actor design patterns, proper message handling, and robust error management.

Why a calculator? Because it's simple enough to understand quickly, yet complex enough to showcase real-world patterns you'll use in production systems. By the end of this tutorial, you'll have a calculator that can handle multiple operations simultaneously, never crashes from division by zero, and maintains perfect operation history—all without a single lock or shared mutable state.

This isn't just another "Hello World" example. We're building production-quality code with proper error handling, lifecycle management, and testing considerations that you can apply to any Akka.NET project.

Learning Objectives

By the end of this tutorial, you will:

  • Create professional UntypedActor classes with proper inheritance and structure
  • Master actor lifecycle methods (PreStart, PostStop, PreRestart) for robust applications
  • Implement sophisticated message handling with OnReceive and pattern matching
  • Design immutable message hierarchies following Commands, Events, and Queries patterns
  • Handle errors gracefully without crashing the entire system

Prerequisites

  • Completed Week 1 tutorial - understanding of basic Actor Model concepts
  • Working Akka.NET development environment (.NET 8+ and Visual Studio)
  • Basic familiarity with C# pattern matching and record types

Actor Class Structure and Inheritance

Every Akka.NET actor inherits from a base actor class. The most common and flexible base class is UntypedActor, which gives you complete control over message handling.

The UntypedActor Base Class

Here's the essential structure every actor follows:

public class MyActor : UntypedActor
{
    // Private state - completely isolated from other actors
    private readonly SomeState _state;
    
    // Constructor - dependency injection happens here
    public MyActor()
    {
        _state = new SomeState();
    }
    
    // The heart of every actor - message processing
    protected override void OnReceive(object message)
    {
        // Pattern matching on message types
        switch (message)
        {
            case SomeMessage msg:
                HandleSomeMessage(msg);
                break;
                
            default:
                // Always handle unknown messages
                Unhandled(message);
                break;
        }
    }
    
    // Lifecycle methods for setup and cleanup
    protected override void PreStart() { /* Initialization */ }
    protected override void PostStop() { /* Cleanup */ }
    protected override void PreRestart(Exception reason, object message) { /* Before restart */ }
    protected override void PostRestart(Exception reason) { /* After restart */ }
}

Key Architectural Principles

  • Single Responsibility: Each actor should have one clear purpose
  • Immutable Messages: All messages should be immutable (prefer records)
  • Private State: Never expose internal state directly
  • Error Isolation: Handle errors locally, let supervisor handle failures

Message Design: Commands vs Events vs Queries


Professional Akka.NET applications organize messages into three clear categories. This isn't just convention—it makes your code more maintainable and your intentions crystal clear.

Commands (Intent to Change State)

Commands represent requests to do something. They can succeed or fail.

// Calculator Commands - requests for calculations
public abstract record CalculatorCommand;
public record AddCommand(double A, double B) : CalculatorCommand;
public record SubtractCommand(double A, double B) : CalculatorCommand;
public record MultiplyCommand(double A, double B) : CalculatorCommand;
public record DivideCommand(double A, double B) : CalculatorCommand;
public record ClearCommand() : CalculatorCommand;

Events (Things That Happened)

Events represent facts about what occurred. They're immutable and represent completed actions.

// Calculator Events - results of operations
public abstract record CalculatorEvent;
public record CalculationCompletedEvent(double Result, string Operation) : CalculatorEvent;
public record CalculationFailedEvent(string Error, string Operation) : CalculatorEvent;
public record CalculatorClearedEvent() : CalculatorEvent;

Queries (Requests for Information)

Queries ask for information without changing state.

// Calculator Queries - requests for information
public abstract record CalculatorQuery;
public record GetResultQuery() : CalculatorQuery;
public record GetHistoryQuery() : CalculatorQuery;
public record GetStatusQuery() : CalculatorQuery;

// Query Responses
public record ResultResponse(double Value);
public record HistoryResponse(List<string> Operations);
public record StatusResponse(bool IsReady, int OperationsCount);

Building the Calculator Actor: Step by Step

Now let's build our calculator actor with proper error handling, operation history, and professional patterns.

Step 1: Define the Calculator State

public class CalculatorActor : UntypedActor
{
    private double _currentResult = 0.0;
    private readonly List<string> _operationHistory = new();
    private int _operationCount = 0;
    
    public CalculatorActor()
    {
        // Actor is ready for work
        _operationHistory.Add("Calculator initialized");
    }

Step 2: Implement Message Handling



    protected override void OnReceive(object message)
    {
        switch (message)
        {
            // Handle calculation commands
            case AddCommand add:
                HandleAddition(add);
                break;
                
            case SubtractCommand subtract:
                HandleSubtraction(subtract);
                break;
                
            case MultiplyCommand multiply:
                HandleMultiplication(multiply);
                break;
                
            case DivideCommand divide:
                HandleDivision(divide);
                break;
                
            case ClearCommand:
                HandleClear();
                break;
                
            // Handle queries
            case GetResultQuery:
                HandleGetResult();
                break;
                
            case GetHistoryQuery:
                HandleGetHistory();
                break;
                
            case GetStatusQuery:
                HandleGetStatus();
                break;
                
            default:
                Console.WriteLine($"Unknown message received: {message?.GetType().Name}");
                Unhandled(message);
                break;
        }
    }

Step 3: Implement Individual Operation Handlers

    private void HandleAddition(AddCommand command)
    {
        try
        {
            var result = command.A + command.B;
            _currentResult = result;
            _operationCount++;
            
            var operation = $"{command.A} + {command.B} = {result}";
            _operationHistory.Add(operation);
            
            Console.WriteLine($"Addition completed: {operation}");
            
            // Send success event back to sender
            Sender.Tell(new CalculationCompletedEvent(result, operation));
        }
        catch (Exception ex)
        {
            HandleCalculationError(ex, $"Addition: {command.A} + {command.B}");
        }
    }
    
    private void HandleSubtraction(SubtractCommand command)
    {
        try
        {
            var result = command.A - command.B;
            _currentResult = result;
            _operationCount++;
            
            var operation = $"{command.A} - {command.B} = {result}";
            _operationHistory.Add(operation);
            
            Console.WriteLine($"Subtraction completed: {operation}");
            Sender.Tell(new CalculationCompletedEvent(result, operation));
        }
        catch (Exception ex)
        {
            HandleCalculationError(ex, $"Subtraction: {command.A} - {command.B}");
        }
    }
    
    private void HandleMultiplication(MultiplyCommand command)
    {
        try
        {
            var result = command.A * command.B;
            _currentResult = result;
            _operationCount++;
            
            var operation = $"{command.A} × {command.B} = {result}";
            _operationHistory.Add(operation);
            
            Console.WriteLine($"Multiplication completed: {operation}");
            Sender.Tell(new CalculationCompletedEvent(result, operation));
        }
        catch (Exception ex)
        {
            HandleCalculationError(ex, $"Multiplication: {command.A} × {command.B}");
        }
    }
    
    private void HandleDivision(DivideCommand command)
    {
        try
        {
            // Explicit division by zero check
            if (Math.Abs(command.B) < double.Epsilon)
            {
                var errorMsg = "Division by zero is not allowed";
                var operation = $"{command.A} ÷ {command.B}";
                
                Console.WriteLine($"Division failed: {errorMsg}");
                _operationHistory.Add($"{operation} - ERROR: {errorMsg}");
                
                Sender.Tell(new CalculationFailedEvent(errorMsg, operation));
                return;
            }
            
            var result = command.A / command.B;
            _currentResult = result;
            _operationCount++;
            
            var successOperation = $"{command.A} ÷ {command.B} = {result}";
            _operationHistory.Add(successOperation);
            
            Console.WriteLine($"Division completed: {successOperation}");
            Sender.Tell(new CalculationCompletedEvent(result, successOperation));
        }
        catch (Exception ex)
        {
            HandleCalculationError(ex, $"Division: {command.A} ÷ {command.B}");
        }
    }
    
    private void HandleClear()
    {
        _currentResult = 0.0;
        _operationHistory.Clear();
        _operationCount = 0;
        
        _operationHistory.Add("Calculator cleared");
        
        Console.WriteLine("Calculator cleared");
        Sender.Tell(new CalculatorClearedEvent());
    }


Step 4: Implement Query Handlers

    private void HandleGetResult()
    {
        Console.WriteLine($"Current result requested: {_currentResult}");
        Sender.Tell(new ResultResponse(_currentResult));
    }
    
    private void HandleGetHistory()
    {
        Console.WriteLine($"History requested: {_operationHistory.Count} operations");
        Sender.Tell(new HistoryResponse(new List<string>(_operationHistory)));
    }
    
    private void HandleGetStatus()
    {
        Console.WriteLine($"Status requested: Ready, {_operationCount} operations completed");
        Sender.Tell(new StatusResponse(true, _operationCount));
    }

Step 5: Implement Error Handling

    private void HandleCalculationError(Exception exception, string operation)
    {
        var errorMessage = $"Calculation error: {exception.Message}";
        _operationHistory.Add($"{operation} - ERROR: {errorMessage}");
        
        Console.WriteLine($"Error in {operation}: {exception.Message}");
        Sender.Tell(new CalculationFailedEvent(errorMessage, operation));
    }

Actor Lifecycle: PreStart, PostStop, PreRestart


Actors have a well-defined lifecycle with hooks you can override to implement initialization, cleanup, and recovery logic.
    protected override void PreStart()
    {
        Console.WriteLine($"CalculatorActor starting up at {DateTime.Now}");
        _operationHistory.Add($"Actor started at {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
        
        // Perfect place for:
        // - Loading configuration
        // - Establishing database connections  
        // - Registering with external services
        // - Starting timers
    }
    
    protected override void PostStop()
    {
        Console.WriteLine($"CalculatorActor shutting down. Completed {_operationCount} operations.");
        
        // Perfect place for:
        // - Saving final state
        // - Closing database connections
        // - Unregistering from services
        // - Releasing resources
    }
    
    protected override void PreRestart(Exception reason, object message)
    {
        Console.WriteLine($"CalculatorActor restarting due to: {reason.Message}");
        Console.WriteLine($"Message that caused restart: {message?.GetType().Name}");
        
        // Save important state before restart
        var backupHistory = new List<string>(_operationHistory);
        // Could persist to database, file, etc.
        
        base.PreRestart(reason, message);
    }
    
    protected override void PostRestart(Exception reason)
    {
        Console.WriteLine($"CalculatorActor restarted successfully after: {reason.Message}");
        
        // Restore state after restart
        // Could reload from database, file, etc.
        _operationHistory.Add($"Actor restarted at {DateTime.Now:yyyy-MM-dd HH:mm:ss} due to: {reason.Message}");
        
        base.PostRestart(reason);
    }
}

Creating and Managing ActorRefs



Now let's create a console application that demonstrates professional ActorRef management and interaction patterns.

class Program
{
    static async Task Main(string[] args)
    {
        // Create the actor system
        using var actorSystem = ActorSystem.Create("CalculatorSystem");
        
        // Create the calculator actor with a meaningful name
        var calculatorRef = actorSystem.ActorOf<CalculatorActor>("calculator");
        
        Console.WriteLine("=== Akka.NET Calculator Demo ===");
        Console.WriteLine("Commands: add, sub, mul, div, result, history, status, clear, quit");
        Console.WriteLine("Example: add 10 5");
        Console.WriteLine();
        
        await RunInteractiveCalculator(calculatorRef);
        
        // Graceful shutdown
        Console.WriteLine("Shutting down calculator system...");
        await actorSystem.Terminate();
    }
    
    static async Task RunInteractiveCalculator(IActorRef calculatorRef)
    {
        string? input;
        while ((input = Console.ReadLine()) != "quit")
        {
            if (string.IsNullOrWhiteSpace(input))
                continue;
                
            var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
            var command = parts[0].ToLower();
            
            try
            {
                switch (command)
                {
                    case "add":
                        if (parts.Length == 3 && 
                            double.TryParse(parts[1], out var a1) && 
                            double.TryParse(parts[2], out var b1))
                        {
                            calculatorRef.Tell(new AddCommand(a1, b1));
                        }
                        else
                        {
                            Console.WriteLine("Usage: add <number1> <number2>");
                        }
                        break;
                        
                    case "sub":
                    case "subtract":
                        if (parts.Length == 3 && 
                            double.TryParse(parts[1], out var a2) && 
                            double.TryParse(parts[2], out var b2))
                        {
                            calculatorRef.Tell(new SubtractCommand(a2, b2));
                        }
                        else
                        {
                            Console.WriteLine("Usage: sub <number1> <number2>");
                        }
                        break;
                        
                    case "mul":
                    case "multiply":
                        if (parts.Length == 3 && 
                            double.TryParse(parts[1], out var a3) && 
                            double.TryParse(parts[2], out var b3))
                        {
                            calculatorRef.Tell(new MultiplyCommand(a3, b3));
                        }
                        else
                        {
                            Console.WriteLine("Usage: mul <number1> <number2>");
                        }
                        break;
                        
                    case "div":
                    case "divide":
                        if (parts.Length == 3 && 
                            double.TryParse(parts[1], out var a4) && 
                            double.TryParse(parts[2], out var b4))
                        {
                            calculatorRef.Tell(new DivideCommand(a4, b4));
                        }
                        else
                        {
                            Console.WriteLine("Usage: div <number1> <number2>");
                        }
                        break;
                        
                    case "result":
                        calculatorRef.Tell(new GetResultQuery());
                        break;
                        
                    case "history":
                        calculatorRef.Tell(new GetHistoryQuery());
                        break;
                        
                    case "status":
                        calculatorRef.Tell(new GetStatusQuery());
                        break;
                        
                    case "clear":
                        calculatorRef.Tell(new ClearCommand());
                        break;
                        
                    case "help":
                        ShowHelp();
                        break;
                        
                    default:
                        Console.WriteLine($"Unknown command: {command}. Type 'help' for available commands.");
                        break;
                }
                
                // Small delay to see actor processing
                await Task.Delay(100);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Error processing command: {ex.Message}");
            }
        }
    }
    
    static void ShowHelp()
    {
        Console.WriteLine();
        Console.WriteLine("Available Commands:");
        Console.WriteLine("  add <a> <b>    - Add two numbers");
        Console.WriteLine("  sub <a> <b>    - Subtract b from a");
        Console.WriteLine("  mul <a> <b>    - Multiply two numbers");
        Console.WriteLine("  div <a> <b>    - Divide a by b");
        Console.WriteLine("  result        - Get current result");
        Console.WriteLine("  history       - Show operation history");
        Console.WriteLine("  status        - Show calculator status");
        Console.WriteLine("  clear         - Clear calculator");
        Console.WriteLine("  help          - Show this help");
        Console.WriteLine("  quit          - Exit application");
        Console.WriteLine();
    }
}

Testing Your Calculator Actor

Let's run through a comprehensive test session to verify everything works correctly:

=== Akka.NET Calculator Demo ===
Commands: add, sub, mul, div, result, history, status, clear, quit
Example: add 10 5

> add 10 5
Addition completed: 10 + 5 = 15

> mul 3 4
Multiplication completed: 3 × 4 = 12

> div 12 0
Division failed: Division by zero is not allowed

> div 12 4
Division completed: 12 ÷ 4 = 3

> result
Current result requested: 3

> history
History requested: 6 operations

> status
Status requested: Ready, 3 operations completed

> clear
Calculator cleared

> quit
Shutting down calculator system...
CalculatorActor shutting down. Completed 0 operations.

Advanced Features and Error Handling

Our calculator demonstrates several production-ready patterns:

1. Graceful Error Handling

  • Division by zero doesn't crash the actor
  • Invalid operations are logged and reported
  • Error messages are descriptive and actionable

2. Operation History Tracking

  • Every operation is recorded with timestamp
  • Errors are tracked alongside successes
  • History survives individual operation failures

3. Comprehensive State Management

  • Current result is always consistent
  • Operation counts are tracked accurately
  • State can be queried without side effects

4. Professional Message Patterns

  • Clear separation between Commands, Events, and Queries
  • Immutable messages prevent accidental modification
  • Descriptive message names make code self-documenting

Key Takeaways

🎯 UntypedActor provides complete control over message handling through the OnReceive method and pattern matching.

🎯 Actor lifecycle methods (PreStart, PostStop, PreRestart, PostRestart) enable robust initialization, cleanup, and recovery.

🎯 Message organization matters - Commands, Events, and Queries create maintainable, testable systems.

🎯 Error handling should be explicit - catch exceptions, log appropriately, and send meaningful responses.

🎯 Actors maintain perfect state consistency because they process one message at a time in isolation.

What's Next?

In Week 3, we'll explore the fundamental difference between Tell vs Ask messaging patterns. You'll learn:

  • Fire-and-forget vs request-response communication
  • When to use Tell for maximum performance
  • How to integrate Ask with async/await patterns
  • Message ordering guarantees and their implications
  • Building a real-time chat system using both patterns

Hands-On Challenge

Before moving to Week 3, try extending your calculator with these features:

  1. Advanced Operations: Add power, square root, and percentage operations
  2. Memory Functions: Implement memory store, recall, and clear (M+, MR, MC)
  3. Operation Chaining: Allow using previous result as input for next operation
  4. Configuration: Add precision settings and rounding modes
  5. Logging Enhancement: Add different log levels and structured logging

Further Reading and Resources

Official Documentation

Best Practices


Ready for Week 3? Next week, we'll dive deep into Tell vs Ask patterns and build a multi-user chat system that demonstrates both approaches in action. Don't forget to experiment with your calculator and try the challenge exercises!

Have questions about actor lifecycle or message design? Drop a comment below. Happy coding!


This post is part of a comprehensive 16-week Akka.NET learning series. Subscribe to the newsletter to get notified when new tutorials are published

Comments

Popular posts from this blog

Building a Microservices Order Processing System with RabbitMQ and .NET 9.0

Complete Beginner's Guide to C# Types: Class vs Record vs Struct + Pattern Matching

Part 1 - What is the Actor Model? A Complete Guide for C# Developers