Skip to main content

Migrating from MassTransit

A comprehensive guide for migrating from MassTransit to Excalibur.Dispatch, covering message bus patterns, sagas, outbox, and distributed messaging.

Before You Start​

Overview​

While MassTransit is a powerful distributed application framework, Excalibur offers a simpler, more focused approach for teams that need messaging, event sourcing, and DDD patterns without the complexity of full-featured service bus infrastructure.

Consumer compatibility shim​

For simple message consumers, the Excalibur.Dispatch.Compat.MassTransit package provides a source-compatible IConsumer<TMessage> shape so consumer classes compile after a namespace swap, then bridge onto Excalibur.Dispatch.

Migration path, not a transport port

This shim covers the deterministic "consume the message" case only. Advanced ConsumeContext<TMessage> capabilities (Respond / Publish / Send / Redeliver) are not provided and require a manual migration step onto the canonical Dispatch/Outbox model.

dotnet add package Excalibur.Dispatch.Compat.MassTransit
using Excalibur.Dispatch.Compat.MassTransit;

public class OrderCreatedConsumer : IConsumer<OrderCreated>
{
public Task Consume(ConsumeContext<OrderCreated> context)
{
var message = context.Message;
// handle the message
return Task.CompletedTask;
}
}

The consumed message type must be annotated with IDispatchEvent (the documented manual step) so it is routable through the dispatch pipeline. Register the migrated consumer with the reflection-free, AOT-safe entry point:

builder.Services.AddMassTransitConsumer<OrderCreatedConsumer, OrderCreated>();

This registers the consumer (scoped) and bridges its Consume(...) onto an IEventHandler<TMessage> so dispatched/published events of that type reach it.

When to Migrate​

Consider Excalibur if you:

  • Want to reduce infrastructure complexity (no RabbitMQ/Azure Service Bus required)
  • Need built-in event sourcing and outbox patterns
  • Prefer direct database-backed messaging over message brokers
  • Want simpler saga/process manager patterns
  • Need lower operational overhead

Keep MassTransit if you:

  • Heavily use distributed message brokers (RabbitMQ, Azure Service Bus)
  • Need complex routing and topology
  • Require publish/subscribe across multiple services

Key Differences​

FeatureMassTransitExcalibur
Message TransportRabbitMQ, Azure Service Bus, Amazon SQSDatabase (SQL Server) + optional message bus
OutboxOptional (via Entity Framework)Built-in, first-class support
SagasAutomatonymous state machinesSimple process managers
Event SourcingNot includedBuilt-in with AggregateRoot
Pub/SubNative broker supportOutbox + external publisher
Request/ResponseVia message brokerDirect dispatcher pattern
ComplexityHigh (full-featured service bus)Low (focused on core patterns)

Side-by-Side Comparison​

Publishing Messages​

MassTransit:

// Message contract
public record OrderCreated
{
public string OrderId { get; init; }
public string CustomerId { get; init; }
public decimal TotalValue { get; init; }
}

// Publisher
public class OrderService
{
private readonly IPublishEndpoint _publishEndpoint;

public async Task CreateOrderAsync(CreateOrderRequest request)
{
// Create order
var orderId = Guid.NewGuid().ToString();

// Publish event via message broker
await _publishEndpoint.Publish(new OrderCreated
{
OrderId = orderId,
CustomerId = request.CustomerId,
TotalValue = request.TotalValue
});
}
}

Excalibur.Dispatch:

// Domain event
[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreatedEvent(
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; }
}

// Publisher (via event sourcing)
public class CreateOrderHandler : IActionHandler<CreateOrderAction>
{
private readonly IEventSourcedRepository<Order> _repository;

public async Task HandleAsync(
CreateOrderAction action,
CancellationToken cancellationToken)
{
var order = Order.Create(action.OrderId, action.CustomerId);

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

Key Differences:

  • MassTransit: Immediate broker publish (at-least-once via broker)
  • Dispatch: Transactional outbox — the message is staged in the same database transaction as your state change, so it cannot be lost if the broker is unreachable. Delivery is still at-least-once: a dispatcher crash or a retry can deliver a message more than once, so consumers must be idempotent. Pair it with the Inbox pattern for consumer-side deduplication.

Consuming Messages​

MassTransit:

// Consumer
public class OrderCreatedConsumer : IConsumer<OrderCreated>
{
private readonly ILogger<OrderCreatedConsumer> _logger;

public async Task Consume(ConsumeContext<OrderCreated> context)
{
var message = context.Message;

_logger.LogInformation(
"Order {OrderId} created for customer {CustomerId}",
message.OrderId,
message.CustomerId);

// Process message
}
}

// Registration
cfg.ReceiveEndpoint("order-created", e =>
{
e.ConfigureConsumer<OrderCreatedConsumer>(context);
});

Excalibur.Dispatch:

// Event handler
public class OrderCreatedEventHandler : IEventHandler<OrderCreatedEvent>
{
private readonly ILogger<OrderCreatedEventHandler> _logger;

public async Task HandleAsync(
OrderCreatedEvent @event,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Order {OrderId} created for customer {CustomerId}",
@event.OrderId,
@event.CustomerId);

// Process event
}
}

// Registration (automatic assembly scanning)
builder.Services.AddDispatch(typeof(Program).Assembly);

Key Differences:

