Skip to content

v7: Handlers, RequestAsync and MaxConcurrency - #114

Merged
christopherread merged 1 commit into
v7from
v7-handlers
Oct 25, 2026
Merged

christopherread merged 1 commit into
v7from
v7-handlers

Conversation

@christopherread

Copy link
Copy Markdown
Owner

Fourth v7 increment, part of #87 (design, section 4). Targets v7.

API

obvs.AddService<IOrderMessage>("orders", service => service
    .UseRabbitMq(...)
    .HandleCommand<PlaceOrder>(async (cmd, ctx, ct) => await ctx.PublishAsync(new OrderPlaced(cmd.OrderId), ct),
                               options => options.MaxConcurrency = 4)     // one at a time, in order, by default
    .HandleRequest<GetQuote, Quote>((req, ctx, ct) => Task.FromResult(new Quote(req.Sku, 9.99m)))
    .HandleEvent<OrderPlaced>(...)
    .HandleCommand<ShipOrder, ShipOrderHandler>());                       // ICommandHandler<ShipOrder>

Quote quote = await client.RequestAsync<GetQuote, Quote>(new GetQuote("sku-1"), TimeSpan.FromSeconds(5), ct);

Behaviour

  • Handler shapes: delegates, or ICommandHandler<T>, IEventHandler<T> and IRequestHandler<TReq, TResp> classes. A class handler comes from DI if registered there; otherwise it's created with its dependencies.
  • One DI scope per message. MessageContext exposes the service name, the scope's services, PublishAsync and SendAsync. A request handler's return value is sent back as the reply.
  • Built on the observable core: each handler subscribes to the bus's IObservable stream and queues messages in a channel. MaxConcurrency workers, 1 by default, take them off the queue in order.
  • Errors: a handler that throws is logged at Error level with the service and message type, and the next message is still handled.
  • Shutdown: stopping the host waits for running and queued messages. If shutdown runs out of time, running handlers are cancelled through their token.
  • Startup checks: a command or request handler on a client-only service, an event handler on a server-only service, a handler for another service's message type, or MaxConcurrency < 1 all fail at startup, with the fix in the message.
  • RequestAsync is an extension on IServiceBusClient, so it works on any bus, hosted or not. It throws TimeoutException ("No Quote response to GetQuote within …") measured on an optional TimeProvider, and honours cancellation.

Dependencies (core)

  • Microsoft.Extensions.Logging.Abstractions 10.0.13, for logging handler errors.
  • System.Threading.Channels and Microsoft.Bcl.TimeProvider 10.0.13, for net472 and netstandard2.0 only, since .NET 10 has both built in.

Tests (written first)

  • 16 new tests in Obvs.Tests/Hosting/Handlers/, run on the in-memory transport from v7: Obvs.Testing - in-memory transport and test harness #109. All failed with NotImplementedException first.
  • Do the concurrency tests catch real bugs? I broke the code on purpose to check:
    • with the limit ignored, ShouldHandleUpToMaxConcurrencyMessagesAtOnce fails (6 ran at once, not 3);
    • with the default changed to 5, ShouldHandleOneMessageAtATimeInOrderByDefault fails (5 at once, not 1).
  • Repeat runs: all 16 passed in 10 consecutive runs.
  • Whole solution: a clean build has 0 warnings across net472, netstandard2.0 and net10.0, and all 189 unit tests pass.

Not in this increment (rest of #87)

🤖 Generated with Claude Code

ObvsServiceBuilder gains HandleCommand, HandleEvent and HandleRequest,
each taking a delegate or an ICommandHandler/IEventHandler/IRequestHandler
class. Each message is handled in its own DI scope, with a MessageContext
for the service name, the scope's services and publishing or sending
further messages. Request handlers' responses are sent back as replies.

Handlers start with the host. By default each handles one message at a
time, in order; HandlerOptions.MaxConcurrency allows more. A handler that
throws is logged with the service and message type, and later messages
are still handled. Stopping the host waits for running and queued
messages, and cancels running handlers if shutdown runs out of time.
Handlers on the wrong side of a service (a command handler on a client,
an event handler on a server) or for another service's messages fail at
startup with the fix.

IServiceBusClient.RequestAsync<TRequest, TResponse> sends a request and
returns the first response, or throws TimeoutException, measured on an
optional TimeProvider so timeouts can be tested with virtual time.

Part of #87.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant