The BookStore.ApiService.Analyzers project enforces architectural patterns for Event Sourcing, CQRS, and Domain-Driven Design in the backend API service.
The analyzer provides 17 rules across 5 categories to ensure consistent architecture:
- Event Sourcing Rules (BS1xxx): Enforce event immutability and proper structure
- Best Practices (BS1xxx): Enforce modern C# features and performance improvements
- CQRS Command Rules (BS2xxx): Ensure commands follow CQRS patterns
- Aggregate Rules (BS3xxx): Validate Marten conventions and event sourcing patterns
- Handler Rules (BS4xxx): Enforce Wolverine handler conventions
- Severity: Warning
- Category: EventSourcing
Events represent immutable historical facts and should use C# record types.
❌ Bad:
namespace BookStore.ApiService.Events;
public class BookAdded // Should be a record
{
public Guid Id { get; init; }
public string Title { get; init; }
}✅ Good:
namespace BookStore.ApiService.Events;
public record BookAdded(Guid Id, string Title);- Severity: Warning
- Category: EventSourcing
Event properties must not have mutable setters to preserve historical integrity.
❌ Bad:
namespace BookStore.ApiService.Events;
public record BookAdded
{
public string Title { get; set; } // Should use init
}✅ Good:
namespace BookStore.ApiService.Events;
public record BookAdded(string Title);
// or
public record BookAdded
{
public string Title { get; init; }
}- Severity: Warning
- Category: Architecture
Events should be organized in namespaces ending with .Events for consistency.
❌ Bad:
namespace BookStore.ApiService.Models;
public record BookAdded(Guid Id); // Should be in Events namespace✅ Good:
namespace BookStore.ApiService.Events;
public record BookAdded(Guid Id);- Severity: Warning
- Category: BestPractices
Use Guid.CreateVersion7() (time-ordered UUID v7) instead of Guid.NewGuid() (random UUID v4). Version 7 GUIDs are monotonically increasing, which improves database index performance and provides natural time-based sorting.
❌ Bad:
var id = Guid.NewGuid();✅ Good:
var id = Guid.CreateVersion7();- Severity: Warning
- Category: BestPractices
Use DateTimeOffset.UtcNow for timezone-aware timestamps. DateTime.Now is timezone-local and error-prone in distributed systems; DateTime.UtcNow loses timezone offset information. Both are flagged.
❌ Bad:
var timestamp = DateTime.Now;
var utc = DateTime.UtcNow;✅ Good:
var timestamp = DateTimeOffset.UtcNow;- Severity: Warning
- Category: BestPractices
Use generic math methods (e.g., int.Max, double.Pow) instead of System.Math. This provides better type safety and performance.
❌ Bad:
var max = Math.Max(1, 2);
var val = Math.Ceiling(1.5);✅ Good:
var max = int.Max(1, 2);
var val = double.Ceiling(1.5);- Severity: Warning
- Category: CQRS
Commands are immutable DTOs and should use C# record types.
❌ Bad:
namespace BookStore.ApiService.Commands.Books;
public class CreateBook // Should be a record
{
public string Title { get; init; }
}✅ Good:
namespace BookStore.ApiService.Commands.Books;
public record CreateBook(string Title);- Severity: Warning
- Category: Architecture
Commands should be organized in namespaces ending with .Commands.
❌ Bad:
namespace BookStore.ApiService.Endpoints.Admin;
public record CreateBookRequest(string Title); // Should be in Commands namespace✅ Good:
namespace BookStore.ApiService.Commands.Books;
public record CreateBook(string Title);- Severity: Info (Suggestion)
- Category: CQRS
Command properties should use init-only setters to ensure immutability after construction.
❌ Suboptimal:
namespace BookStore.ApiService.Commands.Books;
public record CreateBook
{
public string Title { get; set; } // Should use init
}✅ Good:
namespace BookStore.ApiService.Commands.Books;
public record CreateBook
{
public string Title { get; init; }
}- Severity: Error
- Category: EventSourcing
Marten requires Apply methods to return void for event application.
❌ Bad:
public class BookAggregate
{
public BookAdded Apply(BookAdded @event) // Should return void
{
Id = @event.Id;
return @event;
}
}✅ Good:
public class BookAggregate
{
void Apply(BookAdded @event)
{
Id = @event.Id;
}
}- Severity: Error
- Category: EventSourcing
Marten requires Apply methods to have exactly one parameter (the event).
❌ Bad:
public class BookAggregate
{
void Apply(BookAdded @event, string reason) // Too many parameters
{
Id = @event.Id;
}
}✅ Good:
public class BookAggregate
{
void Apply(BookAdded @event)
{
Id = @event.Id;
}
}- Severity: Warning
- Category: EventSourcing
Apply methods are called by Marten during rehydration and should not be public.
❌ Bad:
public class BookAggregate
{
public void Apply(BookAdded @event) // Should be private
{
Id = @event.Id;
}
}✅ Good:
public class BookAggregate
{
void Apply(BookAdded @event) // private by default
{
Id = @event.Id;
}
}- Severity: Warning
- Category: EventSourcing
Aggregate command methods generate events for event sourcing and should return event types.
❌ Bad:
public class BookAggregate
{
public void UpdateTitle(string title) // Should return event
{
// ...
}
}✅ Good:
public class BookAggregate
{
public BookTitleUpdated UpdateTitle(string title)
{
return new BookTitleUpdated(Id, title);
}
}- Severity: Warning
- Category: DomainModel
Aggregate state changes should only occur through Apply methods, not direct property setters.
❌ Bad:
public class BookAggregate
{
public Guid Id { get; set; } // Should use init or private set
public string Title { get; set; }
}✅ Good:
public class BookAggregate
{
public Guid Id { get; init; }
public string Title { get; private set; } = string.Empty;
void Apply(BookTitleUpdated @event)
{
Title = @event.Title; // State changes through Apply
}
}- Severity: Info (Suggestion)
- Category: CQRS
Wolverine discovers handlers by the method name Handle.
❌ Suboptimal:
public static class BookHandlers
{
public static IResult ProcessCreateBook(CreateBook cmd) // Should be named Handle
{
// ...
}
}✅ Good:
public static class BookHandlers
{
public static IResult Handle(CreateBook cmd)
{
// ...
}
}- Severity: Warning
- Category: CQRS
Static handler methods provide better performance in Wolverine.
❌ Bad:
public class BookHandlers
{
public IResult Handle(CreateBook cmd) // Should be static
{
// ...
}
}✅ Good:
public static class BookHandlers
{
public static IResult Handle(CreateBook cmd)
{
// ...
}
}- Severity: Info (Suggestion)
- Category: CQRS
Wolverine routes messages based on the first parameter type, which should be from a .Commands namespace.
❌ Suboptimal:
public static IResult Handle(string bookId) // Should accept a command
{
// ...
}✅ Good:
public static IResult Handle(CreateBook cmd)
{
// ...
}You can configure rule severities in .editorconfig:
# Make BS2002 an error instead of warning
dotnet_diagnostic.BS2002.severity = error
# Disable BS4001 if you prefer different handler naming
dotnet_diagnostic.BS4001.severity = noneFor specific cases where you need to suppress a rule:
#pragma warning disable BS2002
public record SpecialRequest(string Data); // Not in Commands namespace for a reason
#pragma warning restore BS2002Or use attributes:
[System.Diagnostics.CodeAnalysis.SuppressMessage("Architecture", "BS2002")]
public record SpecialRequest(string Data);✅ Consistent Architecture: Enforces Event Sourcing and CQRS patterns across the codebase
✅ Early Detection: Catches architectural violations during development
✅ Team Alignment: Helps new developers follow established patterns
✅ Reduced Code Review: Automated checks reduce manual review burden
✅ IDE Integration: Real-time feedback in Visual Studio, VS Code, and Rider
The analyzer includes comprehensive unit tests using actual C# files (not strings) for better maintainability. Tests are organized in TestData folders by diagnostic ID.
Run tests:
dotnet test tests/BookStore.ApiService.Analyzers.UnitTests/BookStore.ApiService.Analyzers.UnitTests.csproj