  • MassTransit: IConsumer<T> with ConsumeContext<T>
  • Dispatch: IEventHandler<T> with direct event access
  • MassTransit: Queue configuration required
  • Dispatch: Automatic handler discovery

Sagas / Process Managers​

MassTransit (Automatonymous):

// State machine
public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
public OrderStateMachine()
{
InstanceState(x => x.CurrentState);

Event(() => OrderSubmitted);
Event(() => PaymentProcessed);
Event(() => OrderShipped);

Initially(
When(OrderSubmitted)
.Then(context => context.Instance.OrderId = context.Data.OrderId)
.TransitionTo(Submitted));

During(Submitted,
When(PaymentProcessed)
.TransitionTo(Paid),
When(PaymentFailed)
.TransitionTo(Cancelled));

During(Paid,
When(OrderShipped)
.TransitionTo(Shipped)
.Finalize());
}

public State Submitted { get; private set; }
public State Paid { get; private set; }
public State Shipped { get; private set; }
public State Cancelled { get; private set; }

public Event<OrderSubmittedEvent> OrderSubmitted { get; private set; }
public Event<PaymentProcessedEvent> PaymentProcessed { get; private set; }
public Event<OrderShippedEvent> OrderShipped { get; private set; }
}

public class OrderState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; }
public string OrderId { 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<OrderSubmittedEvent>(h => h
.TransitionTo("PaymentPending")
.Then(ctx => ctx.Data.OrderId = ctx.Message.OrderId)));

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

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

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

Key Differences:

  • MassTransit: Declarative state machine (Automatonymous) with State properties
  • Dispatch: Declarative state machine via ProcessManager<TData> with Initially(), During(), Finally() fluent API
  • MassTransit: CorrelationId-based saga correlation
  • Dispatch: Automatic correlation via event AggregateId

Migration Strategies​

Keep MassTransit for inter-service communication, use Dispatch for domain logic:

// MassTransit consumer bridges to Dispatch
public class OrderCreatedConsumer : IConsumer<OrderCreated>
{
private readonly IDispatcher _dispatcher;

public async Task Consume(ConsumeContext<OrderCreated> context)
{
var message = context.Message;

// Convert MassTransit message to Dispatch command
var command = new ProcessOrderCommand(
message.OrderId,
message.CustomerId,
message.TotalValue);

// Process via Dispatch
await _dispatcher.DispatchAsync(command, context.CancellationToken);
}
}

// Dispatch handler processes domain logic
public class ProcessOrderHandler
: IActionHandler<ProcessOrderAction>
{
private readonly IEventSourcedRepository<Order> _repository;

public async Task HandleAsync(
ProcessOrderAction action,
CancellationToken cancellationToken)
{
// Domain logic with event sourcing
var order = await _repository.GetByIdAsync(
action.OrderId,
cancellationToken);

order.Process();

await _repository.SaveAsync(order, cancellationToken);
}
}

Benefits:

  • Leverage MassTransit's distributed messaging
  • Use Dispatch for domain logic and event sourcing
  • Best of both worlds

Strategy 2: Replace with Outbox + External Publisher​

Remove MassTransit broker, use Dispatch outbox with custom publisher:

// Use Dispatch outbox with external transport
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)));
}));

// For external broker integration, use the transport layer
// (RabbitMQ, Azure Service Bus, etc.) via transport packages
builder.Services.AddAzureServiceBusTransport(options =>
{
options.ConnectionString = serviceBusConnectionString;
});

Benefits:

  • Remove MassTransit dependency
  • Keep existing message broker (Azure Service Bus, RabbitMQ)
  • Simpler configuration

Strategy 3: Database-Only Messaging​

Remove message broker entirely, use database for messaging:

// Database-backed messaging via outbox
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)));
}));

// Background service processes outbox
builder.Services.AddOutboxHostedService();

Benefits:

  • Simplest deployment (no broker infrastructure)
  • Transactional consistency via database
  • Good for monoliths or simple distributed systems

Step-by-Step Migration​

Step 1: Install Packages​

dotnet remove package MassTransit
dotnet remove package MassTransit.RabbitMQ # or Azure.ServiceBus, etc.

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

Step 2: Update Configuration​

Before (MassTransit):

builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderCreatedConsumer>();

x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost", "/", h =>
{
h.Username("guest");
h.Password("guest");
});

cfg.ReceiveEndpoint("order-created", e =>
{
e.ConfigureConsumer<OrderCreatedConsumer>(context);
});
});
});

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 Message Contracts​

Before:

public record OrderCreated
{
public string OrderId { get; init; }
public decimal TotalValue { get; init; }
}

After:

[MessageName("Contoso.Orders.OrderCreated")]
public record OrderCreatedEvent(
string OrderId,
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; }
}

Step 4: Migrate Consumers​

Before:

public class OrderCreatedConsumer : IConsumer<OrderCreated>
{
public async Task Consume(ConsumeContext<OrderCreated> context)
{
var message = context.Message;
// Process message
}
}

After:

public class OrderCreatedEventHandler : IEventHandler<OrderCreatedEvent>
{
public async Task HandleAsync(
OrderCreatedEvent @event,
CancellationToken cancellationToken)
{
// Process event (same logic)
}
}

Step 5: Migrate Publishers​

Before:

public class OrderService
{
private readonly IPublishEndpoint _publishEndpoint;

public async Task CreateOrderAsync(CreateOrderRequest request)
{
// Create order
await _publishEndpoint.Publish(new OrderCreated
{
OrderId = orderId,
TotalValue = totalValue
});
}
}

After:

public class CreateOrderHandler : IActionHandler<CreateOrderAction>
{
private readonly IEventSourcedRepository<Order> _repository;

public async Task HandleAsync(
CreateOrderAction action,
CancellationToken cancellationToken)
{
var order = Order.Create(action.OrderId, action.CustomerId);

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

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

Step 6: Migrate Sagas​

Before (MassTransit State Machine):

public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
// Complex state machine definition
}

After (Dispatch Process Manager):

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

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

Feature Mapping​

Request/Response​

MassTransit:

// Request
public record GetOrderStatus
{
public string OrderId { get; init; }
}

// Response
public record OrderStatusResponse
{
public string Status { get; init; }
}

// Client
var client = bus.CreateRequestClient<GetOrderStatus>();
var response = await client.GetResponse<OrderStatusResponse>(
new GetOrderStatus { OrderId = orderId });

Dispatch:

// Action with return value
public record GetOrderStatusAction(string OrderId)
: IDispatchAction<OrderStatusResult>;

// Result
public record OrderStatusResult(string Status);

// Handler
public class GetOrderStatusHandler
: IActionHandler<GetOrderStatusAction, OrderStatusResult>
{
public async Task<OrderStatusResult> HandleAsync(
GetOrderStatusAction action,
CancellationToken cancellationToken)
{
// Get status
return new OrderStatusResult(status);
}
}

// Usage
var result = await _dispatcher.DispatchAsync<GetOrderStatusAction, OrderStatusResult>(
new GetOrderStatusAction(orderId),
cancellationToken);

Publish/Subscribe​

MassTransit:

// Publisher
await _publishEndpoint.Publish(new OrderCreated { OrderId = orderId });

// Subscriber
public class OrderCreatedConsumer : IConsumer<OrderCreated>
{
public async Task Consume(ConsumeContext<OrderCreated> context) { }
}

Dispatch:

// Publisher (via aggregate)
var order = Order.Create(orderId);
await _repository.SaveAsync(order, cancellationToken);
// OrderCreatedEvent published via outbox

// Subscriber
public class OrderCreatedEventHandler : IEventHandler<OrderCreatedEvent>
{
public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken ct) { }
}

Scheduling​

MassTransit:

await context.ScheduleSend(
TimeSpan.FromHours(24),
new SendOrderReminderEmail { OrderId = orderId });

Dispatch:

// Use Hangfire or similar for scheduling
BackgroundJob.Schedule<SendOrderReminderEmailHandler>(
x => x.SendAsync(orderId),
TimeSpan.FromHours(24));

Note: Dispatch doesn't include built-in scheduling. Use external job scheduler (Hangfire, Quartz.NET).

Common Migration Issues​

Issue 1: No Message Broker​

Problem: Dispatch uses database-backed messaging by default.

Solution: Use the transport layer for external broker integration:

// Use RabbitMQ transport package for broker-backed messaging
builder.Services.AddRabbitMqTransport(options =>
{
options.HostName = "localhost";
options.UserName = "guest";
options.Password = "guest";
});

// Or use the outbox with transport integration
builder.Services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString(connectionString))
.WithProcessing(p => p.PollingInterval(TimeSpan.FromSeconds(5)));
}));

Issue 2: Complex Routing​

Problem: MassTransit supports complex routing and topology.

Solution: Dispatch uses event handlers for routing. Register handlers for different event types:

// Each event type is routed to its registered handlers automatically
builder.Services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});

// Handler for order events
public class OrderEventHandler : IEventHandler<OrderCreatedEvent>
{
public async Task HandleAsync(
OrderCreatedEvent @event, CancellationToken cancellationToken)
{
// Route to orders service
}
}

// Handler for payment events
public class PaymentEventHandler : IEventHandler<PaymentProcessedEvent>
{
public async Task HandleAsync(
PaymentProcessedEvent @event, CancellationToken cancellationToken)
{
// Route to payments service
}
}

Issue 3: Distributed Transactions​

Problem: MassTransit supports distributed transactions via broker.

Solution: Use outbox pattern for local consistency. For idempotent handling, track processed event IDs in your own store:

public class IdempotentEventHandler : IEventHandler<OrderCreatedEvent>
{
private readonly IProcessedEventTracker _tracker;

public async Task HandleAsync(
OrderCreatedEvent @event,
CancellationToken cancellationToken)
{
// Check if already processed (implement using your data store)
if (await _tracker.HasBeenProcessedAsync(@event.EventId, cancellationToken))
{
return; // Idempotent - skip duplicate
}

// Process event
// ...

// Mark as processed
await _tracker.MarkAsProcessedAsync(@event.EventId, cancellationToken);
}
}
// Note: IProcessedEventTracker is your own interface — implement using
// your database to track processed event IDs for idempotency.

Issue 4: Missing Scheduling​

Problem: Dispatch doesn't include message scheduling.

Solution: Use Hangfire for background jobs:

dotnet add package Hangfire
dotnet add package Hangfire.SqlServer
builder.Services.AddHangfire(config =>
config.UseSqlServerStorage(connectionString));

builder.Services.AddHangfireServer();

// Schedule messages
BackgroundJob.Schedule<IDispatcher>(
dispatcher => dispatcher.DispatchAsync(
new SendReminderCommand(orderId),
CancellationToken.None),
TimeSpan.FromHours(24));

Testing Migration​

Unit Tests​

Before (MassTransit Consumer):

[Fact]
public async Task Consume_ShouldProcessOrder()
{
// Arrange
var consumer = new OrderCreatedConsumer(_repository);
var context = Mock.Of<ConsumeContext<OrderCreated>>(c =>
c.Message == new OrderCreated { OrderId = "123" });

// Act
await consumer.Consume(context);

// Assert
// Verify
}

After (Dispatch Event Handler):

[Fact]
public async Task HandleAsync_ShouldProcessOrder()
{
// Arrange
var handler = new OrderCreatedEventHandler(_repository);
var @event = new OrderCreatedEvent("123", "customer-1");

// Act
await handler.HandleAsync(@event, CancellationToken.None);

// Assert
// Verify
}

Integration Tests​

Before:

var harness = new InMemoryTestHarness();
var consumerHarness = harness.Consumer<OrderCreatedConsumer>();

await harness.Start();
try
{
await harness.InputQueueSendEndpoint.Send(new OrderCreated { OrderId = "123" });

Assert.True(await harness.Consumed.Any<OrderCreated>());
Assert.True(await consumerHarness.Consumed.Any<OrderCreated>());
}
finally
{
await harness.Stop();
}

After:

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

var @event = new OrderCreatedEvent("123", "customer-1");
await dispatcher.DispatchAsync(@event, CancellationToken.None);

// Verify handler processed event

Migration Checklist​

  • Evaluate whether to keep MassTransit for inter-service messaging
  • Install Dispatch packages
  • Configure outbox with SQL Server
  • Migrate message contracts to IDomainEvent, declaring a [MessageName] on each
  • Migrate consumers to IEventHandler<T>
  • Migrate publishers to event-sourced aggregates
  • Migrate sagas to process managers
  • Implement custom IOutboxPublisher if needed
  • Replace MassTransit scheduling with Hangfire
  • Update unit tests
  • Update integration tests
  • Remove MassTransit packages (if fully migrated)

Performance Comparison​

MetricMassTransit (RabbitMQ)Excalibur (SQL Outbox)
Publish latency~2-5ms~3-7ms (includes DB write)
Throughput~10,000 msg/sec~2,000 msg/sec (depends on DB)
Message orderingPer-queuePer-aggregate (event sourcing)
Delivery guaranteeAt-least-onceExactly-once (via outbox)
InfrastructureRequires brokerDatabase only

Conclusion: MassTransit has higher throughput for high-volume scenarios, but Dispatch provides stronger consistency guarantees and simpler infrastructure.

Getting Help​

Next Steps​

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

See Also​