18 min readUpdated May 5, 2023

What is pub/sub and how to apply it in C# .NET to build a chat app

What is pub/sub and how to apply it in C# .NET to build a chat app
Marc DuikerMarc Duiker

In this post I'll explain the publish/subscribe (pub/sub) pattern, and how you can apply it in a .NET 6 console app to make a chat application.

You'll learn:

TLDR: Here's the GitHub repo with the finished project.

With the rise of cloud-based and distributed systems, messaging solutions appear to be everywhere. Rather than direct client-server communication, messaging and event-driven architecture allow loosely coupled components to communicate with each other and results in better scalability of the system.

Messaging solutions in the Microsoft landscape have existed since 1997 when Microsoft Message Queuing (MSMQ) was used to deliver messages between applications reliably. These days, however, cloud-based messaging components are used, such as Azure Service Bus, SignalR, and Web PubSub to name a few. In this article, I'll zoom in on another cloud-based, and highly scalable messaging solution, Ably, and use that to build a console based chat application in .NET 6.

What is pub/sub?

The Publish/Subscribe (pub/sub) pattern is one which allows for messages to be sent from one entity (a 'publisher' or 'producer') to other entities (‘subscribers’ or 'consumers') without a direct connection between them. Communication is done through the use of a message broker, which receives messages from publishers, and then distributes them to the relevant subscribers.

To identify who each message should be sent to, the premise of topics (also known as channels) is used. This is an ID which represents a collection of communication, to which a publisher can publish to, and subscribers can subscribe to. An example would be to have a topic called ‘sport’, which a publisher will be publishing sports updates to, and subscribers would subscribe to for said sports updates.

There is no inherent need for a publisher to be only a publisher, nor a subscriber to be just a subscriber. Many use cases, such as chat applications, require clients to both publish messages and subscribe to messages. The main concept is that all communication is sent to the broker, identified by a topic ID, and then sent onwards to any client which has subscribed to that topic.

Find out more about publish and subscribe.

When should you use pub/sub?

Use the pub/sub pattern when the application:

  • Needs to send messages to multiple clients.
  • Does not require a direct (synchronous) response from the clients.

Typical use cases are:

  • Chat; Every user is both a publisher and a subscriber in a chat channel.
  • Location tracking; GPS data of a transport vehicle is broadcasted to people who are expecting a delivery.
  • Event notification; A news agency publishes a news item and all users of the news app receive a notification.
  • Distributed caching; Clients use a local data store to achieve an optimum performance. These local data stores are updated via change events sent by the publisher.

Benefits of pub/sub for your .NET apps

The asynchronous nature of pub/sub increases scalability, reliability, and improves responsiveness of the publisher. The publisher can quickly send messages to a topic, then return to its other responsibilities. The messaging infrastructure is responsible for delivering the messages to the subscribers.

Pub/Sub messaging enables independent management of the publisher and subscriber systems since they are decoupled. The pub and sub applications can be developed independently, using different languages, frameworks, or even communication protocols.

The pub/sub pattern provides separation of concerns for your applications, enabling a microservices architecture. Each application can focus on its core business capabilities, while the messaging infrastructure handles reliable routing of messages to multiple subscribers.

Going beyond pub/sub

In addition to the benefits that pub/sub can bring in general, Ably offers some features which can be used to provide an even richer functionality to your applications. Being able to retrieve old messages, see who’s currently active, and being notified via push notifications are examples of such features.

History

For some uses cases, it can be beneficial to see the messages sent in the past, when a user was offline. Using chat as an example, when the user goes online, the application can retrieve not only the current messages, but also the historical messages for a channel.

Presence

One popular feature added to pub/sub is the ability to check who is present on a topic. Although in many scenarios a publisher won’t need to be concerned with who’s subscribed before publishing, sometimes it can be useful to know. With a chat application, knowing who is online can be useful to users to determine whether someone is available to talk or not.

Push notifications

It’s common to expect devices to receive updates and notifications, even when their apps are operating in the background or closed. In the background, both iOS and Android will usually put any communication on hold until the app is opened again, only allowing for their own Push Notification interactions to be allowed.

Because of this, it’s important to be able to send notifications where required, and it makes sense to embed this communication within your existing messaging system. pub/sub can be perfect for this due to the fact it separates the publishers from the consumers. A publisher can publish a message in exactly the same way, but the subscriber can indicate to the broker how it wants to receive these messages.

This can be extended further, by allowing the publishers of messages to be using completely different protocols to subscribers. A publisher may use a REST endpoint of the broker to publish a message, and you can then have some subscribers using MQTT, some SSE, and some WebSockets to subscribe. The broker is responsible for translation and ensuring that all of these different systems and protocols can interact seamlessly.

Pub/Sub using the Ably .NET SDK

Here at Ably, we use a protocol for pub/sub that is built on top of WebSockets. To demonstrate how simple it is, here’s how you create a client with Ably:

var ably = new AblyRealtime(settings.AblyApiKey);

This is how you subscribe to a channel to receive messages:

var channel = ably.Channels.Get(settings.Channel);

channel.Subscribe(message =>
{
    var chatMessage = (string)message.Data;
    // Do something with the message
});

And here’s how you publish messages to a channel:

await channel.PublishAsync("chat", "hello world!");

Implementing pub/sub in a C# .NET console app

One popular use case for pub/sub is a chat application, so to demonstrate the power of pub/sub and C# let’s create a chat program in a .NET 6 console application. The console can be started to either publish messages or subscribe to messages. The console should prompt for the user's name and a color that will be used for the messages.

To see the end result, you can have a look at the pubsub-demo-dotnet repository. If you want to build the solution from the ground up, follow these instructions.

1. Prerequisites

You'll need the following before creating the console application:

2. Creating a new console app

  1. Open a terminal and start by creating a new folder named ConsoleChat:

    mkdir ConsoleChat
    
  2. Navigate into that folder:

    cd ConsoleChat
    
  3. Use the dotnet CLI to create a new console application:

    dotnet new console
    

Open the created ConsoleChat application in your code editor.

3. Adding dependencies

The ConsoleChat app will have two external dependencies:

  • Spectre.Console, a library for creating beautiful console applications.
  • Ably .NET, a library to build realtime messaging applications.

Install the packages via the dotnet CLI:

dotnet add package spectre.console
dotnet add package ably.io

The ConsoleChat.csproj should look like this now:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="ably.io" Version="1.2.7" />
    <PackageReference Include="spectre.console" Version="0.43.0" />
  </ItemGroup>

</Project>

4. Add Global Usings

To keep the C# classes tidy, let's add a GlobalUsings.cs file that contains the following:

global using Spectre.Console;
global using Spectre.Console.Cli;
global using IO.Ably;
global using System.Diagnostics.CodeAnalysis;
global using Newtonsoft.Json.Linq;

These global using statements will be available for all our C# classes that will be part of the project.

5. Add Settings

The ConsoleChat application will be started with three command arguments:

  • pub or sub: to indicate if the console will be used for publishing or subscribing.
  • channel: the name of the channel to publish or subscribe to.
  • ablyApiKey: the Ably API key that is required to create a new Ably Realtime client.

The benefit of using Spectre.Console is that this library contains many useful objects to create a good-looking console application in just a few steps.

Create a new file called Settings.cs and add the following code:

public sealed class Settings : CommandSettings
{
    public Settings(string channel, string ablyApiKey)
    {
        Channel = channel;
        AblyApiKey = ablyApiKey;
    }

    [CommandArgument(0, "<channel>")]
    public string Channel { get; }

    [CommandArgument(1, "<ablyApiKey>")]
    public string AblyApiKey { get; }
}

The above code ensures that the channel and ablyApiKey values are available in the application as part of the Spectre.Console Settings.

6. Add the PublishCommand

Let's add the functionality to publish a message. This will be implemented as a Spectre.Console Command by inheriting from AsyncCommand<Settings>. Create a new file named PublishCommand.cs and add the following code:

public sealed class PublishCommand : AsyncCommand<Settings>
{
    public override async Task<int> ExecuteAsync([NotNull] CommandContext context, [NotNull] Settings settings)
    {
        (string Name, string Color) input = DrawConsoleAndGetInput(settings);

        var clientOptions = new ClientOptions(settings.AblyApiKey) { ClientId = input.Name };
        var ably = new AblyRealtime(clientOptions);
        var channel = ably.Channels.Get(settings.Channel);
        channel.Presence.Enter(input.Color);

        while (true)
        {
            var text = AnsiConsole.Ask<string>($"[{input.Color}]{input.Name }: [/]");
            var result  = await channel.PublishAsync("chat", text);
            if (result.IsFailure)
            {
                AnsiConsole.MarkupLine($"[red]{result.Error.Message}[/]");
            }
        }

        return 0;
    }

