This post is part of the .NET Advent Calendar 2023.
Many thanks to Dustin Moris Gorski for organizing all of this. Checkout his other many excellent posts.
At CodicePlastico we love the actor model. Some of us are great fans of Elixir and its stack, others love Akka.NET and the way it provides to develop applications adopting actor model in the .net ecosystem.
But why should I choose the actor model for my next application? Because it could be the right choice in some scenarios. If you are involved in developing IoT systems, vehicles, object tracking, betting or banking applications, then the actor model is a great choice. In general, if you need to track the state of a domain entity and access it frequently, the actor model could be the right tool! The actor model could be the right choice even if we need to develop something with entities that would “speak” to each other -e.g. by sending messages to notify changes and letting them update their status, if needed.
Interesting, right?! Many programming languages and frameworks are based on the actor model: Scala, Elixir, Erlang, Pony and Akka are only a few of them, maybe the most famous. Considering .NET world, Akka.net, Dapr, Proto.Actor, Coyote are some libraries or frameworks that allow us to adopt the actor model and, in the case of Dapr and Proto.Actor, to build a language-agnostic system, where actors implemented in different languages interact with each other. While I was researching all these libraries and frameworks, for sure, I missed a great, stable framework Microsoft has been developing since 2009: Microsoft Orleans. It’s been used for Halo 4, Skype and some Azure services, only to name a few.
So, if you are curious to explore something about actor model and this exciting topic in the .net ecosystem, this could be the right post for you!
What is an Actor Model?
Let’s start from the definition that Carl Hewitt, the creator of the Actor model, gave in 1973:
From this definition we understand that an Actor can:
- receive messages,
- process the messages it receives,
- apply its business logic to the data contained into the message and take decision if
- modify and persist its state
- send messages to other actors
This model is great to develop cloud native, concurrent, distributed and scalable systems easily.
Let’s introduce Microsoft Orleans
If you want to fix in your mind the components of a Microsoft Orleans Cloud Native application, you have to imagine a large cultivated field of cereals. What are the main objects that you find in this relaxing picture?
First of all, the most important part of this picture is the cereals, because without cereals the cultivated field is useless. And what are cereals made of? Obviously… grains! Bingo! Grains are the first item you have to fix in your mind! How many grains do you find in a cultivated field? So many, and their number can grow during the season, when the field is… “running”.
The second focal point is the container of those grains: where do we deploy our grains? When we want to use those grains we have to store them in a silo. And what happens when the number of grains grows exponentially and a single silo is not enough? We need more silos and… we need something to manage those silos. So we need a cluster, something that keeps track of the status of each Silo and decides how grains are stored and retrieved.
So, let’s recap and apply this metaphor to the software.
Grains
A grain is an actor or, better, is a virtual actor. Actors in Orleans are virtual because they are managed by the runtime. Developers shouldn’t worry about creating or destroying them, or knowing about the hierarchy of those system elements: they tell the runtime they need an instance of an actor and… voilà, the instance is provided. If the system needs:
- a new grain, then the runtime creates it and returns its reference to the caller;
- an existing grain, the runtime returns the reference to the instanced actor, if the grain is already in memory. However, if a previously active actor was unloaded from memory and persisted to storage due to inactivity, then the runtime will have to reactivate it first by loading its persisted state from the database and then return its reference;
An actor activation occurs just when another actor sends a message to it. If the actor is not receiving messages for a configurable amount of time, the runtime deactivates it.
The following image shows the actor’s life cycle
To identify grains, developers use an identifier. Orleans supports these types of identifiers:
- Long
- GUID
- String
- GUID + String
- Long + String
Finally, an actor-based application could have different kinds of actors. Since an actor is a class that implements the IGrainWith…Key (where the … is where you define the kind of the identifier) and inherits from Grain, the methods of the grain define its behavior.
To recap, the following is the equation of a grain
Silos
Silos are the runtime that manages grains. A silo could be a console application or an api, hosted in a Windows Service or in IIS, deployed on premise, in a Docker container or in the cloud (i.e., using Azure App Service). Typically there’s a silo per machine.
Silos need 2 TCP ports to be opened:
- 30000 for silo-to-silo communications
- 11111 for client-to-silo communication
Clusters
As a silo manages grains, a cluster manages silos. A cluster needs a database to persist and share silos-related data: SQL Server, MySQL, Postgres and Oracle are supported. The cluster stores its data into cluster membership tables and communicates using a cluster membership protocol, as shown in the following image
Hosting
We can deploy an Orleans actor system in two ways:
- in a co-hosted application, the silo and its clients are hosted in the same application, i.e. a minimal API. The client activates grains and the runtime manages them.
This is perfect for simple applications and a great opportunity to start with a small footprint, keeping complexity low.
- in an external clients application, clients and runtimes are hosted in different applications. Those applications share a library containing the grains contracts.
The amazing thing is that the developers’ experience is the same. Through the client, developers activate a grain that could be on the same machine or somewhere in the cloud, but for us it is only a reference of an instanced grain managed by the silo. In simple words, Microsoft Orleans manages for us all this complexity.
Creating an application from scratch with Orleans
Requirements
Now that we know what Microsoft Orleans is and how it works, we’ll start to create our actor model-based application for a new restaurant born a few days ago: PizzaPlastica.
PizzaPlastica commits us to a new, revolutionary ordering system platform. The requirements are the following:
- the application must be as fast as possible during rush hours (they heard about something called cache to improve responsiveness…)
- the restaurant has got an unique identifier and each table is numbered
- customers can add and remove items
- customers can close the order
- a table order item has got these data: name and cost of the dish and quantity
- the table order should be closed automatically after 3 hours, removing all table order items
The CEO of PizzaPlastica will start with a single restaurant with 100 tables near here, but she wants to grow during the first year: she wants to open 10000 restaurants and manage at least 1000000 tables at the same time.
So, we need an application that scales, probably we could deploy the application on premise initially, but after a few months we should migrate the application to the cloud. We want to keep the application as easy as possible, with minimal dependencies on the infrastructure (our dream is to manage only the application and the database), but we need a cache because our tables are stateful entities. The migration to the cloud should be painless, theoretically moving only the data to the cloud database and deploying the application somewhere. And we need a scheduled task to close the table ordination.
With these requirements and these bullet points (cached and stateful entities, cloud-native application, at least a scheduled job) Microsoft Orleans could be the right choice 💪
Co-hosted application setup
To setup the application, open your preferred folder and execute the following commands in the terminal
md PizzaPlastica.OrderingSystem
cd PizzaPlastica.OrderingSystem
dotnet new sln
md src
cd src
dotnet new webapi -n PizzaPlastica.OrderingSystem
dotnet new classlib -n PizzaPlastica.OrderingSystem.Abstractions
cd ..
dotnet sln add src\PizzaPlastica.OrderingSystem\PizzaPlastica.OrderingSystem.csproj
dotnet sln add src\PizzaPlastica.OrderingSystem.Abstractions\PizzaPlastica.OrderingSystem.Abstractions.csproj
These two projects have different purposes:
- PizzaPlastica.OrderingSystem.Abstractions contains the grain interfaces and shared objects
- PizzaPlastica.OrderingSystem is a minimal api that hosts the silo and the grains (this is why we call this application co-hosted)
Developing your first grain
First of all, we have to create the grain interface. As said before, a grain must implement a IGrainWith…Key interface. These interfaces are provided by the Microsoft.Orleans.Core.Abstractions NuGet package that we are going to add to the PizzaPlastica.OrderingSystem.Abstractions project using the command
dotnet add package Microsoft.Orleans.Core.Abstractions
Then add the ITableOrderGrain interface
using Orleans.Concurrency;
using System.Collections.ObjectModel;
namespace PizzaPlastica.OrderingSystem.Abstractions;
public interface ITableOrderGrain : IGrainWithGuidCompoundKey
{
Task OpenTableOrder();
Task CloseTableOrder();
Task<Guid> AddOrderItem(string name, double cost, int quantity);
Task RemoveOrderItem(Guid orderItemId);
[ReadOnly]
Task<TableOrderItem> GetOrderItemDetails(Guid orderItemId);
[ReadOnly]
Task<ReadOnlyCollection<TableOrderItem>> GetOrderItems();
}
For this kind of grain we choose a compound key, composed by a guid and a string: the guid stands for the restaurant unique identifier, and the string represents the unique number of the table of this specific restaurant.
As you can see, methods GetOrderItemDetails and GetOrderItems are decorated by the [ReadOnly] attribute. Orleans provides some attributes to enhance multi-threading performance: in particular [ReadOnly] tells the runtime that this method doesn’t change the grain state.
Go to the PizzaPlastica.OrderingSystem project and define the class TableOrderGrain . Let’s add some logic
using Orleans;
using PizzaPlastica.OrderingSystem.Abstractions;
namespace PizzaPlastica.OrderingSystem.Grains;
public class TableOrderGrain : Grain, ITableOrderGrain
{
// STATE
private bool IsOpen { get; set; }
private List<TableOrderItem> OrderItems { get; set; }
// BEHAVIOR
public Task OpenTableOrder()
{
if(IsOpen)
throw new InvalidStateException("Table has already opened.");
this.IsOpen = true;
OrderItems = new List<TableOrderItem>();
return Task.CompletedTask;
}
public async Task<Guid> AddOrderItem(string name, double cost, int quantity)
{
if (!IsOpen)
throw new InvalidStateException("Table should be opened.");
var orderItemId = Guid.NewGuid();
OrderItems.Add(new TableOrderItem
{
Id = orderItemId,
Name = name,
Cost = cost,
Quantity = quantity
});
return orderItemId;
}
...
}
As you can see, the grain type represents the behavior of the grain and its properties represent the state.
The PizzaPlastica.OrderingSystem is the project where the grains live and it contains the silo of our application, hosted in a minimal web api. To accomplish this purpose it is necessary to add the Microsoft.Orleans.Server NuGet package by executing the command
dotnet add package Microsoft.Orleans.Server
Testing a grain
Move to the solution folder and create the test project
md test
cd test
dotnet new nunit -n PizzaPlastica.OrderingSystem.Testsdotnet sln add src\PizzaPlastica.OrderingSystem.Tests\PizzaPlastica.OrderingSystem.Tests.csproj
Remove the UnitTest1.cs file and add the reference to Microsoft.Orleans.TestingHost using the command
dotnet add package Microsoft.Orleans.TestingHost
Then add this fixtures class which will bootstrap the testing environment needed by each test class in the project.
using Orleans.TestingHost;
namespace PizzaPlastica.OrderingSystem.Tests.Fixtures;
public class ClusterFixture : IDisposable
{
public ClusterFixture()
{
var builder = new TestClusterBuilder();
builder.AddSiloBuilderConfigurator<TestSiloConfigurations>();
Cluster = builder.Build();
Cluster.Deploy();
}
public void Dispose() => Cluster.StopAllSilos();
public TestCluster Cluster { get; }
}
public class TestSiloConfigurations : ISiloConfigurator
{
public void Configure(ISiloBuilder siloBuilder)
{
// PUT HERE SERVICE DEPENDENCIES...
}
}
And finally, create your test class TableOrderGrainTests. The following logic tests an entire scenario where we open a table order and add a new item
using PizzaPlastica.OrderingSystem.Abstractions;
using PizzaPlastica.OrderingSystem.Tests.Fixtures;
namespace PizzaPlastica.OrderingSystem.Tests;
public class TableOrderGrainTests
{
private ClusterFixture fixture;
[SetUp]
public void SetUp()
{
fixture = new ClusterFixture();
}
[Test]
public async Task Open_a_table_order_and_add_an_item()
{
// ARRANGE
var restaurantId = Guid.NewGuid();
var tableId = 3;
var tableOrderGrain = fixture.Cluster.GrainFactory.GetGrain<ITableOrderGrain>(restaurantId, tableId.ToString());
// ACT
await tableOrderGrain.OpenTableOrder();
var orderItemId = await tableOrderGrain.AddOrderItem("Pizza Hawaii", 6.5, 1);
var orderItems = await tableOrderGrain.GetOrderItems();
// ASSERT
Assert.That(orderItems.Count, Is.EqualTo(1));
Assert.That(orderItems[0].Id, Is.EqualTo(orderItemId));
Assert.That(orderItems[0].Name, Is.EqualTo("Pizza Hawaii"));
Assert.That(orderItems[0].Cost, Is.EqualTo(6.5));
Assert.That(orderItems[0].Quantity, Is.EqualTo(1));
}
}
Create your API
Now, to expose the application logic via api, it is necessary to modify the Program.cs class and register Orleans after the web application builder was created
// *** ORLEANS REGISTRATION E CONFIGURATION ***
builder.Host.UseOrleans(siloBuilder =>
{
if (builder.Environment.IsDevelopment())
{
siloBuilder.UseLocalhostClustering();
}
});
and expose the logic through different endpoints
app.MapPost("restaurants/{restaurantId}/tables/{tableId}",
async (
IClusterClient client,
Guid restaurantId,
int tableId) =>
{
try
{
var grainRef = client.GetGrain<ITableOrderGrain>(restaurantId, tableId.ToString());
await grainRef.OpenTableOrder();
return Results.Ok();
}
catch(InvalidStateException exc)
{
return Results.BadRequest(exc.Message);
}
})
.WithName("OpenTable")
.WithOpenApi();
Coding the interaction between the api and grains is very easy. The code block above shows that only two lines of code are necessary
- obtain a grain reference
- call the desired grain behavior, represented by a method
The Orleans runtime hides and manages the complexity!
Now the grain is running but when it is deactivated by the runtime or due to an application restart, the application loses its state. It’s time to add persistence.
The grain persistence
Staying is a local development environment, the persistence is only in-memory but this step prepares grains to be production ready. Adding persistence for development purposes is easy: we have to modify the Orleans registration by adding in-memory persistence
// *** ORLEANS REGISTRATION E CONFIGURATION ***
builder.Host.UseOrleans(siloBuilder =>
{
if (builder.Environment.IsDevelopment())
{
siloBuilder.UseLocalhostClustering();
siloBuilder.AddMemoryGrainStorage("tableorderstorage");
}
});
Now that the environment is configured, we need to provide the TableOrderGrain class with the structure to persist data. So it is necessary to create a new class that represents the grain state
[GenerateSerializer]
public class TableOrderState
{
[Id(0)]
public bool IsOpen { get; set; }
[Id(1)]
public IList<TableOrderItem> OrderItems { get; set; }
}
The grain should be modified to accomplish the persistence requirements
public class TableOrderGrain : Grain, ITableOrderGrain
{
private IPersistentState<TableOrderState> TableOrder { get; }
public TableOrderGrain(
[PersistentState(stateName: "table-order",
storageName: "tableorderstorage")]
IPersistentState<TableOrderState> state)
{
TableOrder = state;
}
public Task OpenTableOrder()
{
if (TableOrder.State.IsOpen)
throw new InvalidStateException("Table has already opened.");
TableOrder.State.IsOpen = true;
TableOrder.State.OrderItems = new List<TableOrderItem>();
return TableOrder.WriteStateAsync();
}
...
}
Finally it is necessary to align the test environment dependencies into ClusterFixture
public class TestSiloConfigurations : ISiloConfigurator
{
public void Configure(ISiloBuilder siloBuilder)
{
// PUT HERE SERVICE DEPENDENCIES...
siloBuilder.AddMemoryGrainStorage("tableorderstorage");
}
}
Use reminders for scheduled tasks
Reminders are useful to schedule tasks in the future when the period is greater than or equal to 1 minute (for shorter periods timers should be the right choice). Reminders could be recurring tasks and they could be stopped. As we did for persistence, we start adding reminders for local development purposes and we will be ready for production configuration. First of all, it is necessary to add the dependency executing the command
dotnet add package Microsoft.Orleans.Reminders
Then the service should be registered in Program.cs class
// *** ORLEANS REGISTRATION E CONFIGURATION ***
builder.Host.UseOrleans(siloBuilder =>
{
if (builder.Environment.IsDevelopment())
{
siloBuilder.UseLocalhostClustering();
siloBuilder.AddMemoryGrainStorage("tableorderstorage");
siloBuilder.UseInMemoryReminderService();
}
});
Remember to add this dependency to ClusterFixture class in the test project.
Finally we have to modify the grain class to add this logic
public class TableOrderGrain : Grain, ITableOrderGrain, IRemindable
{
private IPersistentState<TableOrderState> TableOrder { get; }
private IGrainReminder _reminder = null;
...
public async Task OpenTableOrder()
{
if (TableOrder.State.IsOpen)
throw new InvalidStateException("Table has already opened.");
TableOrder.State.IsOpen = true;
TableOrder.State.OrderItems = new List<TableOrderItem>();
_reminder = await this.RegisterOrUpdateReminder("TableOrderExpired",
dueTime: TimeSpan.Zero,
period: TimeSpan.FromHours(3));
await TableOrder.WriteStateAsync();
}
public async Task CloseTableOrder()
{
if (!TableOrder.State.IsOpen)
throw new InvalidStateException("Table has already closed.");
TableOrder.State.IsOpen = false;
TableOrder.State.OrderItems = new List<TableOrderItem>();
if (_reminder is not null)
{
await this.UnregisterReminder(_reminder);
_reminder = null;
}
await TableOrder.WriteStateAsync();
}
public Task ReceiveReminder(string reminderName, TickStatus status)
{
return reminderName switch
{
"TableOrderExpired" => CloseTableOrder(),
_ => Task.CompletedTask
};
}
...
}
When an order is opened on a table, we register a reminder named TableOrderExpired that will fire after 3 hours. The runtime invokes the ReceiveReminder method that calls CloseTableOrder() where the reminder is unregistered.
Going to production
Going to production it’s easy but it requires some configuration.
First of all we need a database for the runtime: Microsoft Orleans provides connectors to different databases (SQL Server, Postgres, MySql, Oracle). We choose SQL Server.
When your sql instance is ready, create tables by running the scripts Microsoft has made available in the documentation.
In particular, we setup our database running these scripts:
- https://github.com/dotnet/orleans/blob/main/src/AdoNet/Shared/SQLServer-Main.sql
- https://github.com/dotnet/orleans/blob/main/src/AdoNet/Orleans.Clustering.AdoNet/SQLServer-Clustering.sql
- https://github.com/dotnet/orleans/blob/main/src/AdoNet/Orleans.Persistence.AdoNet/SQLServer-Persistence.sql
- https://github.com/dotnet/orleans/blob/main/src/AdoNet/Orleans.Reminders.AdoNet/SQLServer-Reminders.sql
Don’t forget to execute this last command, missing from the scripts above
INSERT INTO OrleansQuery(QueryKey, QueryText)
VALUES
(
'CleanupDefunctSiloEntriesKey','
DELETE FROM OrleansMembershipTable
WHERE DeploymentId = @DeploymentId
AND @DeploymentId IS NOT NULL
AND IAmAliveTime < @IAmAliveTime
AND Status != 3;
');
Now the database instance is ready
Finally, open the PizzaPlastica.OrderingSystem project and add the following dependencies, based on the database you choose
dotnet add package Microsoft.Orleans.Clustering.AdoNet
dotnet add package Microsoft.Orleans.Persistence.AdoNet
dotnet add package Microsoft.Orleans.Reminders.AdoNet
dotnet add package System.Data.SqlClient
and add configuration for the production environment
builder.Host.UseOrleans(siloBuilder =>
{
if (builder.Environment.IsDevelopment())
{
siloBuilder.UseLocalhostClustering();
siloBuilder.AddMemoryGrainStorage("tableorderstorage");
siloBuilder.UseInMemoryReminderService();
}
else
{
siloBuilder.Services.AddSerializer(serializerBuilder =>
{
serializerBuilder.AddNewtonsoftJsonSerializer(
isSupported: type => type.Namespace.StartsWith("PizzaPlastica.OrderingSystem.Abstractions"));
});
// CREAZIONE DEL CLUSTER PER AMBIENTI DI STAGING / PRODUZIONE
siloBuilder.Configure<ClusterOptions>(options =>
{
options.ClusterId = "PizzaPlasticaCluster";
options.ServiceId = "BestOrderingSystemEver";
})
.UseAdoNetClustering(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("SqlOrleans");
options.Invariant = "System.Data.SqlClient";
})
.ConfigureEndpoints(siloPort: 11111, gatewayPort: 30000);
// REGISTRAZIONE REMINDERS PER AMBIENTI DI STAGING / PRODUZIONE
siloBuilder.UseAdoNetReminderService(reminderOptions => {
reminderOptions.ConnectionString = builder.Configuration.GetConnectionString("SqlOrleans");
reminderOptions.Invariant = "System.Data.SqlClient";
});
// REGISTRAZIONE STORAGE PER AMBIENTI DI STAGING / PRODUZIONE
siloBuilder.AddAdoNetGrainStorage("tableorderstorage", storageOptions =>
{
storageOptions.ConnectionString = builder.Configuration.GetConnectionString("SqlOrleans");
storageOptions.Invariant = "System.Data.SqlClient";
});
}
});
If you start the application now, after the invoking OpenTableOrder method, your tables start to collect data for…
…cluster management
…reminder persistence
…and grain states
As you can see, the field PayloadBinary of the OrleansStorage table contains the state of the grain where persistence is identified by the string table-order . If you want to deserialize the field content, run this query
SELECT CAST([PayloadBinary] AS VARCHAR(MAX)) AS Payload
FROM [OrderingSystem].[dbo].[OrleansStorage]
and you obtain this result
{
"$id": "1",
"$type": "PizzaPlastica.OrderingSystem.Grains.TableOrderState, PizzaPlastica.OrderingSystem",
"IsOpen": true,
"OrderItems": {
"$type": "System.Collections.Generic.List`1[[PizzaPlastica.OrderingSystem.Abstractions.TableOrderItem, PizzaPlastica.OrderingSystem.Abstractions]], System.Private.CoreLib",
"$values": [{
"$id": "2",
"$type": "PizzaPlastica.OrderingSystem.Abstractions.TableOrderItem, PizzaPlastica.OrderingSystem.Abstractions",
"Id": "2a62dd13-163c-4d3b-bccb-33c9d8879f46",
"Name": "Pizza",
"Cost": 7.8,
"Quantity": 2
}, {
"$id": "3",
"$type": "PizzaPlastica.OrderingSystem.Abstractions.TableOrderItem, PizzaPlastica.OrderingSystem.Abstractions",
"Id": "bab4e43e-58ae-4903-b72b-5590088a3514",
"Name": "Pizza",
"Cost": 7.8,
"Quantity": 2
}
]
}
}
And finally… scale with the external clients mode
If you want to scale you cannot leave both the silo and the api code in the same project. In a mission critical application, silos could manage many thousands of grains and probability you should scale your application. Maybe api should not scale as silos, so it could be better to separate silo and api projects.
To accomplish this purpose, create a new minimal project named PizzaPlastica.OrderingSystem.Api. Add the Microsoft.Orleans.Client package via Nuget and move all endpoints here. Finally the api must know where the cluster membership table is, so copy the same connection string to the api project and add it to the Program.cs file, where the cluster registration is configured.
builder.Host.UseOrleansClient((hostContext, clientBuilder) =>
{
if (builder.Environment.IsDevelopment())
{
// DEV CLUSTER
clientBuilder.UseLocalhostClustering();
}
else
{
// STAGING/PRODUCTION CLUSTER
clientBuilder.Configure<ClusterOptions>(options =>
{
options.ClusterId = "PizzaPlasticaCluster";
options.ServiceId = "BestOrderingSystemEver";
})
.UseAdoNetClustering(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("SqlOrleans");
options.Invariant = "System.Data.SqlClient";
});
}
});
Run both the silo and api project specifying production environment
dotnet run --environment Production
and if everything was done correctly, you should see the application is still in the exact same state you left it when it ran in the co-hosted environment.
Repository
You can find all the source code here:
https://github.com/CodicePlastico/net-advent-calendar-2023-ms-orleans
You’ll find both solutions, co-hosted and external clients
Microsoft Orleans ❤️ .NET Aspire
Recently Microsoft released a new “stack for building resilient, observable, and configurable cloud-native applications with .NET”, as they said in the post where this new tool was launched. If you are asking yourself how to merge these topics, Reuben Bond has got the answer:
.NET Aspire support for Orleans! Here's a minimal app @msftorleans #dotnet pic.twitter.com/ipGCJbyR3B
— Reuben Bond (@reubenbond) November 22, 2023
Conclusions
I hope this long post could help you to discover and learn something more about this amazing framework. Orleans has got many other features: streams, filters, dashboards… so enjoy!
I cannot be clearer than the community to explain what Orleans is so I want to close this article sharing with you this takeaway from the GitHub page of the project:
Finally, but not least, I want to express my gratitude to my colleague Moreno Gentili, who shared the effort of creating this article with me. His willingness for a valuable and timely review was crucial. Thank you also for the late-night discussions and for the skill in providing feedback. This highlights the value of being part of a team and learning from each other.
Merry Xmas folks and Happy New Year!
Links
About Actor Model:
- Hewitt, Meijer and Szyperski: The Actor Model (everything you wanted to know…)
- Avanscoperta – Emanuele DelBono – Actor Model Workshop [2023]
About Microsoft Orleans: