Building a Real-Time Chat Service with .NET Core and SignalR

Introduction

In today’s world, real-time communication is an essential feature of modern applications. Whether it’s chatting on platforms like Messenger, WhatsApp, or Microsoft Teams, real-time messaging has become integral to our lives. These platforms not only support messaging but also provide advanced features like audio and video calling, sparking my curiosity about implementing real-time communication functionalities.

This article focuses on creating a backend service for a real-time chat application. The app allows users to sign up, create chat rooms, and share the room ID or URL with others for group conversations. While I used ReactJS (primarily generated by ChatGPT) to test the backend, the emphasis here is on the backend development.

What is SignalR?

SignalR is a library that simplifies adding real-time web functionality to applications. It primarily uses WebSockets for communication but can fallback to older transport protocols when necessary. SignalR supports “server push” capabilities, enabling the server to call client-side methods, unlike traditional HTTP’s request-response model.

SignalR applications can scale to thousands of clients with built-in or third-party providers like Redis or SQL Server.

SignalR Transport Selection Process

SignalR automatically selects the best transport method based on the client’s capabilities, falling back to older protocols like Long Polling when necessary. This ensures reliability across different environments. SignalR selects the transport method as follows:

SignalR Communication Models

Setting Up the Backend

Project Setup

To begin, ensure the following prerequisites are installed:

These services can be set up locally which I have done, but Docker Compose is an excellent option for managing dependencies efficiently.

Domain and Infrastructure

In this project, I used both SQL Server and MongoDB for different parts of the application. SQL Server is used to manage relational data such as user information, rooms, and members. For these entities, I designed the models as follows:

Here’s an example of the User entity, stored in SQL Server:

namespace ChatService.Domain.Users
{
    public class User
    {
        public long Id { get; private set; }
        public string Name { get; private set; }
        public string Email { get; private set; }
        public string PasswordHash { get; private set; }
        public DateTime CreatedOn { get; private set; }
    }
}

Here’s an example of the Room entity, stored in SQL Server:

namespace ChatService.Domain.Rooms
{
    public class Room
    {
        public long Id { get; private set; }
		public string Name { get; private set; }
		public string PasswordHash { get; private set; }
		public DateTime CreatedOn { get; private set; }
		public List<Member> Members { get; private set; } = new List<Member>();
    }
}

Here’s an example of the Member entity, stored in SQL Server:

namespace ChatService.Domain.Rooms
{
    public class Member
    {
        public long Id { get; private set; }
		public long UserId { get; private set; }
		public User User { get; private set; }  // Navigation property
		public long RoomId { get; private set; }
		public Role Role { get; private set; }
		public DateTime JoinedOn { get; private set; }
    }
}

For the SQL Server entities, additional configuration was implemented using Entity Framework Core to ensure optimal performance and integrity. Below are the configurations for the key entities: User, Room, and Member.

User Configuration

namespace ChatService.Infrastructure.EntityConfigurations
{
    public class UserConfiguration : IEntityTypeConfiguration<User>
    {
        public void Configure(EntityTypeBuilder<User> builder)
        {
            builder.ToTable("Users");
            builder.HasKey(user => user.Id);
            builder.Property(user => user.Id)
                   .ValueGeneratedOnAdd();
            builder.Property(user => user.Name)
                   .HasMaxLength(200);
            builder.Property(user => user.Email)
                   .HasMaxLength(200);
            builder.Property(user => user.PasswordHash);
            builder.Property(user => user.CreatedOn)
                   .HasDefaultValueSql("GetUtcDate()");
            builder.HasIndex(user => user.Email).IsUnique();
        }
    }
}

In this configuration, the User entity is mapped to the Users table. The Id is set as the primary key with auto-increment, and the Email field is indexed to ensure uniqueness. The CreatedOn field defaults to the current UTC date.

Room Configuration

namespace ChatService.Infrastructure.EntityConfigurations
{
    public class RoomConfiguration : IEntityTypeConfiguration<Room>
    {
        public void Configure(EntityTypeBuilder<Room> builder)
        {
            builder.ToTable("Rooms");
            builder.HasKey(room => room.Id);
            builder.Property(room => room.Id)
                   .ValueGeneratedOnAdd();
            builder.Property(room => room.Name)
                   .HasMaxLength(200);
            builder.Property(room => room.PasswordHash);
            builder.Property(room => room.CreatedOn)
                   .HasDefaultValueSql("GetUtcDate()");
        }
    }
}

The Room entity is configured similarly, with properties like Name and CreatedOn configured to ensure appropriate length and default value.

Member Configuration

namespace ChatService.Infrastructure.EntityConfigurations
{
    public class MemberConfiguration : IEntityTypeConfiguration<Member>
    {
        public void Configure(EntityTypeBuilder<Member> builder)
        {
            builder.ToTable("Members");
            builder.HasKey(member => member.Id);
            builder.Property(member => member.Id)
                   .ValueGeneratedOnAdd();
            builder.Property(member => member.UserId);
            builder.Property(member => member.RoomId);
            builder.Property(member => member.Role);
            builder.Property(member => member.JoinedOn)
                   .HasDefaultValueSql("GetUtcDate()");
            builder.HasIndex(member => new { member.UserId, member.RoomId })
                   .IsUnique();
            builder.HasOne<User>(member => member.User)
                   .WithMany()
                   .HasForeignKey(member => member.UserId)
                   .OnDelete(DeleteBehavior.NoAction);
            builder.HasOne<Room>()
                   .WithMany(room => room.Members)
                   .HasForeignKey(member => member.RoomId)
                   .OnDelete(DeleteBehavior.NoAction);
        }
    }
}

The Member entity configuration ensures that each member’s UserId and RoomId combination is unique, preventing duplicate entries. The JoinedOn field defaults to the current UTC date, and navigation properties to User and Room are set up with appropriate foreign key relationships.

These configurations enable Entity Framework Core to map the SQL Server entities accurately and ensure referential integrity between users, rooms, and members.

On the other hand, MongoDB is used for managing non-relational data like conversations and messages. These are dynamic and more suitable for a NoSQL database, where data such as messages and conversations can change frequently and require quick retrieval.

Here’s an example of the Conversation entity, stored in MongoDB:

namespace ChatService.Domain.Conversations
{
    public class Conversation
    {
        [BsonId]
        [BsonRepresentation(BsonType.ObjectId)]
        public string Id { get; private set; }
        public long? RoomId { get; private set; }
        public List<long> Participants { get; private set; }
        public bool IsGroup { get; private set; }
        public DateTime CreatedOn { get; private set; }
        public DateTime UpdatedOn { get; private set; }
    }
}

Here’s an example of the Message entity, stored in MongoDB:

namespace ChatService.Domain.Conversations;

public class Message
{
    [BsonId]
    [BsonRepresentation(BsonType.ObjectId)]
    public string Id { get; private set; }
    public string ConversationId { get; private set; }
    public long SenderId { get; private set; }
    public string SenderName { get; private set; }
    public string Content { get; private set; }
    public DateTime CreatedOn { get; private set; }
}

This approach allows for an efficient combination of the strengths of both relational and NoSQL databases, providing flexibility and performance for different use cases in the chat application.

Designing the API

Authentication Endpoints

Register: Allows users to sign up.

POST /api/users/register
{
	"name": "John Doe",
	"email": "john@example.com",
	"password": "securepassword"
}

Login: Authenticates users and provides a JWT token.

POST /api/users/login
{
	"email": "john@example.com",
	"password": "securepassword"
}

The JWT token is required for protected routes.

Room Management Endpoints

CreateRoom: Creates a chat room with a name and password.

POST /api/rooms
{
	"name": "Project Discussion",
	"password": "1234"
}

JoinRoom: Adds users to an existing room. Here password is optional

POST /api/rooms/join
{
	"roomId": 2,
	"password": ""
}

GetRoom: Retrieves room details.

GET /api/rooms/2

Conversation Management Endpoints

Create Conversation:

POST /api/conversations
{
	"roomId": 2,
	"participants": []
}

Get Conversation: Fetches chat messages.

GET /api/conversations?roomId=2&page=1&pageSize=20

SignalR Hub Implementation

Configuring SignalR

To integrate SignalR into the application:

Install SignalR:

dotnet add package Microsoft.AspNetCore.SignalR

Define the Hub: Create a ChatHub class extending SignalR’s Hub class.

public class ChatHub : Hub
{
}

Register Middleware: Configure SignalR in Startup.cs or Program.cs.

app.MapHub<ChatHub>("/chat");

Configure CORS: Allow frontend communication.

builder.Services.AddCors(options =>
{
    options.AddPolicy("ChatClient", builder =>
    {
        builder.WithOrigins("http://localhost:5173")
                .AllowAnyHeader()
                .AllowAnyMethod()
                .AllowCredentials();
    });
});
app.UseCors("ChatClient");

The ChatHub class is the SignalR hub that manages real-time communication in the chat application. It enables authentication using JWT tokens and provides key features such as joining chat rooms, sending messages, and notifying about user activities like typing or disconnecting.

Key Features in the ChatHub Class:

JWT Authentication
public override async Task OnConnectedAsync()
{
    var token = Context.GetHttpContext()?.Request.Query["access_token"].ToString();

    if (string.IsNullOrEmpty(token))
    {
        await Clients.Caller.SendAsync("Error", "Authentication failed: No token provided");
        Context.Abort();
        return;
    }

    if (!ValidateJwtToken(token))
    {
        await Clients.Caller.SendAsync("Error", "Authentication failed: Invalid token");
        Context.Abort();
        return;
    }

    await base.OnConnectedAsync();
}

