V10 Migration Guide

Overview

Brighter V10 introduces significant improvements and new features while maintaining a clear migration path from V9. This guide provides step-by-step instructions for upgrading your application to V10, addressing breaking changes, and adopting new features.

Key Changes in V10:

  • Cloud Events support

  • OpenTelemetry Semantic Conventions

  • Default Message Mappers

  • Dynamic Message Deserialization

  • Nullable Reference Types (breaking)

  • Simplified Configuration (breaking)

  • Reactor and Proactor terminology (breaking)

  • Polly Resilience Pipeline v8 (breaking)

  • Request Context enhancements

  • InMemory options for testing

  • Transport improvements (PostgreSQL, RabbitMQ, Kafka, AWS)

  • Request ID is now an Id type

Migration Effort: Most applications can be migrated in 1-4 hours, depending on complexity.

Before You Start

Prerequisites

  1. Backup your code: Commit all changes to version control

  2. Review the release notes: Read Release Notes for V10

  3. Update test suite: Ensure your tests are passing on V9

  4. Check dependencies: Review third-party package compatibility

  1. Upgrade in a feature branch: Don't upgrade directly in main/master

  2. Address breaking changes first: Fix compilation errors before adopting new features

  3. Test thoroughly: Run your full test suite after each step

  4. Deploy to staging: Validate in a non-production environment

  5. Monitor production: Watch for issues after deployment

Step 1: Update Package References

Update NuGet Packages

Update all Brighter packages to V10:

# Core packages
dotnet add package Paramore.Brighter --version 10.0.0
dotnet add package Paramore.Brighter.Extensions.DependencyInjection --version 10.0.0

# Transport packages (update as needed)
dotnet add package Paramore.Brighter.MessagingGateway.RMQ --version 10.0.0
dotnet add package Paramore.Brighter.MessagingGateway.Kafka --version 10.0.0
dotnet add package Paramore.Brighter.MessagingGateway.AWSSQS --version 10.0.0

# Outbox/Inbox packages (update as needed)
dotnet add package Paramore.Brighter.Outbox.MsSql --version 10.0.0
dotnet add package Paramore.Brighter.Inbox.MsSql --version 10.0.0

Check for Package Conflicts

# List all packages and check for conflicts
dotnet list package

Step 2: Address Breaking Changes

1. Nullable Reference Types

Breaking Change: Nullable reference types are now enabled across all Brighter projects.

Migration Steps:

  1. Enable nullable reference types in your project (if not already enabled):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
  1. Address compiler warnings in Commands, Events, and Handlers:

Before (V9):

public class CreatePersonCommand : Command
{
    public string Name { get; set; }  // Warning: Non-nullable property
    public string Email { get; set; }  // Warning: Non-nullable property
}

After (V10):

public class CreatePersonCommand : Command
{
    public required string Name { get; set; }  // Required property
    public required string Email { get; set; }  // Required property

    // Or with constructor
    public CreatePersonCommand(Guid id, string name, string email) : base(id)
    {
        Name = name;
        Email = email;
    }
}
  1. Update Message Mappers to handle nullable warnings:

public class PersonCreatedMapper : IAmAMessageMapper<PersonCreated>
{
    public Message MapToMessage(PersonCreated request)
    {
        // Validate non-null properties
        ArgumentNullException.ThrowIfNull(request.Name);

        var header = new MessageHeader(
            messageId: request.Id,
            topic: new RoutingKey("PersonCreated"),
            messageType: MessageType.MT_EVENT
        );

        var body = new MessageBody(JsonSerializer.Serialize(request));
        return new Message(header, body);
    }
}

See also: Nullable Reference Types Documentation

2. Simplified Configuration

Breaking Change: Builder methods renamed for clarity.

Before (V9):

services.AddBrighter()
    .UseExternalBus(new RmqProducerRegistryFactory(...).Create())
    .AddServiceActivator(options =>
    {
        options.Subscriptions = subscriptions;
        options.ChannelFactory = new ChannelFactory(...);
    });

After (V10):

services.AddBrighter()
    .AddProducers(options =>
    {
        options.ProducerRegistry = new RmqProducerRegistryFactory(...).Create();
    })
    .AddConsumers(options =>
    {
        options.Subscriptions = subscriptions;
        options.ChannelFactory = new ChannelFactory(...);
    });

Migration Steps:

  1. Replace UseExternalBus with AddProducers

  2. Replace AddServiceActivator with AddConsumers

  3. Update property names: ProducerRegistry instead of passing directly

3. Reactor and Proactor Terminology

Breaking Change: The runAsync flag on Subscription has been renamed to MessagePumpType.

Before (V9):

var subscription = new Subscription<MyEvent>(
    new SubscriptionName("my-subscription"),
    new ChannelName("my-channel"),
    new RoutingKey("my.routing.key"),
    runAsync: true  // Old parameter
);

After (V10):

var subscription = new Subscription<MyEvent>(
    new SubscriptionName("my-subscription"),
    new ChannelName("my-channel"),
    new RoutingKey("my.routing.key"),
    messagePumpType: MessagePumpType.Proactor  // New parameter
);

Migration Table:

V9 Parameter
V10 Parameter
Description

runAsync: false

messagePumpType: MessagePumpType.Reactor

Synchronous, blocking I/O

runAsync: true

messagePumpType: MessagePumpType.Proactor

Asynchronous, non-blocking I/O

See also: Reactor and Proactor Documentation

4. Polly Resilience Pipeline

Breaking Change: TimeoutPolicyAttribute is obsolete. Use UseResiliencePipeline attribute.

Before (V9):

public class MyHandler : RequestHandlerAsync<MyCommand>
{
    [TimeoutPolicy(milliseconds: 5000, step: 1)]
    public override async Task<MyCommand> HandleAsync(
        MyCommand command,
        CancellationToken cancellationToken = default)
    {
        // Handler logic
        return await base.HandleAsync(command, cancellationToken);
    }
}

After (V10):

  1. Define a Resilience Pipeline:

var resiliencePipelineRegistry = new ResiliencePipelineRegistry<string>();
resiliencePipelineRegistry.TryAddBuilder<ResiliencePropertyKey<RequestContext>>(
    "MyPipeline",
    (builder, context) =>
    {
        builder.AddTimeout(TimeSpan.FromSeconds(5));
        builder.AddRetry(new RetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            Delay = TimeSpan.FromMilliseconds(100)
        });
    });
  1. Use the new attribute:

public class MyHandler : RequestHandlerAsync<MyCommand>
{
    [UseResiliencePipeline(policy: "MyPipeline", step: 1)]
    public override async Task<MyCommand> HandleAsync(
        MyCommand command,
        CancellationToken cancellationToken = default)
    {
        // Handler logic
        return await base.HandleAsync(command, cancellationToken);
    }
}
  1. Register the pipeline with Brighter:

services.AddBrighter(options =>
{
    options.PolicyRegistry = resiliencePipelineRegistry;
});

See also: Policy Retry and Circuit Breaker Documentation

5. Request Context Interface Changes

Breaking Change: IRequestContext interface has new properties.

New Properties:

  • PartitionKey: Set message partition keys dynamically

  • CustomHeaders: Add custom headers via request context

  • ResilienceContext: Integration with Polly resilience pipeline

  • OriginatingMessage: Access the original message (for consumers)

Migration: Most code should not be affected unless you implement IRequestContext directly.

If you implement IRequestContext (rare):

public class MyRequestContext : IRequestContext
{
    public Guid Id { get; set; }
    public ISpan Span { get; set; }
    public Dictionary<string, object> Bag { get; set; }

    // V10: Add new properties
    public string? PartitionKey { get; set; }
    public Dictionary<string, string> CustomHeaders { get; set; } = new();
    public ResilienceContext? ResilienceContext { get; set; }
    public Message? OriginatingMessage { get; set; }
}

Using new properties:

public class MyHandler : RequestHandlerAsync<MyCommand>
{
    public override async Task<MyCommand> HandleAsync(
        MyCommand command,
        CancellationToken cancellationToken = default)
    {
        // Set partition key for message routing
        Context.PartitionKey = command.TenantId;

        // Add custom headers
        Context.CustomHeaders["X-Correlation-Id"] = command.CorrelationId;

        // Access originating message (for consumers)
        if (Context.OriginatingMessage != null)
        {
            var receivedTimestamp = Context.OriginatingMessage.Header.TimeStamp;
        }

        return await base.HandleAsync(command, cancellationToken);
    }
}

6. Request Id and CorrelationId type change

In V9 the Request type used a Guid to represent the identity of a Command or Event. In V10, as part of a move away from primitives, we have changed this to be a type Id. An Id can be constructed from a string using its ToString() method. If you have used a Guid then you will need to turn the Guid into a string.

Within IRequest both Id and CorrelationId both use the type Id in V10.

If you were using a Guid to create a random identity, you can just use Id.Random() instead which has the same behavior.

Before:


class MyCommand() : Command(Guid.NewGuid())
{
    public string Value { get; set; } = string.Empty;
}

After:


class MyCommand() : Command(Id.Random())
{
    public string Value { get; set; } = string.Empty;
}

Step 3: Adopt New Features (Optional)

1. Cloud Events Support

V10 adds full Cloud Events specification support.

Benefits:

  • Standardized event metadata

  • Better interoperability with other systems

  • Rich event context information

Adoption Steps:

  1. Update Publication to include Cloud Events properties:

new RmqPublication
{
    Topic = new RoutingKey("PersonCreated"),
    CloudEventsType = new CloudEventsType("io.paramore.person.created"),
    Source = new Uri("https://api.example.com/persons"),
    Subject = "person/created",
    MakeChannels = OnMissingChannel.Create
}
  1. Use Cloud Events headers in your mapper (optional):

public class PersonCreatedMapper : IAmAMessageMapper<PersonCreated>
{
    public Message MapToMessage(PersonCreated request, Publication publication)
    {
        var header = new MessageHeader(
            messageId: request.Id,
            topic: publication.Topic,
            messageType: MessageType.MT_EVENT,
            type: publication.CloudEventsType,  // Use Cloud Events type
            source: publication.Source,
            subject: publication.Subject
        );

        var body = new MessageBody(JsonSerializer.Serialize(request));
        return new Message(header, body);
    }
}

See also: Cloud Events Documentation

2. Default Message Mappers

V10 allows you to omit message mappers for simple JSON serialization.

Benefits:

  • Less boilerplate code

  • Faster development

  • Still supports custom mappers for complex scenarios

Before (V9) - Required Mapper:

public class PersonCreatedMapper : IAmAMessageMapper<PersonCreated>
{
    public Message MapToMessage(PersonCreated request)
    {
        var header = new MessageHeader(
            messageId: request.Id,
            topic: new RoutingKey("PersonCreated"),
            messageType: MessageType.MT_EVENT
        );

        var body = new MessageBody(JsonSerializer.Serialize(request));
        return new Message(header, body);
    }

    public PersonCreated MapToRequest(Message message)
    {
        return JsonSerializer.Deserialize<PersonCreated>(message.Body.Value)
            ?? throw new InvalidOperationException("Failed to deserialize");
    }
}

// Register mapper
messageMapperRegistry.Register<PersonCreated, PersonCreatedMapper>();

After (V10) - No Mapper Needed:

// No mapper registration needed for simple JSON serialization!
// Brighter uses JsonMessageMapper<T> by default

// Just define your message
public class PersonCreated : Event
{
    public string Name { get; set; }
    public string Email { get; set; }
}

// Publish directly
await commandProcessor.PublishAsync(new PersonCreated
{
    Name = "Alice",
    Email = "alice@example.com"
});

When to use custom mappers:

  • Complex transformations

  • Non-JSON formats (XML, Protobuf, etc.)

  • Transform pipelines (encryption, compression, claim check)

  • Custom header mapping

See also: Message Mappers Documentation

3. Dynamic Message Deserialization

V10 supports multiple message types on the same channel.

Benefits:

  • Content-based routing

  • Flexible message processing

  • Better use of infrastructure

Example:

new KafkaSubscription(
    new SubscriptionName("task-state-subscription"),
    channelName: new ChannelName("task.state"),
    routingKey: new RoutingKey("task.update"),
    getRequestType: message => message switch
    {
        var m when m.Header.Type == new CloudEventsType("io.paramore.task.created")
            => typeof(TaskCreated),
        var m when m.Header.Type == new CloudEventsType("io.paramore.task.updated")
            => typeof(TaskUpdated),
        var m when m.Header.Type == new CloudEventsType("io.paramore.task.deleted")
            => typeof(TaskDeleted),
        _ => throw new ArgumentException(
            $"No type mapping found for message with type {message.Header.Type}",
            nameof(message))
    },
    groupId: "task-consumer-group",
    messagePumpType: MessagePumpType.Proactor
);

See also: Dynamic Deserialization Documentation

4. OpenTelemetry Semantic Conventions

V10 adopts OpenTelemetry Semantic Conventions for messaging.

Impact: Trace spans will have different names and attributes than V9.

Benefits:

  • Standard messaging conventions

  • Better integration with APM tools

  • Consistent telemetry across systems

Migration:

  1. Update dashboards and alerts to use new span names

  2. Review trace queries for compatibility

  3. Test observability in staging environment

V9 Span Names:

  • Paramore.Brighter.CommandProcessor.Send

  • Paramore.Brighter.MessagePump.Receive

V10 Span Names (OTel Semantic Conventions):

  • messaging.send

  • messaging.receive

  • messaging.process

See also: Telemetry Documentation

5. InMemory Options for Testing

V10 provides comprehensive InMemory implementations for testing.

Benefits:

  • Fast test execution

  • No external dependencies

  • Easy CI/CD integration

Example:

// Test setup with InMemory components
var internalBus = new InternalBus();

