SignalR Hub Patterns: Structuring Real-Time Endpoints in .NET
SignalR hubs are the central abstraction for real-time communication in .NET. They handle connection lifecycle, message routing, and client invocation — but as your application grows, a single hub can quickly become unwieldy. Let's look at patterns that keep your hubs clean and maintainable.
The Basics: What a Hub Does
A hub is a server-side class that clients connect to. Methods on the hub can be called by clients, and the hub can invoke methods on connected clients. Here's the simplest possible hub:
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
}
Registration is straightforward in Program.cs:
builder.Services.AddSignalR();
var app = builder.Build();
app.MapHub<ChatHub>("/chat");
This works, but SendAsync with magic strings is fragile. We'll address that shortly with strongly-typed hubs, but first let's focus on structural patterns.
Pattern 1: Single Responsibility Hubs
Avoid the temptation to put everything into one hub. Instead, split by domain concern:
public class OrderHub : Hub
{
public async Task SubscribeToOrder(string orderId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");
}
}
public class NotificationHub : Hub
{
public async Task SubscribeToUserNotifications()
{
var userId = Context.UserIdentifier;
await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}");
}
}
Each hub gets its own endpoint:
app.MapHub<OrderHub>("/hubs/orders");
app.MapHub<NotificationHub>("/hubs/notifications");
This keeps each hub focused, testable, and independently deployable behind feature flags if needed.
Pattern 2: Extracting Logic into Services
Hubs should be thin. Business logic belongs in services, injected via constructor injection:
public class AuctionHub : Hub
{
private readonly IAuctionService _auctionService;
public AuctionHub(IAuctionService auctionService)
{
_auctionService = auctionService;
}
public async Task PlaceBid(string auctionId, decimal amount)
{
var userId = Context.UserIdentifier
?? throw new HubException("Authentication required.");
var result = await _auctionService.PlaceBidAsync(auctionId, userId, amount);
if (result.Success)
{
await Clients.Group($"auction-{auctionId}")
.SendAsync("BidPlaced", result.Bid);
}
else
{
await Clients.Caller.SendAsync("BidRejected", result.Reason);
}
}
}
Hubs are transient — a new instance is created per invocation — so inject scoped or transient services freely.
Pattern 3: Leveraging Hub Lifecycle Methods
The Hub base class provides OnConnectedAsync and OnDisconnectedAsync. Use these for tracking presence, joining default groups, or logging:
public class PresenceHub : Hub
{
private readonly IPresenceTracker _tracker;
public PresenceHub(IPresenceTracker tracker)
{
_tracker = tracker;
}
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
if (userId is not null)
{
await _tracker.UserConnectedAsync(userId, Context.ConnectionId);
await Clients.Others.SendAsync("UserOnline", userId);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.UserIdentifier;
if (userId is not null)
{
var isOffline = await _tracker.UserDisconnectedAsync(userId, Context.ConnectionId);
if (isOffline)
{
await Clients.Others.SendAsync("UserOffline", userId);
}
}
await base.OnDisconnectedAsync(exception);
}
}
Pattern 4: Sending Messages from Outside the Hub
You often need to push messages from background services or API controllers. Use IHubContext<T>:
public class OrderProcessor : BackgroundService
{
private readonly IHubContext<OrderHub> _hubContext;
public OrderProcessor(IHubContext<OrderHub> hubContext)
{
_hubContext = hubContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// When an order updates, notify the relevant group
await _hubContext.Clients.Group($"order-{orderId}")
.SendAsync("OrderStatusChanged", newStatus, stoppingToken);
}
}
This is one of SignalR's most powerful features — it decouples message production from the hub entirely.
Error Handling
Throw HubException to send error details to the client. Any other exception type will send a generic error message unless you're in development mode:
public async Task JoinRoom(string roomId)
{
if (!await _roomService.ExistsAsync(roomId))
{
throw new HubException($"Room '{roomId}' does not exist.");
}
await Groups.AddToGroupAsync(Context.ConnectionId, roomId);
}
Key Takeaways
- Keep hubs thin — delegate to services.
- Split hubs by domain, not by technical concern.
- Use lifecycle methods for presence and group management.
- Use
IHubContext<T>to send messages from anywhere in your application. - Throw
HubExceptionfor client-visible errors.
These patterns form the foundation for everything else in this series. Next, we'll look at strongly-typed hubs to eliminate those magic strings entirely.