Skip to content

[WIP] Fix inconsistent projection name resolution in event handling#51

Merged
JeanMarcMbouma merged 5 commits intomasterfrom
copilot/fix-projection-name-resolution
Feb 1, 2026
Merged

[WIP] Fix inconsistent projection name resolution in event handling#51
JeanMarcMbouma merged 5 commits intomasterfrom
copilot/fix-projection-name-resolution

Conversation

Copy link
Contributor

Copilot AI commented Feb 1, 2026

Summary

Successfully fixed the inconsistent projection name resolution across BbQ.Events components.

  • Create shared ProjectionNameResolver class in Engine namespace
  • Update DefaultProjectionEngine to use ProjectionNameResolver
  • Update DefaultReplayService to use ProjectionNameResolver
  • Update DefaultProjectionRebuilder to use ProjectionNameResolver
  • Add unit tests for ProjectionNameResolver (8 comprehensive tests)
  • Run build and tests to verify changes (all 161 tests passing)
  • Address code review feedback
  • Run CodeQL security scan (no vulnerabilities)

Changes Made

  1. Created ProjectionNameResolver.cs: Shared utility for consistent projection name resolution
  2. Updated DefaultProjectionEngine.cs: Uses resolver for attribute-based configuration
  3. Updated DefaultReplayService.cs: Uses resolver in two locations for projection lookup
  4. Updated DefaultProjectionRebuilder.cs: Uses resolver for projection enumeration
  5. Added ProjectionNameResolverTests.cs: Comprehensive test coverage

Benefits

✅ Consistent projection naming across registration, runtime, replay, and rebuild
✅ No breaking changes - maintains backward compatibility
✅ Fixes subtle bugs from mismatched projection identifiers
✅ All existing tests pass
✅ No security vulnerabilities introduced

Original prompt

This section details on the original issue you should resolve

<issue_title>Inconsistent projection name resolution between DefaultProjectionEngine, DefaultReplayService, and DefaultProjectionRebuilder</issue_title>
<issue_description>Summary

Across BbQ.Events, projection names are not resolved consistently.

• DefaultProjectionEngine resolves the projection name using:• ProjectionOptions.ProjectionName (when provided),
• otherwise the projection type name.

• DefaultReplayService and DefaultProjectionRebuilder do not use ProjectionOptions at all and always fall back to the type name.

Because ProjectionAttribute does not define a name property, the only valid naming sources are:

• ProjectionOptions.ProjectionName
• the projection type name

This inconsistency leads to mismatched projection identifiers between runtime processing and replay/rebuild operations.


Affected files

• DefaultProjectionEngine.cs
• DefaultProjectionRebuilder.cs
• DefaultReplayService.cs
• ProjectionOptions.cs
• ProjectionAttribute.cs


Expected behavior

All components should resolve projection names using the same logic:

  1. If ProjectionOptions.ProjectionName is set → use it
  2. Otherwise → use the projection type name

Replay and rebuild should target the same projection name that the engine uses during normal operation.


Actual behavior

• DefaultProjectionEngine respects ProjectionOptions.ProjectionName
• DefaultReplayService and DefaultProjectionRebuilder ignore ProjectionOptions and always use the type name

This can cause:

• replaying into a different projection name than the one used at runtime
• rebuilds that don’t match the engine’s naming
• inconsistent storage and operational behavior


Proposed fix

Introduce a shared projection name resolver, for example:

public static class ProjectionNameResolver
{
public static string Resolve(Type projectionType, ProjectionOptions? options)
{
if (!string.IsNullOrWhiteSpace(options?.ProjectionName))
return options.ProjectionName;

    return projectionType.Name;
}

}

Then update:

• DefaultProjectionEngine
• DefaultReplayService
• DefaultProjectionRebuilder

to use this resolver instead of deriving names independently.


Benefit

This ensures consistent projection naming across:

• registration
• runtime processing
• replay
• rebuild
• storage

