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
Idtype
Migration Effort: Most applications can be migrated in 1-4 hours, depending on complexity.
Before You Start
Prerequisites
Backup your code: Commit all changes to version control
Review the release notes: Read Release Notes for V10
Update test suite: Ensure your tests are passing on V9
Check dependencies: Review third-party package compatibility
Recommended Migration Approach
Upgrade in a feature branch: Don't upgrade directly in main/master
Address breaking changes first: Fix compilation errors before adopting new features
Test thoroughly: Run your full test suite after each step
Deploy to staging: Validate in a non-production environment
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.0Check for Package Conflicts
# List all packages and check for conflicts
dotnet list packageStep 2: Address Breaking Changes
1. Nullable Reference Types
Breaking Change: Nullable reference types are now enabled across all Brighter projects.
Migration Steps:
Enable nullable reference types in your project (if not already enabled):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>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;
}
}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:
Replace
UseExternalBuswithAddProducersReplace
AddServiceActivatorwithAddConsumersUpdate property names:
ProducerRegistryinstead 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:
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):
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)
});
});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);
}
}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 dynamicallyCustomHeaders: Add custom headers via request contextResilienceContext: Integration with Polly resilience pipelineOriginatingMessage: 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:
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
}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:
Update dashboards and alerts to use new span names
Review trace queries for compatibility
Test observability in staging environment
V9 Span Names:
Paramore.Brighter.CommandProcessor.SendParamore.Brighter.MessagePump.Receive
V10 Span Names (OTel Semantic Conventions):
messaging.sendmessaging.receivemessaging.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
Run existing unit tests:
dotnet testAddress test failures:
Update mocks for
IRequestContextnew propertiesUpdate assertions for OpenTelemetry span names
Fix nullable reference warnings
Integration Tests
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);
}Test with real transports (slower, more complete):
# Start dependencies (Docker Compose)
docker-compose up -d rabbitmq postgres
# Run integration tests
dotnet test --filter Category=IntegrationPerformance 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
Deploy to staging environment
Run smoke tests
Monitor metrics:
Message processing latency
Error rates
Resource usage (CPU, memory)
Check logs for warnings or errors
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
Deploy during low-traffic period
Use blue-green or canary deployment if possible
Monitor closely for first 24 hours
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:
Revert package versions:
dotnet add package Paramore.Brighter --version 9.x.xRevert code changes:
git revert <commit-hash>Redeploy V9 version
Investigate issues before attempting V10 migration again
Getting Help
Resources
Reporting Issues
If you encounter issues during migration:
Check existing issues: Search GitHub Issues
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:
Update packages to V10
Address breaking changes:
Nullable reference types
Simplified configuration API
Reactor/Proactor terminology
Polly Resilience Pipeline
Adopt new features (optional):
Cloud Events
Default message mappers
Dynamic deserialization
OpenTelemetry conventions
InMemory testing options
Test thoroughly
Deploy to staging, then production
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?
