Skip to content

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

  1. Use strongly-typed hubs
  2. Define client interfaces (IWebChatClient)
  3. Use generic hub base class (Hub<IClient>)
  4. Enable compile-time checking

  5. Implement proper authorization

  6. Use [Authorize] attributes on hubs or methods
  7. Validate user permissions in hub methods
  8. Protect sensitive operations

  9. Handle connection lifecycle

  10. Override OnConnectedAsync() and OnDisconnectedAsync()
  11. Clean up resources on disconnect
  12. Manage groups and user associations

  13. Use groups for targeted broadcasting

  14. Group clients by user, organization, or feature
  15. Remove clients from groups on disconnect
  16. Prefer groups over broadcasting to all clients

  17. Integrate with domain events

  18. Broadcast domain events via SignalR
  19. Keep real-time updates in sync with domain changes
  20. Use event handlers to trigger broadcasts

  21. Enable Redis backplane for scale-out

  22. Configure Redis for multi-server deployments
  23. Ensure messages reach all clients across servers
  24. Test backplane configuration thoroughly

  25. Monitor and log hub activity

  26. Log connection/disconnection events
  27. Track message rates and errors
  28. Use OpenTelemetry for distributed tracing

  29. Handle errors gracefully

  30. Catch exceptions in hub methods
  31. Notify clients of errors appropriately
  32. Log errors for debugging

Don'ts

  1. Don't put business logic in hubs
  2. Delegate to domain services (Processors/Retrievers)
  3. Keep hubs thin and focused on communication
  4. Maintain architectural boundaries

  5. Don't broadcast sensitive data

  6. Validate user permissions before broadcasting
  7. Filter data based on user roles
  8. Use groups to limit message scope

  9. Don't ignore connection state

  10. Handle reconnection scenarios
  11. Clean up on disconnect
  12. Manage state across reconnections

  13. Don't use SignalR for all communication

  14. Use REST/GraphQL for request/response patterns
  15. Use SignalR for real-time updates and notifications
  16. Choose the right tool for each scenario

Real-World Use Cases

  1. Real-time Notifications
  2. User alerts and notifications
  3. System status updates
  4. Progress updates for long-running operations

  5. Live Dashboards

  6. Real-time metrics and KPIs
  7. Monitoring dashboards
  8. Analytics visualizations

  9. Collaboration Features

  10. Chat and messaging
  11. Collaborative editing
  12. Presence indicators (who's online)

  13. IoT Telemetry

  14. Device status updates
  15. Sensor data streaming
  16. Real-time monitoring

  17. Gaming and Interactive Applications

  18. Multi-player game updates
  19. Real-time leaderboards
  20. 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.