Skip to content

Migration Guide v5

A. Shafie edited this page May 29, 2026 · 7 revisions

Migration Guide: Upgrading to LiteBus v5.0

This guide covers the upgrade from v4.4.0 to v5.0. Version 5 replaces the v4 attribute-based command inbox with explicit inbox and outbox modules. It also keeps the v5 branch diagnostics for message resolution, event predicates, namespace filtering, and open generic handler validation.

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 IEventPublisher.PublishAsync published in-process only. In-process publish is unchanged. Use IOutboxWriter.AddAsync or IIntegrationOutbox.AddAsync 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(liteBus =>
{
    liteBus.AddCommandInboxModule(inbox =>
    {
        inbox.Contracts.Register<ProcessPaymentCommand>(
            "payments.commands.process-payment",
            version: 1);
    });

    liteBus.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);

builder.Services.AddLiteBus(liteBus =>
{
    liteBus.AddPostgreSqlCommandInboxStore(postgres =>
    {
        postgres.UseDataSource(dataSource);
    });
});

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:

liteBus.AddCommandInboxModule(inbox => { /* contracts and processor options */ });

liteBus.AddCommandInboxProcessorHosting(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 Processor Hosting.

Keep retry settings, lease owner, batch size, and polling cadence explicit in your worker setup.

Step 6: Add Outbox Writes For Integration Events

IEventPublisher.PublishAsync still publishes to in-process handlers now. Use the outbox when an event must be stored with the same business transaction and published later.

public sealed record OrderSubmittedIntegrationEvent(Guid OrderId) : IIntegrationEvent;

await integrationOutbox.AddAsync(
    new OrderSubmittedIntegrationEvent(orderId),
    new OutboxOptions
    {
        MessageId = 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:

builder.Services.AddLiteBus(liteBus =>
{
    liteBus.AddOutboxModule(outbox =>
    {
        outbox.Contracts.Register<OrderSubmittedIntegrationEvent>("orders.events.order-submitted", 1);
        outbox.UseLiteBusEventDispatcher();
    });

    liteBus.AddPostgreSqlOutboxStore(postgres =>
    {
        postgres.UseDataSource(dataSource);
    });
});

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

EventMediationRoutingSettings.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-restore

Sample projects are included in the root LiteBus.slnx under /samples/.

Contributor 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.SendAsync for immediate command execution.
  • Use ICommandScheduler.ScheduleAsync for deferred commands.
  • Convert deferred result commands to ICommand and 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.MessageId for stable event ids.
  • Review event handler predicates on PublishAsync(IEvent, ...) calls.
  • Update unsupported open generic handlers.
  • Catch MessageDescriptorNotFoundException for descriptor lookup failures.
  • Update scripts from LiteBus.sln to LiteBus.slnx.
  • Run PostgreSQL integration tests with Docker available.

Clone this wiki locally