and eliminates subtle bugs caused by mismatched projection identifiers.</issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 3 commits February 1, 2026 15:33
Co-authored-by: JeanMarcMbouma <16613177+JeanMarcMbouma@users.noreply.github.com>
Co-authored-by: JeanMarcMbouma <16613177+JeanMarcMbouma@users.noreply.github.com>
Co-authored-by: JeanMarcMbouma <16613177+JeanMarcMbouma@users.noreply.github.com>
Co-authored-by: JeanMarcMbouma <16613177+JeanMarcMbouma@users.noreply.github.com>
@JeanMarcMbouma JeanMarcMbouma marked this pull request as ready for review February 1, 2026 15:38
Copilot AI review requested due to automatic review settings February 1, 2026 15:38
@JeanMarcMbouma JeanMarcMbouma merged commit aa44157 into master Feb 1, 2026
7 checks passed
@JeanMarcMbouma JeanMarcMbouma deleted the copilot/fix-projection-name-resolution branch February 1, 2026 15:39
Copilot AI restored the copilot/fix-projection-name-resolution branch February 1, 2026 15:39
Copilot AI requested a review from JeanMarcMbouma February 1, 2026 15:39
Copilot stopped work on behalf of JeanMarcMbouma due to an error February 1, 2026 15:39
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 fixes inconsistent projection name resolution across BbQ.Events components by introducing a shared ProjectionNameResolver utility class. Previously, DefaultProjectionEngine respected custom ProjectionOptions.ProjectionName values while DefaultReplayService and DefaultProjectionRebuilder only used type names, leading to mismatched projection identifiers during replay and rebuild operations.

Changes:

  • Introduced ProjectionNameResolver class with consistent name resolution logic: use ProjectionOptions.ProjectionName if set, otherwise fall back to type name
  • Updated all three projection components (DefaultProjectionEngine, DefaultReplayService, DefaultProjectionRebuilder) to use the shared resolver
  • Added comprehensive unit tests for the resolver

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/BbQ.Events/Engine/ProjectionNameResolver.cs New utility class providing centralized projection name resolution logic
src/BbQ.Events/Engine/DefaultProjectionEngine.cs Updated to use ProjectionNameResolver when building options from attributes
src/BbQ.Events/Engine/DefaultReplayService.cs Updated to resolve projection names consistently in both GetRegisteredProjections and StreamEventsAndProcessAsync
src/BbQ.Events/Engine/DefaultProjectionRebuilder.cs Updated to resolve projection names consistently in GetRegisteredProjections
tests/BbQ.Cqrs.Tests/ProjectionNameResolverTests.cs Comprehensive unit tests covering all resolver edge cases including null, empty, whitespace, and custom names

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

};

// Use ProjectionNameResolver for consistent name resolution
// Since ProjectionName is not explicitly set in options, the resolver will use the type name
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The comment "Since ProjectionName is not explicitly set in options, the resolver will use the type name" is slightly misleading. The ProjectionName property has a default value of string.Empty (from ProjectionOptions.cs line 55), not null. The resolver checks string.IsNullOrWhiteSpace which correctly treats empty string as "not set", but the comment could be clearer.

Consider revising to: "Since ProjectionName is empty in the newly created options, the resolver will fall back to the type name"

Suggested change
// Since ProjectionName is not explicitly set in options, the resolver will use the type name
// Since ProjectionName is empty in the newly created options, the resolver will fall back to the type name

Copilot uses AI. Check for mistakes.
Comment on lines +391 to +392
// Since ProjectionName is not explicitly set in options, the resolver will use the type name
options.ProjectionName = ProjectionNameResolver.Resolve(concreteType, options);
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

For clarity and consistency, consider passing null instead of options to ProjectionNameResolver.Resolve here. Since we're creating new options with no ProjectionName set, passing the options object is redundant. The call could be simplified to:

options.ProjectionName = ProjectionNameResolver.Resolve(concreteType, null);

This makes it clearer that we're explicitly choosing to use the type name fallback rather than checking an options object we know has no ProjectionName.

