Architecture Overview

Understand the core architecture and design patterns of Nimbus

Core Architecture

Nimbus is designed around a layered architecture that separates concerns and provides flexibility:

┌─────────────────────────────────────┐
│     Your Application Code           │
│  (Commands, Events, Handlers)       │
└──────────────┬──────────────────────┘

┌──────────────▼──────────────────────┐
│          IBus Interface             │
│   (Send, Publish, Request, etc.)    │
└──────────────┬──────────────────────┘

┌──────────────▼──────────────────────┐
│      Message Dispatchers            │
│   (Route messages to handlers)      │
└──────────────┬──────────────────────┘

┌──────────────▼──────────────────────┐
│     Transport Abstraction           │
│      (INimbusTransport)             │
└──────────────┬──────────────────────┘

     ┌─────────┼─────────┬───────────┐
     │         │         │           │
┌────▼───┐ ┌──▼────┐ ┌──▼──────┐ ┌──▼────┐
│ Azure  │ │ Redis │ │  AMQP   │ │InProc │
│Service │ │       │ │         │ │       │
│  Bus   │ │       │ │         │ │       │
└────────┘ └───────┘ └─────────┘ └───────┘

Core Design Patterns

Nimbus implements several messaging patterns to handle different communication scenarios:

Commands

Fire-and-forget messages sent to a single handler. Perfect for actions like “PlaceOrder” or “SendEmail”.

public class PlaceOrderCommand : IBusCommand
{
    public string OrderId { get; set; }
}

Events

Pub/sub messages that notify multiple subscribers. Two types:

  • Competing: One handler processes (load balanced)
  • Multicast: All handlers process
public class OrderPlacedEvent : IBusEvent
{
    public string OrderId { get; set; }
}

Requests

Request/response pattern for querying data. Single handler returns a response.

public class GetOrderRequest :
    IBusRequest<GetOrderRequest, OrderDto>
{
    public string OrderId { get; set; }
}

Multicast Requests

One-to-many request pattern. Multiple handlers can respond.

public class FindAvailableDriversRequest :
    IBusMulticastRequest<Request, DriverDto>
{
    public string Location { get; set; }
}

When to use which pattern?

  • Commands: “Do this” (imperative actions)
  • Events: “This happened” (notifications)
  • Requests: “Get me this” (queries)
  • Multicast Requests: “Who can help?” (distributed queries)

Message Flow

Here’s how a message flows through Nimbus:

Sending a Message

Your Code

   ├─> bus.Send(command)

   ├─> IRouter (determines queue/topic path)

   ├─> ISerializer (serializes message)

   ├─> INimbusMessageSender (transport-specific sender)

   └─> Queue/Topic on messaging infrastructure

Receiving a Message

Queue/Topic on messaging infrastructure

   ├─> INimbusMessageReceiver (transport-specific receiver)

   ├─> MessagePump (pulls messages continuously)

   ├─> IInboundInterceptor (before handling)

   ├─> MessageDispatcher (finds handler)

   ├─> IDependencyResolver (creates handler instance)

   ├─> Handler.Handle(message)

   └─> IInboundInterceptor (after handling)

Key Components

IBus Interface

The main entry point for all messaging operations:

public interface IBus
{
    // Commands
    Task Send<TCommand>(TCommand command) where TCommand : IBusCommand;

    // Events
    Task Publish<TEvent>(TEvent @event) where TEvent : IBusEvent;

    // Requests
    Task<TResponse> Request<TRequest, TResponse>(TRequest request)
        where TRequest : IBusRequest<TRequest, TResponse>;

    // Multicast Requests
    IEnumerable<Task<TResponse>> MulticastRequest<TRequest, TResponse>(
        TRequest request, TimeSpan timeout)
        where TRequest : IBusMulticastRequest<TRequest, TResponse>;

    // Lifecycle
    Task Start();
    Task Stop();
}

NimbusMessage Envelope

All messages are wrapped in a NimbusMessage envelope that contains:

  • MessageId: Unique identifier for the message
  • CorrelationId: For tracking related messages
  • From/To/DeliverTo: Routing information
  • DeliverAfter/ExpiresAfter: Scheduling and TTL
  • DeliveryAttempts: Retry tracking
  • Properties: Metadata dictionary
  • Payload: The actual message object

Transport Abstraction

All transports implement INimbusTransport:

internal interface INimbusTransport
{
    INimbusMessageSender GetQueueSender(string queuePath);
    INimbusMessageReceiver GetQueueReceiver(string queuePath);
    INimbusMessageSender GetTopicSender(string topicPath);
    INimbusMessageReceiver GetTopicReceiver(
        string topicPath,
        string subscriptionName,
        IFilterCondition filter);
}

This abstraction allows Nimbus to work with any messaging infrastructure without changing your code.

Dependency Injection

Nimbus uses its own DI abstractions to stay container-agnostic:

public interface IDependencyResolver
{
    object Resolve(Type type);
    IDependencyResolverScope CreateChildScope();
}

Scope Management

  • Each message creates a child scope for handler resolution
  • Handlers and dependencies are resolved per-message
  • Singleton services: Bus, senders, routers, serializers
  • Scoped services: Handlers, message-specific dependencies

This ensures handlers don’t accidentally share state between messages and makes testing easier.

Routing

The IRouter interface determines how message types map to queue/topic paths:

public interface IRouter
{
    string Route(Type messageType, QueueOrTopic queueOrTopic);
}

Default Router: DestinationPerMessageTypeRouter

  • Creates one queue/topic per message type
  • Path format: {applicationName}.{messageTypeName}
  • Example: OrderService.PlaceOrderCommand

Type Resolution

The ITypeProvider scans assemblies for:

  • Message types (IBusCommand, IBusEvent, etc.)
  • Handler types (IHandleCommand<T>, etc.)
var typeProvider = new AssemblyScanningTypeProvider(
    typeof(MyCommand).Assembly,
    typeof(MyHandler).Assembly
);

This information is used for:

  • Routing messages to correct queues/topics
  • Dispatching messages to correct handlers
  • Efficient serialization/deserialization

Interceptors

Add cross-cutting concerns using interceptors:

Inbound Interceptors

Process messages before/after handling:

public class LoggingInterceptor : IInboundInterceptor
{
    public async Task OnCommandHandlerExecuting<TCommand>(
        TCommand command, HandlerContext context)
    {
        Log.Information("Handling command: {CommandType}",
            typeof(TCommand).Name);
    }

    public async Task OnCommandHandlerSuccess<TCommand>(
        TCommand command, HandlerContext context)
    {
        Log.Information("Command handled successfully");
    }

    public async Task OnCommandHandlerError<TCommand>(
        TCommand command, HandlerContext context, Exception exception)
    {
        Log.Error(exception, "Command handling failed");
    }
}

Outbound Interceptors

Process messages before/after sending:

public class MetricsInterceptor : IOutboundInterceptor
{
    public async Task OnCommandSending<TCommand>(
        TCommand command)
    {
        Metrics.Increment("commands_sent");
    }
}

Next Steps

Dive deeper into specific aspects of the architecture: