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:
- Internet Explorer 8 or earlier: Uses Long Polling.
- JSONP enabled: Uses Long Polling.
- Cross-domain connection: Uses WebSocket if the client and server support it; otherwise, falls back to Long Polling.
- Same-domain connection: Uses WebSocket if supported; otherwise, tries Server-Sent Events, then Forever Frame, and finally Long Polling.
SignalR Communication Models
- Persistent Connections: Low-level API for managing messaging, suitable for direct control over protocols.
- Hubs: High-level API for seamless two-way communication, allowing clients and servers to call methods on each other directly.
Setting Up the Backend
Project Setup
To begin, ensure the following prerequisites are installed:
- .NET Core SDK
- MongoDB
- SQL Server
- Redis (Optional but recommended for scaling)
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:
- User: Stores user information such as name, email, and password.
- Room: Stores chat rooms and related data, with an optional password for securing rooms.
- Member: Represents users as members of rooms, with a role and the date they joined.
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.
- Conversation: Represents a conversation, which can either be a private conversation or a group chat.
- Message: Stores the messages exchanged within conversations.
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:
- For group chats, specify
roomId
. - For one-on-one chats, use a
null
roomId and a list of participants.
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
- The hub validates the user’s JWT token when they connect.
- Tokens are passed as a query parameter (
access_token
) during the WebSocket handshake.
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
- Users join specific rooms based on their provided details.
- Caching ensures user connection details persist temporarily.
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
- Users send messages to a specific chat room, with caching and persistence of messages.
- Notifications, like typing status, are broadcast to the room.
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
- Removes the user from groups and clears their cached connection data when they disconnect.
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:
- AuthContext: Manages user authentication, storing the JWT token for API requests.
- Login/Register: Allows users to log in and register using the authentication endpoints.
- SelectRoom: Displays options for creating or joining a room.
- CreateRoom: Allows users to create a new chat room by calling the
CreateRoom
API. - JoinRoom: Lets users join an existing room by providing the room ID.
- ChatRoom: Where the real-time messaging happens. It connects to the SignalR hub and sends/receives messages.
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
- Initializes a connection to the
ChatHub
using the user’s JWT token.
const connection = new HubConnectionBuilder()
.withUrl('http://localhost:5223/chat', { accessTokenFactory: () => token })
.withAutomaticReconnect()
.build();
connectionRef.current = connection;
await connection.start();
Join Room and Receive Messages
- Invokes the
JoinSpecificChatRoom
method on the hub to join the chat room. - Listens for incoming messages and updates the UI.
await connection.invoke('JoinSpecificChatRoom', { username, roomId });
connection.on('ReceiveMessage', (senderName, content) => {
setMessages(prev => [...prev, { senderName, content }]);
});
Send Messages
- Sends a message to the hub using the
SendMessage
method.
// 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:
- Notifies other users in the chat room when the user is typing.
// 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
-
Ease of Real-Time Implementation with SignalR
SignalR simplifies adding real-time functionality to applications by leveraging WebSockets and providing fallback options for older browsers or constrained environments. Its flexibility makes it an excellent choice for building chat applications. -
Scalable and Secure Backend Design
The implementation demonstrated how to use JWT authentication for secure user access, SignalR Hubs for real-time communication, and Redis for caching to support scalability. -
Practical API Design
By structuring endpoints for user authentication, room management, and messaging, the backend provides a clean interface that can be easily consumed by frontend applications. -
Integration of Frontend with Backend
Using ReactJS as the frontend demonstrates how SignalR seamlessly connects the user interface with the backend for real-time updates and messaging.
Future Enhancements
Advanced Features
- Implement audio and video calling using WebRTC.
- Add real-time read receipts, message reactions, and notifications for an improved chat experience.
Improved Scalability
- Explore additional backplane options like SQL Server or Redis for distributed messaging.
Database Optimization
- Enhance message storage with search capabilities and archiving features.
- Use database indexing to improve query performance for chat history.
Enhanced Security
- Add end-to-end encryption for messages to ensure privacy.
- Implement multi-factor authentication for user accounts.
User Experience
- Enhance the frontend with a modern design and responsive UI for better usability.
- Provide offline message storage and delivery for seamless communication.
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
- Chat Service (Backend) GitHub Repository - https://github.com/sayyedulawwab/ChatService
- Chat Client (Frontend) GitHub Repository - https://github.com/sayyedulawwab/ChatClient
- Introduction to SignalR by Microsoft - https://learn.microsoft.com/en-us/aspnet/signalr/overview/getting-started/introduction-to-signalr