services.AddBrighter(options =>
{
    options.HandlerLifetime = ServiceLifetime.Scoped;
})
.AddProducers(options =>
{
    var publication = new Publication { Topic = new RoutingKey("TestTopic") };
    options.ProducerRegistry = new InMemoryProducerRegistryFactory(
        internalBus,
        new[] { publication },
        InstrumentationOptions.All
    ).Create();
    options.Outbox = new InMemoryOutbox(TimeProvider.System);
})
.AddConsumers(options =>
{
    options.Inbox = new InboxConfiguration(
        new InMemoryInbox(TimeProvider.System),
        InboxConfiguration.NoActionOnExists
    );
    options.Subscriptions = subscriptions;
    options.ChannelFactory = new InMemoryChannelFactory(internalBus, TimeProvider.System);
})
.UseScheduler(new InMemorySchedulerFactory())
.AutoFromAssemblies();

See also: InMemory Options Documentation

Step 4: Test Your Migration

Unit Tests

  1. Run existing unit tests:

dotnet test
  1. Address test failures:

    • Update mocks for IRequestContext new properties

    • Update assertions for OpenTelemetry span names

    • Fix nullable reference warnings

Integration Tests

  1. Test with InMemory components (fast):

[Fact]
public async Task Should_Process_Message_With_V10_Components()
{
    // Arrange
    var internalBus = new InternalBus();
    var serviceProvider = BuildServiceProvider(internalBus);
    var commandProcessor = serviceProvider.GetRequiredService<IAmACommandProcessor>();

    // Act
    await commandProcessor.PublishAsync(new PersonCreated
    {
        Name = "Alice",
        Email = "alice@example.com"
    });

    // Assert
    var messages = internalBus.Stream(new RoutingKey("PersonCreated"));
    Assert.NotEmpty(messages);
}
  1. Test with real transports (slower, more complete):

# Start dependencies (Docker Compose)
docker-compose up -d rabbitmq postgres

# Run integration tests
dotnet test --filter Category=Integration

Performance Testing

Compare V9 and V10 performance:

[Benchmark]
public async Task V10_MessageProcessing()
{
    for (int i = 0; i < 1000; i++)
    {
        await _commandProcessor.SendAsync(new TestCommand { Value = i });
    }
}

Expected: V10 should have similar or better performance due to optimizations.

Step 5: Deploy to Staging

Pre-Deployment Checklist

Deployment Steps

  1. Deploy to staging environment

  2. Run smoke tests

  3. Monitor metrics:

    • Message processing latency

    • Error rates

    • Resource usage (CPU, memory)

  4. Check logs for warnings or errors

  5. Validate telemetry (traces, metrics)

Monitoring

Watch for:

  • Increased error rates

  • Performance degradation

  • OpenTelemetry trace issues

  • Null reference exceptions

Step 6: Deploy to Production

Production Deployment

  1. Deploy during low-traffic period

  2. Use blue-green or canary deployment if possible

  3. Monitor closely for first 24 hours

  4. Have rollback plan ready

Post-Deployment

  • Review logs for issues

  • Check application metrics

  • Validate message processing

  • Monitor OpenTelemetry traces

Common Migration Issues

Issue 1: Nullable Reference Warnings

Symptom: CS8618, CS8600, CS8602 warnings

Solution: See Nullable Reference Types Documentation

Issue 2: Method Not Found

Symptom: UseExternalBus method not found

Solution: Replace with AddProducers

Issue 3: Property Not Found on Subscription

Symptom: runAsync property does not exist

Solution: Use messagePumpType: MessagePumpType.Proactor or MessagePumpType.Reactor

Issue 4: TimeoutPolicy Not Working

Symptom: TimeoutPolicyAttribute marked as obsolete

Solution: Migrate to UseResiliencePipeline with Polly v8

Issue 5: Telemetry Spans Changed

Symptom: Dashboard queries not finding spans

Solution: Update queries to use OpenTelemetry Semantic Convention names

Rollback Plan

If you need to roll back to V9:

  1. Revert package versions:

dotnet add package Paramore.Brighter --version 9.x.x
  1. Revert code changes:

git revert <commit-hash>
  1. Redeploy V9 version

  2. Investigate issues before attempting V10 migration again

Getting Help

Resources

Reporting Issues

If you encounter issues during migration:

  1. Check existing issues: Search GitHub Issues

  2. Create a new issue with:

    • V9 and V10 versions

    • Minimal reproduction example

    • Error messages and stack traces

    • Environment details (.NET version, OS, transport)

Summary

Migrating to Brighter V10 involves:

  1. Update packages to V10

  2. Address breaking changes:

    • Nullable reference types

    • Simplified configuration API

    • Reactor/Proactor terminology

    • Polly Resilience Pipeline

  3. Adopt new features (optional):

    • Cloud Events

    • Default message mappers

    • Dynamic deserialization

    • OpenTelemetry conventions

    • InMemory testing options

  4. Test thoroughly

  5. Deploy to staging, then production

  6. Monitor and address any issues

Most migrations can be completed in 1-4 hours. The breaking changes are straightforward, and V10 provides significant improvements in features, performance, and developer experience.

Good luck with your migration! 🚀

Last updated

Was this helpful?