    private static (string name, string color) DrawConsoleAndGetInput(Settings settings)
    {
        var intro = new FigletText(FigletFont.Default, "Welcome to Console Chat!").Color(Color.Yellow).Centered();
        AnsiConsole.Write(intro);

        var channelInfo = new Rule($"You're publishing to the {settings.Channel} channel.")
            .Centered();
        AnsiConsole.Write(channelInfo);

        var name = AnsiConsole.Ask<string>("What is your name?");
        var color = AnsiConsole.Prompt(
            new SelectionPrompt<string>()
                .Title("Select a color for your messages:")
                .AddChoices(new[] {
                    "red",
                    "green",
                    "blue"
        }));

        return (name, color);
    }
}

Two code blocks are related to Ably:

  • In the first block, a new instance of the AblyRealtime client is created, a channel is created on demand and the user presence is entered. An optional input (input.Color) is provided with the presence to indicate the color that will be used for this user to render the message on the subscriber side.

    var clientOptions = new ClientOptions(settings.AblyApiKey) { ClientId = input.Name };
    var ably = new AblyRealtime(clientOptions);
    var channel = ably.Channels.Get(settings.Channel);
    channel.Presence.Enter(input.Color);
    
  • The second block deals with publishing a message. The PublishAsync method requires an event name and the payload. The result status is checked, and underlying errors can be obtained to inform the user about the cause of the failure. More information on the Publish method can be found in the Ably docs.

    var result  = await channel.PublishAsync("chat", text);
    if (result.IsFailure)
    {
        ...
    }
    

7. Add the SubscribeCommand

Let's add the functionality to subscribe to messages. This will also be implemented as a Spectre.Console Command by inheriting from Command<Settings>. Create a new file named SubscribeCommand.cs and add the following code:

public sealed class SubscribeCommand : Command<Settings>
{
    private Dictionary<string, string> clientColors = new Dictionary<string, string?>();
    private record ConsoleMessage(string Name, string Message, string Color);

    public override int Execute([NotNull] CommandContext context, [NotNull] Settings settings)
    {
        var name = DrawConsoleAndGetName(settings);

        var clientOptions = new ClientOptions(settings.AblyApiKey) { ClientId = name };
        var ably = new AblyRealtime(clientOptions);
        var channel = ably.Channels.Get(
            settings.Channel,
            new ChannelOptions
            {
                Params = new ChannelParams { { "rewind", "2m" } }
            });

        var consoleMessageQueue = new Queue<ConsoleMessage>();

        channel.Presence.Subscribe(member => {
            clientColors.Add(member.ClientId, (string)member.Data);
            var color = GetColorForClient(member.ClientId);
            ConsoleMessage? presenceMessage = null;
            switch (member.Action)
            {
                case PresenceAction.Enter:
                    presenceMessage = new ConsoleMessage(string.Empty, $"{member.ClientId} has joined.", color);
                    break;
                case PresenceAction.Leave:
                     presenceMessage = new ConsoleMessage(string.Empty, $"{member.ClientId} has left.", color);
                    break;
                default:
                    break;
            }

            if (presenceMessage != null)
            {
                consoleMessageQueue.Enqueue(presenceMessage);
            }
        });
        channel.Presence.Enter();

        channel.Subscribe(message =>
        {
            var color = GetColorForClient(message.ClientId);
            var consoleMessage = new ConsoleMessage(message.ClientId, (string)message.Data, color);
            consoleMessageQueue.Enqueue(consoleMessage);
        });

        while (true)
        {
            if (consoleMessageQueue.TryDequeue(out ConsoleMessage? consoleMessage))
            {
                var panel = new Panel(consoleMessage.Message)
                    .Header(new PanelHeader(consoleMessage.Name, Justify.Left))
                    .BorderColor(ConvertStringToColor(consoleMessage.Color));
                AnsiConsole.Write(panel);
            }
            
        }

        return 0;
    }

