Migration Guide v5
This guide covers the upgrade from v4.4.0 to v5.0. New projects should start on Migration Guide v6 (current v6 schemas, nested module builders, AcceptAsync / EnqueueAsync). This page remains for teams stepping through historical v4 to v5 upgrades only.
Breaking Changes
| Area | v4.4 behavior | v5 behavior |
|---|---|---|
| Deferred commands | Commands marked with [StoreInInbox] were diverted by ICommandMediator.SendAsync. | SendAsync always executes in process. Use ICommandScheduler.ScheduleAsync for deferred execution. |
| Command inbox API | StoreInInboxAttribute, ICommandInbox, ICommandInboxBatch, ICommandInboxProcessor, and CommandBatchHandler lived under command abstractions. | Those APIs are removed. Use LiteBus.Inbox.Abstractions and LiteBus.Inbox. |
| Command inbox hosting | LiteBus.Commands.Extensions.Microsoft.Hosting hosted the old inbox processor. | The package is removed. Run ICommandInboxProcessor.ProcessPendingAsync from your worker or host adapter. |
| Result commands | A result command could be marked for inbox storage, then return default(TResult). | Deferred commands must be ICommand. Use queries or tracking endpoints for results. |
| Store roles | v4 used application-owned inbox contracts. | Stores are split by role: writer, lease store, and state store. |
| Contracts | v4 inbox storage did not use the shared contract registry. | Every scheduled command and outbox event must have a stable contract name and version. |
| Event publication | In-process event publish only. | In-process publish unchanged. Use durable outbox writer APIs to store events for later publication. |
| Message descriptor failures | Some descriptor lookup failures used InvalidOperationException. | Descriptor lookup failures throw MessageDescriptorNotFoundException. |
| Open generic handlers | Unsupported multi-parameter open generic handlers could be ignored. | Unsupported open generic handler shapes throw UnsupportedOpenGenericHandlerException. |
| Event predicates | Handler predicates did not apply consistently to all event publishing overloads. | Predicates apply to both PublishAsync(IEvent, ...) and PublishAsync<TEvent>(...). |
| Solution file | The repository used LiteBus.sln. | The repository uses LiteBus.slnx. |
Step 1: Replace [StoreInInbox]
The command mediator no longer stores commands. A call to SendAsync means execute now.
Before:
[StoreInInbox]
public sealed record ProcessPaymentCommand(Guid OrderId, decimal Amount) : ICommand;
await commandMediator.SendAsync(new ProcessPaymentCommand(orderId, amount), cancellationToken: cancellationToken);After:
public sealed record ProcessPaymentCommand(Guid OrderId, decimal Amount) : ICommand;
var receipt = await commandScheduler.ScheduleAsync(
new ProcessPaymentCommand(orderId, amount),
new CommandScheduleOptions
{
IdempotencyKey = $"payment:{orderId}",
CorrelationId = correlationId
},
cancellationToken);CommandReceipt<TCommand> means the store accepted the command. It does not mean the handler has run.
Step 2: Move Result Work Out of Deferred Commands
Deferred execution cannot return a result to the caller. Convert deferred result commands to ICommand, then expose status through queries or application endpoints.
Before:
[StoreInInbox]
public sealed record GenerateInvoiceCommand(Guid OrderId) : ICommand<Guid>;
var invoiceId = await commandMediator.SendAsync(new GenerateInvoiceCommand(orderId), cancellationToken: cancellationToken);After:
public sealed record GenerateInvoiceCommand(Guid OrderId) : ICommand;
var receipt = await commandScheduler.ScheduleAsync(new GenerateInvoiceCommand(orderId), cancellationToken: cancellationToken);
return Results.Accepted($"/invoice-requests/{receipt.CommandId}");If the caller needs the value in the same request, keep ICommand<TResult> and call ICommandMediator.SendAsync without scheduling.
Step 3: Register Contracts
Every scheduled command and stored event needs a stable name and version. The stored row uses this contract, not an assembly-qualified CLR name.
builder.Services.AddLiteBus(builder =>
{
registry.AddInboxModule(inbox =>
{
inbox.Contracts.Register<ProcessPaymentCommand>(
"payments.commands.process-payment",
version: 1);
});
registry.AddOutboxModule(outbox =>
{
outbox.Contracts.Register<OrderSubmittedIntegrationEvent>(
"orders.events.order-submitted",
version: 1);
});
});Closed generic messages are supported when each closed type is registered. Open generic contracts are rejected.
inbox.Contracts.Register<ArchiveCommand<CustomerSnapshot>>("archive.commands.customer-snapshot", 1);
outbox.Contracts.Register<ExportCompletedEvent<CustomerExport>>("exports.events.customer-export-completed", 1);Step 4: Register Inbox Store Roles
The inbox scheduler and processor depend on narrow store roles.
| Role | Used by | Purpose |
|---|---|---|
ICommandInboxWriter | ICommandScheduler | Append accepted command envelopes. |
ICommandInboxLeaseStore | ICommandInboxProcessor | Lease due commands for one worker. |
ICommandInboxStateStore | ICommandInboxProcessor | Record completed, failed, and dead-lettered state. |
A single database class can implement all three roles. Application services should depend only on the role they need.
With PostgreSQL:
var dataSource = NpgsqlDataSource.Create(connectionString);
// v5: register PostgreSQL inbox storage through the flat module registry API (removed in v6).
// v6: inbox.UsePostgreSqlStorage(pg => pg.UseDataSource(dataSource)) inside AddInbox.
await PostgreSqlInboxSchema.EnsureAsync(dataSource, cancellationToken: cancellationToken);For production, prefer copying PostgreSqlInboxSchema.GetCreateScript into your migration pipeline instead of calling EnsureAsync from every pod. See PostgreSQL Schema Management.
Step 5: Run the Inbox Processor from a Worker
The old LiteBus.Commands.Extensions.Microsoft.Hosting package was removed. Run the new processor from your application host boundary.
Option A: LiteBus inbox hosting module (recommended when you use the generic host). Reference LiteBus.Inbox.Extensions.Microsoft.Hosting:
registry.AddInboxModule(inbox => { /* contracts and processor options */ });
LiteBus.AddInboxProcessorHosting(host => host.PollInterval = TimeSpan.FromSeconds(1));Option B: your own worker calling one pass at a time:
public sealed class CommandInboxWorker : IHostedService
{
private readonly ICommandInboxProcessor _processor;
private Task? _loop;
public CommandInboxWorker(ICommandInboxProcessor processor) => _processor = processor;
public Task StartAsync(CancellationToken cancellationToken)
{
_loop = RunAsync(cancellationToken);
return Task.CompletedTask;
}
private async Task RunAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var pass = await _processor.ProcessPendingAsync(stoppingToken);
if (pass.LeasedCount == 0)
{
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
}
}
public Task StopAsync(CancellationToken cancellationToken) => _loop ?? Task.CompletedTask;
}There is no combined inbox+outbox host package in v5. Register two hosts or one worker that calls both processors explicitly. See Hosted services.
Keep retry settings, lease owner, batch size, and polling cadence explicit in your worker setup.
Step 6: Add Outbox Writes for Integration Events
In-process IEventMediator.PublishAsync still publishes to handlers immediately in v6. Use the outbox when an event must be stored with the same business transaction and published later (IOutbox.EnqueueAsync in v6).
public sealed record OrderSubmittedIntegrationEvent(Guid OrderId);
await outbox.EnqueueAsync(
new OrderSubmittedIntegrationEvent(orderId),
new OutboxOptions
{
Id = eventId,
Topic = "orders",
CorrelationId = correlationId
},
cancellationToken);Use OutboxOptions.MessageId when the application already has a stable event id. The event contract should carry business data, not storage identity.
Step 7: Register Outbox Store Roles and Dispatcher
The outbox processor depends on narrow store roles and an IOutboxDispatcher.
| Role | Used by | Purpose |
|---|---|---|
IOutboxMessageWriter | IOutboxWriter | Append accepted event envelopes. |
IOutboxMessageLeaseStore | IOutboxProcessor | Lease due messages for one publisher. |
IOutboxMessageStateStore | IOutboxProcessor | Record published, failed, and dead-lettered state. |
IOutboxDispatcher | IOutboxProcessor | Publish a leased envelope to LiteBus or an external transport. |
With PostgreSQL and the LiteBus in-process dispatcher:
// v5 flat PostgreSQL outbox registration (removed in v6).
// v6: outbox.UsePostgreSqlStorage(...) and outbox.UseInProcessDispatch() inside AddOutbox.
await PostgreSqlOutboxSchema.EnsureAsync(dataSource, cancellationToken: cancellationToken);See PostgreSQL Schema Management for migration-owned DDL and opt-in host bootstrap.
For a broker, register your own IOutboxDispatcher and map OutboxMessageEnvelope.Topic to the broker destination.
Step 8: Update Event Handler Predicates
EventRoutingSettings.HandlerPredicate now applies to both event publishing overloads:
Task PublishAsync(IEvent @event, EventMediationSettings? settings = null, CancellationToken cancellationToken = default);
Task PublishAsync<TEvent>(TEvent @event, EventMediationSettings? settings = null, CancellationToken cancellationToken = default)
where TEvent : notnull;Review code that passed an IEvent variable and expected all handlers to run regardless of the predicate.
Step 9: Update Open Generic Handlers
Open generic handlers that LiteBus closes automatically must have one generic type parameter. v5 throws UnsupportedOpenGenericHandlerException for unsupported shapes.
Before:
public sealed class AuditPreHandler<TCommand, TContext> : ICommandPreHandler<TCommand>
where TCommand : ICommand;
module.Register(typeof(AuditPreHandler<,>));After:
public sealed class AuditPreHandler<TCommand> : ICommandPreHandler<TCommand>
where TCommand : ICommand
{
private readonly AuditContext _context;
public AuditPreHandler(AuditContext context)
{
_context = context;
}
public Task PreHandleAsync(TCommand message, CancellationToken cancellationToken = default)
{
return _context.RecordAsync(message, cancellationToken);
}
}
module.Register(typeof(AuditPreHandler<>));Closed generic messages with concrete handlers resolve the registered concrete handler type. This matters for handlers such as ICommandHandler<ArchiveCommand<CustomerSnapshot>>.
Step 10: Update Descriptor Exception Handling
Code or tests that catch InvalidOperationException for unresolved message descriptors should catch MessageDescriptorNotFoundException instead.
catch (MessageDescriptorNotFoundException exception)
{
logger.LogError(
exception,
"Descriptor lookup failed for {MessageType} with {ResolveStrategy}",
exception.MessageType,
exception.ResolveStrategyType);
}The exception exposes the message type, resolve strategy type, whether on-the-spot registration was enabled, and the registered message count.
Step 11: Check System Namespace Filtering
The registry now skips only the real System namespace and System.* namespaces. Application namespaces such as Systematic.Domain.Events are registered normally.
If you had messages in a namespace that merely starts with System, they may now become visible to the registry.
Step 12: Move Scripts to .slnx
The repository now uses LiteBus.slnx. Update local scripts and CI commands.
dotnet restore LiteBus.slnx
dotnet build LiteBus.slnx --configuration Release --no-restore
dotnet test LiteBus.slnx --no-restoreContributor Notes
PostgreSQL integration tests use Testcontainers and require Docker. CI now reports Docker availability before running tests.
Checklist
- Remove
[StoreInInbox]and old command inbox abstractions from application code. - Use
ICommandMediator.SendAsyncfor immediate command execution. - Use
ICommandScheduler.ScheduleAsyncfor deferred commands. - Convert deferred result commands to
ICommandand expose tracking or query endpoints. - Register command and event contracts with stable names and versions.
- Register inbox and outbox store roles, or use the PostgreSQL packages.
- Start inbox and outbox processors from your worker or host adapter.
- Use
OutboxOptions.MessageIdfor stable event ids. - Review event handler predicates on
PublishAsync(IEvent, ...)calls. - Update unsupported open generic handlers.
- Catch
MessageDescriptorNotFoundExceptionfor descriptor lookup failures. - Update scripts from
LiteBus.slntoLiteBus.slnx. - Run PostgreSQL integration tests with Docker available.
Next
Return to the Migration Guides index, or jump to the Cheat Sheet for the current API.