private bool ValidateJwtToken(string token)
{
    try
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.UTF8.GetBytes(_jwtOptions.SecretKey);
        var validationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidIssuer = _jwtOptions.Issuer,
            ValidAudience = _jwtOptions.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(key)
        };

        SecurityToken validatedToken;
        var principal = tokenHandler.ValidateToken(token, validationParameters, out validatedToken);

        return principal.Identity.IsAuthenticated;

    }
    catch (Exception)
    {
        return false;
    }
}
Joining a Chat Room
public async Task JoinSpecificChatRoom(UserConnection connection)
{
    // Add the connection to the group and cache the user details
    await Groups.AddToGroupAsync(Context.ConnectionId, connection.roomId);
    await _cacheRepository.SetAsync($"connection-{Context.ConnectionId}", connection, TimeSpan.FromHours(1));

    // Notify group members about the new user
    await Clients.Group(connection.roomId)
                .SendAsync("UserJoined", "admin", $"{connection.username} has joined");
}
Messaging and Notifications
public async Task SendMessage(string roomId, string senderId, string content)
{
    // Fetch the cached connection details
    var cachedConnection = await _cacheRepository.GetAsync<UserConnection>($"connection-{Context.ConnectionId}");
    if (cachedConnection is null)
    {
        await Clients.Caller.SendAsync("Error", "User not in any chat room.");
        return;
    }

    // Prepare the message and cache it in the room's chat history
    var message = new
    {
        Username = cachedConnection.username,
        Message = content,
        Timestamp = DateTime.UtcNow
    };
    await _cacheRepository.AddToListAsync($"room-{cachedConnection.roomId}", message);

    // Persist the message to the database
    var command = new AddMessageCommand(Convert.ToInt64(roomId), Convert.ToInt64(senderId), content);
    await _sender.Send(command);

    // Broadcast the message to the group
    await Clients.Group(cachedConnection.roomId)
                .SendAsync("ReceiveMessage", cachedConnection.username, content);
}
Handling Disconnections
public override async Task OnDisconnectedAsync(Exception? exception)
{
    // Retrieve the cached connection information
    var cachedConnection = await _cacheRepository.GetAsync<UserConnection>($"connection-{Context.ConnectionId}");
    if (cachedConnection is not null)
    {
        // Clean up the cache and notify the group
        await _cacheRepository.RemoveAsync($"connection-{Context.ConnectionId}");
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, cachedConnection.roomId);
        await Clients.Group(cachedConnection.roomId)
                    .SendAsync("UserLeft", cachedConnection.username);
    }
    await base.OnDisconnectedAsync(exception);
}

Frontend Overview

The frontend of the application is built using ReactJS, providing an interface to interact with the backend. While the main focus of this article is on the backend, I will provide a high-level overview of the frontend components and how they interact with the backend.

Key Components in the Frontend:

SignalR Client Integration

The React component ChatRoom interacts with the ChatHub by leveraging SignalR’s JavaScript client. It manages the user’s connection, messaging, and activity notifications.

SignalR Connection
const connection = new HubConnectionBuilder()
  .withUrl('http://localhost:5223/chat', { accessTokenFactory: () => token })
  .withAutomaticReconnect()
  .build();

connectionRef.current = connection;

await connection.start();
Join Room and Receive Messages
await connection.invoke('JoinSpecificChatRoom', { username, roomId });
connection.on('ReceiveMessage', (senderName, content) => {
  setMessages(prev => [...prev, { senderName, content }]);
});
Send Messages
// Send a message
const sendMessage = async () => {
  if (!connectionRef.current || !roomId || !message.trim() || !userId) return;
  try {
    await connectionRef.current.invoke('SendMessage', roomId, userId, message);
    setMessage('');
  } catch (error) {
    console.error('Error sending message:', error);
  }
};
Typing Notifications:
// Notify typing
const handleTyping = () => {
  if (!connectionRef.current || !roomId || !username) return;
  connectionRef.current.invoke('Typing', roomId, username).catch(error => {
    console.error('Error sending typing event:', error);
  });
};

Key Takeaways

Future Enhancements

Advanced Features

Improved Scalability

Database Optimization

Enhanced Security

User Experience

Conclusion

This article covered the foundational steps to build a real-time chat application using .NET Core and SignalR, showcasing its capability to handle real-time messaging effectively. While the current implementation is functional, there are exciting opportunities for enhancements, as outlined in the Future Enhancements section. Your thoughts and feedback are highly appreciated and can contribute to refining this solution further - feel free to reach out or share your thoughts on LinkedIn or via email.

Reference