SignalR in ConnectSoft Microservice Template¶
Purpose & Overview¶
SignalR is a .NET library that adds real-time web functionality to applications. In the ConnectSoft Microservice Template, SignalR enables instant two-way communication between the server and clients, supporting real-time notifications, live updates, and interactive features. It integrates seamlessly with the template's Clean Architecture, messaging patterns, and observability infrastructure.
Why SignalR?¶
SignalR provides several key benefits for microservices:
- Real-Time Communication: Push updates instantly to all connected clients without polling
- Multiple Transport Protocols: Automatically selects the best available transport (WebSockets, Server-Sent Events, Long Polling)
- Scalability: Built-in Redis backplane support for horizontal scaling across multiple servers
- Strongly Typed Hubs: Type-safe communication between server and clients
- Observability: Full integration with OpenTelemetry, structured logging, and distributed tracing
- Resilience: Automatic reconnection and heartbeat support
- Security: Integration with ASP.NET Core authentication and authorization
Architecture Overview¶
SignalR integrates with the template's architecture:
Client Applications
↓
SignalR Hub (ServiceModel.SignalR)
├── Hub Methods (server-to-client)
└── Client Methods (client-to-server)
↓
Domain Services (DomainModel)
├── Processors (for mutations triggered by clients)
└── Retrievers (for queries)
↓
Messaging System (MessagingModel)
└── Domain Events (trigger real-time notifications)
↓
Repository Layer (PersistenceModel)
Core Components¶
1. SignalR Hub¶
Hubs are the primary abstraction for SignalR communication:
// WebChatHub.cs - Example hub from template
public class WebChatHub : Hub<IWebChatClient>
{
private readonly ILogger<WebChatHub> logger;
public WebChatHub(ILogger<WebChatHub> logger)
{
this.logger = logger;
}
/// <summary>
/// Broadcasts a message to all connected clients.
/// </summary>
public async Task BroadcastMessage(string message)
{
this.logger.LogInformation("Broadcasting message to all clients: {Message}", message);
// Send message to all connected clients
await this.Clients.All.NotifyMessageToAll(message);
}
/// <summary>
/// Sends a message to a specific user.
/// </summary>
public async Task SendMessageToUser(string userId, string message)
{
this.logger.LogInformation("Sending message to user {UserId}: {Message}", userId, message);
await this.Clients.User(userId).NotifyMessageToAll(message);
}
/// <summary>
/// Sends a message to a specific group.
/// </summary>
public async Task SendMessageToGroup(string groupName, string message)
{
this.logger.LogInformation("Sending message to group {GroupName}: {Message}", groupName, message);
await this.Clients.Group(groupName).NotifyMessageToAll(message);
}
/// <summary>
/// Adds a client to a group.
/// </summary>
public async Task AddToGroup(string groupName)
{
await this.Groups.AddToGroupAsync(this.Context.ConnectionId, groupName);
this.logger.LogInformation("Client {ConnectionId} added to group {GroupName}",
this.Context.ConnectionId, groupName);
}
/// <summary>
/// Removes a client from a group.
/// </summary>
public async Task RemoveFromGroup(string groupName)
{
await this.Groups.RemoveFromGroupAsync(this.Context.ConnectionId, groupName);
this.logger.LogInformation("Client {ConnectionId} removed from group {GroupName}",
this.Context.ConnectionId, groupName);
}
/// <summary>
/// Called when a client connects.
/// </summary>
public override async Task OnConnectedAsync()
{
this.logger.LogInformation("Client connected: {ConnectionId}", this.Context.ConnectionId);
await base.OnConnectedAsync();
}
/// <summary>
/// Called when a client disconnects.
/// </summary>
public override async Task OnDisconnectedAsync(Exception? exception)
{
this.logger.LogInformation("Client disconnected: {ConnectionId}", this.Context.ConnectionId);
if (exception != null)
{
this.logger.LogError(exception, "Client disconnected with error: {ConnectionId}",
this.Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception);
}
}
// Strongly-typed client interface
public interface IWebChatClient
{
Task NotifyMessageToAll(string message);
Task NotifyMessageToUser(string message);
Task NotifyUserJoined(string userId);
Task NotifyUserLeft(string userId);
}
2. Integration with Domain Events¶
SignalR can broadcast domain events in real-time:
// DomainEventNotificationHub.cs
public class DomainEventNotificationHub : Hub<IDomainEventClient>
{
private readonly ILogger<DomainEventNotificationHub> logger;
public DomainEventNotificationHub(ILogger<DomainEventNotificationHub> logger)
{
this.logger = logger;
}
public override async Task OnConnectedAsync()
{
// Subscribe to groups based on user permissions
if (this.Context.User?.Identity?.IsAuthenticated == true)
{
var userId = this.Context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userId != null)
{
await this.Groups.AddToGroupAsync(this.Context.ConnectionId, $"user-{userId}");
this.logger.LogInformation("User {UserId} connected to notifications", userId);
}
}
await base.OnConnectedAsync();
}
}
// Event handler that broadcasts domain events
public class DomainEventSignalRBroadcaster : INotificationHandler<MicroserviceAggregateRootCreatedEvent>
{
private readonly IHubContext<DomainEventNotificationHub, IDomainEventClient> hubContext;
private readonly ILogger<DomainEventSignalRBroadcaster> logger;
public DomainEventSignalRBroadcaster(
IHubContext<DomainEventNotificationHub, IDomainEventClient> hubContext,
ILogger<DomainEventSignalRBroadcaster> logger)
{
this.hubContext = hubContext;
this.logger = logger;
}
public async Task Handle(MicroserviceAggregateRootCreatedEvent notification, CancellationToken cancellationToken)
{
this.logger.LogInformation(
"Broadcasting domain event: {EventType} for aggregate {AggregateId}",
nameof(MicroserviceAggregateRootCreatedEvent),
notification.ObjectId);
// Broadcast to all connected clients
await this.hubContext.Clients.All.AggregateRootCreated(new AggregateRootCreatedNotification
{
AggregateId = notification.ObjectId,
CreatedAt = notification.OccurredAt
});
// Or broadcast to specific user/group
// await this.hubContext.Clients.User(userId).AggregateRootCreated(...);
}
}
public interface IDomainEventClient
{
Task AggregateRootCreated(AggregateRootCreatedNotification notification);
Task AggregateRootUpdated(AggregateRootUpdatedNotification notification);
Task AggregateRootDeleted(AggregateRootDeletedNotification notification);
}
3. Integration with CQRS¶
SignalR can integrate with Processors and Retrievers:
// Hub that delegates to domain services
public class MicroserviceAggregateRootHub : Hub<IMicroserviceAggregateRootClient>
{
private readonly IMicroserviceAggregateRootsProcessor processor;
private readonly IMicroserviceAggregateRootsRetriever retriever;
private readonly ILogger<MicroserviceAggregateRootHub> logger;
public MicroserviceAggregateRootHub(
IMicroserviceAggregateRootsProcessor processor,
IMicroserviceAggregateRootsRetriever retriever,
ILogger<MicroserviceAggregateRootHub> logger)
{
this.processor = processor;
this.retriever = retriever;
this.logger = logger;
}
/// <summary>
/// Client-initiated command via SignalR.
/// </summary>
[Authorize]
public async Task CreateAggregateRoot(CreateMicroserviceAggregateRootInput input)
{
try
{
var result = await this.processor.CreateMicroserviceAggregateRoot(input, this.Context.ConnectionAborted);
// Notify all clients about the creation
await this.Clients.All.AggregateRootCreated(new AggregateRootDto
{
ObjectId = result.ObjectId,
// Map other properties
});
// Notify the caller of success
await this.Clients.Caller.OperationCompleted("Aggregate root created successfully");
}
catch (Exception ex)
{
this.logger.LogError(ex, "Error creating aggregate root via SignalR");
await this.Clients.Caller.OperationFailed(ex.Message);
}
}
/// <summary>
/// Client-initiated query via SignalR.
/// </summary>
[Authorize]
public async Task GetAggregateRootDetails(Guid objectId)
{
try
{
var input = new GetMicroserviceAggregateRootDetailsInput { ObjectId = objectId };
var result = await this.retriever.GetMicroserviceAggregateRootDetails(input, this.Context.ConnectionAborted);
if (result != null)
{
await this.Clients.Caller.AggregateRootDetailsReceived(new AggregateRootDto
{
ObjectId = result.ObjectId,
// Map other properties
});
}
else
{
await this.Clients.Caller.OperationFailed("Aggregate root not found");
}
}
catch (Exception ex)
{
this.logger.LogError(ex, "Error retrieving aggregate root via SignalR");
await this.Clients.Caller.OperationFailed(ex.Message);
}
}
}
public interface IMicroserviceAggregateRootClient
{
Task AggregateRootCreated(AggregateRootDto aggregate);
Task AggregateRootUpdated(AggregateRootDto aggregate);
Task AggregateRootDeleted(Guid aggregateId);
Task AggregateRootDetailsReceived(AggregateRootDto aggregate);
Task OperationCompleted(string message);
Task OperationFailed(string error);
}
Configuration¶
SignalR Service Registration¶
// SignalRExtensions.cs
internal static IServiceCollection AddSignalRCommunication(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
var signalRBuilder = services.AddSignalR(configure =>
{
var options = OptionsExtensions.SignalROptions;
configure.EnableDetailedErrors = options.EnableDetailedErrors;
configure.HandshakeTimeout = options.HandshakeTimeout;
configure.KeepAliveInterval = options.KeepAliveInterval;
configure.ClientTimeoutInterval = options.ClientTimeoutInterval;
configure.MaximumReceiveMessageSize = options.MaximumReceiveMessageSize;
configure.MaximumParallelInvocationsPerClient = options.MaximumParallelInvocationsPerClient;
});
// Configure Redis backplane for scale-out
if (OptionsExtensions.SignalROptions.RedisSignalRBackplaneOptions?.ConfigurationString != null)
{
var redisOptions = OptionsExtensions.SignalROptions.RedisSignalRBackplaneOptions;
signalRBuilder.AddStackExchangeRedis(
redisOptions.ConfigurationString,
options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal(redisOptions.ChannelPrefix);
});
}
return services;
}
internal static IApplicationBuilder UseSignalRCommunication(this IApplicationBuilder application)
{
ArgumentNullException.ThrowIfNull(application);
// WebSockets are required for SignalR
application.UseWebSockets();
return application;
}
internal static IEndpointRouteBuilder MapSignalR(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
// Map hubs to endpoints
endpoints.MapHub<WebChatHub>("/webChatHub");
endpoints.MapHub<DomainEventNotificationHub>("/domainEvents");
endpoints.MapHub<MicroserviceAggregateRootHub>("/aggregateRoots")
.RequireAuthorization(); // Require authentication for this hub
return endpoints;
}
Configuration Options¶
{
"SignalR": {
"EnableDetailedErrors": true,
"HandshakeTimeout": "00:00:10",
"KeepAliveInterval": "00:00:15",
"ClientTimeoutInterval": "00:00:30",
"MaximumReceiveMessageSize": 65536,
"MaximumParallelInvocationsPerClient": 5,
"RedisSignalRBackplaneOptions": {
"ConfigurationString": "localhost:6379",
"ChannelPrefix": "ConnectSoft.SignalR"
}
}
}
Health Checks Integration¶
SignalR integrates with the template's health check subsystem:
// HealthChecksExtensions.cs
internal static IHealthChecksBuilder AddSignalRHealthChecks(
this IHealthChecksBuilder healthChecksBuilder)
{
healthChecksBuilder
.AddSignalRHub<WebChatHub>(
hub => hub.ConfigureHttp = (sp, http) =>
{
http.Transports = HttpTransportType.LongPolling;
http.CloseTimeout = TimeSpan.FromSeconds(3);
},
name: "signalr-webchathub",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "signalr", "websocket", "hub" });
return healthChecksBuilder;
}
Observability Integration¶
Logging¶
SignalR hubs automatically log connection events:
public class WebChatHub : Hub<IWebChatClient>
{
private readonly ILogger<WebChatHub> logger;
public WebChatHub(ILogger<WebChatHub> logger)
{
this.logger = logger;
}
public override async Task OnConnectedAsync()
{
this.logger.LogInformation(
"Client connected: {ConnectionId}, User: {UserId}",
this.Context.ConnectionId,
this.Context.User?.Identity?.Name);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (exception != null)
{
this.logger.LogError(
exception,
"Client disconnected with error: {ConnectionId}",
this.Context.ConnectionId);
}
else
{
this.logger.LogInformation(
"Client disconnected: {ConnectionId}",
this.Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception);
}
}
OpenTelemetry Tracing¶
SignalR operations are automatically traced when OpenTelemetry is enabled:
// Traces are automatically created for:
// - Hub method invocations
// - Client connection/disconnection
// - Message sending/receiving
// Custom activity for specific operations
public async Task BroadcastMessage(string message)
{
using var activity = ActivitySource.StartActivity("SignalR.BroadcastMessage");
activity?.SetTag("message.length", message.Length);
await this.Clients.All.NotifyMessageToAll(message);
activity?.SetStatus(ActivityStatusCode.Ok);
}
Metrics¶
Custom metrics can be collected:
public class SignalRMetrics
{
private readonly Counter<int> broadcastCounter;
private readonly Counter<int> connectionCounter;
private readonly Histogram<double> messageSizeHistogram;
public SignalRMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("ConnectSoft.SignalR");
this.broadcastCounter = meter.CreateCounter<int>("signalr_broadcast_count");
this.connectionCounter = meter.CreateCounter<int>("signalr_connection_count");
this.messageSizeHistogram = meter.CreateHistogram<double>("signalr_message_size_bytes");
}
public void RecordBroadcast(int clientCount)
{
this.broadcastCounter.Add(clientCount);
}
public void RecordConnection()
{
this.connectionCounter.Add(1);
}
public void RecordMessageSize(double size)
{
this.messageSizeHistogram.Record(size);
}
}
Client Examples¶
JavaScript Client¶
// browser-client.js
const connection = new signalR.HubConnectionBuilder()
.withUrl("/webChatHub")
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Information)
.build();
// Register handlers for server-to-client calls
connection.on("NotifyMessageToAll", (message) => {
console.log("Received message:", message);
displayMessage(message);
});
connection.on("NotifyUserJoined", (userId) => {
console.log("User joined:", userId);
});
// Handle connection state changes
connection.onreconnecting((error) => {
console.log("Connection lost. Reconnecting...", error);
});
connection.onreconnected((connectionId) => {
console.log("Reconnected. Connection ID:", connectionId);
});
connection.onclose((error) => {
console.log("Connection closed", error);
});
// Start connection
connection.start()
.then(() => {
console.log("Connected to SignalR hub");
// Call server method (client-to-server)
connection.invoke("BroadcastMessage", "Hello from browser!");
})
.catch((error) => {
console.error("Error starting connection:", error);
});
// Send message
function sendMessage(message) {
connection.invoke("BroadcastMessage", message)
.catch((error) => {
console.error("Error sending message:", error);
});
}
C# Client¶
// dotnet-client.cs
public class SignalRClient
{
private HubConnection? connection;
public async Task ConnectAsync(string hubUrl)
{
this.connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect()
.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Information);
})
.Build();
// Register handlers
this.connection.On<string>("NotifyMessageToAll", (message) =>
{
Console.WriteLine($"Received: {message}");
this.OnMessageReceived?.Invoke(message);
});
this.connection.On<string>("NotifyUserJoined", (userId) =>
{
Console.WriteLine($"User joined: {userId}");
});
// Handle reconnection
this.connection.Reconnecting += (error) =>
{
Console.WriteLine($"Connection lost. Reconnecting... {error?.Message}");
return Task.CompletedTask;
};
this.connection.Reconnected += (connectionId) =>
{
Console.WriteLine($"Reconnected. Connection ID: {connectionId}");
return Task.CompletedTask;
};
this.connection.Closed += (error) =>
{
Console.WriteLine($"Connection closed. {error?.Message}");
return Task.CompletedTask;
};
await this.connection.StartAsync();
}
public async Task BroadcastMessageAsync(string message)
{
if (this.connection?.State == HubConnectionState.Connected)
{
await this.connection.InvokeAsync("BroadcastMessage", message);
}
}
public async Task DisconnectAsync()
{
if (this.connection != null)
{
await this.connection.DisposeAsync();
}
}
public event Action<string>? OnMessageReceived;
}
Testing¶
Unit Testing Hubs¶
[Fact]
public async Task BroadcastMessage_Should_Send_To_All_Clients()
{
// Arrange
var mockClients = new Mock<IHubClients<IWebChatClient>>();
var mockClientProxy = new Mock<IWebChatClient>();
var mockAll = new Mock<IWebChatClient>();
mockClients.Setup(c => c.All).Returns(mockAll.Object);
var hub = new WebChatHub(Mock.Of<ILogger<WebChatHub>>())
{
Clients = mockClients.Object
};
// Act
await hub.BroadcastMessage("Test message");
// Assert
mockAll.Verify(c => c.NotifyMessageToAll("Test message"), Times.Once);
}
Integration Testing¶
[Fact]
public async Task SignalR_Hub_Should_Broadcast_Message()
{
// Arrange
using var host = await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.UseStartup<Startup>();
})
.StartAsync();
var server = host.GetTestServer();
var connection = server.CreateHubConnection<WebChatHub, IWebChatClient>("/webChatHub");
string? receivedMessage = null;
connection.On("NotifyMessageToAll", (message) => receivedMessage = message);
await connection.StartAsync();
// Act
await connection.InvokeAsync("BroadcastMessage", "Test message");
// Assert
await Task.Delay(100); // Allow message to propagate
Assert.Equal("Test message", receivedMessage);
}
Best Practices¶
Do's¶
- Use strongly-typed hubs
- Define client interfaces (
IWebChatClient) - Use generic hub base class (
Hub<IClient>) -
Enable compile-time checking
-
Implement proper authorization
- Use
[Authorize]attributes on hubs or methods - Validate user permissions in hub methods
-
Protect sensitive operations
-
Handle connection lifecycle
- Override
OnConnectedAsync()andOnDisconnectedAsync() - Clean up resources on disconnect
-
Manage groups and user associations
-
Use groups for targeted broadcasting
- Group clients by user, organization, or feature
- Remove clients from groups on disconnect
-
Prefer groups over broadcasting to all clients
-
Integrate with domain events
- Broadcast domain events via SignalR
- Keep real-time updates in sync with domain changes
-
Use event handlers to trigger broadcasts
-
Enable Redis backplane for scale-out
- Configure Redis for multi-server deployments
- Ensure messages reach all clients across servers
-
Test backplane configuration thoroughly
-
Monitor and log hub activity
- Log connection/disconnection events
- Track message rates and errors
-
Use OpenTelemetry for distributed tracing
-
Handle errors gracefully
- Catch exceptions in hub methods
- Notify clients of errors appropriately
- Log errors for debugging
Don'ts¶
- Don't put business logic in hubs
- Delegate to domain services (Processors/Retrievers)
- Keep hubs thin and focused on communication
-
Maintain architectural boundaries
-
Don't broadcast sensitive data
- Validate user permissions before broadcasting
- Filter data based on user roles
-
Use groups to limit message scope
-
Don't ignore connection state
- Handle reconnection scenarios
- Clean up on disconnect
-
Manage state across reconnections
-
Don't use SignalR for all communication
- Use REST/GraphQL for request/response patterns
- Use SignalR for real-time updates and notifications
- Choose the right tool for each scenario
Real-World Use Cases¶
- Real-time Notifications
- User alerts and notifications
- System status updates
-
Progress updates for long-running operations
-
Live Dashboards
- Real-time metrics and KPIs
- Monitoring dashboards
-
Analytics visualizations
-
Collaboration Features
- Chat and messaging
- Collaborative editing
-
Presence indicators (who's online)
-
IoT Telemetry
- Device status updates
- Sensor data streaming
-
Real-time monitoring
-
Gaming and Interactive Applications
- Multi-player game updates
- Real-time leaderboards
- Live event broadcasting
Summary¶
SignalR in the ConnectSoft Microservice Template provides:
- ✅ Real-Time Communication: Instant two-way communication
- ✅ Scalability: Redis backplane support for multi-server deployments
- ✅ Observability: Full integration with OpenTelemetry and logging
- ✅ Security: Authentication and authorization support
- ✅ Integration: Works with CQRS, domain events, and domain services
- ✅ Resilience: Automatic reconnection and error handling
- ✅ Type Safety: Strongly-typed hubs and clients
By following these patterns, SignalR becomes a powerful real-time communication layer that maintains architectural integrity while providing instant updates to clients.