DEV Community

Cover image for Using the Actor System in ASP.NET
EDEXADE
EDEXADE

Posted on

Using the Actor System in ASP.NET

Introduction

Recently, I completed the development of a chess application on the .NET platform using Actors that utilizes modern approaches and technologies to ensure scalability and performance. In this post, I want to share key aspects of the architecture and technologies that helped me achieve this goal*.

Original state: https://www.linkedin.com/state

Layered Architecture

The application is built using a layered architecture, which allows for clear separation of responsibilities among different components and simplifies maintenance and development. The main layers include:

  • Presentation Layer: ASP.NET controllers and views.
  • Infrastructure Layer: Repositories, Entity Framework, PostgreSQL, actors.
  • Application Layer: Service classes, CQRS (Command and Query), MediatR.
  • Domain Layer: Domain entities and chess logic (my own library).

Technologies

  • Akka.NET and Microsoft Orleans To support multiple games concurrently, I used an actor system. Each actor manages the state of a single chess game. For implementing actors, I considered two options: Akka.NET and Microsoft Orleans.
  • ASP.NET ASP.NET is used for creating controllers and views, providing user interaction through a web interface.
  • PostgreSQL and Entity Framework For data storage, I chose PostgreSQL as the database and Entity Framework for database operations. This allows efficient data management and takes advantage of ORM.
  • CQRS and MediatR Using the CQRS (Command Query Responsibility Segregation) pattern allows separating read and write operations, simplifying maintenance and scalability. The MediatR library helps manage commands and queries, ensuring flexibility and extensibility.

Common interface for Actor's Service

First, it was necessary to create a common interface. Each of the services responsible for a particular implementation of the actor model needed to implement this interface. This way, we can switch between specific implementations by changing just one line of code.

The main operations we needed were "Get game", "Join game," and "Make move." "Create game" is a fairly simple operation that doesn't require conditions, so it was decided to exclude it from the logic. Thus, we get the following interface:

// IChessActorService.cs
public interface IChessActorService
{
    Task<Game> GetGameAsync(GetGameQuery getGameQuery, CancellationToken cancellationToken);
    Task<Game> JoinGameAsync(JoinGameCommand joinGameCommand, CancellationToken cancellationToken);
    Task<Game> MoveGameAsync(MoveGameCommand moveGameCommand, CancellationToken cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

Thanks to interface, we can easily switch between a specific implementation, determining the necessary one directly in the code or using "Configuration" for it.

Akka.NET

Akka.NET provides a powerful and flexible actor model that allows for the creation of lightweight actors. Each game is managed by a dedicated actor, which handles game state, player moves, and game logic. The actor model in Akka.NET is highly concurrent and resilient, making it an excellent choice for real-time applications like a chess game.

Actors in Akka.NET communicate with each other asynchronously using message passing, which helps to avoid the complexities of multithreading and ensures that the application remains responsive even under heavy load. The actor hierarchy in Akka.NET also allows for easy supervision and fault tolerance, as parent actors can manage the lifecycle and error handling of their child actors.

Architecture

As previously mentioned, Akka.NET offers a lower-level interface for implementing the actor model, which must be considered during development. For example, when creating any actor, it belongs to a specific parent. The parent, in turn, becomes responsible for the new actor, monitoring its state and reacting to various events, such as its unexpected termination.

Top-level architecture (Part 1. Top-level Architecture)

In our case, we needed an actor for each game so that each game could be processed in parallel with others, increasing the speed of move and event handling. Returning to the architecture, we find that in addition to the game actors, we need a main actor that can route each request to the appropriate actor for the specific game. Looking ahead, there is no need for supervisors to monitor each actor externally. In this case, the logic of the parent actor suffices.

Thus, the actor system will look as shown in the image below:

System of Actors for app

Realize of service interface

// AkkaNetSystem.cs
public class AkkaNetSystem: IChessActorService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private IActorRef _chessMaster;

    public AkkaNetSystem(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
        var system = ActorSystem.Create("chess-system");
        _chessMaster = system.ActorOf(
            ChessMaster.Props(_scopeFactory), 
            "chess-master"
        );
    }

    public async Task<Game> GetGameAsync(
        GetGameQuery getGameQuery, 
        CancellationToken cancellationToken
    )
    {
        return await _chessMaster.Ask<Game>(
            message: getGameQuery, 
            timeout: TimeSpan.FromSeconds(3),
            cancellationToken: cancellationToken
        );
    }

    // Other methods with same realization
    …
}
Enter fullscreen mode Exit fullscreen mode

Integrating with ASP.NET

Given that actors run throughout the application's lifecycle in a single context, we register them as singletons. The service starts up perfectly, initializing the actor system. However, there is a nuance: during a "soft" shutdown, the service simply stops without gracefully shutting down the actors. Even if we override the Dispose method, issues with IServiceScopeFactory arise.

Due to these problems, the correct solution was to use HostedService so that we could define events for starting and stopping the service. To do this, we update the AkkaNetSystem class by also inheriting from IHostedService and overriding the StartAsync() and StopAsync() methods:

// AkkaNetSystem.cs
public class AkkaNetSystem: IChessActorService, IHostedService
{
    …
    public AkkaNetSystem(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }
   public async Task StartAsync(CancellationToken cancellationToken)
    {
        var system = ActorSystem.Create("chess-system");
        _chessMaster = system.ActorOf(
            ChessMaster.Props(_scopeFactory), 
            "chess-master"
        );
        await Task.CompletedTask;
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        await _chessMaster.GracefulStop(TimeSpan.FromSeconds(3));
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, register the service as IHostedService:

// DependencyInjection.cs
…
public static class DependencyInjection
{
   private static void AddActorAkkaNet(this IServiceCollection services)
    {
        services.AddSingleton<IChessActorService, Actors.AkkaNet.AkkaNetSystem>();
        // starts the IHostedService, which creates the ActorSystem and actors
        services.AddHostedService<Actors.AkkaNet.AkkaNetSystem>(
            sp => (Actors.AkkaNet.AkkaNetSystem)sp.GetRequiredService<IChessActorService>()
        );
    }
    …
}
Enter fullscreen mode Exit fullscreen mode

Microsoft Orleans

Orleans invented an abstraction called a virtual actor, where actors exist all the time. In this case, an actor, when used by a client, is not explicitly created or destroyed, and it is not affected by failures. This is why they are always accessible.

In this regard, the Orleans programming model reduces the complexity inherent in highly parallel distributed applications without limiting capabilities or imposing restrictions on the developer.

Architecture

The actor model implementation in Microsoft Orleans differs from the system previously discussed in Akka.NET. To understand how to organize your system using Orleans, it's essential to grasp what Grain, Silo, and Cluster mean.

Grain is one of several primitives in Orleans. From an actor model perspective, grains are virtual subjects. The primary standard unit in any Orleans application is a grain. Grains are entities composed of user-defined credentials, behaviors, and state. Let's consider the following visual representation of a grain.

Visual performing of Grain

In Orleans, we only need one type of grain to represent the game. This single grain will denote the entire game, and we will interact with it within the cluster through silos.

Realize of service interface

// MicrosoftOrleansSystem.cs
…
public class MicrosoftOrleansSystem: IChessActorService 
{
    IGrainFactory GrainFactory { get; init; }

    public MicrosoftOrleansSystem(IGrainFactory grainFactory)
    {
        GrainFactory = grainFactory;
    }

    public async Task<Game> GetGameAsync(GetGameQuery getGameQuery, CancellationToken cancellationToken)
    {
        IGameMasterGrain gameMaster = GrainFactory.GetGrain<IGameMasterGrain>(getGameQuery.GameId);
        return await gameMaster.GetGameAsync(cancellationToken);
    }

    //
    ...
}
Enter fullscreen mode Exit fullscreen mode

Integrating with ASP.NET

Unlike Akka.NET, we don't need to connect the service as an IHostedService because Orleans handles this himself. However, to make this work, we need to use the special "UseOrleans()" method extension for the HostBuilder:

// DependencyInjection.cs (doesn't working without serialization for Game)
…
   private static void AddActorMicrosoftOrleans(this IServiceCollection services, IHostBuilder hostBuilder)
    {
        hostBuilder.UseOrleans(siloBuilder =>
        {
            siloBuilder
                .UseLocalhostClustering();
        });

        services.AddSingleton<IChessActorService, Actors.MicrosoftOrleans.MicrosoftOrleansSystem>();
    }
...
Enter fullscreen mode Exit fullscreen mode

When attempting to start the server, however, we may encounter a data serialization error. This is because when deploying to the cloud or different machines, issues may arise with our Game class returned by the grain in each method call. To address this, we also need to add a serializer. "JsonSerializer" is well-suited for this task:

// DependencyInjection.cs (working without serialization for Game)
…
       hostBuilder.UseOrleans(siloBuilder =>
        {
            services.AddSerializer(
                serializerBuilder =>
                {
                    serializerBuilder.AddJsonSerializer(_ => true);
                }
            );
            siloBuilder
                .UseLocalhostClustering();
        });
…
Enter fullscreen mode Exit fullscreen mode

Performance test

200 users (100 games), 10 minutes. Akka.NET vs Orleans

Conclusion

This architecture and set of technologies enable supporting multiple chess games concurrently with high performance and scalability. The application of CQRS and MediatR simplifies managing commands and queries, while the use of an actor system ensures efficient state management.

I hope my experience will be useful to those considering building complex applications on the .NET platform, or for those just starting to explore its use. If you have any questions or want to discuss details, feel free to leave comments!

Top comments (0)