Learn how to build a production-ready REST API using Akka.NET's actor model, Event Sourcing, and the Child-Per-Entity pattern with PostgreSQL persistence.
Building scalable, concurrent applications has always been challenging. Traditional approaches using locks and shared state often lead to complex, error-prone code. Enter Akka.NET - a powerful toolkit that brings the Actor Model to .NET, enabling you to build highly concurrent, distributed systems with ease.
In this comprehensive guide, we'll explore how to build a production-ready REST API using Akka.NET, incorporating Event Sourcing patterns and PostgreSQL persistence. By the end, you'll understand how actors can revolutionize your approach to building APIs.
The Actor Model is a computational model that treats "actors" as the fundamental units of computation. Unlike traditional object-oriented programming where objects share state through methods and properties, actors:
This model provides several powerful benefits:
Traditional multi-threaded programming requires careful coordination with locks, semaphores, and other synchronization primitives. A single mistake can lead to deadlocks, race conditions, or data corruption. With the Actor Model, each actor processes messages sequentially, so you never need locks for its internal state.
// Traditional approach - requires locking
public class BankAccount
{
private decimal _balance;
private readonly object _lock = new object();
public void Deposit(decimal amount)
{
lock (_lock)
{
_balance += amount; // Must lock to prevent race conditions
}
}
}
// Actor approach - no locks needed
public class BankAccountActor : ReceiveActor
{
private decimal _balance;
public BankAccountActor()
{
Receive<DepositMessage>(msg =>
{
_balance += msg.Amount; // Safe without locks!
Sender.Tell(new DepositSuccessMessage(_balance));
});
}
}
Actors can communicate whether they're in the same process, on the same machine, or distributed across a network. Your code remains the same regardless of where the actor lives:
// Send a message to an actor - could be local or remote
actorRef.Tell(new ProcessOrder(orderId));
Actors are organized in hierarchies where parent actors supervise their children. When a child actor fails, the parent can decide how to handle it:
public class SupervisorActor : ReceiveActor
{
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: 3,
withinTimeRange: TimeSpan.FromMinutes(1),
localOnlyDecider: ex =>
{
if (ex is RecoverableException)
return Directive.Restart; // Restart the failed actor
return Directive.Stop; // Stop for unrecoverable errors
});
}
}
Akka.NET is a .NET port of the popular Akka framework from the JVM ecosystem. It provides:
You might wonder: "Why use actors for a REST API when frameworks like ASP.NET Core work fine?" Here are compelling reasons:
1. Natural State Management: Each actor maintains its own state, making it perfect for modeling domain entities. No need for complex caching strategies or database locking.
2. Scalability: Actors scale horizontally. Start with actors in a single process, then distribute them across multiple nodes as needed.
3. Resilience: Supervisor hierarchies provide automatic fault recovery. A failing actor doesn't crash your entire application.
4. Event Sourcing: Built-in support for event sourcing means your system records every state change as an event, enabling audit trails, time travel debugging, and more.
5. Back-pressure: Akka's mailbox system naturally handles load, preventing your system from being overwhelmed.
Event Sourcing is a pattern where state changes are stored as a sequence of events rather than updating records in place. Instead of:
// Traditional approach
UPDATE addresses SET street = '123 Main St' WHERE id = 1
You store events:
// Event sourcing approach
INSERT INTO events (persistence_id, sequence_nr, event) VALUES
('address-1', 1, '{"Type":"AddressCreated","Street":"123 Main St"}'),
('address-1', 2, '{"Type":"StreetUpdated","OldStreet":"123 Main St","NewStreet":"456 Oak Ave"}')
To reconstruct current state, you replay all events for that entity. This provides:
Akka.Persistence.Sql integrates Event Sourcing with Akka.NET, providing:
Here's how it works:
public class AddressActor : ReceivePersistentActor
{
public override string PersistenceId { get; }
private AddressState _state = new();
public AddressActor(string addressId)
{
PersistenceId = $"address-{addressId}";
// Command handler
Command<CreateAddress>(cmd =>
{
var evt = new AddressCreated(cmd.Street, cmd.City, cmd.ZipCode);
Persist(evt, e =>
{
UpdateState(e);
Sender.Tell(new AddressCreatedResponse(PersistenceId));
});
});
// Event handler
Recover<AddressCreated>(evt => UpdateState(evt));
}
private void UpdateState(AddressCreated evt)
{
_state = new AddressState
{
Street = evt.Street,
City = evt.City,
ZipCode = evt.ZipCode
};
}
}
When you call Persist, Akka.Persistence.Sql:
The Child-Per-Entity pattern is a core Akka design pattern where a parent actor manages child actors, with each child representing a single domain entity. Think of it as:
ParentActor (AddressBookActor)
├── ChildActor (Address-1)
├── ChildActor (Address-2)
└── ChildActor (Address-3)
The parent maintains a mapping of entity IDs to child actor references:
public class AddressBookActor : ReceiveActor
{
private readonly Dictionary<string, IActorRef> _addresses = new();
public AddressBookActor()
{
Receive<CreateAddress>(cmd =>
{
if (!_addresses.ContainsKey(cmd.AddressId))
{
// Create child actor for this address
var child = Context.ActorOf(
Props.Create(() => new AddressActor(cmd.AddressId)),
$"address-{cmd.AddressId}");
_addresses[cmd.AddressId] = child;
}
// Forward message to child
_addresses[cmd.AddressId].Forward(cmd);
});
}
}
1. Simplified Stateful Programming
Each entity has its own actor with its own state. No need to worry about concurrent access:
// Each AddressActor handles its own state
// Address-1 and Address-2 are processed concurrently without interference
2. Reliable Routing
The parent actor provides a stable address for routing messages to entities:
// Always route through parent
addressBookActor.Tell(new UpdateAddress("address-123", newStreet));
// Parent forwards to correct child
3. Smaller Code Footprint
You don't need separate repository classes, caching layers, or complex state management:
// No need for:
// - IAddressRepository
// - IAddressCache
// - Locking mechanisms
// - State synchronization
// Just actors!
4. Ideal Use Cases
Let's build a complete Address Book REST API with Akka.NET, demonstrating all these concepts.
First, create an ASP.NET Core Web API project and install the required packages:
dotnet new webapi -n AddressBookApi
cd AddressBookApi
dotnet add package Akka.Hosting
dotnet add package Akka.Persistence.Sql
dotnet add package Akka.Persistence.Sql.Hosting
dotnet add package Npgsql
Events represent state changes in our system:
// Events/AddressEvents.cs
public abstract record AddressEvent;
public record AddressCreated(
string Street,
string City,
string State,
string ZipCode
) : AddressEvent;
public record AddressUpdated(
string? Street,
string? City,
string? State,
string? ZipCode
) : AddressEvent;
public record AddressDeleted : AddressEvent;
Commands are messages that request state changes:
// Messages/AddressMessages.cs
public abstract record AddressCommand(string AddressId);
public record CreateAddress(
string AddressId,
string Street,
string City,
string State,
string ZipCode
) : AddressCommand(AddressId);
public record UpdateAddress(
string AddressId,
string? Street,
string? City,
string? State,
string? ZipCode
) : AddressCommand(AddressId);
public record GetAddress(string AddressId) : AddressCommand(AddressId);
public record DeleteAddress(string AddressId) : AddressCommand(AddressId);
// Responses
public abstract record AddressResponse;
public record AddressCreatedResponse(string AddressId) : AddressResponse;
public record AddressUpdatedResponse(string AddressId) : AddressResponse;
public record AddressDeletedResponse(string AddressId) : AddressResponse;
public record AddressDetailsResponse(
string AddressId,
string Street,
string City,
string State,
string ZipCode
) : AddressResponse;
public record AddressNotFoundResponse(string AddressId) : AddressResponse;
// Models/AddressState.cs
public class AddressState
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string ZipCode { get; set; } = string.Empty;
public bool IsDeleted { get; set; }
public void Apply(AddressEvent evt)
{
switch (evt)
{
case AddressCreated created:
Street = created.Street;
City = created.City;
State = created.State;
ZipCode = created.ZipCode;
IsDeleted = false;
break;
case AddressUpdated updated:
if (updated.Street != null) Street = updated.Street;
if (updated.City != null) City = updated.City;
if (updated.State != null) State = updated.State;
if (updated.ZipCode != null) ZipCode = updated.ZipCode;
break;
case AddressDeleted:
IsDeleted = true;
break;
}
}
}
The AddressActor is a persistent actor that stores events in PostgreSQL:
// Actors/AddressActor.cs
using Akka.Persistence;
public class AddressActor : ReceivePersistentActor
{
public override string PersistenceId { get; }
private AddressState _state = new();
private bool _isInitialized;
public AddressActor(string addressId)
{
PersistenceId = $"address-{addressId}";
// Commands are handled when the actor is active
Command<CreateAddress>(HandleCreateAddress);
Command<UpdateAddress>(HandleUpdateAddress);
Command<GetAddress>(HandleGetAddress);
Command<DeleteAddress>(HandleDeleteAddress);
// Recovery handlers replay events on actor restart
Recover<AddressEvent>(evt =>
{
_state.Apply(evt);
_isInitialized = true;
});
Recover<SnapshotOffer>(offer =>
{
if (offer.Snapshot is AddressState state)
{
_state = state;
_isInitialized = true;
}
});
}
private void HandleCreateAddress(CreateAddress cmd)
{
if (_isInitialized && !_state.IsDeleted)
{
Sender.Tell(new Status.Failure(new InvalidOperationException(
$"Address {cmd.AddressId} already exists")));
return;
}
var evt = new AddressCreated(cmd.Street, cmd.City, cmd.State, cmd.ZipCode);
Persist(evt, e =>
{
_state.Apply(e);
_isInitialized = true;
// Save snapshot every 10 events for performance
if (LastSequenceNr % 10 == 0)
{
SaveSnapshot(_state);
}
Sender.Tell(new AddressCreatedResponse(cmd.AddressId));
});
}
private void HandleUpdateAddress(UpdateAddress cmd)
{
if (!_isInitialized || _state.IsDeleted)
{
Sender.Tell(new AddressNotFoundResponse(cmd.AddressId));
return;
}
var evt = new AddressUpdated(cmd.Street, cmd.City, cmd.State, cmd.ZipCode);
Persist(evt, e =>
{
_state.Apply(e);
if (LastSequenceNr % 10 == 0)
{
SaveSnapshot(_state);
}
Sender.Tell(new AddressUpdatedResponse(cmd.AddressId));
});
}
private void HandleGetAddress(GetAddress cmd)
{
if (!_isInitialized || _state.IsDeleted)
{
Sender.Tell(new AddressNotFoundResponse(cmd.AddressId));
return;
}
Sender.Tell(new AddressDetailsResponse(
cmd.AddressId,
_state.Street,
_state.City,
_state.State,
_state.ZipCode
));
}
private void HandleDeleteAddress(DeleteAddress cmd)
{
if (!_isInitialized || _state.IsDeleted)
{
Sender.Tell(new AddressNotFoundResponse(cmd.AddressId));
return;
}
var evt = new AddressDeleted();
Persist(evt, e =>
{
_state.Apply(e);
SaveSnapshot(_state);
Sender.Tell(new AddressDeletedResponse(cmd.AddressId));
});
}
}
The parent actor manages all address child actors using the Child-Per-Entity pattern:
// Actors/AddressBookActor.cs
public class AddressBookActor : ReceiveActor
{
private readonly Dictionary<string, IActorRef> _addresses = new();
public AddressBookActor()
{
Receive<AddressCommand>(cmd =>
{
var addressId = cmd.AddressId;
// Get or create child actor for this address
if (!_addresses.TryGetValue(addressId, out var addressActor))
{
addressActor = Context.ActorOf(
Props.Create(() => new AddressActor(addressId)),
$"address-{addressId}");
_addresses[addressId] = addressActor;
}
// Forward message to the appropriate child actor
addressActor.Forward(cmd);
});
}
protected override SupervisorStrategy SupervisorStrategy()
{
return new OneForOneStrategy(
maxNrOfRetries: 3,
withinTimeRange: TimeSpan.FromMinutes(1),
localOnlyDecider: ex =>
{
// Log error
Context.GetLogger().Error(ex, "Address actor failed");
// Restart the actor to recover from transient failures
return Directive.Restart;
});
}
}
Set up Akka.Hosting with PostgreSQL persistence in Program.cs:
// Program.cs
using Akka.Hosting;
using Akka.Persistence.Sql.Hosting;
var builder = WebApplication.CreateBuilder(args);
// Add controllers
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Configure Akka.NET with PostgreSQL persistence
builder.Services.AddAkka("AddressBookSystem", (configBuilder, provider) =>
{
configBuilder
.WithSqlPersistence(
connectionString: builder.Configuration.GetConnectionString("PostgreSQL")!,
providerName: LinqToDB.ProviderName.PostgreSQL15,
tagStorageMode: TagMode.Csv,
databaseMapping: DatabaseMapping.PostgreSql,
autoInitialize: true) // Automatically create tables
.WithActors((system, registry) =>
{
// Register the parent AddressBookActor
var addressBook = system.ActorOf(
Props.Create<AddressBookActor>(),
"address-book");
registry.Register<AddressBookActor>(addressBook);
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
In appsettings.json:
{
"ConnectionStrings": {
"PostgreSQL": "Host=localhost;Port=5432;Database=addressbook;Username=postgres;Password=postgres"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Akka": "Information"
}
}
}
Finally, create the ASP.NET Core controller that bridges HTTP requests to actor messages:
// Controllers/AddressesController.cs
using Akka.Actor;
using Akka.Hosting;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class AddressesController : ControllerBase
{
private readonly IActorRef _addressBook;
public AddressesController(IRequiredActor<AddressBookActor> addressBookActor)
{
_addressBook = addressBookActor.ActorRef;
}
[HttpPost]
public async Task<IActionResult> CreateAddress([FromBody] CreateAddressRequest request)
{
var addressId = Guid.NewGuid().ToString();
var command = new CreateAddress(
addressId,
request.Street,
request.City,
request.State,
request.ZipCode);
var response = await _addressBook.Ask<AddressResponse>(
command,
timeout: TimeSpan.FromSeconds(5));
return response switch
{
AddressCreatedResponse created => CreatedAtAction(
nameof(GetAddress),
new { id = created.AddressId },
new { id = created.AddressId }),
Status.Failure failure => BadRequest(failure.Cause.Message),
_ => StatusCode(500, "Unexpected response")
};
}
[HttpGet("{id}")]
public async Task<IActionResult> GetAddress(string id)
{
var query = new GetAddress(id);
var response = await _addressBook.Ask<AddressResponse>(
query,
timeout: TimeSpan.FromSeconds(5));
return response switch
{
AddressDetailsResponse details => Ok(new
{
id = details.AddressId,
street = details.Street,
city = details.City,
state = details.State,
zipCode = details.ZipCode
}),
AddressNotFoundResponse => NotFound(),
_ => StatusCode(500, "Unexpected response")
};
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateAddress(
string id,
[FromBody] UpdateAddressRequest request)
{
var command = new UpdateAddress(
id,
request.Street,
request.City,
request.State,
request.ZipCode);
var response = await _addressBook.Ask<AddressResponse>(
command,
timeout: TimeSpan.FromSeconds(5));
return response switch
{
AddressUpdatedResponse => NoContent(),
AddressNotFoundResponse => NotFound(),
_ => StatusCode(500, "Unexpected response")
};
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAddress(string id)
{
var command = new DeleteAddress(id);
var response = await _addressBook.Ask<AddressResponse>(
command,
timeout: TimeSpan.FromSeconds(5));
return response switch
{
AddressDeletedResponse => NoContent(),
AddressNotFoundResponse => NotFound(),
_ => StatusCode(500, "Unexpected response")
};
}
}
// DTOs
public record CreateAddressRequest(
string Street,
string City,
string State,
string ZipCode);
public record UpdateAddressRequest(
string? Street,
string? City,
string? State,
string? ZipCode);
Akka.Persistence.Sql will automatically create the required tables when autoInitialize: true is set. The tables include:
If you prefer manual control, set autoInitialize: false and run the SQL scripts from the Akka.Persistence.Sql repository.
Start PostgreSQL (using Docker):
docker run --name postgres-addressbook \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=addressbook \
-p 5432:5432 \
-d postgres:15
Run the application:
dotnet run
Create an address:
curl -X POST https://localhost:7001/api/addresses \
-H "Content-Type: application/json" \
-d '{
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zipCode": "62701"
}'
Response:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
Get an address:
curl https://localhost:7001/api/addresses/a1b2c3d4-e5f6-7890-abcd-ef1234567890
Response:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zipCode": "62701"
}
Update an address:
curl -X PUT https://localhost:7001/api/addresses/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{
"street": "456 Oak Ave"
}'
Delete an address:
curl -X DELETE https://localhost:7001/api/addresses/a1b2c3d4-e5f6-7890-abcd-ef1234567890
Akka.Persistence.Query lets you read events from the journal to build read models (CQRS):
using Akka.Persistence.Query;
using Akka.Persistence.Query.Sql;
using Akka.Streams;
using Akka.Streams.Dsl;
public class AddressEventProjection
{
private readonly ActorSystem _system;
private readonly IReadJournal _readJournal;
public AddressEventProjection(ActorSystem system)
{
_system = system;
_readJournal = PersistenceQuery.Get(system)
.ReadJournalFor<SqlReadJournal>(SqlReadJournal.Identifier);
}
public void ProjectAllEvents()
{
var materializer = _system.Materializer();
_readJournal
.AllEvents()
.RunForeach(envelope =>
{
if (envelope.Event is AddressEvent evt)
{
// Project event to read model database
Console.WriteLine($"Projecting event: {evt.GetType().Name}");
}
}, materializer);
}
}
Distribute actors across multiple nodes for scalability and fault tolerance:
// Program.cs
builder.Services.AddAkka("AddressBookSystem", (configBuilder, provider) =>
{
configBuilder
.WithClustering()
.WithClusterSharding(options =>
{
options.AddShardRegion<AddressBookActor>(
typeName: "addresses",
entityPropsFactory: (entityId, shardRegion, system) =>
Props.Create(() => new AddressActor(entityId)),
extractEntityId: message => message switch
{
AddressCommand cmd => (cmd.AddressId, cmd),
_ => throw new NotSupportedException()
},
extractShardId: message => message switch
{
AddressCommand cmd => (cmd.AddressId.GetHashCode() % 10).ToString(),
_ => throw new NotSupportedException()
});
});
});
Akka.TestKit provides excellent support for testing actors:
using Akka.TestKit.Xunit2;
using Xunit;
public class AddressActorTests : TestKit
{
[Fact]
public async Task CreateAddress_ShouldPersistEvent()
{
// Arrange
var addressId = "test-123";
var actor = Sys.ActorOf(Props.Create(() => new AddressActor(addressId)));
// Act
var command = new CreateAddress(
addressId,
"123 Test St",
"Test City",
"TS",
"12345");
actor.Tell(command);
// Assert
var response = await ExpectMsgAsync<AddressCreatedResponse>();
Assert.Equal(addressId, response.AddressId);
// Verify state
actor.Tell(new GetAddress(addressId));
var details = await ExpectMsgAsync<AddressDetailsResponse>();
Assert.Equal("123 Test St", details.Street);
}
}
Taking snapshots periodically improves recovery time:
if (LastSequenceNr % 100 == 0)
{
SaveSnapshot(_state);
}
Use PersistAll to persist multiple events atomically:
var events = new[]
{
new AddressUpdated("456 Oak Ave", null, null, null),
new AuditEventRecorded(DateTime.UtcNow, "address_updated")
};
PersistAll(events, evt =>
{
_state.Apply(evt);
if (AllEventsProcessed)
{
Sender.Tell(new AddressUpdatedResponse(PersistenceId));
}
});
Configure PostgreSQL connection pooling for optimal performance:
{
"ConnectionStrings": {
"PostgreSQL": "Host=localhost;Port=5432;Database=addressbook;Username=postgres;Password=postgres;Maximum Pool Size=100;Minimum Pool Size=10"
}
}
Building a REST API with Akka.NET, the Actor Model, and Event Sourcing provides powerful benefits:
The Child-Per-Entity pattern gives you a clean, maintainable architecture where each domain entity is represented by an actor. Combined with Akka.Persistence.Sql, you get Event Sourcing with PostgreSQL, providing both the benefits of event-driven architecture and the reliability of a mature relational database.
While there's a learning curve to understanding the Actor Model, the payoff is substantial: highly concurrent, resilient systems that scale gracefully. If you're building APIs that need to handle significant load, complex stateful workflows, or distributed processing, Akka.NET is worth serious consideration.
Happy coding with Akka.NET! 🚀