Skip to main content

Migrating from NServiceBus

A comprehensive guide for migrating from NServiceBus to Excalibur, covering messaging patterns, sagas, outbox, and enterprise integration.

Before You Start​

Overview​

NServiceBus is a mature, enterprise-grade messaging platform with extensive features. Excalibur offers a lighter-weight, domain-focused alternative for teams that need messaging and event sourcing without the complexity and licensing costs of NServiceBus.

When to Migrate​

Consider Excalibur if you:

  • Want to eliminate NServiceBus licensing costs
  • Need simpler infrastructure (database-backed vs. message broker)
  • Prefer built-in event sourcing over separate implementation
  • Want lower operational complexity
  • Don't need advanced enterprise features (saga timeouts, critical errors, etc.)

Keep NServiceBus if you:

  • Require advanced enterprise patterns (delayed retries, circuit breakers)
  • Need comprehensive monitoring and ServiceControl/ServicePulse
  • Have complex distributed transactions
  • Rely on Particular Platform ecosystem

Key Differences​

FeatureNServiceBusExcalibur
LicenseCommercial (per endpoint)Open source (free)
Message TransportRabbitMQ, Azure Service Bus, MSMQ, SQLDatabase (SQL Server) + optional broker
OutboxRequires persistence configurationBuilt-in, first-class support
SagasFull-featured with timeoutsSimple process managers
Event SourcingNot includedBuilt-in with AggregateRoot
RetriesFirst-level + second-level retriesSimple retry via middleware
MonitoringServiceControl + ServicePulseStandard observability (metrics, logs, traces)
ComplexityHigh (enterprise-grade)Low (focused on core patterns)

Side-by-Side Comparison​

Message Handlers​

NServiceBus:

// Message
public class PlaceOrder : ICommand
{
public string OrderId { get; set; }
public string CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
}

// Handler
public class PlaceOrderHandler : IHandleMessages<PlaceOrder>
{
private readonly IOrderRepository _repository;

public async Task Handle(
PlaceOrder message,
IMessageHandlerContext context)
{
// Create order
var order = new Order(message.OrderId, message.CustomerId);

await _repository.SaveAsync(order);

// Publish event
await context.Publish(new OrderPlaced
{
OrderId = message.OrderId,
CustomerId = message.CustomerId
});
}
}

Excalibur.Dispatch:

// Action
public record PlaceOrderAction(
string OrderId,
string CustomerId,
List<OrderItem> Items) : IDispatchAction;

// Handler
public class PlaceOrderHandler : IActionHandler<PlaceOrderAction>
{
private readonly IEventSourcedRepository<Order> _repository;

public async Task HandleAsync(
PlaceOrderAction action,
CancellationToken cancellationToken)
{
// Create order (raises OrderPlacedEvent internally)
var order = Order.Create(action.OrderId, action.CustomerId);

// Save aggregate (events published via outbox automatically)
await _repository.SaveAsync(order, cancellationToken);
}
}

// Aggregate
public class Order : AggregateRoot
{
public static Order Create(string orderId, string customerId)
{
var order = new Order { Id = orderId };
order.RaiseEvent(new OrderPlacedEvent(orderId, customerId));
return order;
}
}

Key Differences:

  • NServiceBus: Explicit context.Publish() for events
  • Dispatch: Events raised in aggregate, published via outbox automatically
  • NServiceBus: ICommand marker interface
  • Dispatch: IDispatchAction or IDispatchAction<TResult> with optional return value

Event Handlers​

NServiceBus:

// Event
public class OrderPlaced : IEvent
{
public string OrderId { get; set; }
public string CustomerId { get; set; }
public decimal TotalValue { get; set; }
}

// Handler
public class OrderPlacedHandler : IHandleMessages<OrderPlaced>
{
private readonly IEmailService _emailService;

public async Task Handle(
OrderPlaced message,
IMessageHandlerContext context)
{
await _emailService.SendOrderConfirmationAsync(
message.CustomerId,
message.OrderId);
}
}

Excalibur.Dispatch:

// Domain Event
[MessageName("Contoso.Orders.OrderPlaced")]
public record OrderPlacedEvent(
string OrderId,
string CustomerId,
decimal TotalValue) : IDomainEvent
{
public string EventId { get; init; } = Guid.NewGuid().ToString();
public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
public IDictionary<string, object>? Metadata { get; init; }
}

// Handler
public class OrderPlacedEventHandler : IEventHandler<OrderPlacedEvent>
{
private readonly IEmailService _emailService;

public async Task HandleAsync(
OrderPlacedEvent @event,
CancellationToken cancellationToken)
{
await _emailService.SendOrderConfirmationAsync(
@event.CustomerId,
@event.OrderId);
}
}

Key Differences:

  • NServiceBus: Simple POCO events
  • Dispatch: Rich IDomainEvent interface with metadata and a declared stable message name
  • NServiceBus: IMessageHandlerContext for sending/publishing
  • Dispatch: Direct dependency injection

Sagas​

NServiceBus:

// Saga
public class OrderProcessingSaga : Saga<OrderProcessingSagaData>,
IAmStartedByMessages<OrderPlaced>,
IHandleMessages<PaymentReceived>,
IHandleMessages<OrderShipped>
{
protected override void ConfigureHowToFindSaga(
SagaPropertyMapper<OrderProcessingSagaData> mapper)
{
mapper.MapSaga(saga => saga.OrderId)
.ToMessage<OrderPlaced>(msg => msg.OrderId)
.ToMessage<PaymentReceived>(msg => msg.OrderId)
.ToMessage<OrderShipped>(msg => msg.OrderId);
}

public async Task Handle(
OrderPlaced message,
IMessageHandlerContext context)
{
Data.OrderId = message.OrderId;
Data.CustomerId = message.CustomerId;

// Request payment
await context.Send(new ProcessPayment
{
OrderId = message.OrderId,
Amount = message.TotalValue
});
}

public async Task Handle(
PaymentReceived message,
IMessageHandlerContext context)
{
Data.PaymentReceived = true;

// Request shipping
await context.Send(new ShipOrder
{
OrderId = message.OrderId
});
}

public async Task Handle(
OrderShipped message,
IMessageHandlerContext context)
{
Data.OrderShipped = true;
MarkAsComplete();
}
}

// Saga data
public class OrderProcessingSagaData : ContainSagaData
{
public string OrderId { get; set; }
public string CustomerId { get; set; }
public bool PaymentReceived { get; set; }
public bool OrderShipped { get; set; }
}

Excalibur.Dispatch:

// Process Manager (state machine)
public class OrderProcessManager : ProcessManager<OrderProcessData>
{
public OrderProcessManager(
OrderProcessData data,
IDispatcher dispatcher,
ILogger<OrderProcessManager> logger)
: base(data, dispatcher, logger)
{
Initially(s => s
.When<OrderPlacedEvent>(h => h
.TransitionTo("PaymentPending")
.Then(ctx =>
{
ctx.Data.OrderId = ctx.Message.OrderId;
ctx.Data.CustomerId = ctx.Message.CustomerId;
})));

During("PaymentPending", s => s
.When<PaymentReceivedEvent>(h => h
.TransitionTo("Shipping")));

During("Shipping", s => s
.When<OrderShippedEvent>(h => h
.Complete()));
}
}

public class OrderProcessData : SagaState
{
public string OrderId { get; set; } = string.Empty;
public string CustomerId { get; set; } = string.Empty;
public string CurrentStateName { get; set; } = "Initial";
}

Key Differences:

  • NServiceBus: Saga<T> with ContainSagaData
  • Dispatch: ProcessManager<TData> with SagaState data class
  • NServiceBus: ConfigureHowToFindSaga() for correlation
  • Dispatch: Automatic correlation via event AggregateId
  • NServiceBus: Saga timeouts supported
  • Dispatch: No built-in timeouts (use Hangfire)

Migration Strategies​

Run both frameworks side-by-side, migrating endpoint by endpoint:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Keep NServiceBus for external messaging
var endpointConfiguration = new EndpointConfiguration("MyService");
endpointConfiguration.UseTransport<RabbitMQTransport>();
endpointConfiguration.EnableInstallers();

var endpointInstance = await Endpoint.Start(endpointConfiguration);
builder.Services.AddSingleton<IMessageSession>(endpointInstance);

// Add Dispatch for domain logic
builder.Services.AddDispatch(typeof(Program).Assembly);
builder.Services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString(connectionString))
.WithProcessing(p => p.PollingInterval(TimeSpan.FromSeconds(5)));
}));

var app = builder.Build();

Bridge NServiceBus to Dispatch:

// NServiceBus handler delegates to Dispatch
public class OrderPlacedHandler : IHandleMessages<OrderPlaced>
{
private readonly IDispatcher _dispatcher;

public async Task Handle(
OrderPlaced message,
IMessageHandlerContext context)
{
// Convert NServiceBus message to Dispatch event
var @event = new OrderPlacedEvent(
message.OrderId,
message.CustomerId,
message.TotalValue);

// Process via Dispatch
await _dispatcher.DispatchAsync(@event, CancellationToken.None);
}
}

Strategy 2: Replace Transport​

Keep NServiceBus contract conventions, replace transport with Dispatch outbox:

// Bridge: NServiceBus handler delegates to Dispatch internally
public class NServiceBusBridgeHandler : IHandleMessages<OrderPlaced>
{
private readonly IDispatcher _dispatcher;

public async Task Handle(OrderPlaced message, IMessageHandlerContext context)
{
// Convert NServiceBus message to Dispatch event
var @event = new OrderPlacedEvent(message.OrderId, message.CustomerId);
await _dispatcher.DispatchAsync(@event, context.CancellationToken);
}
}

// Use Dispatch outbox for reliable local messaging
builder.Services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString(connectionString))
.WithProcessing(p => p.PollingInterval(TimeSpan.FromSeconds(5)));
}));

Benefits:

  • Keep existing NServiceBus infrastructure
  • Use Dispatch for domain logic and outbox
  • Gradual internal migration

Strategy 3: Full Replacement​

Remove NServiceBus entirely, replace with Dispatch + external broker:

// Dispatch with outbox and transport integration
builder.Services.AddDispatch(typeof(Program).Assembly);
builder.Services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString(connectionString))
.WithProcessing(p => p.PollingInterval(TimeSpan.FromSeconds(5)));
}));
builder.Services.AddOutboxHostedService();

// For broker-backed transport, add the appropriate transport package
builder.Services.AddRabbitMqTransport(options =>
{
options.HostName = "localhost";
});

Step-by-Step Migration​

Step 1: Install Packages​

# Optional: Keep NServiceBus for gradual migration
# dotnet add package NServiceBus

dotnet add package Excalibur.Dispatch
dotnet add package Excalibur.EventSourcing
dotnet add package Excalibur.EventSourcing.SqlServer

Step 2: Update Configuration​

Before (NServiceBus):

var endpointConfiguration = new EndpointConfiguration("MyService");

var transport = endpointConfiguration.UseTransport<RabbitMQTransport>();
transport.ConnectionString("host=localhost");
transport.UseConventionalRoutingTopology();

var persistence = endpointConfiguration.UsePersistence<SqlPersistence>();
persistence.SqlDialect<SqlDialect.MsSqlServer>();
persistence.ConnectionBuilder(() => new SqlConnection(connectionString));

endpointConfiguration.EnableOutbox();
endpointConfiguration.EnableInstallers();

var endpointInstance = await Endpoint.Start(endpointConfiguration);

After (Dispatch):

builder.Services.AddDispatch(typeof(Program).Assembly);
builder.Services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString(connectionString))
.WithProcessing(p => p.PollingInterval(TimeSpan.FromSeconds(5)));
}));
builder.Services.AddOutboxHostedService();

Step 3: Migrate Commands​

Before:

public class PlaceOrder : ICommand
{
public string OrderId { get; set; }
public string CustomerId { get; set; }
}

public class PlaceOrderHandler : IHandleMessages<PlaceOrder>
{
public async Task Handle(PlaceOrder message, IMessageHandlerContext context)
{
// Handler logic
}
}

After:

public record PlaceOrderAction(string OrderId, string CustomerId)
: IDispatchAction;

public class PlaceOrderHandler : IActionHandler<PlaceOrderAction>
{
public async Task HandleAsync(
PlaceOrderAction action,
CancellationToken cancellationToken)
{
// Handler logic (same)
}
}

Step 4: Migrate Events​

Before:

public class OrderPlaced : IEvent
{
public string OrderId { get; set; }
public string CustomerId { get; set; }
}

// Publishing
await context.Publish(new OrderPlaced
{
OrderId = orderId,
CustomerId = customerId
});

After:

[MessageName("Contoso.Orders.OrderPlaced")]
public record OrderPlacedEvent(string OrderId, string CustomerId)
: IDomainEvent
{
public string EventId { get; init; } = Guid.NewGuid().ToString();
public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
public IDictionary<string, object>? Metadata { get; init; }
}

// Publishing (via aggregate)
var order = Order.Create(orderId, customerId);
order.RaiseEvent(new OrderPlacedEvent(orderId, customerId));
await _repository.SaveAsync(order, cancellationToken);

Step 5: Migrate Sagas​

Before:

public class OrderProcessingSaga : Saga<OrderProcessingSagaData>,
IAmStartedByMessages<OrderPlaced>,
IHandleMessages<PaymentReceived>
{
protected override void ConfigureHowToFindSaga(
SagaPropertyMapper<OrderProcessingSagaData> mapper)
{
mapper.MapSaga(saga => saga.OrderId)
.ToMessage<OrderPlaced>(msg => msg.OrderId)
.ToMessage<PaymentReceived>(msg => msg.OrderId);
}

public async Task Handle(OrderPlaced message, IMessageHandlerContext context)
{
Data.OrderId = message.OrderId;
await context.Send(new ProcessPayment { OrderId = message.OrderId });
}

public async Task Handle(PaymentReceived message, IMessageHandlerContext context)
{
MarkAsComplete();
}
}

After:

public class OrderProcessManager : ProcessManager<OrderProcessData>
{
public OrderProcessManager(
OrderProcessData data,
IDispatcher dispatcher,
ILogger<OrderProcessManager> logger)
: base(data, dispatcher, logger)
{
Initially(s => s
.When<OrderPlacedEvent>(h => h
.TransitionTo("PaymentPending")
.Then(ctx => ctx.Data.OrderId = ctx.Message.OrderId)));

During("PaymentPending", s => s
.When<PaymentReceivedEvent>(h => h
.Complete()));
}
}

Step 6: Migrate Outbox​

NServiceBus outbox is automatically replaced by Dispatch outbox when using event sourcing.

Before (NServiceBus):

endpointConfiguration.EnableOutbox();

var persistence = endpointConfiguration.UsePersistence<SqlPersistence>();
persistence.ConnectionBuilder(() => new SqlConnection(connectionString));

After (Dispatch):

builder.Services.AddDispatch(typeof(Program).Assembly);
builder.Services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString(connectionString));
}));

Feature Mapping​

Retries​

NServiceBus:

endpointConfiguration.Recoverability().Immediate(
immediate => immediate.NumberOfRetries(3));

endpointConfiguration.Recoverability().Delayed(
delayed =>
{
delayed.NumberOfRetries(5);
delayed.TimeIncrease(TimeSpan.FromSeconds(10));
});

Dispatch:

// Simple retry middleware
public class RetryMiddleware : IDispatchMiddleware
{
private const int MaxRetries = 3;

public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.ErrorHandling;

public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate nextDelegate,
CancellationToken cancellationToken)
{
for (int attempt = 0; attempt <= MaxRetries; attempt++)
{
try
{
return await nextDelegate(message, context, cancellationToken);
}
catch (Exception) when (attempt < MaxRetries)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
// Retry
}
}

throw; // Final attempt failed
}
}

Delayed Messages​

NServiceBus:

var sendOptions = new SendOptions();
sendOptions.DelayDeliveryWith(TimeSpan.FromHours(24));
await context.Send(new SendReminderEmail(), sendOptions);

Dispatch:

// Use Hangfire for scheduling
BackgroundJob.Schedule<IDispatcher>(
dispatcher => dispatcher.DispatchAsync(
new SendReminderEmailCommand(),
CancellationToken.None),
TimeSpan.FromHours(24));

Message Routing​

NServiceBus:

var routing = transport.Routing();
routing.RouteToEndpoint(
typeof(PlaceOrder),
"Sales");

Dispatch:

// Routing is handled via handler registration — each message type
// is automatically routed to its registered handler(s)
builder.Services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});

// For external transport routing, use transport-specific configuration
// (e.g., topic-based routing in RabbitMQ or Azure Service Bus)

Common Migration Issues​

Issue 1: Missing Saga Timeouts​

Problem: Dispatch doesn't support saga timeouts natively.

Solution: Use Hangfire for delayed commands:

// In your startup, schedule the timeout using Hangfire alongside the process manager
// The process manager handles the timeout command like any other message:

public class OrderProcessManager : ProcessManager<OrderProcessData>
{
public OrderProcessManager(
OrderProcessData data,
IDispatcher dispatcher,
ILogger<OrderProcessManager> logger)
: base(data, dispatcher, logger)
{
Initially(s => s
.When<OrderPlacedEvent>(h => h
.TransitionTo("AwaitingPayment")
.Then(ctx => ctx.Data.OrderId = ctx.Message.OrderId)));
}
}

// Schedule timeout externally via Hangfire
BackgroundJob.Schedule<IDispatcher>(
dispatcher => dispatcher.DispatchAsync(
new OrderTimeoutCommand(orderId),
CancellationToken.None),
TimeSpan.FromHours(24));

Issue 2: No ServiceControl​

Problem: Dispatch doesn't include ServiceControl/ServicePulse equivalent.

Solution: Use standard observability:

// Application Insights
builder.Services.AddApplicationInsightsTelemetry();

// Or Prometheus + Grafana
builder.Services.AddPrometheusMetrics();

// Custom metrics middleware
public class MetricsMiddleware : IDispatchMiddleware
{
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.PreProcessing;

public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate nextDelegate,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();

try
{
var result = await nextDelegate(message, context, cancellationToken);
Metrics.RecordSuccess(message.GetType().Name, stopwatch.Elapsed);
return result;
}
catch (Exception)
{
Metrics.RecordFailure(message.GetType().Name, stopwatch.Elapsed);
throw;
}
}
}

Issue 3: Different Error Handling​

Problem: NServiceBus has sophisticated error handling with error queues.

Solution: Implement custom error handling:

public class ErrorHandlingMiddleware : IDispatchMiddleware
{
private readonly IErrorQueue _errorQueue;

public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.ErrorHandling;

public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate nextDelegate,
CancellationToken cancellationToken)
{
try
{
return await nextDelegate(message, context, cancellationToken);
}
catch (Exception ex)
{
// Log to error queue/table
await _errorQueue.WriteAsync(new ErrorMessage
{
MessageType = typeof(TRequest).Name,
Payload = JsonSerializer.Serialize(request),
Exception = ex.ToString(),
OccurredAt = DateTimeOffset.UtcNow
});

throw;
}
}
}

Testing Migration​

Unit Tests​

Before:

[Fact]
public async Task Handle_ShouldPlaceOrder()
{
var handler = new PlaceOrderHandler(_repository);
var context = new TestableMessageHandlerContext();

await handler.Handle(
new PlaceOrder { OrderId = "123" },
context);

Assert.Single(context.PublishedMessages);
}

After:

[Fact]
public async Task HandleAsync_ShouldPlaceOrder()
{
var handler = new PlaceOrderCommandHandler(_repository);

await handler.HandleAsync(
new PlaceOrderCommand("123", "customer-1"),
CancellationToken.None);

// Verify via repository
}

Integration Tests​

Before:

var endpointConfiguration = new EndpointConfiguration("TestEndpoint");
endpointConfiguration.UseTransport<LearningTransport>();
var endpoint = await Endpoint.Start(endpointConfiguration);

await endpoint.Send(new PlaceOrder { OrderId = "123" });

After:

var dispatcher = serviceProvider.GetRequiredService<IDispatcher>();

await dispatcher.DispatchAsync(
new PlaceOrderCommand("123", "customer-1"),
CancellationToken.None);

Migration Checklist​

  • Evaluate licensing cost savings vs. feature trade-offs
  • Install Dispatch packages
  • Configure outbox with SQL Server
  • Migrate commands: ICommand → IDispatchAction or IDispatchAction<T>
  • Migrate handlers: IHandleMessages<T> → IActionHandler<T> or IActionHandler<T, TResult>
  • Update method signatures: Handle() → HandleAsync()
  • Migrate events: IEvent → IDomainEvent (each with a [MessageName])
  • Migrate sagas: Saga<T> → ProcessManager<TData>
  • Replace saga timeouts with Hangfire
  • Implement custom retry logic
  • Set up observability (Application Insights/Prometheus)
  • Migrate error handling
  • Update unit tests
  • Update integration tests
  • Remove NServiceBus packages (if fully migrated)

Performance Comparison​

MetricNServiceBus (RabbitMQ)Excalibur (SQL Outbox)
Throughput~8,000 msg/sec~2,000 msg/sec
Latency (p99)~10ms~15ms
Infrastructure costHigh (broker + licensing)Low (database only)
Delivery guaranteeAt-least-onceExactly-once (outbox)

Conclusion: NServiceBus offers higher performance but at higher cost. Dispatch provides strong consistency guarantees with simpler infrastructure.

Getting Help​

Next Steps​

  1. Review Event Sourcing
  2. Learn Outbox Pattern
  3. Implement Sagas
  4. Set up Monitoring
  5. Review Deployment

See Also​