Learn to Build a Chat Application Using Microsoft Orleans


In my previous post I’ve written about the basics of Microsoft Orleans. In this post, I’d like to build a chat application together using this amazing technology. Within this post, you’ll learn to build Grains, a silo, and a client that uses the Microsoft Orleans Cluster. All the code in this post is available on GitHub. So without any delay, let’s get started!
Overview
Before we dive into the coding part, its important to define what we are exactly gonna build.
Within this post we’ll build a silo project that contains all the Grains and acts as a back-end service. We’ll also build a Blazor Client that acts as the front-end application to provide a GUI. This client will connect to the Microsoft Orleans Cluster to interact with the Grains. If we visualize this, it would look like this:
Within this application we define the following requirements we’ll implement:
Users should have a random username
Users should be able to create or join chatrooms
The system should remember which rooms a user joined so he can rejoin later
Within a chatroom, users must be able to send message to each other. These message must be persisted.
To get started, we’ll create a new solution called MicrosoftOrleansBasicExample
but feel free to choose a other name as you desire. Within this solution we’ll add five different projects as listed below. Please note, this example uses .NET 8.
Project name | Type | Goal |
MicrosoftOrleansBasicExample.Client | Blazor Web App (With Interactive Render Mode Server) | This is the client application that will contain the GUI and interacts with the Grains in the Silo |
MicrosoftOrleansBasicExample.Common | Class Library | Will contain classes shared between Client and Silo |
MicrosoftOrleansBasicExample.Grains | Class Library | Will contain the Grain implementations |
MicrosoftOrleansBasicExample.Grains.Interfaces | Class Library | Will contain the Interfaces of the Grains |
MicrosoftOrleansBasicExample.Silo | Console Application | Will be the host in which the Grains will live. This is the back-end application |
The silo
We’ll start off with the Silo. This is the back-end application in which the Grains will live. To get started, we first install multiple NuGet packages in various projects as listed below.
Project | Nuget Package(s) |
MicrosoftOrleansBasicExample.Silo | Microsoft.Orleans.Server Microsoft.Orleans.Persistence.AzureStorage Microsoft.Orleans.Clustering.AzureStorage |
MicrosoftOrleansBasicExample.Grains.Interfaces | Microsoft.Orleans.Sdk |
MicrosoftOrleansBasicExample.Grains | Microsoft.Orleans.Sdk Microsoft.Orleans.Persistence.AzureStorage |
MicrosoftOrleansBasicExample.Common | Microsoft.Orleans.Sdk Microsoft.Orleans.Core.Abstractions |
These packages are needed for Microsoft Orleans to function properly. The AzureStorage
packages are optional and only needed if you wish to host it using Azure Storage. This will be covered later in the post. Please not that you might need a other Persistence package if you don’t want the AzureStorage
.
Clustering
To properly run the Silo, we need to configure the way it uses clustering. To do this we add the following code within the program.cs of the Silo project:
namespace MicrosoftOrleansBasicExample.Silo
{
internal class Program
{
static async Task Main(string[] args)
{
IHostBuilder builder = Host.CreateDefaultBuilder(args)
.UseOrleans((context, silo) =>
{
if (context.HostingEnvironment.IsDevelopment())
{
silo.UseLocalhostClustering()
.AddMemoryGrainStorage("azure");
}
})
.UseConsoleLifetime();
using IHost host = builder.Build();
await host.RunAsync();
}
}
}
This code configures Microsoft Orleans to use localhost clustering when running as development (Yes, it’s that simple). Please note the ASPNETCORE_ENVIRONMENT
environment variable should have the value Development
locally. This is normally handled by the launchSettings.json
. Yes, this check is obsolete right now but will make sense later in this post.
You now should be able to successfully run the Silo project. When you launch you should see a output like this:
Now the silo is running, we must create the two Grains containing our logic. Before we dive in these, make sure the Silo project references the MicrosoftOrleansBasicExample.Grains.Interfaces
and MicrosoftOrleansBasicExample.Grains
projects. This is needed so Orleans can detect the Grains.
UserGrain
The first Grain we’ll focus on is the UserGrain
. This Grain will resemble a user interacting with our application. To get started we create a new interface called IUser
in the MicrosoftOrleansBasicExample.Grains.Interfaces
project. This interface will inherit IGrainWithGuidKey
which is a marker interface provided by Microsoft Orleans to tell a Grain is using a GUID as key. Within this interface we’ll define multiple methods to support all requirements as stated in the beginning of this post. The interface should look like this:
namespace MicrosoftOrleansBasicExample.Grains.Interfaces
{
public interface IUser : IGrainWithGuidKey
{
public ValueTask SetUsernameAsync(string username);
public ValueTask JoinChatroomAsync(string chatroom);
public ValueTask<string?> GetUsernameAsync();
public ValueTask<List<string>> GetChatroomsAsync();
}
}
Now we have the interface, we’ll define the model to preserve the users state. This state is used to remember the username and the chatrooms he joined. To do this, we’ll add a new folder called States
within the MicrosoftOrleansBasicExample.Grains
project. Within this folder, we add a class called UserState
which looks like this:
namespace MicrosoftOrleansBasicExample.Grains.States
{
public class UserState
{
public string? Username { get; set; }
public List<string> Chatrooms { get; set; } = new();
}
}
Now that the interface and state class are defined, we can now build the Grain itself. Within the MicrosoftOrleansBasicExample.Grains
project we add a class called UserGrain
. This class should inherit from the Grain
class. This is a class provided by Microsoft Orleans. Furthermore the UserGrain
must implement the IUser
interface we created earlier. To also preserve state, we add the following code add the beginning of the class:
private readonly IPersistentState<UserState> user;
public UserGrain([PersistentState(stateName: "User", storageName: "azure")] IPersistentState<UserState> user)
{
this.user = user;
}
This makes sure the Microsoft Orleans framework knows we use the State and automatically fetches and saves it for us. This also makes it possible for use to do things like user.WriteStateAsync();
and user.State.Username = username;
within the Grain to safe/modify its state. To make sure the state is persisted when a grain is deactivated, we add the following code within the Grain:
public override async Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
{
// store the state
await user.WriteStateAsync();
}
As you can see in the code snippet provided earlier, we use a storage with the name azure
which we defined in the program.cs of the Silo project with the following code:
silo.UseLocalhostClustering()
.AddMemoryGrainStorage("azure");
This code will add a in memory storage with the name azure
.
When you followed the steps above and implemented the IUser
interface, the Grain should look something like this:
using MicrosoftOrleansBasicExample.Grains.Interfaces;
using MicrosoftOrleansBasicExample.Grains.States;
namespace MicrosoftOrleansBasicExample.Grains
{
public class UserGrain : Grain, IUser
{
private readonly IPersistentState<UserState> user;
public UserGrain([PersistentState(stateName: "User", storageName: "azure")] IPersistentState<UserState> user)
{
this.user = user;
}
public ValueTask JoinChatroomAsync(string chatroom)
{
chatroom = chatroom.ToLower();
if (!user.State.Chatrooms.Contains(chatroom))
{
user.State.Chatrooms.Add(chatroom);
}
return ValueTask.CompletedTask;
}
public ValueTask SetUsernameAsync(string username)
{
this.user.State.Username = username;
return ValueTask.CompletedTask;
}
public ValueTask<string?> GetUsernameAsync()
{
return new ValueTask<string?>(user.State.Username);
}
public ValueTask<List<string>> GetChatroomsAsync()
{
return new ValueTask<List<string>>(user.State.Chatrooms);
}
public override async Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
{
// store the state
await user.WriteStateAsync();
}
}
}
ChatroomGrain
The second and last Grain we will need to implement is the ChatroomGrain. To do this we start with creating the interface IChatroom
in the MicrosoftOrleansBasicExample.Grains.Interfaces
project. Since every chatroom will have a unique name, we’ll inherit the IGrainWithStringKey
interface to let Microsoft Orleans know the Grain is identifiable with a string.
We then define the methods so someone can join, leave, send a message and get the message history. This would look something like this:
using MicrosoftOrleansBasicExample.Common;
using MicrosoftOrleansBasicExample.Grains.Interfaces.Observers;
namespace MicrosoftOrleansBasicExample.Grains.Interfaces
{
public interface IChatroom : IGrainWithStringKey
{
public ValueTask JoinAsync(IReceiveMessage observer, string username);
public ValueTask LeaveAsync(IReceiveMessage observer, string username);
public Task PublishAsync(string message, string username);
public ValueTask<List<ChatMessage>> GetMessageHistory();
}
}
As you can see we use a interface called IReceiveMessage
. This is a interface we have to create. This interface is used to make sure all clients (receivers of the message) implement the needed interface to receive the message. Within the MicrosoftOrleansBasicExample.Grains.Interfaces
project we’ll add an folder called Observers
. Within this folder create a interface like this:
using MicrosoftOrleansBasicExample.Common;
namespace MicrosoftOrleansBasicExample.Grains.Interfaces.Observers
{
public interface IReceiveMessage : IGrainObserver
{
void ReceiveMessage(ChatMessage message);
}
}
This interface implements the IGrainObserver
which is a interface Microsoft Orleans uses to implement observers. More information about observers within Microsoft Orleans can be found here: https://learn.microsoft.com/en-us/dotnet/orleans/grains/observers.
Besides the IReceiveMessage
there is also a ChatMessage
object which we have to implement. This object will resemble the chat messages send and received by the clients. To implement this, we add a record called ChatMessage
within the MicrosoftOrleansBasicExample.Common
project. This record has to store the message itself, the username of the user who send it and the DateTimeOffset it was send. By default objects can’t be used in communication between clients and Grains. To support this, we’ll add an attribute called GenerateSerializer
to the record. This tells Microsoft Orleans to automatically create a serializer for the record on build. When implementing we should have something like this:
namespace MicrosoftOrleansBasicExample.Common
{
[GenerateSerializer]
public record ChatMessage(string Message, string Username, DateTimeOffset DateTimeSend);
}
Now the ChatMessage is done, we’ll create the state class for the Grain. Within the MicrosoftOrleansBasicExample.Grains
project add a new class called ChatroomState
in the States
folder. Within this state we store a list of ChatMessages. This should look like this:
using MicrosoftOrleansBasicExample.Common;
namespace MicrosoftOrleansBasicExample.Grains.States
{
public class ChatroomState
{
public List<ChatMessage> ChatMessages { get; set; } = new();
}
}
Next we create the class ChatroomGrain
in the MicrosoftOrleansBasicExample.Grains
project. This Grain has to inherit the Grain
class of Microsoft Orleans and implement our interface IChatroom
. If you are building this project with me, I’ll like to challenge you to implement this Grain by yourself. All information that you need is already provided when we created the UserGrain. For the PublishAsync
method you might need the information provided on https://learn.microsoft.com/en-us/dotnet/orleans/grains/observers.
When you are done, you can check if your code looks something like below.
using Microsoft.Extensions.Logging;
using MicrosoftOrleansBasicExample.Common;
using MicrosoftOrleansBasicExample.Grains.Interfaces;
using MicrosoftOrleansBasicExample.Grains.Interfaces.Observers;
using MicrosoftOrleansBasicExample.Grains.States;
namespace MicrosoftOrleansBasicExample.Grains
{
public class ChatroomGrain : Grain, IChatroom
{
private readonly List<IReceiveMessage> observers = new();
private readonly IPersistentState<ChatroomState> chatroom;
private readonly ILogger logger;
public ChatroomGrain(ILogger<ChatroomGrain> logger,
[PersistentState(stateName: "Chatroom", storageName: "azure")] IPersistentState<ChatroomState> chatroom)
{
this.logger = logger;
this.chatroom = chatroom;
}
public async ValueTask JoinAsync(IReceiveMessage observer, string username)
{
logger.LogInformation($"{username} joined the chatroom '{this.GetPrimaryKeyString()}'");
observers.Add(observer);
await PublishAsync($"User {username} joined the chat :D", "System");
}
public async ValueTask LeaveAsync(IReceiveMessage observer, string username)
{
logger.LogInformation($"{username} left the chatroom '{this.GetPrimaryKeyString()}'");
observers.Remove(observer);
await PublishAsync($"User {username} left the chat >:(", "System");
}
public Task PublishAsync(string message, string username)
{
var chatMessage = new ChatMessage(message, username, DateTimeOffset.UtcNow);
logger.LogInformation($"{username} send '{chatMessage.Message}' at {chatMessage.DateTimeSend} in '{this.GetPrimaryKeyString()}'");
chatroom.State.ChatMessages.Add(chatMessage);
foreach (var observer in observers)
{
observer.ReceiveMessage(chatMessage);
}
return Task.CompletedTask;
}
public ValueTask<List<ChatMessage>> GetMessageHistory()
{
return ValueTask.FromResult(chatroom.State.ChatMessages.OrderBy(x => x.DateTimeSend).ToList());
}
public override async Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
{
// Store the state
await chatroom.WriteStateAsync();
}
}
}
The most important part is we have a private list of observers. These are used to add the clients and send them the messages when there is a new message. We don’t want this in the state since clients might not be there when the Grain instantiate later.
Adding Azure hosting support
Currently we only are able to run this locally using a in memory storage and localhost clustering. But what if we wanna host this on something like Azure? This is easily implemented. We already installed all the needed NuGet packages earlier in this post. All we have to do is tweak the program.cs so that we use different clustering and storage when not running as development. In this example we implement clustering and storage with Azure Table Storage. For more information see: https://learn.microsoft.com/en-us/dotnet/orleans/grains/grain-persistence/azure-storage.
Lets modify the program.cs
within the MicrosoftOrleansBasicExample.Silo
by changing the main method like this:
static async Task Main(string[] args)
{
IHostBuilder builder = Host.CreateDefaultBuilder(args)
.UseOrleans((context, silo) =>
{
if (context.HostingEnvironment.IsDevelopment())
{
silo.UseLocalhostClustering()
.AddMemoryGrainStorage("azure");
}
else
{
silo.Configure<ClusterOptions>(options =>
{
options.ClusterId = "default";
options.ServiceId = "defaultService";
})
.UseAzureStorageClustering(options =>
{
options.TableServiceClient = new(context.Configuration.GetConnectionString("ORLEANS_AZURE_STORAGE_CONNECTION_STRING"));
options.TableName = $"defaultCluster";
})
.AddAzureTableGrainStorage("azure", options => {
options.TableServiceClient = new(context.Configuration.GetConnectionString("ORLEANS_AZURE_STORAGE_CONNECTION_STRING"));
options.TableName = $"defaultPersistence";
});
//The application is hosted in an Azure Web App
//This Web App has some pre defined environment variables, these are used to get the correct IP and ports
var endpointAddress = IPAddress.Parse(context.Configuration["WEBSITE_PRIVATE_IP"]!);
var strPorts = context.Configuration["WEBSITE_PRIVATE_PORTS"]!.Split(',');
if (strPorts.Length < 2)
{
throw new ArgumentException("Insufficient private ports configured.");
}
var (siloPort, gatewayPort) = (int.Parse(strPorts[0]), int.Parse(strPorts[1]));
silo.ConfigureEndpoints(endpointAddress, siloPort, gatewayPort);
}
})
.UseConsoleLifetime();
using IHost host = builder.Build();
await host.RunAsync();
}
Please note this code also provides configuration for hosting it on an Azure App Service. See the official documentation for more information about hosting it on a Azure App service: https://learn.microsoft.com/en-us/dotnet/orleans/deployment/deploy-to-azure-app-service
The client
Now that the silo is finished, we’ll start implementing the client application. To get started we’ll need to install the following NuGet packes in the MicrosoftOrleansBasicExample.Client
project:
NuGet package | Purpose |
Blazored.LocalStorage | Package so we can store some data locally on the client. |
Microsoft.Orleans.Client | Used to communicate with the Microsoft Orleans Cluster |
Microsoft.Orleans.Clustering.AzureStorage | Same package as the silo. Needed to connect with the Cluster if hosted in Azure Tables. |
MudBlazor | Opensource front-end library so we can easily build good looking applications. |
When all packages are installed, we start with correctly configuring MudBlazor
. To do this follow the steps described at: https://www.mudblazor.com/getting-started/installation#manual-install-add-imports .
When MudBlazor is correctly installed, we’ll clean up the client project so that only the Home.razor
and Error.razor
pages are left. We can also remove the NavMenu.razor
file and replace the content of MainLayout.razor with the following code:
@inherits LayoutComponentBase
@* Required *@
<MudThemeProvider />
<MudPopoverProvider />
@* Needed for dialogs *@
<MudDialogProvider />
@* Needed for snackbars *@
<MudSnackbarProvider />
<MudLayout>
<MudAppBar Elevation="1">
<MudText Typo="Typo.h5" Class="ml-3">Simple Chat Example</MudText>
<MudSpacer />
<MudIconButton Icon="@Icons.Material.Filled.MoreVert" Color="Color.Inherit" Edge="Edge.End" />
</MudAppBar>
<MudMainContent Class="px-6">
@Body
</MudMainContent>
</MudLayout>
We wont remove the MainLayout.razor.css
file, though you could still remove it if desired.
Now that the files are cleaned up, we’ll start configuring the Program.cs
. Within the Main method before the var app = builder.Build();
we’ll add the following code to configure the connection to Microsoft Orleans:
// Add Orleans client.
builder.UseOrleansClient((client) =>
{
if (builder.Environment.IsDevelopment())
{
client.UseLocalhostClustering();
}
else
{
client.Configure<ClusterOptions>(options =>
{
options.ClusterId = "default";
options.ServiceId = "defaultService";
})
.UseAzureStorageClustering(options =>
{
options.TableServiceClient = new(builder.Configuration.GetConnectionString("ORLEANS_AZURE_STORAGE_CONNECTION_STRING"));
options.TableName = $"defaultCluster";
});
}
});
As you may noticed, the code is very similar to the configuration of the Silo. We should make sure the Client is 100% connecting to the same Cluster as the Silo uses.
To also make sure we can use the localstorage NuGet package we installed, add the following code right before the var app =
builder.Build
();
:
// Add Localstorage
builder.Services.AddBlazoredLocalStorage();
We should now be able to successfully run the client. If not, make sure all steps are followed correctly. The page should look something like this:
Home page
The first page we’ll implement is the Home page. This is the first page a user lands on. On this page we need to make sure a user gets a username, can join or create a room and can see a list of rooms he already joined.
To do this, we’ll start off by editing the Home.razor
file. We’ll use MudBlazor components for the visuals. Please see the MudBlazor documentation if you want to learn more about those. On this page we start of by showing the user his username (we’ll add logic to set his username later). Under his username, we’ll show a input field with a button to start the chat. Below this, we’ll show a list of all chatrooms the user already joined. Within the Home.razor
file this should look something like this:
@page "/"
@rendermode InteractiveServer
<PageTitle>Chat selection</PageTitle>
<MudText Typo="Typo.caption">Username: @username</MudText>
<MudStack>
<MudStack Row>
<MudTextField TextUpdateSuppression="false" @bind-Value="chatroomName" OnKeyUp="KeyUpCreateChatAsync" Label="Chatroom" />
<MudButton OnClick="CreateChatAsync">Start Chat</MudButton>
</MudStack>
@foreach (var room in chatrooms)
{
<MudButton Variant="Variant.Filled" OnClick="() => JoinChatAsync(room)">@room</MudButton>
}
</MudStack>
Note, you’ll see some errors because we use values that are not defined yet, well do this next.
Now that the interface is set, lets add some logic to it. To do this, we start with creating a Home.razor.cs
file within the same folder. This will nest the cs file below the razor file. Make sure the class within the file is marked public partial
so its linked to the razor file.
Within this file, start injecting various services so we can use them later
[Inject]
public required IClusterClient clusterClient { get; set; }
[Inject]
public required ILocalStorageService localStorage { get; set; }
[Inject]
public required NavigationManager navigationManager { get; set; }
We’ll also define some private field so we have some temporary state within the page:
private List<string> chatrooms = new();
private readonly Random random = new();
private string username = string.Empty;
private Guid userGuid = Guid.Empty;
private string chatroomName = string.Empty;
Within the Silo we created a UserGrain. This Grain has a GUID key and resembles a user in our application. To get/set his data, we first need to make sure we have a GUID. To do this, we’ll create the following method:
private async Task SetCorrectUserGuidAsync()
{
userGuid = await localStorage.GetItemAsync<Guid>("userGuid");
if (userGuid == Guid.Empty)
{
userGuid = Guid.NewGuid();
await localStorage.SetItemAsync("userGuid", userGuid);
}
}
The above code checks if there is already a GUID set on the localstorage. If not, it creates it and stores is. It also writes it to the private field so we can use it somewhere else later.
Now that we have a GUID, we can implement the logic to get or set the users username. To do this we’ll add two methods. One method to generate a random username, the other to get or set the username on the corresponding UserGrain. We do this with the following code:
private async Task SetCorrectUsernameAsync()
{
// Ensure the user exists with the username
var user = clusterClient.GetGrain<IUser>(userGuid);
var currentUsername = await user.GetUsernameAsync();
if (string.IsNullOrWhiteSpace(currentUsername))
{
username = GenerateRandomUsername();
await user.SetUsernameAsync(username);
}
else
{
username = currentUsername;
}
}
private string GenerateRandomUsername()
{
string[] adjectives = { "Quick", "Lazy", "Happy", "Sad", "Brave", "Clever", "Bold", "Shy", "Calm", "Eager" };
string[] nouns = { "Fox", "Dog", "Cat", "Mouse", "Bear", "Lion", "Tiger", "Wolf", "Eagle", "Shark" };
return $"{adjectives[random.Next(adjectives.Length)]}{nouns[random.Next(nouns.Length)]}{random.Next(1000, 9999)}";
}
As you can see, we connect to the cluster using the clusterClient
. We try to get the UserGrain by the userGuid. We then try to get the username. If it does exist, we store it in a private field. If it does not, we’ll generate a random one and write it to the Grain and private field. We now have successfully created the first call to a Grain from the client!
So now we have correctly implemented the logic for the username, we should add the logic to get the chatrooms the user already joined. This is easily implemented by the following code:
private async Task LoadChatroomsAsync()
{
var user = clusterClient.GetGrain<IUser>(userGuid);
chatrooms = await user.GetChatroomsAsync();
}
To make sure all the code is executed when opening the page, we’ll override the OnAfterRenderAsync
method:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
await SetCorrectUserGuidAsync();
await SetCorrectUsernameAsync();
await LoadChatroomsAsync();
StateHasChanged();
}
Next we still need to logic to join or create a chatroom. The code for this is very similar to each other. I’ll like to challenge you to implement it yourself before continuing this post with the provided code. Make sure add the end of the join/create methods you navigate to a page /chats/[chatroomname]
. Tip: We already implemented logic to join a chatroom on the UserGrain.
If you figured it out, or just want to continue, the code should look something like this:
private async Task JoinChatAsync(string chatroom)
{
chatroom = chatroom.ToLower();
var userGrain = clusterClient.GetGrain<IUser>(userGuid);
await userGrain.JoinChatroomAsync(chatroom);
navigationManager.NavigateTo($"chats/{chatroom}");
}
private async Task KeyUpCreateChatAsync(KeyboardEventArgs args)
{
if (args.Code == "Enter" || args.Code == "NumpadEnter")
{
await CreateChatAsync();
}
}
private async Task CreateChatAsync()
{
if (string.IsNullOrWhiteSpace(chatroomName))
{
return;
}
var userGrain = clusterClient.GetGrain<IUser>(userGuid);
await userGrain.JoinChatroomAsync(chatroomName.ToLower());
navigationManager.NavigateTo($"chats/{chatroomName.ToLower()}");
}
This code should finish the Home page. You now should be able to run the application, get a username and create chatrooms. The page should look like this:
Next up is actually adding the chatroom page.
Chatroom page
To get started, add a Chat.razor
file in the same folder as the Home.razor
file lives. Also add a Chat.razor.cs
file the same way as we did for the Home.razor
file.
Just as the home page, we start off by creating the graphical interface. Edit the Chat.razor
like below:
@page "/chats/{Chatroom}"
@rendermode InteractiveServer
<PageTitle>@Chatroom</PageTitle>
<MudText Typo="Typo.caption">Username: @username</MudText>
<MudStack Row>
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" OnClick="() => NavigationManager.NavigateTo(string.Empty)" Size="Size.Large" />
<MudText Typo="Typo.h3">@Chatroom</MudText>
</MudStack>
<MudStack>
<MudStack id="chat" Style="height:70vh; overflow-x:scroll; display:flex; flex-direction: column-reverse;">
@foreach (var message in messages)
{
<MudCard Outlined Elevation="3" Class="pa-3">
<MudStack AlignItems="message.Username == this.username ? AlignItems.End : AlignItems.Start">
<MudText Typo="Typo.subtitle2">@message.Username</MudText>
<MudText Typo="Typo.body1">@message.Message</MudText>
<MudText Typo="Typo.caption">@message.DateTimeSend.ToLocalTime()</MudText>
</MudStack>
</MudCard>
}
</MudStack>
<MudStack Row>
<MudTextField TextUpdateSuppression="false" @bind-Value="draftMessage" OnKeyUp="KeyUpSendMessageAsync" Label="Message" />
<MudButton OnClick="SendMessageAsync">Send</MudButton>
</MudStack>
</MudStack>
<script>
function updateScroll() {
var element = document.getElementById("chat");
element.scrollTop = element.scrollHeight;
}
</script>
The above code makes sure we show the users username, a scrollable view with all messages and a input field with a button to send messages. We also added a javascript method we’ll invoke from the .cs
file so make sure the view is scrolled to the bottom when a new message is received.
Just as the Home.razor.cs
file, we have to make the Chat.razor.cs
class public partial
. As you might remember, we created a interface called IReceiveMessage
when we were building the Silo. The chat class must implement this interface to receive messages. It also has to implement the IAsyncDisposable
method so we can unsubscribe from the chat when the page closes.
Within the Chat.razor.cs
file we inject various services:
[Inject]
public required IClusterClient ClusterClient { get; set; }
[Inject]
public required ILocalStorageService LocalStorage { get; set; }
[Inject]
public required IJSRuntime Js { get; set; }
[Inject]
public required NavigationManager NavigationManager { get; set; }
We also have to add the Chatroom parameter we get from the url:
[Parameter]
public required string Chatroom { get; set; }
Besides these injections and parameter, we also add multiple private fields:
private string username = string.Empty;
private List<ChatMessage> messages = new();
private string draftMessage = string.Empty;
private IReceiveMessage? chatroomClientReference;
To show the users username, we start by getting the username again. If we can not find it, we want to return to the homepage. To implement this, we’ll add the following method:
private async Task SetUsernameAsync()
{
var userGuid = await LocalStorage.GetItemAsync<Guid>("userGuid");
if (userGuid == Guid.Empty)
{
// Return to home
NavigationManager.NavigateTo(string.Empty);
return;
}
var user = ClusterClient.GetGrain<IUser>(userGuid);
username = await user.GetUsernameAsync() ?? string.Empty;
if (string.IsNullOrWhiteSpace(username))
{
NavigationManager.NavigateTo(string.Empty);
}
}
Besides getting the username, we also have to subscribe to the chatroom to start receiving messages. To do this we implement this method:
private async Task SubscribeToChatroomAsync()
{
var chatroomGrain = ClusterClient.GetGrain<IChatroom>(Chatroom.ToLower());
messages = await chatroomGrain.GetMessageHistory();
chatroomClientReference = ClusterClient.CreateObjectReference<IReceiveMessage>(this);
await chatroomGrain.JoinAsync(chatroomClientReference, username);
}
Within this method we get the corresponding ChatroomGrain by the name of the Chatroom. We then call the GetMessageHistory()
method to get all messages already send in the chat. To subscribe to the chat, we have to create ObjectReference for the ClusterClient by adding this line chatroomClientReference = ClusterClient.CreateObjectReference<IReceiveMessage>(this);
. We then call our own JoinAsync
method on the Grain to subscribe.
Both this method and the SetUsernameAsync
must be executed when the page is loaded. To this we add the following override:
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
// We have to do this in the OnAfterRenderAsync method since we run serverside
// and cant't use the JSRuntime and localStorage in the OnInitializedAsync method
await SetUsernameAsync();
await SubscribeToChatroomAsync();
}
So now we are subscribed to a chatroom when we open the page, though we still do nothing when we receive a message. To show the new message, implement the ReceiveMessage(ChatMessage message)
method from the IReceiveMessage
like this:
/// <summary>
/// The receive message method that will be called by the chatroom grain
/// This method must be void since Microsoft Orleans cant't handle async observers
/// </summary>
/// <param name="message">The new message</param>
public void ReceiveMessage(ChatMessage message)
{
messages.Add(message);
InvokeAsync(StateHasChanged).Wait();
Js.InvokeVoidAsync(identifier: "updateScroll", args: null).AsTask().Wait();
}
In this method we simply add the message to our list, notify Blazor that the state has changed and invoke javascript to scroll the messages to the bottom.
Now if a user decides to leave the page, we’ll start getting exceptions because the ChatroomGrain still tries to call it when a new message is received. To make sure we unsubscribe, we implement the DisposeAsync
method:
public async ValueTask DisposeAsync()
{
if (chatroomClientReference is null)
{
return;
}
// If the chat gets disposed, it means the page is closed
// Leave the chat
var chatroomGrain = ClusterClient.GetGrain<IChatroom>(Chatroom);
await chatroomGrain.LeaveAsync(chatroomClientReference, username);
}
The lastly, we have to make it possible to send messages. To this we create the following two methods:
private async Task KeyUpSendMessageAsync(KeyboardEventArgs args)
{
if (args.Code == "Enter" || args.Code == "NumpadEnter")
{
await SendMessageAsync();
}
}
private async Task SendMessageAsync()
{
var chatroomGrain = ClusterClient.GetGrain<IChatroom>(Chatroom);
await chatroomGrain.PublishAsync(draftMessage, username);
// Reset the message
draftMessage = string.Empty;
await InvokeAsync(StateHasChanged);
}
Just as the other methods, this method gets the ChatroomGrain and simply calls a method to invoke the desired action.
And that’s all! You should now be able to run the client and silo (make sure you run them both) and start chatting! The result should look something like this:
Wrapping up
So here we are, we’ve successfully built a chat application using Microsoft Orleans. In this post, we created a Silo that uses localhost or Azure Tables clustering, and a Blazor Web App client that connects to the Silo. We also implemented real-time communication between these applications using Microsoft Orleans Observers. Please note that all code in this post is for demo purposes. Some code (especially in the client) could definitely be more robust and/or optimized and should not directly be used in a production environment.
I hope you enjoyed building this chat app as much as I did. If you have any suggestions, feedback, or questions, feel free to reach out to me! All the code in this post is also on GitHub: https://github.com/hansmuns/MicrosoftOrleansChatExample
That’s all for now, enjoy coding!
Subscribe to my newsletter
Read articles from Hans Muns directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Hans Muns
Hans Muns
Hi, I’m Hans, a .NET developer from the Netherlands with over five years of experience, mostly working with cloud technologies. I’m certified in Azure (AZ-104, AZ-204, AZ-400, AZ-305), CKAD, and PSM1, and I’m also a Microsoft Certified Trainer (MCT), so I enjoy sharing what I’ve learned along the way. I’m interested in just about every new tech that comes my way, which keeps me constantly learning. It’s a bit of a blessing and a curse, but it keeps things interesting. Outside of work, I’m really into strength training and try to hit the gym four times a week. It helps me stay focused and balanced. On this blog, I’ll be sharing my thoughts, tips, and lessons learned from my work in .NET and Azure, and hopefully, it’ll be useful to anyone on a similar path.