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:

Example.cs
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:

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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
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>:

Example.cs
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:

Example.cs
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

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.