Handlers
A handler is the code that runs when a message arrives. You write one class per message type (or more, if you want several things to happen for the same message), implement a single interface, and ServiceConnect takes care of dispatch: it pulls the message off the queue, deserialises it, resolves your handler from the container, and invokes you.
Handlers are the simplest unit of work in a ServiceConnect application — and the one you will write most often.
The interface
Section titled “The interface”public interface IMessageHandler<in TMessage> where TMessage : Message{ Task HandleAsync(TMessage message, IConsumeContext context, CancellationToken cancellationToken = default);}One method you implement. The pipeline passes the per-message context directly as a parameter.
using ServiceConnect.Interfaces;
public sealed class WorkSubmittedHandler : IMessageHandler<WorkSubmitted>{ public async Task HandleAsync(WorkSubmitted message, IConsumeContext context, CancellationToken cancellationToken = default) { Console.WriteLine($"Processing {message.WorkId}"); await Task.CompletedTask; }}The contravariant in on the generic parameter means a handler written for a base message type also runs for any derived message types registered against the same handler. Most handlers target a single concrete type.
The consume context
Section titled “The consume context”IConsumeContext is passed directly to HandleAsync by the dispatch pipeline. It exposes the things a handler needs to do something useful beyond just reading the incoming message:
public interface IConsumeContext{ IBus Bus { get; } IReadOnlyDictionary<string, object> Headers { get; } string? MessageId { get; } Guid CorrelationId { get; } CancellationToken CancellationToken { get; }
Task ReplyAsync<TReply>( TReply message, ReplyOptions? options = null, CancellationToken cancellationToken = default) where TReply : Message;}A few things worth knowing:
Busis the sameIBusyou inject elsewhere. Prefer usingcontext.Businside a handler — it makes the data-flow explicit: “this message produced that message.”CorrelationIdis the incoming message’s correlation id. Pass it through to any outgoing messages you produce in this handler so logs and process managers can tie them back to the same conversation.CancellationTokenis tied to the consumer’s lifetime. When the bus stops consuming, the token fires. Long-running handlers should pass it to downstream awaits so they unwind cleanly on shutdown.ReplyAsync<TReply>is the correct way to reply to a request sent bySendRequestAsync. It sets theResponseMessageIdheader that ServiceConnect uses to correlate the reply. A plainBus.SendAsyncback to the caller will not resolve the pending request — always useReplyAsyncwhen replying to a request.
A typical request/reply handler:
public sealed class QuoteRequestHandler : IMessageHandler<QuoteRequest>{ public async Task HandleAsync(QuoteRequest message, IConsumeContext context, CancellationToken cancellationToken = default) { var price = Quote(message.ProductCode); await context.ReplyAsync(new QuoteResponse(message.CorrelationId) { Price = price, }); }}Registering handlers
Section titled “Registering handlers”ServiceConnect offers two ways to tell the bus which handlers exist.
For full applications, let the bus find handlers by reflection. Set ScanForMessageHandlers in the bus configuration and ServiceConnect walks your assemblies looking for classes that implement IMessageHandler<>:
services.AddServiceConnect(builder =>{ builder.UseRabbitMQ(/* … */); builder.ConfigureQueues(q => q.QueueName = "orders"); builder.ConfigureBus(bus => bus.ScanForMessageHandlers = true);});By default the scanner searches AppDomain.CurrentDomain.GetAssemblies(). If your handlers live in a library that isn’t yet loaded at startup, register it explicitly:
services.AddServiceConnect(builder =>{ builder.ConfigureBus(bus => bus.ScanForMessageHandlers = true); builder.ScanAssemblies(typeof(WorkSubmittedHandler).Assembly);});Explicit registration
Section titled “Explicit registration”For tests, examples, or when you want a single place to see every handler, register a HandlerReference list manually:
services.AddSingleton<IReadOnlyList<HandlerReference>>(new List<HandlerReference>{ new() { HandlerType = typeof(WorkSubmittedHandler), MessageType = typeof(WorkSubmitted) }, new() { HandlerType = typeof(OrderPlacedAuditHandler), MessageType = typeof(OrderPlaced) },});
services.AddServiceConnect(builder =>{ builder.ConfigureBus(bus => bus.ScanForMessageHandlers = false); // …});This is what the examples in this repository use, and what the Getting Started guide shows. Keeping handler registration explicit makes the wiring obvious to any reader.
You can combine approaches — enable scanning and also add a HandlerReference entry for something the scanner wouldn’t pick up, like a handler type resolved dynamically.
Handler lifetime
Section titled “Handler lifetime”ServiceConnect registers handlers as transient and rejects pre-registered singletons at AddServiceConnect time.
// This will throw during AddServiceConnect:services.AddSingleton<WorkSubmittedHandler>();services.AddServiceConnect(builder => { /* … */ });If you need shared state across handlers — a cache, a database connection pool, a metrics client — inject it. The handler stays transient; the dependency is a singleton that the handler resolves from the container.
public sealed class WorkSubmittedHandler(IWorkItemStore store, ILogger<WorkSubmittedHandler> log) : IMessageHandler<WorkSubmitted>{ public async Task HandleAsync(WorkSubmitted message, IConsumeContext context, CancellationToken cancellationToken = default) { log.LogInformation("Received {WorkId}", message.WorkId); await store.SaveAsync(message); }}Multiple handlers for one message
Section titled “Multiple handlers for one message”Any number of IMessageHandler<T> implementations can coexist for the same T. When a message of that type arrives, every registered handler runs. This is useful for splitting cross-cutting concerns — one handler does the work, another writes an audit row, a third emits a metric.
public sealed class RefundDomainHandler : IMessageHandler<RefundRequested> { /* does the refund */ }public sealed class RefundAuditHandler : IMessageHandler<RefundRequested> { /* records it */ }public sealed class RefundMetricsHandler : IMessageHandler<RefundRequested> { /* emits a gauge */ }All three are registered, all three run, each with its own transient instance. If any handler throws, the message is retried and eventually moved to the error queue per the bus’s retry policy — so prefer short, idempotent handlers that can cope with being invoked more than once.
Exceptions and retries
Section titled “Exceptions and retries”If HandleAsync throws, ServiceConnect applies the configured retry policy and, after retries are exhausted, forwards the message to the error queue. Write handlers to be:
- Idempotent — the same message may be delivered again after a transient failure. Design the effect so re-delivery is safe (use the
CorrelationIdas a deduplication key, upsert rather than insert). - Quick to recognise failure — blocking a consumer for minutes on a downstream timeout blocks every other message in the queue. Wrap external calls in reasonable timeouts.
See Error Handling for how to customise the behaviour.
Reference
Section titled “Reference”IMessageHandler<T>— the handler contractIConsumeContext— per-message contextIStreamHandler— streaming messages
What comes next
Section titled “What comes next”- Endpoints — where sends land and how subscribers are chosen.
- Pub/Sub — publish a message and let multiple handlers react.
- Request/Reply — for when a handler needs to return a value.