    private static string DrawConsoleAndGetName(Settings settings)
    {
        var intro = new FigletText(FigletFont.Default, "Welcome to Console Chat!")
                    .Color(Color.Yellow)
                    .Centered();
        AnsiConsole.Write(intro);

        var channelInfo = new Rule($"You're subscribing to the {settings.Channel} channel.")
            .Centered();
        AnsiConsole.Write(channelInfo);

        var name = AnsiConsole.Ask<string>("What is your name?");
        return name;
    }

    private string GetColorForClient(string clientId)
    {
        return clientColors.TryGetValue(clientId,out string? messageColor) ? messageColor : "White";
    }

    private Color ConvertStringToColor(string color)
    {
        if (Enum.TryParse<ConsoleColor>(color, true, out ConsoleColor consoleColor))
        {
            return Color.FromConsoleColor(consoleColor);
        }

        return Color.White;
    }
}

The bottom half of this class deals with Spectre.Console specific code on how the console will be rendered and which colors are used. Let's focus on the top half part of this class.

  • The first Ably related code block is largely the same as in the PublishCommand where a new instance of AblyRealtime is created. The difference is where the channel is obtained. Besides the channel name, ChannelOptions are provided with ChannelParams including a "rewind" of "2m". This instructs the client to rewind the history of the channel to the last two minutes when the connection is established. This allows the client to see chat messages that were published before the client was connected. More information about rewind can be found in the Ably docs.

    var clientOptions = new ClientOptions(settings.AblyApiKey) { ClientId = name };
    var ably = new AblyRealtime(clientOptions);
    var channel = ably.Channels.Get(
        settings.Channel,
        new ChannelOptions
        {
            Params = new ChannelParams { { "rewind", "2m" } }
        });
    
  • The second block of Ably related code deals with presence. Clients can subscribe to presence events, such as entering a channel, leaving a channel, or updating their user data. In this case, the presence is also used to keep track of which client is using which color for their messages. The switch block on member.Action is used to create a specific message when a client has either entered or left the channel. A client can explicitly announce their presence in the channel by using channel.Presence.Enter(), as can be seen on the last line of this code block. More information on Presence can be found in the Ably docs.

    channel.Presence.Subscribe(member => {
        clientColors.Add(member.ClientId, (string)member.Data);
        var color = GetColorForClient(member.ClientId);
        ConsoleMessage? presenceMessage = null;
        switch (member.Action)
        {
            case PresenceAction.Enter:
                presenceMessage = new ConsoleMessage(string.Empty, $"{member.ClientId} has joined.", color);
                break;
            case PresenceAction.Leave:
                presenceMessage = new ConsoleMessage(string.Empty, $"{member.ClientId} has left.", color);
                break;
            default:
                break;
        }
    
        if (presenceMessage != null)
        {
            consoleMessageQueue.Enqueue(presenceMessage);
        }
    });
    channel.Presence.Enter();
    
  • The final Ably code block is about subscribing to messages. The channel.Subscribe() method is used and requires an Action<Message> as its argument. Here, the message.ClientId is used to lookup the color for this user. A new ConsoleMessage is created that contains, the username, message text, and color. The ConsoleMessage is put on a queue to be dequeued and rendered to the console later. More information on the Subscribe method can be found in the Ably docs.

    channel.Subscribe(message =>
    {
        var color = GetColorForClient(message.ClientId);
        var consoleMessage = new ConsoleMessage(message.ClientId, (string)message.Data, color);
        consoleMessageQueue.Enqueue(consoleMessage);
    });
    

8. Update Program.cs

Now that the PublishCommand and SubscribeCommand are in place, we need a way to call them.
Let's update the Program.cs file with the following code:

public class Program
{
    public static async Task<int> Main(string[] args)
    {
        var app = new CommandApp();
        app.Configure(config =>
        {
            config.AddCommand<PublishCommand>("pub")
                .WithDescription("Publish messages to a channel.")
                .WithExample(new[] { "pub", "channel1", "AblyApiKey" });
            config.AddCommand<SubscribeCommand>("sub")
                .WithDescription("Subscribe to a channel.")
                .WithExample(new[] { "sub", "channel1", "AblyApiKey" });
            config.SetApplicationName("ConsoleChat.exe");
        });

        return await app.RunAsync(args);
    }
}

In the above code sample, a new CommandApp() (from the Spectre.Console library) is instantiated and configured with the commands that have been created before. This allows the console app to be called with either the "pub" or "sub" argument that will execute the corresponding command.

9 Debugging

Being able to debug any application is essential when doing development. Since we're developing a console application that requires input arguments, we require a place to provide these arguments when the app is started in debug mode.

If you're using VSCode, you can update the launch.json file located in the .vscode folder with the following configuration:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": ".NET (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/src/ConsoleChat/bin/Debug/net6.0/ConsoleChat.dll",
            "args": ["${input:type}", "${input:ablyChannel}", "${input:ablyApiKey}"],
            "cwd": "${workspaceFolder}/src/ConsoleChat",
            "console": "integratedTerminal",
            "stopAtEntry": false
        },
        {
            "name": ".NET Attach",
            "type": "coreclr",
            "request": "attach"
        }
    ],
    "inputs": [
        {
            "id": "type",
            "type": "pickString",
            "options": ["pub", "sub"],
            "description": "Select if your going to publish or subscribe",
        },
        {
            "id": "ablyChannel",
            "type": "promptString",
            "description": "Enter a channel name",
        },
        {
            "id": "ablyApiKey",
            "type": "promptString",
            "description": "Enter the Ably API key",
        }
    ]
}

Now, when debugging starts by pressing F5, VSCode will show a prompt for the three input arguments.

10. Build & run

To test this application, a minimum of two running instances are required, one instance that publishes messages and one that subscribes to messages.

Let's first build the project to create the executable.

  1. Open a terminal and ensure you're in the same folder as the ConsoleChat.csproj file.

  2. Run the dotnet build command:

    dotnet build
    
  3. The executable should be located in the bin/Debug/net6.0 folder. Navigate to this folder.

  4. First start the ConsoleChat app without any arguments by running .\ConsoleChat.exe. This should be the output:

    USAGE:
        ConsoleChat.exe [OPTIONS] <COMMAND>
    
    EXAMPLES:
        ConsoleChat.exe pub channel1 AblyApiKey
        ConsoleChat.exe sub channel1 AblyApiKey
    
    OPTIONS:
        -h, --help       Prints help information
        -v, --version    Prints version information
    
    COMMANDS:
        pub <channel> <ablyApiKey>    Publish messages to a
                                    channel
        sub <channel> <ablyApiKey>    Subscribe to a channel
    
  5. Start an instance that will be used for subscribing to messages:

    .\ConsoleChat.exe sub pubsub1 <API_KEY>
    
    • pubsub1 is the name of the channel you're subscribing to, you're free to use any other name since the channel will be created at runtime.
    • <API_KEY> should be the Ably API key that you created in the Prerequisites step.
    • Follow the instructions in the console app.
  6. Start an instance that will be used for publishing:

    .\ConsoleChat.exe pub pubsub1 <API_KEY>
    
    • pubsub1 is the name of the channel you're publishing to, make sure the name is the same as used in the subscribing console app.
    • <API_KEY> should be the Ably API key that you created in the Prerequisites step.
    • Follow the instructions in the console app.

Finally, start typing in the publisher app, and you'll be seeing the messages appear in the subscriber app. You can even add more subscriber and publisher instances to the same channel.

11. Publishing the application

In case you want to publish and distribute this console app to use this with your co-workers or friends, you need to do the following:

  1. Ensure you're in the folder where the csproj file is located.

  2. Run the dotnet publish command:

    dotnet publish -c Release -r <RUNTIME_IDENTFIER> --self-contained=false /p:PublishSingleFile=true
    

    Example for windows x64 machines:

    dotnet publish -c Release -r win-x64 --self-contained=false /p:PublishSingleFile=true
    

    For more information on the available runtime identifiers, see the .NET RID Catalog.

  3. The release version of the application is now available in the src\ConsoleChat\bin\Release\net6.0\<RUNTIME_IDENTIFIER>\publish folder.

Summary

Overall, pub/sub is a powerful pattern and is used often in many large messaging scenarios. It can turn complex communications problems into far more manageable chunks by separating the publishers from subscribers, and is particularly versatile in its use.

You've learned what pub/sub is, how it can benefit your .NET applications, and how to apply it by using the Ably .NET SDK.

I encourage you to try out the ConsoleChat project available on GitHub and see if you can extend it. Please don't hesitate to contact me on Twitter or join our Discord server in case you have any questions or suggestions related to this project.

Further reading

Join the Ably newsletter today

1000s of industry pioneers trust Ably for monthly insights on the realtime data economy.
Enter your email