Suggested change
// Since ProjectionName is not explicitly set in options, the resolver will use the type name
options.ProjectionName = ProjectionNameResolver.Resolve(concreteType, options);
// Since ProjectionName is not explicitly set in options, explicitly use the type name fallback
options.ProjectionName = ProjectionNameResolver.Resolve(concreteType, null);

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +140
using BbQ.Events.Engine;
using NUnit.Framework;

namespace BbQ.Cqrs.Tests;

/// <summary>
/// Tests for ProjectionNameResolver to ensure consistent projection name resolution.
/// </summary>
[TestFixture]
public class ProjectionNameResolverTests
{
// Test projection class
private class TestProjection { }

[Test]
public void Resolve_WithNullProjectionType_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
ProjectionNameResolver.Resolve(null!, null));
}

[Test]
public void Resolve_WithNullOptions_ReturnsTypeName()
{
// Arrange
var projectionType = typeof(TestProjection);

// Act
var result = ProjectionNameResolver.Resolve(projectionType, null);

// Assert
Assert.That(result, Is.EqualTo("TestProjection"));
}

[Test]
public void Resolve_WithOptionsButNullProjectionName_ReturnsTypeName()
{
// Arrange
var projectionType = typeof(TestProjection);
var options = new ProjectionOptions
{
ProjectionName = null!
};

// Act
var result = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result, Is.EqualTo("TestProjection"));
}

[Test]
public void Resolve_WithOptionsAndEmptyProjectionName_ReturnsTypeName()
{
// Arrange
var projectionType = typeof(TestProjection);
var options = new ProjectionOptions
{
ProjectionName = string.Empty
};

// Act
var result = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result, Is.EqualTo("TestProjection"));
}

[Test]
public void Resolve_WithOptionsAndWhitespaceProjectionName_ReturnsTypeName()
{
// Arrange
var projectionType = typeof(TestProjection);
var options = new ProjectionOptions
{
ProjectionName = " "
};

// Act
var result = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result, Is.EqualTo("TestProjection"));
}

[Test]
public void Resolve_WithOptionsAndCustomProjectionName_ReturnsCustomName()
{
// Arrange
var projectionType = typeof(TestProjection);
var customName = "CustomProjectionName";
var options = new ProjectionOptions
{
ProjectionName = customName
};

// Act
var result = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result, Is.EqualTo(customName));
}

[Test]
public void Resolve_WithDifferentProjectionTypes_ReturnsCorrectTypeNames()
{
// Arrange & Act & Assert
Assert.That(
ProjectionNameResolver.Resolve(typeof(TestProjection), null),
Is.EqualTo("TestProjection"));

Assert.That(
ProjectionNameResolver.Resolve(typeof(ProjectionNameResolverTests), null),
Is.EqualTo("ProjectionNameResolverTests"));

Assert.That(
ProjectionNameResolver.Resolve(typeof(string), null),
Is.EqualTo("String"));
}

[Test]
public void Resolve_ConsistencyBetweenCalls_ReturnsSameResult()
{
// Arrange
var projectionType = typeof(TestProjection);
var options = new ProjectionOptions
{
ProjectionName = "ConsistentName"
};

// Act
var result1 = ProjectionNameResolver.Resolve(projectionType, options);
var result2 = ProjectionNameResolver.Resolve(projectionType, options);

// Assert
Assert.That(result1, Is.EqualTo(result2));
Assert.That(result1, Is.EqualTo("ConsistentName"));
}
}
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

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

The tests comprehensively cover the ProjectionNameResolver itself, but there are no integration tests that verify the fix works end-to-end. Consider adding a test that:

  1. Registers a projection with a custom ProjectionName via AddProjection configureOptions
  2. Verifies that DefaultReplayService.GetRegisteredProjections() returns the custom name (not the type name)
  3. Verifies that replay operations target the correct projection with the custom name

This would ensure the fix solves the original issue described in the PR.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inconsistent projection name resolution between DefaultProjectionEngine, DefaultReplayService, and DefaultProjectionRebuilder

2 participants