Strongly-Typed SignalR Hubs: Eliminating Magic Strings
One of the first frustrations with SignalR is the reliance on magic strings. Every SendAsync("MethodName", ...) call is a runtime contract — misspell it and nothing happens, silently. Strongly-typed hubs solve this by introducing a compile-time contract between your server and the methods it invokes on clients.
The Problem with Untyped Hubs
Consider this standard hub:
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
await Clients.Caller.SendAsync("MessageSent", true);
}
}
The strings "ReceiveMessage" and "MessageSent" are invisible to the compiler. Rename them in one place but not another, and you'll spend time debugging a silent failure.
Defining the Client Interface
The fix is to define an interface describing every method the server can call on the client:
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task MessageSent(bool success);
Task UserJoined(string user);
Task UserLeft(string user);
}
Every method must return Task. SignalR uses this interface to generate proxy calls — it doesn't support return values from client methods (the server doesn't wait for a response).
Creating the Strongly-Typed Hub
Inherit from Hub<T> instead of Hub:
public class ChatHub : Hub<IChatClient>
{
public async Task SendMessage(string user, string message)
{
await Clients.All.ReceiveMessage(user, message);
await Clients.Caller.MessageSent(true);
}
public override async Task OnConnectedAsync()
{
var user = Context.UserIdentifier ?? "Anonymous";
await Clients.Others.UserJoined(user);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var user = Context.UserIdentifier ?? "Anonymous";
await Clients.Others.UserLeft(user);
await base.OnDisconnectedAsync(exception);
}
}
Notice the difference: Clients.All.ReceiveMessage(user, message) instead of Clients.All.SendAsync("ReceiveMessage", user, message). The compiler now verifies the method name, parameter types, and parameter count.
Strongly-Typed IHubContext
When sending messages from outside the hub — say, from a background service — you get the same type safety with IHubContext<THub, TClient>:
public class AlertService
{
private readonly IHubContext<ChatHub, IChatClient> _hubContext;
public AlertService(IHubContext<ChatHub, IChatClient> hubContext)
{
_hubContext = hubContext;
}
public async Task BroadcastSystemMessage(string message)
{
await _hubContext.Clients.All.ReceiveMessage("System", message);
}
public async Task NotifyUser(string userId, string message)
{
await _hubContext.Clients.User(userId).ReceiveMessage("System", message);
}
}
No magic strings anywhere in the call chain.
Organising Client Interfaces
For larger applications, keep your client interfaces alongside your hubs or in a shared contracts project:
Hubs/
├── Chat/
│ ├── ChatHub.cs
│ └── IChatClient.cs
├── Orders/
│ ├── OrderHub.cs
│ └── IOrderClient.cs
└── Notifications/
├── NotificationHub.cs
└── INotificationClient.cs
If you share a contracts library between server and a .NET client (such as a Blazor app or MAUI app), both sides can reference the same interface. The server implements it implicitly through Hub<T>, and the client can use it to register handlers:
public static class HubConnectionExtensions
{
public static IDisposable OnReceiveMessage(
this HubConnection connection,
Func<string, string, Task> handler)
{
return connection.On("ReceiveMessage", handler);
}
}
This isn't automatic — you still need to write the registration extension — but having the interface as a single source of truth prevents drift.
Constraints and Considerations
There are a few things to keep in mind:
No overloads. SignalR dispatches by method name only, not by signature. Two methods with the same name but different parameters won't work.
No return values. Client interface methods must return Task, not Task<T>. SignalR's invocation model is fire-and-forget from the server's perspective. If you need a response from the client, use a separate hub method that the client calls back.
Parameter serialisation. The parameters you pass through the interface are serialised (JSON by default, or MessagePack if configured). Complex objects work fine, but make sure they're serialisable.
Testing Benefits
Strongly-typed hubs are significantly easier to test. You can mock IChatClient directly:
[Fact]
public async Task SendMessage_BroadcastsToAll()
{
var mockClients = new Mock<IHubCallerClients<IChatClient>>();
var mockClientProxy = new Mock<IChatClient>();
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
var hub = new ChatHub { Clients = mockClients.Object };
await hub.SendMessage("alice", "hello");
mockClientProxy.Verify(
c => c.ReceiveMessage("alice", "hello"),
Times.Once);
}
Compare this to verifying a SendAsync call with magic strings and object[] parameters — the strongly-typed version is clearer and catches mistakes at compile time.
Key Takeaways
- Define an interface for every method the server calls on clients.
- Use
Hub<T>andIHubContext<THub, T>for compile-time safety. - Keep client interfaces in a shared location for consistency.
- All client methods must return
Taskwith no return value. - Strongly-typed hubs make unit testing far more straightforward.
This is a small change with a big payoff. Every SignalR project should start here.