Skip to content

Conversation

@andreasohlund
Copy link
Member

@andreasohlund andreasohlund commented Oct 31, 2025

This PR introduces a significant refactoring of the saga finder mechanism in NServiceBus, replacing automatic assembly scanning for custom saga finders with an explicit registration API.

Summary

The changes introduce a new explicit mapping API for custom saga finders through the ConfigureFinderMapping<TMessage, TFinder>() method. This is a breaking change that removes the previous automatic discovery of saga finders via assembly scanning. Additionally, the PR aligns exception types for validation to use ArgumentException consistently.

Key Changes

  1. Explicit Finder Registration: Custom saga finders must now be explicitly registered using mapper.ConfigureFinderMapping<TMessage, TFinder>() in the saga's ConfigureHowToFindSaga method.

  2. Removal of Assembly Scanning: The framework no longer automatically discovers and registers custom finders through assembly scanning.

  3. Exception Type Alignment: Validation exceptions have been standardized to throw ArgumentException instead of mixed exception types.

  4. Code Modernization: Extensive use of C# modern features including:

    • Primary constructors
    • Expression-bodied members
    • Collection expressions
    • Target-typed new expressions

Breaking Changes

1. Custom Finder Registration

Before:

// Custom finders were automatically discovered through assembly scanning
public class CustomFinder : ISagaFinder<MySaga.SagaData, StartMessage>
{
    public Task<MySaga.SagaData> FindBy(StartMessage message, ...)
    {
        // Implementation
    }
}

protected override void ConfigureHowToFindSaga(SagaPropertyMapper<SagaData> mapper)
{
    // No explicit configuration needed - finder discovered automatically
}

After:

// Custom finders must be explicitly registered
public class CustomFinder : ISagaFinder<MySaga.SagaData, StartMessage>
{
    public Task<MySaga.SagaData> FindBy(StartMessage message, ...)
    {
        // Implementation
    }
}

protected override void ConfigureHowToFindSaga(SagaPropertyMapper<SagaData> mapper)
{
    // Explicit registration required
    mapper.ConfigureFinderMapping<StartMessage, CustomFinder>();
}

2. Exception Type Changes

Validation errors that previously threw different exception types now consistently throw ArgumentException:

  • Property mapping validation
  • Finder configuration validation
  • Saga correlation validation

API Changes

New Public APIs

  1. IConfigureHowToFindSagaWithFinder Interface

    public interface IConfigureHowToFindSagaWithFinder
    {
        void ConfigureMapping<TSagaEntity, TMessage, TFinder>()
            where TFinder : ISagaFinder<TSagaEntity, TMessage>
            where TSagaEntity : IContainSagaData;
    }
  2. SagaPropertyMapper.ConfigureFinderMapping Method

    public class SagaPropertyMapper<TSagaData>
    {
        public void ConfigureFinderMapping<TMessage, TFinder>()
            where TFinder : ISagaFinder<TSagaData, TMessage>;
    }

Modified APIs

  1. IConfigureHowToFindSagaWithMessage

    • Added class constraint to TSagaEntity generic parameter
  2. IConfigureHowToFindSagaWithMessageHeaders

    • Added class constraint to TSagaEntity generic parameter

Removed/Obsoleted APIs

  1. SagaMetadata.Create overload - Removed overload accepting IEnumerable<Type> availableTypes and Conventions
  2. SagaMetadataCollection.Initialize overload - Removed overload accepting Conventions parameter
  3. SagaFinderDefinition properties:
    • Removed Type property
    • Removed MessageTypeName property
    • Removed Properties dictionary

Internal Refactoring

Several internal classes were removed or significantly refactored:

  • CorrelationSagaToMessageMap - Removed
  • CustomFinderSagaToMessageMap - Removed
  • HeaderFinderSagaToMessageMap - Removed
  • PropertyFinderSagaToMessageMap - Removed
  • SagaFinder abstract class - Removed
  • SagaToMessageMap - Removed

Impact on Different Areas

1. Core Saga Framework

  • CustomFinderAdapter: Now uses ObjectFactory<TFinder> for strongly-typed finder instantiation
  • PropertySagaFinder: Changed to resolve ISagaPersister via DI instead of constructor injection
  • HeaderPropertySagaFinder: Changed to resolve ISagaPersister via DI instead of constructor injection
  • SagaMapper: New class consolidating saga mapping logic previously in SagaMetadata
  • SagaMetadata: Simplified by removing finder scanning and delegating mapping to SagaMapper

2. Finder Disposal

The PR adds proper disposal support for custom finders:

  • Finders implementing IDisposable are synchronously disposed
  • Finders implementing IAsyncDisposable are asynchronously disposed

3. Test Updates

All acceptance tests and unit tests were updated to use the new explicit finder registration:

  • When_adding_state_to_context.cs
  • When_finder_cant_find_saga_instance.cs
  • When_finder_returns_existing_saga.cs
  • Various saga metadata creation tests

4. Public API Surface

The public API surface was reduced by removing:

  • Type-based overloads for finder discovery
  • Internal properties exposed on public types
  • Convention-based initialization methods

Migration Guide

For Users with Custom Saga Finders

If you have custom saga finders in your codebase:

  1. Identify Custom Finders: Find all classes implementing ISagaFinder<TSagaData, TMessage>

  2. Add Explicit Registration: In each saga's ConfigureHowToFindSaga method, add:

    mapper.ConfigureFinderMapping<MessageType, CustomFinderType>();
  3. Update Validation: If you have custom validation that was catching specific exception types, update to catch ArgumentException

Example Migration

Before:

public class OrderSaga : Saga<OrderSagaData>,
    IAmStartedByMessages<StartOrder>
{
    protected override void ConfigureHowToFindSaga(SagaPropertyMapper<OrderSagaData> mapper)
    {
        // Finder automatically discovered
    }
    
    public class OrderFinder : ISagaFinder<OrderSagaData, StartOrder>
    {
        public async Task<OrderSagaData> FindBy(StartOrder message, ...)
        {
            // Custom logic
        }
    }
}

After:

public class OrderSaga : Saga<OrderSagaData>,
    IAmStartedByMessages<StartOrder>
{
    protected override void ConfigureHowToFindSaga(SagaPropertyMapper<OrderSagaData> mapper)
    {
        mapper.ConfigureFinderMapping<StartOrder, OrderFinder>();
    }
    
    public class OrderFinder : ISagaFinder<OrderSagaData, StartOrder>
    {
        public async Task<OrderSagaData> FindBy(StartOrder message, ...)
        {
            // Custom logic
        }
    }
}

Rationale

The move from implicit scanning to explicit registration provides several benefits:

  1. Performance: Eliminates assembly scanning overhead during endpoint startup
  2. Clarity: Makes finder registration explicit and visible in code
  3. AOT Compatibility: Supports ahead-of-time compilation scenarios where reflection is limited
  4. Trimming Support: Better compatibility with IL trimming for smaller deployment sizes
  5. Type Safety: Compile-time validation of finder configurations

Review Feedback

From the Copilot review summary:

  • The changes consolidate saga metadata creation logic
  • Code modernization improves readability
  • The explicit API makes finder configuration more discoverable
  • Removal of automatic scanning aligns with the broader NServiceBus 10 direction

Testing

The PR includes comprehensive test updates:

  • Unit tests for finder disposal (sync and async)
  • Acceptance tests for finder integration
  • Validation tests for proper error handling
  • Tests ensuring finder invocation with proper context

Related Work

This PR is part of a broader effort in NServiceBus 10 to:

  • Move away from convention-based scanning
  • Provide explicit registration APIs
  • Improve AOT and trimming compatibility
  • Modernize the codebase with current C# features

Similar changes have been made for:

Documentation Needs

The following documentation should be updated:

  1. Saga finder configuration documentation
  2. Migration guide from NServiceBus 9 to 10
  3. Custom finder implementation examples
  4. Troubleshooting guide for common migration issues

@andreasohlund
Copy link
Member Author

@danielmarbach still a bit rought and some types might have to be added back but please take a first look and see if you can spot something obvious

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR refactors the saga finder mechanism in NServiceBus by introducing a new ConfigureFinderMapping method to explicitly configure custom finders in the saga mapping configuration. The changes consolidate saga metadata creation logic, remove the need for automatic finder scanning, and modernize the codebase with primary constructors and expression-bodied members.

  • Introduces ConfigureFinderMapping<TMessage, TFinder>() method to explicitly configure custom saga finders
  • Refactors saga metadata creation by moving logic from SagaMetadata to a new SagaMapper class
  • Removes automatic custom finder registration and scanning in favor of explicit configuration
  • Modernizes code style with primary constructors, expression-bodied members, and collection expressions

Reviewed Changes

Copilot reviewed 43 out of 43 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
SagaPropertyMapper.cs Adds new ConfigureFinderMapping method for explicit custom finder configuration
SagaMapper.cs New class that consolidates saga mapping logic previously in SagaMetadata
SagaMetadata.cs Simplified by moving mapping logic to SagaMapper and removing finder scanning
SagaMetadataCollection.cs Removes overload that accepted Conventions parameter
CustomFinderAdapter.cs Refactored to support disposal of custom finders and use ObjectFactory
PropertySagaFinder.cs Changed to use DI to resolve ISagaPersister rather than constructor injection
HeaderPropertySagaFinder.cs Changed to use DI to resolve ISagaPersister rather than constructor injection
Sagas.cs Removed RegisterCustomFindersInContainer method as finders are no longer pre-registered
Test files Updated to use explicit finder mapping and removed Conventions parameter usage
Acceptance tests Updated sagas to explicitly configure finder mappings using new API
Comments suppressed due to low confidence (1)

src/NServiceBus.AcceptanceTests/Core/SelfVerification/When_running_saga_tests.cs:33

            foreach (var property in entity.GetProperties())
            {
                if (!property.GetGetMethod().IsVirtual)
                {
                    offenders++;
                    Console.WriteLine("ERROR: {0}.{1} must be marked as virtual for NHibernate tests to succeed.", entity.FullName, property.Name);
                }
            }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

{
if (correlationProperty != null && correlationProperty.Name != sagaProp.Name)
{
throw new ArgumentException($"Saga already have a mapping to property {correlationProperty.Name} and sagas can only have mappings that correlate on a single saga property. Use a custom finder to correlate {typeof(TMessage)} to saga {sagaType.Name}");
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar error: "Saga already have" should be "Saga already has" (subject-verb agreement).

Copilot uses AI. Check for mistakes.
{
if (sagaMessageFindingConfiguration is not IConfigureHowToFindSagaWithFinder sagaMapperFindingConfiguration)
{
throw new Exception($"Unable to configure header mapping. To fix this, ensure that {sagaMessageFindingConfiguration.GetType().FullName} implements {nameof(IConfigureHowToFindSagaWithFinder)}.");
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message says "Unable to configure header mapping" but this method is for configuring finder mapping, not header mapping. The error message should say "Unable to configure finder mapping" instead.

Copilot uses AI. Check for mistakes.
Comment on lines +133 to +144
foreach (var sagaMessage in sagaMessages)
{
if (sagaMessage.IsAllowedToStartSaga)
{
var match = finders.FirstOrDefault(m => m.MessageType.IsAssignableFrom(sagaMessage.MessageType));
if (match == null)
{
var simpleName = sagaMessage.MessageType.Name;
throw new Exception($"Message type {simpleName} can start the saga {sagaType.Name} (the saga implements IAmStartedByMessages<{simpleName}>) but does not map that message to saga data. In the ConfigureHowToFindSaga method, add a mapping using:{Environment.NewLine} mapper.ConfigureMapping<{simpleName}>(message => message.SomeMessageProperty).ToSaga(saga => saga.MatchingSagaProperty);");
}
}
}
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +111
if (fieldInfo != null)
{
if (fieldInfo.FieldType != sagaProp.PropertyType)
{
throw new ArgumentException($"When mapping a message to a saga, the member type on the message and the saga property must match. {fieldInfo.DeclaringType.FullName}.{fieldInfo.Name} is of type {fieldInfo.FieldType.Name} and {sagaProp.DeclaringType.FullName}.{sagaProp.Name} is of type {sagaProp.PropertyType.Name}.");
}
}
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +159
if (correlationProperty != null)
{
if (!AllowedCorrelationPropertyTypes.Contains(correlationProperty.Type))
{
var supportedTypes = string.Join(",", AllowedCorrelationPropertyTypes.Select(t => t.Name));

throw new Exception($"{correlationProperty.Type.Name} is not supported for correlated properties. Change the correlation property {correlationProperty.Name} on saga {sagaType.Name} to any of the supported types, {supportedTypes}, or use a custom saga finder.");
}
}
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 'if' statements can be combined.

Copilot uses AI. Check for mistakes.
@andreasohlund andreasohlund marked this pull request as ready for review November 11, 2025 21:20
{
void ConfigureMapping<TSagaEntity, TMessage>(System.Linq.Expressions.Expression<System.Func<TSagaEntity, object?>> sagaEntityProperty, System.Linq.Expressions.Expression<System.Func<TMessage, object?>> messageProperty)
where TSagaEntity : NServiceBus.IContainSagaData;
where TSagaEntity : class, NServiceBus.IContainSagaData;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any net effect of this on the user? We already don't/can't allow saga data as struct, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that is my understanding

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants