What is pub/sub and how to apply it in C# .NET to build a chat app
Tags: programming, dotnet, architecture
Published: 2022-03-24 14:27:23


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:
- [What the pub/sub pattern is](#what-is-pubsub).
- [When to use pub/sub](#when-you-should-use-pubsub).
- [The benefits of using pub/sub](#benefits-for-your-net-apps).
- [How to use the Ably .NET SDK to apply the pattern by creating a console application](#pubsub-using-the-ably-net-sdk).

![ConsoleChat](https://ik.imagekit.io/ably/ghost/prod/2022/03/console_chat.gif?tr=w-800)

> **TLDR**: Here's the [GitHub repo](https://github.com/ably-labs/pubsub-demo-dotnet) 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](https://ably.com/blog/introduction-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)](https://docs.microsoft.com/previous-versions/windows/desktop/legacy/ms711472(v=vs.85)) was used to deliver messages between applications reliably. These days, however, cloud-based messaging components are used, such as [Azure Service Bus](https://docs.microsoft.com/azure/service-bus-messaging/), [SignalR](https://docs.microsoft.com/azure/azure-signalr/), and [Web PubSub](https://docs.microsoft.com/azure/azure-web-pubsub/) to name a few. In this article, I'll zoom in on another cloud-based, and highly scalable messaging solution, [Ably](https://ably.com), and use that to build a console based chat application in .NET 6.

## What is pub/sub?

The [Publish/Subscribe (pub/sub) pattern](https://docs.microsoft.com/azure/architecture/patterns/publisher-subscriber) 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](https://ably.com/topic/pub-sub).

![Pub/Sub pattern](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oo8qxjo3e6283ocp5wov.png)
 
## When you should 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.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ncn9ggb662gp3onzhtiv.png)

## Benefits 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](https://ably.com/resources/ebooks/websocket-handbook). To demonstrate how simple it is, here’s how you create a client with Ably:

```csharp
var ably = new AblyRealtime(settings.AblyApiKey);
```

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

```csharp
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:

```csharp
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](https://github.com/ably-labs/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:

- An Ably account, which is [free to sign up for](https://ably.com/sign-up).
  - Create a new Ably App, and [create an API key](https://faqs.ably.com/setting-up-and-managing-api-keys) with *Publish*, *Subscribe*, and *Presence* capabilities.
- The [.NET 6 SDK](https://dotnet.microsoft.com/download/dotnet/6.0).
- A code editor such as [VSCode](https://code.visualstudio.com/Download).

### 2. Creating a new console app

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

    ```shell
    mkdir ConsoleChat
    ```

2. Navigate into that folder:

    ```shell
    cd ConsoleChat
    ```

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

    ```shell
    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](https://github.com/spectreconsole/spectre.console), a library for creating beautiful console applications.
- [Ably .NET](https://github.com/ably/ably-dotnet), a library to build realtime messaging applications.

Install the packages via the `dotnet` CLI:

```shell
dotnet add package spectre.console
```

```shell
dotnet add package ably.io
```

The *ConsoleChat.csproj* should look like this now:

```xml
<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:

```csharp
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:

```csharp
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:

```csharp
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.

    ```csharp
    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](https://ably.com/documentation/realtime/channels#publish).

    ```csharp
    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:

```csharp
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](https://ably.com/documentation/realtime/channels/channel-parameters/rewind).

    ```csharp
    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](https://ably.com/documentation/realtime/presence).

    ```csharp
    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](https://ably.com/documentation/realtime/channels#subscribe).

    ```csharp
    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:

```csharp
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:

```json
{
    "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.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d0b2i5mj01cqs86jyza5.gif)

### 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`](https://docs.microsoft.com/dotnet/core/tools/dotnet-build) command:

    ```shell
    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:

    ```shell
    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:

    ```shell
    .\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:

    ```shell
    .\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`](https://docs.microsoft.com/dotnet/core/tools/dotnet-publish) command:

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

    *Example for windows x64 machines:*

    ```shell
    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](https://docs.microsoft.com/dotnet/core/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](https://github.com/ably-labs/pubsub-demo-dotnet) and see if you can extend it. Please don't hesitate to [contact me on Twitter](https://twitter.com/marcduiker) or [join our Discord server](https://discord.gg/q89gDHZcBK) in case you have any questions or suggestions related to this project.