Compare commits

..

41 Commits

Author SHA1 Message Date
jp f6e67986fa docs(cqrs): add architecture diagram, package index, and getting started guide
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 14:02:19 -04:00
jp 2c5059d947 docs(libraries): add library manifest and discovery index
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 13:59:00 -04:00
jp 5c7736db98 docs(governance): standardize documentation across polyrepo
- CLAUDE.md: repo-specific tech stack, commands, deps (points to root)
- LICENSE: MIT 2026 svrnty (standardized)
- CONTRIBUTING.md: unified workflow, correct co-author email
- SECURITY.md: unified vulnerability reporting policy
- CHANGELOG.md: Keep a Changelog template (if new)
- lefthook.yml: added doc-hygiene hook, improved bootstrap

Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 12:01:24 -04:00
jp 41eb5b97cb chore: add bootstrap-siblings post-commit hook
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:32:15 -04:00
jp c7d9228a88 chore: add post-commit repo registry hook
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:29:59 -04:00
jp 313b8c83ea chore: standardize CLAUDE.md and lefthook hooks
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:22:05 -04:00
jp 3fa59306c2 docs: add security policy
Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-03-05 05:59:26 -05:00
jp 697b36900b docs: standardize documentation structure
- CLAUDE.md: universal development guidelines
- README.md: project description (consistent template)
- CONTRIBUTING.md: contribution workflow
- CHANGELOG.md: version history

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-03-05 05:53:27 -05:00
jp 7ef3e56759 chore: remove perfecto references from CLAUDE.md
- Removed perfecto version header
- Replaced perfecto build instructions with generic guidance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 05:18:04 -05:00
Svrnty acde9ec22a test: add FluentValidation tests for command and query validators
Add tests verifying that FluentValidation integrates correctly with
the CQRS discovery and handler registration pipeline.

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-28 17:36:34 -05:00
Svrnty 2ff8eae75c docs: update CLAUDE.md via perfecto
🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-28 17:35:42 -05:00
Svrnty 7e12f73160 docs: add perfecto-managed documentation triad v1.0.0
Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-28 12:33:15 -05:00
Svrnty 16ca6f722b ci: add release pipeline with NuGet packaging and quality gates
Triggered by tag push (v*) or manual dispatch. Validates semver tag format,
runs build, test, and format check, then packs NuGet package with version
from tag and creates GitHub Release with artifacts attached.

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 22:01:05 -05:00
Svrnty 1f12cc8c59 ci: add CodeQL scanning, format check, and .env.example
Add weekly CodeQL analysis for C# code. Add dotnet format
verification step to CI. Create .env.example documenting
required environment variables.

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 21:09:14 -05:00
Svrnty 7ead822067 ci: fix dotnet version to 10.0.x and add concurrency controls
Change CI dotnet-version from 8.x to 10.0.x to match the project's
net10.0 target framework (security.yml already used 10.0.x). Add
concurrency groups and permissions: contents: read to both workflows.

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 21:03:50 -05:00
Svrnty 346c4ac77c feat: add build/test CI pipeline and dependabot
- Add .NET CI pipeline (restore, build --warnaserror, test on JP branch)
- Add Dependabot for nuget and github-actions ecosystems

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 19:43:43 -05:00
Svrnty 5f3602d071 fix: resolve nullability warnings, add CI/CD and security workflows, harden .gitignore
- Add nullable annotations across discovery interfaces, dynamic query
  models, and filter/aggregate types to eliminate CS8600-series warnings
- Replace unsafe cast in DynamicQueryHandlerBase with pattern match
- Add CI workflow (build --warnaserror + test on JP branch)
- Add weekly security vulnerability scan workflow
- Extend .gitignore with secret/credential patterns (.env, *.key, secrets/, credentials.json)

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 19:28:24 -05:00
Svrnty 92231df745 test: add xUnit test project for Svrnty.CQRS core library
Add tests/Svrnty.CQRS.Tests with 61 tests covering:
- CommandMeta and QueryMeta metadata creation and naming conventions
- CommandDiscovery and QueryDiscovery lookup, existence, and enumeration
- DI service registration for commands (with/without result) and queries
- Full pipeline integration (register -> discover -> resolve)
- CqrsBuilder fluent API configuration
- CqrsConfiguration generic storage and mapping callbacks
- Handler execution via DI-resolved instances

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 18:38:44 -05:00
Svrnty 5a35e23234 chore: add .editorconfig with standard C# conventions
Adds EditorConfig for consistent formatting across the solution:
- 4-space indentation, UTF-8 encoding, LF line endings
- Standard .NET naming conventions (_camelCase private fields, PascalCase types)
- File-scoped namespace preference matching existing code style
- Allman brace style, var preferences, expression-bodied member rules
- Appropriate indentation for XML/JSON/proto files

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 18:37:51 -05:00
Svrnty 148a9573e0 chore: remove .DS_Store files, add to .gitignore, add commit authorship rule
Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 18:21:25 -05:00
Svrnty 9ed9400e4d docs: add GRAPH.md with unicode architecture diagrams, update CLAUDE.md
- Create GRAPH.md: package layers, metadata-driven endpoint flow
- Add GRAPH.md maintenance rule to CLAUDE.md

Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-02-27 14:31:02 -05:00
Svrnty 3df094b9e7 docs: sanitise product references, add "Where This Fits" to README
Co-Authored-By: Svrnty Inc. <jp@svrnty.io, mathias@svrnty.io>
2026-02-27 13:08:17 -05:00
david.nguyen 6aece5a769 Handle generic types in proto message name generation
Publish NuGets / build (release) Successful in 39s
Generic types like Translation<T> now produce qualified message names
(e.g. TranslationOfFaqTranslationQueryItem) to avoid duplicate message
definitions in generated .proto files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:56:37 -05:00
david.nguyen b372805c4e Fix string filter values not converting to correct CLR types for DynamicQuery
Publish NuGets / build (release) Successful in 41s
Convert string filter values (e.g. from gRPC transport) to their actual
property types (DateTime, DateTimeOffset) so PoweredSoft.DynamicLinq can
build valid LINQ expressions. Also removes filters with null values and
recurses into composite filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:28:09 -05:00
david.nguyen 89ccbe990f add AND / OR support when filtering
Publish NuGets / build (release) Successful in 34s
2026-02-02 17:53:43 -05:00
david.nguyen 433b852a43 Refactor proto generation from source generator to MSBuild task
Publish NuGets / build (release) Successful in 40s
Replace ProtoFileSourceGenerator and WriteProtoFileTask with a new
GenerateProtoFileTask that creates its own Roslyn compilation. This
solves the timing issue where source generators run too late for
Grpc.Tools to process the generated proto files.

The new task runs after ResolveAssemblyReferences but before
_gRPC_GetProtoc and CoreCompile, ensuring the proto file exists
when Grpc.Tools needs it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 14:35:56 -05:00
david.nguyen 03041721ca Preserve existing proto files instead of overwriting
Publish NuGets / build (release) Successful in 39s
If a proto file already exists (committed to repo), don't overwrite it
with a placeholder. This allows first-time builds to work correctly
when the proto file is already in the repository.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 12:05:45 -05:00
david.nguyen 05449b9a28 Add empty service definitions to placeholder proto
Grpc.Tools needs service definitions to generate the base classes
(CommandService+CommandServiceBase, etc.) that GrpcGenerator looks for.
Without these, the service bases wouldn't exist on first build.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 02:21:43 -05:00
david.nguyen dfbef9d161 Fix placeholder proto namespace to use project's actual namespace
The WriteProtoFileTask now receives RootNamespace and AssemblyName from
MSBuild and uses them for the placeholder proto's csharp_namespace instead
of hardcoded "Generated.Grpc". This ensures GrpcGenerator can find the
service base types on first build, enabling gRPC service registration
to work without requiring a second build.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 02:19:50 -05:00
david.nguyen 377977b080 Make GeneratedProtoFile class public for cross-assembly discovery
Publish NuGets / build (release) Successful in 34s
The generated proto file holder class was internal, preventing
reflection-based service registration from discovering it across
assembly boundaries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 00:31:34 -05:00
david.nguyen 20147bfec7 Add diagnostic logging when gRPC generated code not found
Publish NuGets / build (release) Successful in 37s
When AddGrpcFromConfiguration method is not found via reflection,
logs detailed diagnostics to help identify the root cause:
- Entry assembly name and total type count
- All Grpc-related types with their IsClass/IsSealed/IsPublic flags
- Whether the target method exists on each type
- ReflectionTypeLoadException details if type loading fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:10:18 -05:00
david.nguyen 18f81a28e8 Add authorization checks to gRPC service implementations
Publish NuGets / build (release) Successful in 44s
- Add ICommandAuthorizationService check to CommandServiceImpl
- Add IQueryAuthorizationService check to QueryServiceImpl
- Add IQueryAuthorizationService check to DynamicQueryServiceImpl
- Return Unauthenticated/PermissionDenied gRPC status codes
- Use global:: prefix for Grpc.Core namespace to avoid conflicts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:18:07 -05:00
david.nguyen 201768e716 Revert AllowAnonymous endpoint propagation
Publish NuGets / build (release) Successful in 35s
Remove the WithAllowAnonymousIfAttributePresent helper method.
Authorization should be handled by IQueryAuthorizationService and
ICommandAuthorizationService implementations, not by ASP.NET Core
middleware.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:07:03 -05:00
david.nguyen 932ee6e632 add AllowAnonymous support for MinimalApi endpoints
Publish NuGets / build (release) Successful in 37s
Endpoints with [AllowAnonymous] attribute on query/command class
now bypass ASP.NET Core authorization middleware.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:29:26 -05:00
jp 4bf03446c0 docs: update .NET 8 references to .NET 10
Publish NuGets / build (release) Successful in 50s
Consolidated roadmap to show .NET 10 with C# 14 as current target.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:22:27 -05:00
david.nguyen 227be70f95 Fix MinimalApi to resolve authorization services per-request
- Resolve ICommandAuthorizationService and IQueryAuthorizationService from request-scoped serviceProvider
- Allows Scoped authorization services that depend on DbContext
- Updated both MinimalApi and DynamicQuery.MinimalApi

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:56:31 -05:00
david.nguyen bd43bc9bde Fix gRPC source generator for complex nested types
- Add DateTime/Timestamp conversion in nested property mapping
- Add IsReadOnly property detection to skip computed properties
- Extract ElementNestedProperties for complex list element types
- Skip read-only properties in GenerateComplexObjectMapping and GenerateComplexListMapping

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:25:01 -05:00
david.nguyen 661f5b4b1c Fix GrpcGenerator type mapping for commands and nullable primitives
- Add proper complex type mapping for command results (same as queries already had)
- Handle nullable primitives (long?, int?, etc.) with default value fallback
- Fixes CS0029 and CS0266 compilation errors in generated gRPC service implementations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:29:32 -05:00
david.nguyen 99aebcf314 Fix proto generation for collection types (NpgsqlPolygon, etc.)
- Add IsCollectionTypeByInterface() to detect types implementing IList<T>, ICollection<T>, IEnumerable<T>
- Add GetCollectionElementTypeByInterface() to extract element type from collection interfaces
- Add IsCollectionInternalProperty() to filter out Count, Capacity, IsReadOnly, etc.
- Update GenerateComplexTypeMessage to generate `repeated T items` for collection types
- Filter out indexers (!p.IsIndexer) and collection-internal properties from all property extraction

This fixes the invalid proto syntax where C# indexers (this[]) were being generated
as proto fields, causing proto compilation errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:33:55 -05:00
Mathias Beaulieu-Duncan f76dbb1a97 fix: add Guid to string conversion in gRPC source generator
The MapToProtoModel function was silently failing when mapping Guid
properties to proto string fields, causing IDs to be empty in gRPC
responses. Added explicit Guid → string conversion handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:06:18 -05:00
Mathias Beaulieu-Duncan 9b9e2cbdbe added domain events, fix IQueryalbeProvider abstraction, added support for sagas and RabbitMQ 2025-12-20 15:13:05 -05:00
108 changed files with 9543 additions and 1196 deletions
Vendored
BIN
View File
Binary file not shown.
+261
View File
@@ -0,0 +1,261 @@
# Top-most EditorConfig file
root = true
# All files
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# XML project files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
indent_size = 2
# XML config files
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
indent_size = 2
# JSON and YAML files
[*.{json,yml,yaml}]
indent_size = 2
# Markdown files
[*.md]
trim_trailing_whitespace = false
# Proto files
[*.proto]
indent_size = 2
# Solution files
[*.sln]
indent_style = tab
# C# files
[*.cs]
#### Core EditorConfig Options ####
# Indentation and spacing
indent_size = 4
indent_style = space
tab_width = 4
#### .NET Coding Conventions ####
# Organise usings
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
# this. and Me. preferences
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
dotnet_style_qualification_for_method = false:suggestion
dotnet_style_qualification_for_event = false:suggestion
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
# Expression-level preferences
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true:silent
dotnet_style_prefer_conditional_expression_over_return = true:silent
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
# Field preferences
dotnet_style_readonly_field = true:suggestion
# Parameter preferences
dotnet_code_quality_unused_parameters = all:suggestion
#### C# Coding Conventions ####
# var preferences
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Expression-bodied members
csharp_style_expression_bodied_methods = when_on_single_line:silent
csharp_style_expression_bodied_constructors = false:silent
csharp_style_expression_bodied_operators = when_on_single_line:silent
csharp_style_expression_bodied_properties = when_on_single_line:suggestion
csharp_style_expression_bodied_indexers = when_on_single_line:suggestion
csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
csharp_style_expression_bodied_lambdas = when_on_single_line:silent
csharp_style_expression_bodied_local_functions = when_on_single_line:silent
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_prefer_not_pattern = true:suggestion
csharp_style_prefer_pattern_matching = true:silent
csharp_style_prefer_switch_expression = true:suggestion
# Null-checking preferences
csharp_style_conditional_delegate_call = true:suggestion
csharp_style_prefer_null_check_over_type_check = true:suggestion
# Code-block preferences
csharp_prefer_braces = true:silent
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_prefer_method_group_conversion = true:silent
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_index_operator = true:suggestion
csharp_style_prefer_range_operator = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_throw_expression = true:suggestion
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:suggestion
# Namespace preferences
csharp_style_namespace_declarations = file_scoped:suggestion
#### C# Formatting Rules ####
# New line preferences
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = one_less_than_current
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents_when_block = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_after_comma = true
csharp_space_before_comma = false
csharp_space_after_dot = false
csharp_space_before_dot = false
csharp_space_after_semicolon_in_for_statement = true
csharp_space_before_semicolon_in_for_statement = false
csharp_space_around_declaration_statements = false
csharp_space_before_open_square_brackets = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_statements = false
csharp_preserve_single_line_blocks = true
#### Naming Conventions ####
# Naming rules
dotnet_naming_rule.interface_should_begin_with_i.severity = suggestion
dotnet_naming_rule.interface_should_begin_with_i.symbols = interface
dotnet_naming_rule.interface_should_begin_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.severity = suggestion
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with_underscore.style = camel_case_with_underscore
dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields
dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.async_methods_should_end_with_async.severity = suggestion
dotnet_naming_rule.async_methods_should_end_with_async.symbols = async_methods
dotnet_naming_rule.async_methods_should_end_with_async.style = ends_with_async
dotnet_naming_rule.type_parameters_should_begin_with_t.severity = suggestion
dotnet_naming_rule.type_parameters_should_begin_with_t.symbols = type_parameters
dotnet_naming_rule.type_parameters_should_begin_with_t.style = begins_with_t
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = private, internal, private_protected
dotnet_naming_symbols.constant_fields.applicable_kinds = field
dotnet_naming_symbols.constant_fields.applicable_accessibilities = *
dotnet_naming_symbols.constant_fields.required_modifiers = const
dotnet_naming_symbols.async_methods.applicable_kinds = method
dotnet_naming_symbols.async_methods.applicable_accessibilities = *
dotnet_naming_symbols.async_methods.required_modifiers = async
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
# Naming styles
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.camel_case_with_underscore.required_prefix = _
dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case
dotnet_naming_style.ends_with_async.required_suffix = Async
dotnet_naming_style.ends_with_async.capitalization = pascal_case
dotnet_naming_style.begins_with_t.required_prefix = T
dotnet_naming_style.begins_with_t.capitalization = pascal_case
+9
View File
@@ -0,0 +1,9 @@
# dotnet-cqrs Environment Configuration
# Copy to .env and fill in values before running
# NuGet publishing (required for dotnet pack + push)
NUGET_API_KEY=
# Application URLs (for Svrnty.Sample project)
ASPNETCORE_URLS=http://localhost:19898
ASPNETCORE_ENVIRONMENT=Development
+35
View File
@@ -0,0 +1,35 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: weekly
target-branch: JP
open-pull-requests-limit: 3
labels:
- "dependencies"
groups:
nuget-all:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
target-branch: JP
open-pull-requests-limit: 1
labels:
- "ci"
- "dependencies"
groups:
actions-all:
patterns:
- "*"
update-types:
- minor
- patch
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
push:
branches: [JP]
pull_request:
branches: [JP]
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore
run: dotnet restore Svrnty.CQRS.sln
- name: Build
run: dotnet build Svrnty.CQRS.sln --no-restore --warnaserror
- name: Test
run: dotnet test Svrnty.CQRS.sln --no-build --verbosity normal
- name: Format check
run: dotnet format Svrnty.CQRS.sln --verify-no-changes
+47
View File
@@ -0,0 +1,47 @@
name: CodeQL
on:
push:
branches: [JP]
pull_request:
branches: [JP]
schedule:
- cron: "0 8 * * 1" # Weekly on Monday at 08:00 UTC
concurrency:
group: codeql-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read
security-events: write
jobs:
analyze:
name: CodeQL Analysis
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [csharp]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Build
run: dotnet build Svrnty.CQRS.sln
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
+86
View File
@@ -0,0 +1,86 @@
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v1.2.0)"
required: true
type: string
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
jobs:
release:
name: Validate, Build, Pack & Release
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="${{ inputs.tag }}"
fi
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Tag must match semver format (vX.Y.Z[-suffix]): got ${TAG}"
exit 1
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore
run: dotnet restore Svrnty.CQRS.sln
- name: Build
run: dotnet build Svrnty.CQRS.sln --no-restore --configuration Release --warnaserror
- name: Test
run: dotnet test Svrnty.CQRS.sln --no-build --configuration Release --verbosity normal
- name: Format check
run: dotnet format Svrnty.CQRS.sln --verify-no-changes
- name: Pack NuGet packages
run: |
dotnet pack Svrnty.CQRS.sln \
--no-build \
--configuration Release \
--output ./artifacts \
-p:Version=${{ steps.tag.outputs.version }}
- name: Upload NuGet artifacts
uses: actions/upload-artifact@v4
with:
name: nuget-packages
path: ./artifacts/*.nupkg
retention-days: 30
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
generate_release_notes: true
files: |
artifacts/*.nupkg
artifacts/*.snupkg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+34
View File
@@ -0,0 +1,34 @@
name: Security
on:
push:
branches: [JP]
pull_request:
branches: [JP]
schedule:
- cron: "0 6 * * 1" # Weekly on Monday at 06:00 UTC
concurrency:
group: security-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
vulnerability-scan:
name: .NET vulnerability scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore dependencies
run: dotnet restore
- name: Check for vulnerable packages
run: dotnet list package --vulnerable --include-transitive
+11 -1
View File
@@ -4,6 +4,7 @@
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.research/
.DS_Store
# User-specific files
*.rsuser
@@ -339,4 +340,13 @@ ASALocalRun/
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
healthchecksdb
# Secrets and credentials
.env
.env.local
.env.*
*.key
secrets/
.aws/
credentials.json
+14
View File
@@ -0,0 +1,14 @@
name: dotnet-cqrs
description: Modern CQRS framework for .NET with gRPC source generation and HTTP Minimal API support
owner: mathias@svrnty.io
layer: L3
stack: C# 14/.NET 10
status: stable
dependencies: []
dependents:
- flutter_cqrs_datasource
- a-gent-app
entry_points:
readme: README.md
registry: null
schemas: Svrnty.CQRS.sln
+22
View File
@@ -0,0 +1,22 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `.library-manifest.yaml` for cross-repo discovery and dependency tracking
- Initial project setup
- `docs/ARCHITECTURE.md` -- package dependency graph, CQRS data flows, saga flow, separation of concerns
- `docs/PACKAGE_INDEX.md` -- per-package reference for all 18 NuGet packages with key types and dependencies
- `docs/GETTING_STARTED.md` -- step-by-step guide covering handler registration, gRPC setup, MinimalApi, DynamicQuery, domain events, sagas, and notifications
### Changed
- Updated README.md to reflect correct package count (18), added links to new docs, added Related Libraries section linking to flutter_cqrs_datasource
### Fixed
### Removed
+36 -402
View File
@@ -1,413 +1,47 @@
# CLAUDE.md
# Development Guidelines
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **Source of truth**: All engineering principles, commit rules, documentation standards, and governance policies are defined in the [root CLAUDE.md](../CLAUDE.md). This file contains repo-specific notes only.
## Project Overview
## Quick Reference
This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Segregation (CQRS) for .NET 10. It was forked from PoweredSoft.CQRS and provides:
- **Branch**: `JP` for active development
- **Commit format**: `type(scope): message`
- **Co-Author**: `Co-Authored-By: Svrnty Inc. <jp@svrnty.io>`
- **Hooks**: `lefthook install` — enforces author, secrets, doc hygiene
- **Docs required**: README.md, CHANGELOG.md, LICENSE, CONTRIBUTING.md, SECURITY.md
- CQRS pattern implementation with command/query handlers exposed via HTTP or gRPC
- Automatic HTTP endpoint generation via Minimal API
- Automatic gRPC endpoint generation with source generators and Google Rich Error Model validation
- Dynamic query capabilities (filtering, sorting, grouping, aggregation)
- FluentValidation support with RFC 7807 Problem Details (HTTP) and Google Rich Error Model (gRPC)
- AOT (Ahead-of-Time) compilation compatibility for core packages (where dependencies allow)
## Tech Stack
## Solution Structure
| Tool | Version |
|------|---------|
| C# | 14 |
| .NET | 10.0 |
| AOT | enabled (IsAotCompatible=true) |
| Nullable | enabled |
The solution contains 11 projects organized by responsibility (10 packages + 1 sample project):
## Commands
**Abstractions (interfaces and contracts only):**
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts)
- `Svrnty.CQRS.DynamicQuery.Abstractions` - Dynamic query interfaces (multi-targets netstandard2.1 and net10.0)
- `Svrnty.CQRS.Grpc.Abstractions` - gRPC-specific interfaces and contracts
| Command | Description |
|---------|-------------|
| `dotnet build` | Build all 18 projects |
| `dotnet test` | Run tests |
| `dotnet format` | Format code |
**Implementation:**
- `Svrnty.CQRS` - Core discovery and registration logic
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries (recommended for HTTP)
- `Svrnty.CQRS.DynamicQuery` - PoweredSoft.DynamicQuery integration for advanced filtering
- `Svrnty.CQRS.DynamicQuery.MinimalApi` - Minimal API endpoint mapping for dynamic queries
- `Svrnty.CQRS.FluentValidation` - Validation integration helpers
- `Svrnty.CQRS.Grpc` - gRPC service implementation support
- `Svrnty.CQRS.Grpc.Generators` - Source generator for .proto files and gRPC service implementations
## Key Dependencies
**Sample Projects:**
- `Svrnty.Sample` - Comprehensive demo project showcasing both HTTP and gRPC endpoints
| Package | Description |
|---------|-------------|
| Svrnty.CQRS.Core | Core CQRS abstractions |
| Svrnty.CQRS.DynamicQuery | Dynamic query support |
| Svrnty.CQRS.gRPC | gRPC transport |
| Svrnty.CQRS.Events | Event sourcing |
| Svrnty.CQRS.Sagas | Saga orchestration |
| Svrnty.CQRS.Notifications | Notification handlers |
| Svrnty.CQRS.MinimalApi | Minimal API bindings |
**Key Design Principle:** Abstractions projects contain ONLY interfaces/attributes with minimal dependencies. Implementation projects depend on abstractions. This allows consumers to reference abstractions without pulling in heavy implementation dependencies.
## Repo-Specific Notes
## Build Commands
```bash
# Restore dependencies
dotnet restore
# Build entire solution
dotnet build
# Build in Release mode
dotnet build -c Release
# Create NuGet packages (with version)
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0
# Build specific project
dotnet build Svrnty.CQRS/Svrnty.CQRS.csproj
```
## Testing
This repository does not currently contain test projects. When adding tests:
- Place them in a `tests/` directory or alongside source projects
- Name them with `.Tests` suffix (e.g., `Svrnty.CQRS.Tests`)
## Architecture
### Core CQRS Pattern
The framework uses handler interfaces that follow this pattern:
```csharp
// Command with no result
ICommandHandler<TCommand>
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)
// Command with result
ICommandHandler<TCommand, TResult>
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default)
// Query (always returns result)
IQueryHandler<TQuery, TResult>
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)
```
### Metadata-Driven Discovery
The framework uses a **metadata pattern** for runtime discovery:
1. When you register a handler using `services.AddCommand<TCommand, THandler>()`, it:
- Registers the handler in DI as `ICommandHandler<TCommand, THandler>`
- Creates metadata (`ICommandMeta`) describing the command type, handler type, and result type
- Stores metadata as singleton in DI
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) implemented in `Svrnty.CQRS`:
- Query all registered metadata from DI container
- Provide lookup methods: `GetCommand(string name)`, `GetCommands()`, etc.
3. Endpoint mapping (HTTP and gRPC) uses discovery to:
- Enumerate all registered commands/queries
- Dynamically generate endpoints at application startup
- Apply naming conventions (convert to lowerCamelCase)
- Generate gRPC service implementations via source generators
**Key Files:**
- `Svrnty.CQRS.Abstractions/Discovery/` - Metadata interfaces
- `Svrnty.CQRS/Discovery/` - Discovery implementations
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint generation
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - Dynamic query endpoint generation
- `Svrnty.CQRS.Grpc.Generators/` - gRPC service generation via source generators
### Integration Options
There are two primary integration options for exposing commands and queries:
#### Option 1: gRPC (Recommended for performance-critical scenarios)
The **Svrnty.CQRS.Grpc** package with **Svrnty.CQRS.Grpc.Generators** source generator provides high-performance gRPC endpoints:
**Registration:**
```csharp
var builder = WebApplication.CreateBuilder(args);
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Add gRPC support
builder.Services.AddGrpc();
var app = builder.Build();
// Map auto-generated gRPC service implementations
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcService<QueryServiceImpl>();
// Enable gRPC reflection for tools like grpcurl
app.MapGrpcReflectionService();
app.Run();
```
**How It Works:**
1. Define `.proto` files in `Protos/` directory with your commands/queries as messages
2. Source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
3. Property names in C# commands must match proto field names (case-insensitive)
4. FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
5. Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
**Features:**
- High-performance binary protocol
- Automatic service implementation generation at compile time
- Google Rich Error Model for structured validation errors
- Full FluentValidation integration
- gRPC reflection support for development tools
- Suitable for microservices, internal APIs, and low-latency scenarios
**Key Files:**
- `Svrnty.CQRS.Grpc/` - Runtime support for gRPC services
- `Svrnty.CQRS.Grpc.Generators/` - Source generator for service implementations
#### Option 2: HTTP via Minimal API (Recommended for web/browser scenarios)
The **Svrnty.CQRS.MinimalApi** package provides HTTP endpoints for CQRS commands and queries:
**Registration:**
```csharp
var builder = WebApplication.CreateBuilder(args);
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add your commands and queries
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
// Add Swagger (optional)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Map endpoints (this creates routes automatically)
app.MapSvrntyCommands(); // Maps all commands to POST /api/command/{name}
app.MapSvrntyQueries(); // Maps all queries to POST/GET /api/query/{name}
app.Run();
```
**How It Works:**
1. Extension methods iterate through `ICommandDiscovery` and `IQueryDiscovery`
2. For each command/query, creates Minimal API endpoints using `MapPost()`/`MapGet()`
3. Applies naming conventions (lowerCamelCase)
4. Respects `[CommandControllerIgnore]` and `[QueryControllerIgnore]` attributes
5. Integrates with `ICommandAuthorizationService` and `IQueryAuthorizationService`
6. Supports OpenAPI/Swagger documentation
**Features:**
- Queries support both POST (with JSON body) and GET (with query string parameters)
- Commands only support POST with JSON body
- Authorization via authorization services (returns 401/403 status codes)
- Customizable route prefixes: `MapSvrntyCommands("my-prefix")`
- Automatic OpenAPI tags: "Commands" and "Queries"
- RFC 7807 Problem Details for validation errors
- Full Swagger/OpenAPI support
**Key Files:**
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - Main implementation
#### Option 3: Both gRPC and HTTP (Dual Protocol Support)
You can enable both protocols simultaneously, allowing clients to choose their preferred protocol:
```csharp
var builder = WebApplication.CreateBuilder(args);
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
builder.Services.AddDefaultQueryDiscovery();
// Add commands and queries
AddCommands(builder.Services);
AddQueries(builder.Services);
// Add both gRPC and HTTP support
builder.Services.AddGrpc();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Map both gRPC and HTTP endpoints
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcService<QueryServiceImpl>();
app.MapGrpcReflectionService();
app.MapSvrntyCommands();
app.MapSvrntyQueries();
app.Run();
```
**Benefits:**
- Single codebase supports multiple protocols
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
- HTTP for web browsers, legacy clients, and public APIs
- Same commands, queries, and validation logic for both protocols
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
### Dynamic Query System
Dynamic queries provide OData-like filtering capabilities:
**Core Components:**
- `IDynamicQuery<TSource, TDestination>` - Interface with GetFilters(), GetSorts(), GetGroups(), GetAggregates()
- `IQueryableProvider<TSource>` - Provides base IQueryable to query against
- `IAlterQueryableService<TSource, TDestination>` - Middleware to modify queries (e.g., security filters)
- `DynamicQueryHandler<TSource, TDestination>` - Executes queries using PoweredSoft.DynamicQuery
**Request Flow:**
1. HTTP request with filters/sorts/aggregates
2. Minimal API endpoint receives request
3. DynamicQueryHandler gets base queryable from IQueryableProvider
4. Applies alterations from all registered IAlterQueryableService instances
5. Builds PoweredSoft query criteria
6. Executes and returns IQueryExecutionResult
**Registration Example:**
```csharp
// Register dynamic query
services.AddDynamicQuery<Person, PersonDto>()
.AddDynamicQueryWithProvider<Person, PersonQueryableProvider>()
.AddAlterQueryable<Person, PersonDto, SecurityFilter>();
// Map dynamic query endpoints
app.MapSvrntyDynamicQueries(); // Creates POST/GET /api/query/{queryName} endpoints
```
**Key Files:**
- `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs` - Query execution logic
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint mapping
## Package Configuration
All projects target .NET 10.0 and use C# 14, sharing common configuration:
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions which multi-targets `netstandard2.1;net10.0`)
- **Language Version**: C# 14
- **IsAotCompatible**: Currently set but not enforced (many dependencies are not AOT-compatible yet)
- **Symbols**: Portable debug symbols with source, published as `.snupkg`
- **NuGet metadata**: Icon, README, license (MIT), and repository URL included in packages
- **Authors**: David Lebee, Mathias Beaulieu-Duncan
- **Repository**: https://git.openharbor.io/svrnty/dotnet-cqrs
### Package Dependencies
**Core Dependencies:**
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0
- **FluentValidation**: 11.11.0
- **PoweredSoft.DynamicQuery**: 3.0.1
- **Pluralize.NET**: 1.0.2
**gRPC Dependencies (for Svrnty.CQRS.Grpc):**
- **Grpc.AspNetCore**: 2.68.0 or later
- **Grpc.AspNetCore.Server.Reflection**: 2.71.0 or later (optional, for reflection)
- **Grpc.StatusProto**: 2.71.0 or later (for Rich Error Model validation)
- **Grpc.Tools**: 2.76.0 or later (for .proto compilation)
**Source Generator Dependencies (for Svrnty.CQRS.Grpc.Generators):**
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final
- **Microsoft.CodeAnalysis.Analyzers**: 3.11.0
- **Microsoft.Build.Utilities.Core**: 17.0.0
- Targets: netstandard2.0 (for Roslyn compatibility)
## Publishing
NuGet packages are published automatically via GitHub Actions when a release is created:
**Workflow:** `.github/workflows/publish-nugets.yml`
1. Triggered on release publication
2. Extracts version from release tag
3. Runs `dotnet pack -c Release -p:Version={tag}`
4. Pushes to NuGet.org using `NUGET_API_KEY` secret
**Manual publish:**
```bash
# Create packages with specific version
dotnet pack -c Release -o ./artifacts -p:Version=1.2.3
# Push to NuGet
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key YOUR_KEY
```
## Development Workflow
**Adding a New Command/Query Handler:**
1. Create command/query POCO in consumer project
2. Implement handler: `ICommandHandler<TCommand, TResult>`
3. Register in DI: `services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>()`
4. (Optional) Add validator: `services.AddTransient<IValidator<CreatePersonCommand>, Validator>()`
5. Controller endpoint is automatically generated
**Adding a New Feature to Framework:**
1. Add interface to appropriate Abstractions project
2. Implement in corresponding implementation project
3. Update ServiceCollectionExtensions with registration method
4. Ensure all projects maintain AOT compatibility (unless AspNetCore-specific)
5. Update package version and release notes
**Naming Conventions:**
- Commands/Queries: Use `[CommandName]` or `[QueryName]` attribute for custom names
- Default naming: Strips "Command"/"Query" suffix, converts to lowerCamelCase
- Example: `CreatePersonCommand` -> `createPerson` endpoint
## C# 14 Language Features
The project now uses C# 14, which introduces several new features. Be aware of these breaking changes:
**Potential Breaking Changes:**
- **`field` keyword**: New contextual keyword in property accessors for implicit backing fields
- **`extension` keyword**: Reserved for extension containers; use `@extension` for identifiers
- **`partial` return type**: Cannot use `partial` as return type without escaping
- **Span<T> overload resolution**: New implicit conversions may select different overloads
- **`scoped` as lambda modifier**: Always treated as modifier in lambda parameters
**New Features Available:**
- Extension members (static extension members and extension properties)
- Implicit span conversions
- Unbound generic types with `nameof`
- Lambda parameter modifiers without type specification
- Partial instance constructors and events
- Null-conditional assignment (`?.=` and `?[]=`)
The codebase currently compiles without warnings on C# 14.
## Important Implementation Notes
1. **AOT Compatibility**: Currently not enforced. The `IsAotCompatible` property is set on some projects but many dependencies (including FluentValidation, PoweredSoft.DynamicQuery) are not AOT-compatible. Future work may address this.
2. **Async Everywhere**: All handlers are async. Always support CancellationToken.
3. **Generic Type Safety**: Framework relies heavily on generics for compile-time safety. When adding features, maintain strong typing.
4. **Metadata Pattern**: When extending discovery, always create corresponding metadata classes (implement ICommandMeta/IQueryMeta).
5. **Endpoint Mapping Timing**: Endpoints are mapped at application startup. Discovery services must be registered before calling `MapSvrntyCommands()`/`MapSvrntyQueries()` or mapping gRPC services.
6. **FluentValidation Integration**:
- For HTTP: Validation happens automatically in the Minimal API pipeline. Errors return RFC 7807 Problem Details.
- For gRPC: Validation happens automatically via source-generated services. Errors return Google Rich Error Model with structured FieldViolations.
- The framework REGISTERS validators in DI; actual validation execution is handled by the endpoint implementations.
7. **DynamicQuery Interceptors**: Support up to 5 interceptors per query type. Interceptors modify PoweredSoft DynamicQuery behavior.
## Common Code Locations
- Handler interfaces: `Svrnty.CQRS.Abstractions/ICommandHandler.cs`, `IQueryHandler.cs`
- Discovery implementations: `Svrnty.CQRS/Discovery/`
- Service registration: `*/ServiceCollectionExtensions.cs` in each project
- HTTP endpoint mapping: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
- Dynamic query logic: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
- Dynamic query endpoints: `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs`
- gRPC support: `Svrnty.CQRS.Grpc/` runtime, `Svrnty.CQRS.Grpc.Generators/` source generators
- Sample application: `Svrnty.Sample/` - demonstrates both HTTP and gRPC integration
- Solution file: `Svrnty.CQRS.sln` with 18 projects.
- Lint is handled by .NET analyzers — AOT compatibility and nullable reference types are enforced.
- No Docker or proto files in this repo.
- Published under the `svrnty` org (git.openharbor.io/svrnty), not `a-gent`.
+52
View File
@@ -0,0 +1,52 @@
# Contributing
Thank you for your interest in contributing to this project.
## Development Guidelines
See [CLAUDE.md](./CLAUDE.md) for development practices, engineering principles, and coding standards.
## How to Contribute
1. **Fork & Clone**
```bash
git clone <your-fork-url>
cd <project>
git checkout JP
```
2. **Create a Branch**
```bash
git checkout -b feature/your-feature-name
```
3. **Make Changes**
- Follow the guidelines in CLAUDE.md
- Keep changes focused and minimal
- Write tests if applicable
4. **Validate**
- Run format checks
- Run lint checks
- Run test suite
5. **Commit**
```bash
git commit -m "feat: your change description"
```
AI-authored commits must include:
```
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
```
6. **Push & Create PR**
```bash
git push origin feature/your-feature-name
```
- Open a PR against the `JP` branch
- Provide clear description of changes
## Questions?
Open an issue for questions or discussions.
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 Powered Softwares Inc.
Copyright (c) 2026 svrnty
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+50 -243
View File
@@ -1,274 +1,81 @@
> This project was originally initiated by [Powered Software Inc.](https://poweredsoft.com/) and was forked from the [PoweredSoft.CQRS](https://github.com/PoweredSoft/CQRS) Repository
# Svrnty.CQRS
# CQRS
> Modern CQRS framework for .NET with gRPC source generation and HTTP Minimal API support.
Our implementation of query and command responsibility segregation (CQRS).
## Where This Fits
## Getting Started
**Layer**: libs
**Depends on**: Nothing (standalone .NET framework)
**Depended on by**: a-gent-app (backend services), flutter-cqrs-datasource (client)
**Git**: git.openharbor.io/svrnty/dotnet-cqrs.git
> Install nuget package to your awesome project.
## Tech Stack
| Package Name | NuGet | NuGet Install |
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
| Svrnty.CQRS | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
| Svrnty.CQRS.MinimalApi | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.MinimalApi.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
| Svrnty.CQRS.FluentValidation | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.FluentValidation.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
| Svrnty.CQRS.DynamicQuery | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
| Svrnty.CQRS.DynamicQuery.MinimalApi | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.MinimalApi.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.MinimalApi/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi ``` |
| Svrnty.CQRS.Grpc | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
| Svrnty.CQRS.Grpc.Generators | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.Generators.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
- **Language**: C# 14 / .NET 10
- **Framework**: ASP.NET Core Minimal API, gRPC
- **Key Dependencies**: FluentValidation 11.x, Grpc.AspNetCore, PoweredSoft.DynamicQuery
> Abstractions Packages.
| Package Name | NuGet | NuGet Install |
| ---------------------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -----------------------------------------------------: |
| Svrnty.CQRS.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Abstractions/) | ```dotnet add package Svrnty.CQRS.Abstractions ``` |
| Svrnty.CQRS.DynamicQuery.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.DynamicQuery.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.Abstractions/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions ``` |
| Svrnty.CQRS.Grpc.Abstractions | [![NuGet](https://img.shields.io/nuget/v/Svrnty.CQRS.Grpc.Abstractions.svg?style=flat-square&label=nuget)](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Abstractions/) | ```dotnet add package Svrnty.CQRS.Grpc.Abstractions ``` |
## Sample of startup code for gRPC (Recommended)
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
var builder = WebApplication.CreateBuilder(args);
// Register your commands with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Register your queries
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Configure CQRS with gRPC support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
});
var app = builder.Build();
// Map all configured CQRS endpoints
app.UseSvrntyCqrs();
app.Run();
```
### Important: gRPC Requirements
The gRPC implementation uses **Grpc.Tools** with `.proto` files and **source generators** for automatic service implementation:
#### 1. Install required packages:
## Quick Start
```bash
dotnet add package Grpc.AspNetCore
dotnet add package Grpc.AspNetCore.Server.Reflection
dotnet add package Grpc.StatusProto # For Rich Error Model validation
# Build
dotnet build
# Run
dotnet run --project Svrnty.Sample
# Test
dotnet test
```
#### 2. Add the source generator as an analyzer:
## Architecture
```bash
dotnet add package Svrnty.CQRS.Grpc.Generators
```
18 NuGet packages organized by concern:
The source generator is automatically configured as an analyzer when installed via NuGet and will generate both the `.proto` files and gRPC service implementations at compile time.
- **Abstractions**: Core interfaces (ICommandHandler, IQueryHandler, IDomainEvent, ISaga, INotificationPublisher)
- **Core**: Discovery, registration, handler execution, CqrsBuilder fluent API
- **MinimalApi**: HTTP endpoint mapping with RFC 7807 validation
- **Grpc**: gRPC service support with Google Rich Error Model
- **Grpc.Generators**: Roslyn source generator for .proto files and service implementations
- **DynamicQuery**: PoweredSoft integration for filtering, sorting, paging (with EF Core support)
- **FluentValidation**: Validator registration helpers
- **Events**: Domain event publishing (with RabbitMQ transport)
- **Sagas**: Saga orchestration pattern with compensation and distributed execution (with RabbitMQ transport)
- **Notifications**: Real-time notification streaming (with gRPC transport)
#### 3. Define your C# commands and queries:
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for a full dependency diagram and data flow.
## Configuration
```csharp
public record AddUserCommand
{
public required string Name { get; init; }
public required string Email { get; init; }
public int Age { get; init; }
}
// Register handlers
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, User, GetUserQueryHandler>();
public record RemoveUserCommand
{
public int UserId { get; init; }
}
```
**Notes:**
- The source generator automatically creates:
- `.proto` files in the `Protos/` directory from your C# commands and queries
- `CommandServiceImpl` and `QueryServiceImpl` implementations
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
- No need for protobuf-net attributes - just define your C# types
## Sample of startup code for Minimal API (HTTP)
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args);
// Register your commands with validators
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Register your queries
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
// Configure CQRS with Minimal API support
// Configure CQRS with gRPC + HTTP
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable Minimal API endpoints
cqrs.AddGrpc(grpc => grpc.EnableReflection());
cqrs.AddMinimalApi();
});
// Add Swagger (optional)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Map all configured CQRS endpoints (automatically creates POST /api/command/* and POST/GET /api/query/*)
app.UseSvrntyCqrs();
app.Run();
```
**Notes:**
- FluentValidation is automatically integrated with **RFC 7807 Problem Details** for structured validation errors
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
- Supports both POST and GET (for queries) endpoints
- Automatically generates Swagger/OpenAPI documentation
## Documentation
## Sample enabling both gRPC and HTTP
- [Architecture](./docs/ARCHITECTURE.md) -- Package dependency graph, CQRS data flows, separation of concerns
- [Package Index](./docs/PACKAGE_INDEX.md) -- Per-package reference with key types and dependencies
- [Getting Started](./docs/GETTING_STARTED.md) -- Step-by-step guide covering commands, queries, gRPC, DynamicQuery, events, sagas, and notifications
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
## Related Libraries
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.CQRS.MinimalApi;
- **[flutter_cqrs_datasource](https://git.openharbor.io/svrnty/flutter_cqrs_datasource)** -- Flutter/Dart counterpart for consuming Svrnty.CQRS services from mobile and desktop apps
var builder = WebApplication.CreateBuilder(args);
## Contributing
// Register your commands with validators
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
See [CLAUDE.md](./CLAUDE.md) for development guidelines.
// Register your queries
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
## License
// Configure CQRS with both gRPC and Minimal API support
builder.Services.AddSvrntyCqrs(cqrs =>
{
// Enable gRPC endpoints with reflection
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection();
});
// Enable Minimal API endpoints
cqrs.AddMinimalApi();
});
// Add HTTP support with Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Map all configured CQRS endpoints (both gRPC and HTTP)
app.UseSvrntyCqrs();
app.Run();
```
**Benefits:**
- Single codebase supports multiple protocols
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
- HTTP for web browsers, legacy clients, and public APIs
- Same commands, queries, and validation logic for both protocols
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
# Fluent Validation
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
## With Svrnty.CQRS.FluentValidation (Recommended)
The package exposes extension method overloads that accept the validator as a generic parameter:
```bash
dotnet add package Svrnty.CQRS.FluentValidation
```
```csharp
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
// Command with result - validator as last generic parameter
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
// Command without result - validator included in generics
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
```
**Benefits:**
- **Single line registration** - Handler and validator registered together
- **Type safety** - Compiler ensures validator matches command type
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
- **Cleaner code** - Clear intent that validation is part of command pipeline
## Without Svrnty.CQRS.FluentValidation
If you prefer not to use the FluentValidation package, you need to register commands and validators separately:
```csharp
using FluentValidation;
using Svrnty.CQRS;
// Register command handler
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler>();
// Manually register validator
builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
```
# 2024-2025 Roadmap
| Task | Description | Status |
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
| Support .NET 8 | Ensure compatibility with .NET 8. | ✅ |
| Support .NET 10 | Upgrade to .NET 10 with C# 14 language support. | ✅ |
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ |
| Add gRPC Support with source generators | Implement gRPC endpoints with source generators and Google Rich Error Model for validation. | ✅ |
| Create a demo project (Svrnty.CQRS.Grpc.Sample) | Develop a comprehensive demo project showcasing gRPC and HTTP endpoints. | ✅ |
| Create a website for the Framework | Develop a website to host comprehensive documentation for the framework. | ⬜️ |
# 2026 Roadmap
| Task | Description | Status |
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
| gRPC Compression Support | Smart message compression with automatic threshold detection and per-handler control. | ⬜️ |
| gRPC Metadata & Authorization Support | Expose ServerCallContext to handlers and integrate authorization services for gRPC endpoints. | ⬜️ |
MIT OR Apache-2.0
+52
View File
@@ -0,0 +1,52 @@
# Security Policy
## Reporting a Vulnerability
If you discover a security vulnerability, please report it responsibly.
**Do NOT open a public issue.**
### How to Report
Email: **security@svrnty.com**
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes (optional)
### Response Timeline
- **Acknowledgment**: Within 48 hours
- **Initial Assessment**: Within 7 days
- **Resolution Target**: Within 30 days (depending on severity)
### What to Expect
1. We will acknowledge receipt of your report
2. We will investigate and validate the issue
3. We will work on a fix and coordinate disclosure
4. We will credit you (if desired) when the fix is released
### Scope
This policy applies to:
- Code in this repository
- Dependencies we control
- Infrastructure we operate
### Out of Scope
- Third-party services or dependencies
- Social engineering attacks
- Physical security
## Supported Versions
Security updates are provided for the latest release only.
| Version | Supported |
|---------|-----------|
| Latest | Yes |
| Older | No |
@@ -19,7 +19,7 @@ public sealed class CommandMeta : ICommandMeta
ServiceType = serviceType;
}
private CommandNameAttribute NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
private CommandNameAttribute? NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
public string Name
{
@@ -32,7 +32,7 @@ public sealed class CommandMeta : ICommandMeta
public Type CommandType { get; }
public Type ServiceType { get; }
public Type CommandResultType { get; }
public Type? CommandResultType { get; }
public string LowerCamelCaseName
{
@@ -7,7 +7,7 @@ public interface ICommandMeta
string Name { get; }
Type CommandType { get; }
Type ServiceType { get; }
Type CommandResultType { get; }
Type? CommandResultType { get; }
string LowerCamelCaseName { get; }
}
@@ -5,8 +5,8 @@ namespace Svrnty.CQRS.Abstractions.Discovery;
public interface IQueryDiscovery
{
IQueryMeta FindQuery(string name);
IQueryMeta FindQuery(Type queryType);
IQueryMeta? FindQuery(string name);
IQueryMeta? FindQuery(Type queryType);
IEnumerable<IQueryMeta> GetQueries();
bool QueryExists(string name);
bool QueryExists(Type queryType);
@@ -16,8 +16,8 @@ public interface ICommandDiscovery
{
bool CommandExists(string name);
bool CommandExists(Type commandType);
ICommandMeta FindCommand(string name);
ICommandMeta FindCommand(Type commandType);
ICommandMeta? FindCommand(string name);
ICommandMeta? FindCommand(Type commandType);
IEnumerable<ICommandMeta> GetCommands();
}
@@ -13,7 +13,7 @@ public class QueryMeta : IQueryMeta
QueryResultType = queryResultType;
}
protected virtual QueryNameAttribute NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
protected virtual QueryNameAttribute? NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
public virtual string Name
{
@@ -20,10 +20,10 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
public interface IDynamicQuery
{
List<IFilter> GetFilters();
List<IGroup> GetGroups();
List<ISort> GetSorts();
List<IAggregate> GetAggregates();
List<IFilter>? GetFilters();
List<IGroup>? GetGroups();
List<ISort>? GetSorts();
List<IAggregate>? GetAggregates();
int? GetPage();
int? GetPageSize();
}
@@ -3,5 +3,5 @@
public interface IDynamicQueryParams<out TParams>
where TParams : class
{
TParams GetParams();
TParams? GetParams();
}
@@ -0,0 +1,14 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
/// <summary>
/// Marker interface for custom queryable providers that project entities to DTOs.
/// Extends <see cref="IQueryableProvider{TSource}"/> for semantic clarity in registration.
/// </summary>
/// <typeparam name="TSource">The DTO/Item type returned by the queryable.</typeparam>
public interface IQueryableProviderOverride<TSource> : IQueryableProvider<TSource>
{
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
using PoweredSoft.Data.EntityFrameworkCore;
namespace Svrnty.CQRS.DynamicQuery.EntityFramework;
/// <summary>
/// Extensions for configuring DynamicQuery with Entity Framework Core.
/// </summary>
public static class DynamicQueryServicesBuilderExtensions
{
/// <summary>
/// Uses Entity Framework Core for async queryable operations.
/// This replaces the default in-memory implementation with EF Core's async support.
/// </summary>
/// <param name="builder">The DynamicQuery services builder.</param>
/// <returns>The builder for chaining.</returns>
public static DynamicQueryServicesBuilder UseEntityFramework(this DynamicQueryServicesBuilder builder)
{
// Remove in-memory implementation and add EF Core implementation
builder.Services.RemoveAll<IAsyncQueryableService>();
builder.Services.AddPoweredSoftEntityFrameworkCoreDataServices();
return builder;
}
}
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="PoweredSoft.Data.EntityFrameworkCore" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
</ItemGroup>
</Project>
@@ -23,7 +23,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyDynamicQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
foreach (var queryMeta in queryDiscovery.GetQueries())
{
@@ -43,14 +42,14 @@ public static class EndpointRouteBuilderExtensions
if (dynamicQueryMeta.ParamsType == null)
{
// DynamicQuery<TSource, TDestination>
MapDynamicQueryPost(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryGet(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryPost(endpoints, route, dynamicQueryMeta);
MapDynamicQueryGet(endpoints, route, dynamicQueryMeta);
}
else
{
// DynamicQuery<TSource, TDestination, TParams>
MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta, authorizationService);
MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta);
MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta);
}
}
@@ -60,8 +59,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryPost(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -75,7 +73,7 @@ public static class EndpointRouteBuilderExtensions
.GetMethod(nameof(MapDynamicQueryPostTyped), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(sourceType, destinationType);
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!;
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!;
endpoint
.WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_Post")
@@ -91,8 +89,7 @@ public static class EndpointRouteBuilderExtensions
IEndpointRouteBuilder endpoints,
string route,
Type queryType,
Type handlerType,
IQueryAuthorizationService? authorizationService)
Type handlerType)
where TSource : class
where TDestination : class
{
@@ -102,6 +99,7 @@ public static class EndpointRouteBuilderExtensions
IServiceProvider serviceProvider,
CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -129,8 +127,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryGet(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -141,6 +138,7 @@ public static class EndpointRouteBuilderExtensions
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -199,8 +197,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryWithParamsPost(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -214,7 +211,7 @@ public static class EndpointRouteBuilderExtensions
.GetMethod(nameof(MapDynamicQueryWithParamsPostTyped), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(sourceType, destinationType, paramsType);
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!;
var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!;
endpoint
.WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_WithParams_Post")
@@ -230,8 +227,7 @@ public static class EndpointRouteBuilderExtensions
IEndpointRouteBuilder endpoints,
string route,
Type queryType,
Type handlerType,
IQueryAuthorizationService? authorizationService)
Type handlerType)
where TSource : class
where TDestination : class
where TParams : class
@@ -242,6 +238,7 @@ public static class EndpointRouteBuilderExtensions
IServiceProvider serviceProvider,
CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -269,8 +266,7 @@ public static class EndpointRouteBuilderExtensions
private static void MapDynamicQueryWithParamsGet(
IEndpointRouteBuilder endpoints,
string route,
DynamicQueryMeta dynamicQueryMeta,
IQueryAuthorizationService? authorizationService)
DynamicQueryMeta dynamicQueryMeta)
{
var sourceType = dynamicQueryMeta.SourceType;
var destinationType = dynamicQueryMeta.DestinationType;
@@ -282,6 +278,7 @@ public static class EndpointRouteBuilderExtensions
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken);
@@ -22,7 +22,7 @@ public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResult
}
}
public Type ParamsType { get; internal set; }
public string OverridableName { get; internal set; }
public Type? ParamsType { get; internal set; }
public string? OverridableName { get; internal set; }
}
+11 -11
View File
@@ -18,9 +18,9 @@ public class DynamicQuery<TSource, TDestination, TParams> : DynamicQuery, IDynam
where TDestination : class
where TParams : class
{
public TParams Params { get; set; }
public TParams? Params { get; set; }
public TParams GetParams()
public TParams? GetParams()
{
return Params;
}
@@ -30,23 +30,23 @@ public class DynamicQuery : IDynamicQuery
{
public int? Page { get; set; }
public int? PageSize { get; set; }
public List<Sort> Sorts { get; set; }
public List<DynamicQueryAggregate> Aggregates { get; set; }
public List<Group> Groups { get; set; }
public List<DynamicQueryFilter> Filters { get; set; }
public List<Sort>? Sorts { get; set; }
public List<DynamicQueryAggregate>? Aggregates { get; set; }
public List<Group>? Groups { get; set; }
public List<DynamicQueryFilter>? Filters { get; set; }
public List<IAggregate> GetAggregates()
public List<IAggregate>? GetAggregates()
{
return Aggregates?.Select(t => t.ToAggregate())?.ToList();//.AsEnumerable<IAggregate>()?.ToList();
return Aggregates?.Select(t => t.ToAggregate())?.ToList();
}
public List<IFilter> GetFilters()
public List<IFilter>? GetFilters()
{
return Filters?.Select(t => t.ToFilter())?.ToList();
}
public List<IGroup> GetGroups()
public List<IGroup>? GetGroups()
{
return this.Groups?.AsEnumerable<IGroup>()?.ToList();
}
@@ -61,7 +61,7 @@ public class DynamicQuery : IDynamicQuery
return this.PageSize;
}
public List<ISort> GetSorts()
public List<ISort>? GetSorts()
{
return this.Sorts?.AsEnumerable<ISort>()?.ToList();
}
@@ -6,8 +6,8 @@ namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryAggregate
{
public string Path { get; set; }
public string Type { get; set; }
public required string Path { get; set; }
public required string Type { get; set; }
public IAggregate ToAggregate()
{
@@ -9,14 +9,14 @@ namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryFilter
{
public List<DynamicQueryFilter> Filters { get; set; }
public List<DynamicQueryFilter>? Filters { get; set; }
public bool? And { get; set; }
public string Type { get; set; }
public string? Type { get; set; }
public bool? Not { get; set; }
public string Path { get; set; }
public object Value { get; set; }
public string? Path { get; set; }
public object? Value { get; set; }
public string QueryValue
public string? QueryValue
{
get
{
@@ -32,7 +32,7 @@ public class DynamicQueryFilter
public IFilter ToFilter()
{
var type = Enum.Parse<FilterType>(Type);
var type = Enum.Parse<FilterType>(Type!);
if (type == FilterType.Composite)
{
var compositeFilter = new CompositeFilter
@@ -44,7 +44,7 @@ public class DynamicQueryFilter
return compositeFilter;
}
object value = Value;
object? value = Value;
if (Value is JsonElement jsonElement)
{
switch (jsonElement.ValueKind)
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.DynamicQuery.Abstractions;
@@ -16,10 +19,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
private readonly IQueryHandlerAsync _queryHandlerAsync;
private readonly IEnumerable<IQueryableProvider<TSource>> _queryableProviders;
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination>> _alterQueryableServices;
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> _dynamicQueryInterceptorProviders;
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>>
_dynamicQueryInterceptorProviders;
private readonly IServiceProvider _serviceProvider;
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
@@ -32,10 +38,15 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
_serviceProvider = serviceProvider;
}
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query,
CancellationToken cancellationToken = default)
{
if (_queryableProviders.Any())
return _queryableProviders.ElementAt(0).GetQueryableAsync(query, cancellationToken);
{
// Use Last() to prefer closed generic registrations (overrides) over open generic (default)
// Registration order: open generic first, closed generic (override) last
return _queryableProviders.Last().GetQueryableAsync(query, cancellationToken);
}
throw new Exception($"You must provide a QueryableProvider<TSource> for {typeof(TSource).Name}");
}
@@ -49,10 +60,14 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
{
var types = _dynamicQueryInterceptorProviders.SelectMany(t => t.GetInterceptorsTypes()).Distinct();
foreach (var type in types)
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
{
if (_serviceProvider.GetService(type) is IQueryInterceptor interceptor)
yield return interceptor;
}
}
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query, CancellationToken cancellationToken = default)
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,
CancellationToken cancellationToken = default)
{
var source = await GetQueryableAsync(query, cancellationToken);
source = await AlterSourceAsync(source, query, cancellationToken);
@@ -63,11 +78,13 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
_queryHandlerAsync.AddInterceptor(interceptor);
var criteria = CreateCriteriaFromQuery(query);
var result = await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
var result =
await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
return result;
}
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query,
CancellationToken cancellationToken)
{
foreach (var t in _alterQueryableServices)
source = await t.AlterQueryableAsync(source, query, cancellationToken);
@@ -77,16 +94,94 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
protected virtual IQueryCriteria CreateCriteriaFromQuery(IDynamicQuery query)
{
var filters = query?.GetFilters() ?? new List<IFilter>();
ConvertFilterValuesToPropertyTypes(filters);
var criteria = new QueryCriteria
{
Page = query?.GetPage(),
PageSize = query?.GetPageSize(),
Filters = query?.GetFilters() ?? new List<IFilter>(),
Filters = filters,
Sorts = query?.GetSorts() ?? new List<ISort>(),
Groups = query?.GetGroups() ?? new List<IGroup>(),
Aggregates = query?.GetAggregates() ?? new List<IAggregate>()
};
return criteria;
}
/// <summary>
/// Converts string filter values to the correct CLR type based on TSource property types.
/// This handles the case where transport layers (e.g. gRPC) pass all values as strings,
/// but PoweredSoft.DynamicLinq needs the actual type to build LINQ expressions.
/// </summary>
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2087",
Justification = "TSource properties are preserved by EF Core and DynamicLinq usage")]
private static void ConvertFilterValuesToPropertyTypes(List<IFilter> filters)
{
for (var i = filters.Count - 1; i >= 0; i--)
{
var filter = filters[i];
if (filter is SimpleFilter simpleFilter)
{
if (simpleFilter.Value == null)
{
filters.RemoveAt(i);
continue;
}
if (simpleFilter.Value is string strValue && !string.IsNullOrEmpty(strValue))
{
var propertyType = ResolvePropertyType(typeof(TSource), simpleFilter.Path);
if (propertyType == null)
continue;
var targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
if (targetType == typeof(DateTime))
{
if (DateTime.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal,
out var dt))
{
simpleFilter.Value = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
}
}
else if (targetType == typeof(DateTimeOffset))
{
if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.None,
out var dto))
{
simpleFilter.Value = dto;
}
}
}
}
else if (filter is CompositeFilter compositeFilter && compositeFilter.Filters != null)
{
ConvertFilterValuesToPropertyTypes(compositeFilter.Filters);
}
}
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2070",
Justification = "Property types are preserved by EF Core and DynamicLinq usage")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075",
Justification = "Nested property type resolution is inherently dynamic")]
static Type? ResolvePropertyType(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, string? path)
{
if (string.IsNullOrEmpty(path))
return null;
var currentType = type;
foreach (var part in path.Split('.'))
{
var property = currentType.GetProperty(part,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
if (property == null)
return null;
currentType = property.PropertyType;
}
return currentType;
}
}
}
@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
namespace Svrnty.CQRS.DynamicQuery;
/// <summary>
/// Builder for configuring DynamicQuery services.
/// </summary>
public class DynamicQueryServicesBuilder
{
/// <summary>
/// The service collection being configured.
/// </summary>
public IServiceCollection Services { get; }
internal DynamicQueryServicesBuilder(IServiceCollection services)
{
Services = services;
}
}
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using PoweredSoft.Data.Core;
namespace Svrnty.CQRS.DynamicQuery;
/// <summary>
/// In-memory implementation of IAsyncQueryableService.
/// For EF Core projects, use AddDynamicQueryServices().UseEntityFramework() instead.
/// </summary>
public class InMemoryAsyncQueryableService : IAsyncQueryableService
{
public IEnumerable<IAsyncQueryableHandlerService> Handlers { get; } = Array.Empty<IAsyncQueryableHandlerService>();
public Task<List<TSource>> ToListAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.ToList());
}
public Task<TSource?> FirstOrDefaultAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.FirstOrDefault());
}
public Task<TSource?> FirstOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.FirstOrDefault(predicate));
}
public Task<TSource?> LastOrDefaultAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LastOrDefault());
}
public Task<TSource?> LastOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LastOrDefault(predicate));
}
public Task<bool> AnyAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Any());
}
public Task<bool> AnyAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Any(predicate));
}
public Task<bool> AllAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.All(predicate));
}
public Task<int> CountAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.Count());
}
public Task<long> LongCountAsync<TSource>(IQueryable<TSource> queryable, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.LongCount());
}
public Task<TSource?> SingleOrDefaultAsync<TSource>(IQueryable<TSource> queryable, Expression<Func<TSource, bool>> predicate, CancellationToken cancellationToken = default)
{
return Task.FromResult(queryable.SingleOrDefault(predicate));
}
public IAsyncQueryableHandlerService? GetAsyncQueryableHandler<TSource>(IQueryable<TSource> queryable)
{
return null;
}
}
@@ -1,21 +1,36 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PoweredSoft.Data.Core;
using PoweredSoft.DynamicQuery;
using PoweredSoft.DynamicQuery.Core;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.DynamicQuery.Abstractions;
using Svrnty.CQRS.DynamicQuery.Discover;
using PoweredSoft.DynamicQuery.Core;
namespace Svrnty.CQRS.DynamicQuery;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string name = null)
/// <summary>
/// Registers core DynamicQuery services with in-memory async queryable.
/// For EF Core projects, chain with .UseEntityFramework().
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>A builder for further configuration.</returns>
public static DynamicQueryServicesBuilder AddDynamicQueryServices(this IServiceCollection services)
{
services.TryAddTransient<IAsyncQueryableService, InMemoryAsyncQueryableService>();
services.TryAddTransient<IQueryHandlerAsync, QueryHandlerAsync>();
return new DynamicQueryServicesBuilder(services);
}
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string? name = null)
where TSourceAndDestination : class
=> AddDynamicQuery<TSourceAndDestination, TSourceAndDestination>(services, name: name);
public static IServiceCollection AddDynamicQuery<TSource, TDestination>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQuery<TSource, TDestination>(this IServiceCollection services, string? name = null)
where TSource : class
where TDestination : class
{
@@ -36,7 +51,7 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddDynamicQueryWithProvider<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQueryWithProvider<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string? name = null)
where TQueryableProvider : class, IQueryableProvider<TSource>
where TSource : class
{
@@ -45,7 +60,7 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddDynamicQueryWithParamsAndProvider<TSource, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQueryWithParamsAndProvider<TSource, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string? name = null)
where TQueryableProvider : class, IQueryableProvider<TSource>
where TParams : class
where TSource : class
@@ -55,12 +70,28 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string name = null)
/// <summary>
/// Registers a custom queryable provider override for the specified source type.
/// Use this for DTOs that require custom projection from entities.
/// </summary>
/// <typeparam name="TSource">The DTO/Item type returned by the queryable.</typeparam>
/// <typeparam name="TProvider">The provider implementation type.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddQueryableProviderOverride<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>(this IServiceCollection services)
where TSource : class
where TProvider : class, IQueryableProviderOverride<TSource>
{
services.AddTransient<IQueryableProvider<TSource>, TProvider>();
return services;
}
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string? name = null)
where TSourceAndDestination : class
where TParams : class
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string? name = null)
where TSource : class
where TDestination : class
where TParams : class
@@ -0,0 +1,17 @@
namespace Svrnty.CQRS.Events.Abstractions;
/// <summary>
/// Marker interface for domain events.
/// </summary>
public interface IDomainEvent
{
/// <summary>
/// Unique identifier for this event instance.
/// </summary>
Guid EventId { get; }
/// <summary>
/// Timestamp when the event occurred.
/// </summary>
DateTime OccurredAt { get; }
}
@@ -0,0 +1,16 @@
namespace Svrnty.CQRS.Events.Abstractions;
/// <summary>
/// Interface for publishing domain events to external systems.
/// </summary>
public interface IDomainEventPublisher
{
/// <summary>
/// Publishes a domain event.
/// </summary>
/// <typeparam name="TEvent">The type of event to publish.</typeparam>
/// <param name="event">The event to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
where TEvent : IDomainEvent;
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>
@@ -0,0 +1,163 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// RabbitMQ implementation of the domain event publisher.
/// </summary>
public class RabbitMqDomainEventPublisher : IDomainEventPublisher, IAsyncDisposable
{
private readonly RabbitMqEventOptions _options;
private readonly ILogger<RabbitMqDomainEventPublisher> _logger;
private IConnection? _connection;
private IChannel? _channel;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private bool _disposed;
/// <summary>
/// Creates a new RabbitMQ domain event publisher.
/// </summary>
public RabbitMqDomainEventPublisher(
IOptions<RabbitMqEventOptions> options,
ILogger<RabbitMqDomainEventPublisher> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
where TEvent : IDomainEvent
{
await EnsureConnectionAsync(cancellationToken);
var eventTypeName = typeof(TEvent).Name;
var routingKey = GetRoutingKey(eventTypeName);
var body = JsonSerializer.SerializeToUtf8Bytes(@event);
var properties = new BasicProperties
{
MessageId = @event.EventId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.Durable ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(new DateTimeOffset(@event.OccurredAt).ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["event-type"] = eventTypeName,
["event-id"] = @event.EventId.ToString()
}
};
await _channel!.BasicPublishAsync(
exchange: _options.Exchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published domain event {EventType} with ID {EventId} to routing key {RoutingKey}",
eventTypeName, @event.EventId, routingKey);
}
private static string GetRoutingKey(string eventTypeName)
{
// Convert PascalCase to dot-notation, e.g., "InventoryMovementEvent" -> "events.inventory.movement"
var name = eventTypeName.Replace("Event", "");
var words = new List<string>();
var currentWord = new StringBuilder();
foreach (var c in name)
{
if (char.IsUpper(c) && currentWord.Length > 0)
{
words.Add(currentWord.ToString().ToLowerInvariant());
currentWord.Clear();
}
currentWord.Append(c);
}
if (currentWord.Length > 0)
{
words.Add(currentWord.ToString().ToLowerInvariant());
}
return "events." + string.Join(".", words);
}
private async Task EnsureConnectionAsync(CancellationToken cancellationToken)
{
if (_connection?.IsOpen == true && _channel?.IsOpen == true)
{
return;
}
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_connection?.IsOpen == true && _channel?.IsOpen == true)
{
return;
}
var factory = new ConnectionFactory
{
HostName = _options.HostName,
Port = _options.Port,
UserName = _options.UserName,
Password = _options.Password,
VirtualHost = _options.VirtualHost
};
_connection = await factory.CreateConnectionAsync(cancellationToken);
_channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
// Declare topic exchange for domain events
await _channel.ExchangeDeclareAsync(
exchange: _options.Exchange,
type: ExchangeType.Topic,
durable: _options.Durable,
autoDelete: false,
cancellationToken: cancellationToken);
_logger.LogInformation(
"Connected to RabbitMQ at {Host}:{Port}, exchange: {Exchange}",
_options.HostName, _options.Port, _options.Exchange);
}
finally
{
_connectionLock.Release();
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_channel?.IsOpen == true)
{
await _channel.CloseAsync();
}
_channel?.Dispose();
if (_connection?.IsOpen == true)
{
await _connection.CloseAsync();
}
_connection?.Dispose();
_connectionLock.Dispose();
}
}
@@ -0,0 +1,42 @@
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// Configuration options for RabbitMQ domain event publishing.
/// </summary>
public class RabbitMqEventOptions
{
/// <summary>
/// RabbitMQ host name. Default: localhost
/// </summary>
public string HostName { get; set; } = "localhost";
/// <summary>
/// RabbitMQ port. Default: 5672
/// </summary>
public int Port { get; set; } = 5672;
/// <summary>
/// RabbitMQ username. Default: guest
/// </summary>
public string UserName { get; set; } = "guest";
/// <summary>
/// RabbitMQ password. Default: guest
/// </summary>
public string Password { get; set; } = "guest";
/// <summary>
/// RabbitMQ virtual host. Default: /
/// </summary>
public string VirtualHost { get; set; } = "/";
/// <summary>
/// Exchange name for domain events. Default: domain.events
/// </summary>
public string Exchange { get; set; } = "domain.events";
/// <summary>
/// Whether to use durable exchanges. Default: true
/// </summary>
public bool Durable { get; set; } = true;
}
@@ -0,0 +1,30 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.RabbitMQ;
/// <summary>
/// Extension methods for registering RabbitMQ domain event publishing.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds RabbitMQ domain event publishing to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Optional configuration action for RabbitMQ options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddRabbitMqDomainEvents(
this IServiceCollection services,
Action<RabbitMqEventOptions>? configure = null)
{
if (configure != null)
{
services.Configure(configure);
}
services.AddSingleton<IDomainEventPublisher, RabbitMqDomainEventPublisher>();
return services;
}
}
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Events.Abstractions\Svrnty.CQRS.Events.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RabbitMQ.Client" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,256 @@
#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// MSBuild task that generates .proto files by creating its own Roslyn compilation.
/// This runs BEFORE CoreCompile to solve the source generator timing issue.
/// </summary>
public class GenerateProtoFileTask : Task
{
/// <summary>
/// The project directory
/// </summary>
[Required]
public string ProjectDirectory { get; set; } = string.Empty;
/// <summary>
/// The output directory where the proto file should be written (typically Protos/)
/// </summary>
[Required]
public string OutputDirectory { get; set; } = string.Empty;
/// <summary>
/// The name of the proto file to generate (typically cqrs_services.proto)
/// </summary>
[Required]
public string ProtoFileName { get; set; } = string.Empty;
/// <summary>
/// The C# source files to compile (from @(Compile) ItemGroup)
/// </summary>
[Required]
public ITaskItem[] SourceFiles { get; set; } = Array.Empty<ITaskItem>();
/// <summary>
/// The assembly references (from @(ReferencePath) ItemGroup)
/// </summary>
[Required]
public ITaskItem[] References { get; set; } = Array.Empty<ITaskItem>();
/// <summary>
/// The root namespace of the project
/// </summary>
public string RootNamespace { get; set; } = string.Empty;
/// <summary>
/// The assembly name of the project
/// </summary>
public string AssemblyName { get; set; } = string.Empty;
public override bool Execute()
{
try
{
Log.LogMessage(MessageImportance.High,
"Svrnty.CQRS.Grpc: Generating proto file via MSBuild task...");
// Determine the namespace for the proto file
var projectNamespace = !string.IsNullOrEmpty(RootNamespace) ? RootNamespace
: !string.IsNullOrEmpty(AssemblyName) ? AssemblyName
: "Generated";
var grpcNamespace = $"{projectNamespace}.Grpc";
var packageName = "cqrs";
// Create the compilation
var compilation = CreateCompilation();
if (compilation == null)
{
Log.LogWarning("Svrnty.CQRS.Grpc: Could not create compilation. Writing placeholder proto file.");
WritePlaceholderProto(grpcNamespace);
return true;
}
// Check for compilation errors that would prevent proper analysis
var diagnostics = compilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToList();
if (diagnostics.Count > 0)
{
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Compilation has {diagnostics.Count} errors. Attempting to generate proto anyway...");
// Log first few errors for debugging
foreach (var diag in diagnostics.Take(5))
{
Log.LogMessage(MessageImportance.Low, $" {diag.GetMessage()}");
}
}
// Use the ProtoFileGenerator to generate content
var generator = new ProtoFileGenerator(compilation);
var protoContent = generator.Generate(packageName, grpcNamespace);
// Check if we got meaningful content
if (string.IsNullOrWhiteSpace(protoContent) || !protoContent.Contains("rpc "))
{
Log.LogMessage(MessageImportance.High,
"Svrnty.CQRS.Grpc: No commands/queries/notifications found. Writing minimal proto file.");
WritePlaceholderProto(grpcNamespace);
return true;
}
// Ensure output directory exists
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
? OutputDirectory
: Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(fullOutputPath);
// Write the proto file
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
File.WriteAllText(protoFilePath, protoContent);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
return true;
}
catch (Exception ex)
{
Log.LogErrorFromException(ex, true);
return false;
}
}
private CSharpCompilation? CreateCompilation()
{
try
{
// Parse all source files into syntax trees
var syntaxTrees = new List<SyntaxTree>();
foreach (var sourceFile in SourceFiles)
{
var filePath = sourceFile.ItemSpec;
if (!Path.IsPathRooted(filePath))
{
filePath = Path.Combine(ProjectDirectory, filePath);
}
if (!File.Exists(filePath))
{
Log.LogMessage(MessageImportance.Low, $"Source file not found: {filePath}");
continue;
}
try
{
var sourceText = File.ReadAllText(filePath);
var syntaxTree = CSharpSyntaxTree.ParseText(
sourceText,
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest),
path: filePath);
syntaxTrees.Add(syntaxTree);
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.Low, $"Failed to parse {filePath}: {ex.Message}");
}
}
if (syntaxTrees.Count == 0)
{
Log.LogWarning("Svrnty.CQRS.Grpc: No source files could be parsed.");
return null;
}
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Parsed {syntaxTrees.Count} source files");
// Create metadata references from the References
var metadataReferences = new List<MetadataReference>();
foreach (var reference in References)
{
var refPath = reference.ItemSpec;
if (!File.Exists(refPath))
{
Log.LogMessage(MessageImportance.Low, $"Reference not found: {refPath}");
continue;
}
try
{
var metadataRef = MetadataReference.CreateFromFile(refPath);
metadataReferences.Add(metadataRef);
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.Low, $"Failed to load reference {refPath}: {ex.Message}");
}
}
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Loaded {metadataReferences.Count} references");
// Create the compilation
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
.WithNullableContextOptions(NullableContextOptions.Enable);
var assemblyName = !string.IsNullOrEmpty(AssemblyName) ? AssemblyName : "TempCompilation";
var compilation = CSharpCompilation.Create(
assemblyName,
syntaxTrees,
metadataReferences,
compilationOptions);
return compilation;
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Failed to create compilation: {ex.Message}");
return null;
}
}
private void WritePlaceholderProto(string grpcNamespace)
{
var placeholderProto = $@"syntax = ""proto3"";
option csharp_namespace = ""{grpcNamespace}"";
package cqrs;
// Placeholder proto file - will be regenerated when commands/queries are available
// Using namespace: {grpcNamespace}
// Empty service definitions so Grpc.Tools generates base classes
service CommandService {{
}}
service QueryService {{
}}
service DynamicQueryService {{
}}
";
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
? OutputDirectory
: Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(fullOutputPath);
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
File.WriteAllText(protoFilePath, placeholderProto);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {protoFilePath}");
}
}
File diff suppressed because it is too large Load Diff
@@ -49,6 +49,12 @@ namespace Svrnty.CQRS.Grpc.Generators.Helpers
isRepeated = false;
isOptional = false;
// Handle byte[] as bytes proto type (NOT repeated uint32)
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
{
return "bytes";
}
// Handle arrays
if (csharpType.EndsWith("[]"))
{
@@ -35,6 +35,26 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
public int FieldNumber { get; set; }
public bool IsComplexType { get; set; }
public List<PropertyInfo> NestedProperties { get; set; }
// Type conversion metadata
public bool IsEnum { get; set; }
public bool IsList { get; set; }
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsDateTimeOffset { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
{
@@ -42,6 +62,22 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;
IsComplexType = false;
NestedProperties = new List<PropertyInfo>();
IsEnum = false;
IsList = false;
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsDateTimeOffset = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsValueTypeCollection = false;
IsElementComplexType = false;
IsElementGuid = false;
}
}
}
@@ -0,0 +1,50 @@
using System.Collections.Generic;
namespace Svrnty.CQRS.Grpc.Generators.Models
{
/// <summary>
/// Represents a discovered streaming notification type for proto/gRPC generation.
/// </summary>
public class NotificationInfo
{
/// <summary>
/// The notification type name (e.g., "InventoryChangeNotification").
/// </summary>
public string Name { get; set; }
/// <summary>
/// The fully qualified type name including namespace.
/// </summary>
public string FullyQualifiedName { get; set; }
/// <summary>
/// The namespace of the notification type.
/// </summary>
public string Namespace { get; set; }
/// <summary>
/// The property name used as the subscription key (from [StreamingNotification] attribute).
/// </summary>
public string SubscriptionKeyProperty { get; set; }
/// <summary>
/// The subscription key property info.
/// </summary>
public PropertyInfo SubscriptionKeyInfo { get; set; }
/// <summary>
/// All properties of the notification type.
/// </summary>
public List<PropertyInfo> Properties { get; set; }
public NotificationInfo()
{
Name = string.Empty;
FullyQualifiedName = string.Empty;
Namespace = string.Empty;
SubscriptionKeyProperty = string.Empty;
SubscriptionKeyInfo = new PropertyInfo();
Properties = new List<PropertyInfo>();
}
}
}
+461 -51
View File
@@ -2,29 +2,90 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Svrnty.CQRS.Grpc.Generators.Models;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// Generates Protocol Buffer (.proto) files from C# Command and Query types
/// Generates Protocol Buffer (.proto) files from C# Command, Query, and Notification types
/// </summary>
internal class ProtoFileGenerator
{
private readonly Compilation _compilation;
private readonly HashSet<string> _requiredImports = new HashSet<string>();
private readonly HashSet<string> _generatedMessages = new HashSet<string>();
private readonly HashSet<string> _generatedEnums = new HashSet<string>();
private readonly List<INamedTypeSymbol> _pendingEnums = new List<INamedTypeSymbol>();
private readonly StringBuilder _messagesBuilder = new StringBuilder();
private readonly StringBuilder _enumsBuilder = new StringBuilder();
private List<INamedTypeSymbol>? _allTypesCache;
/// <summary>
/// Gets the discovered notifications after Generate() is called.
/// </summary>
public List<NotificationInfo> DiscoveredNotifications { get; private set; } = new List<NotificationInfo>();
public ProtoFileGenerator(Compilation compilation)
{
_compilation = compilation;
}
/// <summary>
/// Gets all types from the compilation and all referenced assemblies
/// </summary>
private IEnumerable<INamedTypeSymbol> GetAllTypes()
{
if (_allTypesCache != null)
return _allTypesCache;
_allTypesCache = new List<INamedTypeSymbol>();
// Get types from the current assembly
CollectTypesFromNamespace(_compilation.Assembly.GlobalNamespace, _allTypesCache);
// Get types from all referenced assemblies
foreach (var reference in _compilation.References)
{
var assemblySymbol = _compilation.GetAssemblyOrModuleSymbol(reference) as IAssemblySymbol;
if (assemblySymbol != null)
{
CollectTypesFromNamespace(assemblySymbol.GlobalNamespace, _allTypesCache);
}
}
return _allTypesCache;
}
private static void CollectTypesFromNamespace(INamespaceSymbol ns, List<INamedTypeSymbol> types)
{
foreach (var type in ns.GetTypeMembers())
{
types.Add(type);
CollectNestedTypes(type, types);
}
foreach (var nestedNs in ns.GetNamespaceMembers())
{
CollectTypesFromNamespace(nestedNs, types);
}
}
private static void CollectNestedTypes(INamedTypeSymbol type, List<INamedTypeSymbol> types)
{
foreach (var nestedType in type.GetTypeMembers())
{
types.Add(nestedType);
CollectNestedTypes(nestedType, types);
}
}
public string Generate(string packageName, string csharpNamespace)
{
var commands = DiscoverCommands();
var queries = DiscoverQueries();
var dynamicQueries = DiscoverDynamicQueries();
var notifications = DiscoverNotifications();
DiscoveredNotifications = notifications;
var sb = new StringBuilder();
@@ -98,6 +159,24 @@ internal class ProtoFileGenerator
sb.AppendLine();
}
// Notification Service (server streaming)
if (notifications.Any())
{
sb.AppendLine("// NotificationService for real-time streaming notifications");
sb.AppendLine("service NotificationService {");
foreach (var notification in notifications)
{
var methodName = $"SubscribeTo{notification.Name}";
var requestType = $"SubscribeTo{notification.Name}Request";
sb.AppendLine($" // Subscribe to {notification.Name} notifications");
sb.AppendLine($" rpc {methodName} ({requestType}) returns (stream {notification.Name});");
sb.AppendLine();
}
sb.AppendLine("}");
sb.AppendLine();
}
// Generate messages for commands
foreach (var command in commands)
{
@@ -118,7 +197,17 @@ internal class ProtoFileGenerator
GenerateDynamicQueryMessages(dq);
}
// Append all generated messages
// Generate messages for notifications
foreach (var notification in notifications)
{
GenerateNotificationMessages(notification);
}
// Generate any pending enum definitions
GeneratePendingEnums();
// Append all generated enums first, then messages
sb.Append(_enumsBuilder);
sb.Append(_messagesBuilder);
// Insert imports if any were needed
@@ -138,24 +227,78 @@ internal class ProtoFileGenerator
private List<INamedTypeSymbol> DiscoverCommands()
{
return _compilation.GetSymbolsWithName(
name => name.EndsWith("Command"),
SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => !HasGrpcIgnoreAttribute(t))
.Where(t => t.TypeKind == TypeKind.Class || t.TypeKind == TypeKind.Struct)
.ToList();
// First, find all command handlers to know which commands are actually registered
var commandHandlerInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`1");
var commandHandlerWithResultInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`2");
if (commandHandlerInterface == null && commandHandlerWithResultInterface == null)
return new List<INamedTypeSymbol>();
var registeredCommands = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
foreach (var iface in type.AllInterfaces)
{
if (iface.IsGenericType)
{
if ((commandHandlerInterface != null && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerInterface)) ||
(commandHandlerWithResultInterface != null && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerWithResultInterface)))
{
var commandType = iface.TypeArguments[0] as INamedTypeSymbol;
if (commandType != null && !HasGrpcIgnoreAttribute(commandType))
{
registeredCommands.Add(commandType);
}
}
}
}
}
return registeredCommands.ToList();
}
private List<INamedTypeSymbol> DiscoverQueries()
{
return _compilation.GetSymbolsWithName(
name => name.EndsWith("Query"),
SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => !HasGrpcIgnoreAttribute(t))
.Where(t => t.TypeKind == TypeKind.Class || t.TypeKind == TypeKind.Struct)
.ToList();
// First, find all query handlers to know which queries are actually registered
var queryHandlerInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.IQueryHandler`2");
var dynamicQueryInterface2 = _compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IDynamicQuery`2");
var dynamicQueryInterface3 = _compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IDynamicQuery`3");
if (queryHandlerInterface == null)
return new List<INamedTypeSymbol>();
var registeredQueries = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
foreach (var iface in type.AllInterfaces)
{
if (iface.IsGenericType && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, queryHandlerInterface))
{
var queryType = iface.TypeArguments[0] as INamedTypeSymbol;
if (queryType != null && !HasGrpcIgnoreAttribute(queryType))
{
// Skip dynamic queries - they're handled separately
if (queryType.IsGenericType &&
((dynamicQueryInterface2 != null && SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface2)) ||
(dynamicQueryInterface3 != null && SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface3))))
{
continue;
}
registeredQueries.Add(queryType);
}
}
}
}
return registeredQueries.ToList();
}
private bool HasGrpcIgnoreAttribute(INamedTypeSymbol type)
@@ -177,9 +320,14 @@ internal class ProtoFileGenerator
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
{
@@ -199,10 +347,19 @@ internal class ProtoFileGenerator
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// If this is a complex type, generate its message too
if (IsComplexType(prop.Type))
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
GenerateComplexTypeMessage(prop.Type as INamedTypeSymbol);
TrackEnumType(enumType);
}
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
nestedComplexTypes.Add(namedType);
}
fieldNumber++;
@@ -210,6 +367,12 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
// Now generate nested complex type messages
foreach (var nestedType in nestedComplexTypes)
{
GenerateComplexTypeMessage(nestedType);
}
}
private void GenerateResponseMessage(INamedTypeSymbol type)
@@ -250,52 +413,99 @@ internal class ProtoFileGenerator
private void GenerateComplexTypeMessage(INamedTypeSymbol? type)
{
if (type == null || _generatedMessages.Contains(type.Name))
if (type == null)
return;
var messageName = ProtoFileTypeMapper.GetProtoMessageName(type);
if (_generatedMessages.Contains(messageName))
return;
// Don't generate messages for system types or primitives
if (type.ContainingNamespace?.ToString().StartsWith("System") == true)
return;
_generatedMessages.Add(type.Name);
_generatedMessages.Add(messageName);
_messagesBuilder.AppendLine($"// {type.Name} entity");
_messagesBuilder.AppendLine($"message {type.Name} {{");
_messagesBuilder.AppendLine($"// {messageName} entity");
_messagesBuilder.AppendLine($"message {messageName} {{");
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
// Check if this type is a collection (implements IList<T>, ICollection<T>, etc.)
var collectionElementType = ProtoFileTypeMapper.GetCollectionElementTypeByInterface(type);
if (collectionElementType != null)
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
{
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
// This type is a collection - generate a single repeated field for items
var protoElementType = ProtoFileTypeMapper.MapType(collectionElementType, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
_messagesBuilder.AppendLine($" repeated {protoElementType} items = 1;");
// Recursively generate nested complex types
if (IsComplexType(prop.Type))
// Track the element type for nested generation
if (IsComplexType(collectionElementType) && collectionElementType is INamedTypeSymbol elementNamedType)
{
GenerateComplexTypeMessage(prop.Type as INamedTypeSymbol);
nestedComplexTypes.Add(elementNamedType);
}
}
else
{
// Not a collection - generate properties as usual
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
fieldNumber++;
int fieldNumber = 1;
foreach (var prop in properties)
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
{
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
TrackEnumType(enumType);
}
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
nestedComplexTypes.Add(namedType);
}
fieldNumber++;
}
}
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
// Now generate nested complex type messages
foreach (var nestedType in nestedComplexTypes)
{
GenerateComplexTypeMessage(nestedType);
}
}
private ITypeSymbol? GetResultType(INamedTypeSymbol commandOrQueryType)
@@ -305,11 +515,8 @@ internal class ProtoFileGenerator
? "ICommandHandler"
: "IQueryHandler";
// Find all types in the compilation
var allTypes = _compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>();
foreach (var type in allTypes)
// Find all types in the compilation and referenced assemblies
foreach (var type in GetAllTypes())
{
// Check if this type implements the handler interface
foreach (var @interface in type.AllInterfaces)
@@ -372,10 +579,8 @@ internal class ProtoFileGenerator
return new List<INamedTypeSymbol>();
var dynamicQueryTypes = new List<INamedTypeSymbol>();
var allTypes = _compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>();
foreach (var type in allTypes)
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
@@ -471,4 +676,209 @@ internal class ProtoFileGenerator
return word + "es";
return word + "s";
}
/// <summary>
/// Tracks an enum type for later generation
/// </summary>
private void TrackEnumType(INamedTypeSymbol enumType)
{
if (!_generatedEnums.Contains(enumType.Name) && !_pendingEnums.Any(e => e.Name == enumType.Name))
{
_pendingEnums.Add(enumType);
}
}
/// <summary>
/// Generates all pending enum definitions
/// </summary>
private void GeneratePendingEnums()
{
foreach (var enumType in _pendingEnums)
{
if (_generatedEnums.Contains(enumType.Name))
continue;
_generatedEnums.Add(enumType.Name);
_enumsBuilder.AppendLine($"// {enumType.Name} enum");
_enumsBuilder.AppendLine($"enum {enumType.Name} {{");
// Get all enum members
var members = enumType.GetMembers()
.OfType<IFieldSymbol>()
.Where(f => f.HasConstantValue)
.ToList();
foreach (var member in members)
{
var protoFieldName = $"{ProtoFileTypeMapper.ToSnakeCase(enumType.Name).ToUpperInvariant()}_{ProtoFileTypeMapper.ToSnakeCase(member.Name).ToUpperInvariant()}";
var value = member.ConstantValue;
_enumsBuilder.AppendLine($" {protoFieldName} = {value};");
}
_enumsBuilder.AppendLine("}");
_enumsBuilder.AppendLine();
}
}
/// <summary>
/// Discovers types marked with [StreamingNotification] attribute
/// </summary>
private List<NotificationInfo> DiscoverNotifications()
{
var streamingNotificationAttribute = _compilation.GetTypeByMetadataName(
"Svrnty.CQRS.Notifications.Abstractions.StreamingNotificationAttribute");
if (streamingNotificationAttribute == null)
return new List<NotificationInfo>();
var notifications = new List<NotificationInfo>();
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
var attr = type.GetAttributes()
.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(
a.AttributeClass, streamingNotificationAttribute));
if (attr == null)
continue;
// Extract SubscriptionKey from attribute
var subscriptionKeyArg = attr.NamedArguments
.FirstOrDefault(a => a.Key == "SubscriptionKey");
var subscriptionKeyProp = subscriptionKeyArg.Value.Value as string;
if (string.IsNullOrEmpty(subscriptionKeyProp))
continue;
// Get all properties of the notification type
var properties = ExtractNotificationProperties(type);
// Find the subscription key property info
var keyPropInfo = properties.FirstOrDefault(p => p.Name == subscriptionKeyProp);
if (keyPropInfo == null)
continue;
notifications.Add(new NotificationInfo
{
Name = type.Name,
FullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""),
Namespace = type.ContainingNamespace?.ToDisplayString() ?? "",
SubscriptionKeyProperty = subscriptionKeyProp!, // Already validated as non-null above
SubscriptionKeyInfo = keyPropInfo,
Properties = properties
});
}
return notifications;
}
/// <summary>
/// Extracts property information from a notification type
/// </summary>
private List<Models.PropertyInfo> ExtractNotificationProperties(INamedTypeSymbol type)
{
var properties = new List<Models.PropertyInfo>();
int fieldNumber = 1;
foreach (var prop in type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name)))
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
continue;
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out _, out _);
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
properties.Add(new Models.PropertyInfo
{
Name = prop.Name,
Type = prop.Type.Name,
FullyQualifiedType = prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""),
ProtoType = protoType,
FieldNumber = fieldNumber++,
IsEnum = enumType != null,
IsDecimal = prop.Type.SpecialType == SpecialType.System_Decimal ||
prop.Type.ToDisplayString().Contains("decimal"),
IsDateTime = prop.Type.ToDisplayString().Contains("DateTime"),
IsNullable = prop.Type.NullableAnnotation == NullableAnnotation.Annotated ||
(prop.Type is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
});
if (enumType != null)
{
TrackEnumType(enumType);
}
}
return properties;
}
/// <summary>
/// Generates proto messages for a notification type
/// </summary>
private void GenerateNotificationMessages(NotificationInfo notification)
{
// Generate subscription request message (contains only the subscription key)
var requestMessageName = $"SubscribeTo{notification.Name}Request";
if (!_generatedMessages.Contains(requestMessageName))
{
_generatedMessages.Add(requestMessageName);
_messagesBuilder.AppendLine($"// Subscription request for {notification.Name}");
_messagesBuilder.AppendLine($"message {requestMessageName} {{");
_messagesBuilder.AppendLine($" {notification.SubscriptionKeyInfo.ProtoType} {ProtoFileTypeMapper.ToSnakeCase(notification.SubscriptionKeyProperty)} = 1;");
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
}
// Generate the notification message itself
if (!_generatedMessages.Contains(notification.Name))
{
_generatedMessages.Add(notification.Name);
_messagesBuilder.AppendLine($"// {notification.Name} streaming notification");
_messagesBuilder.AppendLine($"message {notification.Name} {{");
foreach (var prop in notification.Properties)
{
var typeSymbol = _compilation.GetTypeByMetadataName(prop.FullyQualifiedType) ??
GetTypeFromName(prop.FullyQualifiedType);
if (typeSymbol != null)
{
ProtoFileTypeMapper.MapType(typeSymbol, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {prop.ProtoType} {fieldName} = {prop.FieldNumber};");
}
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
}
}
/// <summary>
/// Gets a type symbol from a type name by searching all types
/// </summary>
private ITypeSymbol? GetTypeFromName(string fullTypeName)
{
// Try to find the type in all types
return GetAllTypes().FirstOrDefault(t =>
t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") == fullTypeName ||
t.ToDisplayString() == fullTypeName);
}
}
@@ -1,132 +0,0 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// Incremental source generator that generates .proto files from C# commands and queries
/// </summary>
[Generator]
public class ProtoFileSourceGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Register a post-initialization output to generate the proto file
context.RegisterPostInitializationOutput(ctx =>
{
// Generate a placeholder - the actual proto will be generated in the source output
});
// Collect all command and query types
var commandsAndQueries = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (s, _) => IsCommandOrQuery(s),
transform: static (ctx, _) => GetTypeSymbol(ctx))
.Where(static m => m is not null)
.Collect();
// Combine with compilation to have access to it
var compilationAndTypes = context.CompilationProvider.Combine(commandsAndQueries);
// Generate proto file when commands/queries change
context.RegisterSourceOutput(compilationAndTypes, (spc, source) =>
{
var (compilation, types) = source;
if (types.IsDefaultOrEmpty)
return;
try
{
// Get the root namespace from the compilation - this matches what GrpcGenerator does
var rootNamespace = compilation.AssemblyName ?? "Generated";
var packageName = "cqrs";
var csharpNamespace = $"{rootNamespace}.Grpc";
// Generate the proto file content
var generator = new ProtoFileGenerator(compilation);
var protoContent = generator.Generate(packageName, csharpNamespace);
// Output as an embedded resource that can be extracted
var protoFileName = "cqrs_services.proto";
// Generate a C# class that contains the proto content
// This allows build tools to extract it if needed
var csContent = $$"""
// <auto-generated />
#nullable enable
namespace Svrnty.CQRS.Grpc.Generated
{
/// <summary>
/// Contains the auto-generated Protocol Buffer definition
/// </summary>
internal static class GeneratedProtoFile
{
public const string FileName = "{{protoFileName}}";
public const string Content = @"{{protoContent.Replace("\"", "\"\"")}}";
}
}
""";
spc.AddSource("GeneratedProtoFile.g.cs", csContent);
// Report that we generated the proto content
var descriptor = new DiagnosticDescriptor(
"CQRSGRPC002",
"Proto file generated",
"Generated proto file content in GeneratedProtoFile class",
"Svrnty.CQRS.Grpc",
DiagnosticSeverity.Info,
isEnabledByDefault: true);
spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None));
}
catch (Exception ex)
{
// Report diagnostic if generation fails
var descriptor = new DiagnosticDescriptor(
"CQRSGRPC001",
"Proto file generation failed",
"Failed to generate proto file: {0}",
"Svrnty.CQRS.Grpc",
DiagnosticSeverity.Warning,
isEnabledByDefault: true);
spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, ex.Message));
}
});
}
private static bool IsCommandOrQuery(SyntaxNode node)
{
if (node is not TypeDeclarationSyntax typeDecl)
return false;
var name = typeDecl.Identifier.Text;
return name.EndsWith("Command") || name.EndsWith("Query");
}
private static INamedTypeSymbol? GetTypeSymbol(GeneratorSyntaxContext context)
{
var typeDecl = (TypeDeclarationSyntax)context.Node;
var symbol = context.SemanticModel.GetDeclaredSymbol(typeDecl) as INamedTypeSymbol;
// Skip if it has GrpcIgnore attribute
if (symbol?.GetAttributes().Any(a => a.AttributeClass?.Name == "GrpcIgnoreAttribute") == true)
return null;
return symbol;
}
private static string? GetBuildProperty(SourceProductionContext context, string propertyName)
{
// Try to get build properties from the compilation options
// This is a simplified approach - in practice, you might need analyzer config
return null; // Will use defaults
}
}
+285 -78
View File
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace Svrnty.CQRS.Grpc.Generators;
@@ -17,10 +18,69 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var typeName = typeSymbol.Name;
// Nullable types - unwrap
if (typeSymbol.NullableAnnotation == NullableAnnotation.Annotated && typeSymbol is INamedTypeSymbol namedType && namedType.TypeArguments.Length > 0)
// Note: NullableAnnotation.Annotated is for reference type nullability (List<T>?, string?, etc.)
// We don't unwrap these - just use the underlying type. Nullable<T> value types are handled later.
// Handle Nullable<T> value types (e.g., int?, decimal?, enum?) FIRST
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
return MapType(namedType.TypeArguments[0], out needsImport, out importPath);
// Unwrap the nullable and map the inner type
return MapType(nullableType.TypeArguments[0], out needsImport, out importPath);
}
// Handle collections BEFORE basic type checks (to avoid matching List<Guid> as Guid)
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, ICollection etc. (but not Nullable<T>)
var collectionTypeName = collectionType.Name;
if (collectionType.TypeArguments.Length == 1 &&
(collectionTypeName.Contains("List") || collectionTypeName.Contains("Collection") ||
collectionTypeName.Contains("Enumerable") || collectionTypeName.Contains("Array") ||
collectionTypeName.Contains("Set") || collectionTypeName.Contains("IList") ||
collectionTypeName.Contains("ICollection") || collectionTypeName.Contains("IEnumerable")))
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(collectionTypeName.Contains("Dictionary") || collectionTypeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
}
// Handle byte[] array type (check before switch since it's an array)
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return "bytes";
}
// Handle Stream types -> bytes
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return "bytes";
}
// Basic types
@@ -52,67 +112,35 @@ internal static class ProtoFileTypeMapper
return "double";
case "Byte[]":
return "bytes";
}
// Special types that need imports
if (fullTypeName.Contains("System.DateTime"))
{
needsImport = true;
importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Timestamp";
}
if (fullTypeName.Contains("System.TimeSpan"))
{
needsImport = true;
importPath = "google/protobuf/duration.proto";
return "google.protobuf.Duration";
}
if (fullTypeName.Contains("System.Guid"))
{
// Guid serialized as string
return "string";
}
if (fullTypeName.Contains("System.Decimal"))
{
// Decimal serialized as string (no native decimal in proto)
return "string";
}
// Collections
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, etc.
if (collectionType.TypeArguments.Length == 1)
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(typeName.Contains("Dictionary") || typeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
case "Stream":
case "MemoryStream":
case "FileStream":
return "bytes";
case "Guid":
// Guid serialized as string
return "string";
case "Decimal":
// Decimal serialized as string (no native decimal in proto)
return "string";
case "DateTime":
case "DateTimeOffset":
needsImport = true;
importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Timestamp";
case "DateOnly":
// DateOnly serialized as string (YYYY-MM-DD format)
return "string";
case "TimeOnly":
// TimeOnly serialized as string (HH:mm:ss format)
return "string";
case "TimeSpan":
needsImport = true;
importPath = "google/protobuf/duration.proto";
return "google.protobuf.Duration";
case "JsonElement":
needsImport = true;
importPath = "google/protobuf/struct.proto";
return "google.protobuf.Struct";
}
// Enums
@@ -124,7 +152,7 @@ internal static class ProtoFileTypeMapper
// Complex types (classes/records) become message types
if (typeSymbol.TypeKind == TypeKind.Class || typeSymbol.TypeKind == TypeKind.Struct)
{
return typeName; // Reference the message type by name
return GetProtoMessageName(typeSymbol); // Reference the message type by name (handles generics)
}
// Fallback
@@ -132,7 +160,24 @@ internal static class ProtoFileTypeMapper
}
/// <summary>
/// Converts C# PascalCase property name to proto snake_case field name
/// Gets the proto message name for a type, handling generic types by qualifying
/// with type arguments. e.g. Translation&lt;FaqTranslationQueryItem&gt; becomes TranslationOfFaqTranslationQueryItem.
/// </summary>
public static string GetProtoMessageName(ITypeSymbol typeSymbol)
{
if (typeSymbol is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0)
{
var typeArgs = string.Join("And", namedType.TypeArguments.Select(t => GetProtoMessageName(t)));
return $"{namedType.Name}Of{typeArgs}";
}
return typeSymbol.Name;
}
/// <summary>
/// Converts C# PascalCase property name to proto snake_case field name.
/// Uses simple conversion: add underscore before each uppercase letter (except first).
/// This matches protobuf's C# codegen expectations for PascalCase conversion.
/// Example: TotalADeduire -> total_a_deduire -> TotalADeduire (in generated C#)
/// </summary>
public static string ToSnakeCase(string pascalCase)
{
@@ -147,16 +192,8 @@ internal static class ProtoFileTypeMapper
var c = pascalCase[i];
if (char.IsUpper(c))
{
// Handle sequences of uppercase letters (e.g., "APIKey" -> "api_key")
if (i + 1 < pascalCase.Length && char.IsUpper(pascalCase[i + 1]))
{
result.Append(char.ToLowerInvariant(c));
}
else
{
result.Append('_');
result.Append(char.ToLowerInvariant(c));
}
result.Append('_');
result.Append(char.ToLowerInvariant(c));
}
else
{
@@ -175,8 +212,8 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Skip these types - they should trigger a warning/error
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.Threading.CancellationToken") ||
// Note: Stream types are now supported (mapped to bytes)
if (fullTypeName.Contains("System.Threading.CancellationToken") ||
fullTypeName.Contains("System.Threading.Tasks.Task") ||
fullTypeName.Contains("System.Collections.Generic.IAsyncEnumerable") ||
fullTypeName.Contains("System.Func") ||
@@ -188,4 +225,174 @@ internal static class ProtoFileTypeMapper
return false;
}
/// <summary>
/// Checks if a type is a Stream or byte array type (for special ByteString handling)
/// </summary>
public static bool IsBinaryType(ITypeSymbol typeSymbol)
{
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Check for byte[]
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return true;
}
// Check for Stream types
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return true;
}
var typeName = typeSymbol.Name;
return typeName == "Stream" || typeName == "MemoryStream" || typeName == "FileStream";
}
/// <summary>
/// Gets the element type from a collection type, or returns the type itself if not a collection.
/// Also unwraps Nullable types.
/// </summary>
public static ITypeSymbol GetElementOrUnderlyingType(ITypeSymbol typeSymbol)
{
// Unwrap Nullable<T>
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
return GetElementOrUnderlyingType(nullableType.TypeArguments[0]);
}
// Extract element type from collections
if (typeSymbol is INamedTypeSymbol collectionType && collectionType.TypeArguments.Length == 1)
{
var typeName = collectionType.Name;
if (typeName.Contains("List") || typeName.Contains("Collection") ||
typeName.Contains("Enumerable") || typeName.Contains("Array") ||
typeName.Contains("Set") || typeName.Contains("IList") ||
typeName.Contains("ICollection") || typeName.Contains("IEnumerable"))
{
return GetElementOrUnderlyingType(collectionType.TypeArguments[0]);
}
}
return typeSymbol;
}
/// <summary>
/// Checks if the type is an enum (including nullable enums)
/// </summary>
public static bool IsEnumType(ITypeSymbol typeSymbol)
{
var underlying = GetElementOrUnderlyingType(typeSymbol);
return underlying.TypeKind == TypeKind.Enum;
}
/// <summary>
/// Gets the enum type symbol if this is an enum or nullable enum, otherwise null
/// </summary>
public static INamedTypeSymbol? GetEnumType(ITypeSymbol typeSymbol)
{
var underlying = GetElementOrUnderlyingType(typeSymbol);
if (underlying.TypeKind == TypeKind.Enum && underlying is INamedTypeSymbol enumType)
{
return enumType;
}
return null;
}
/// <summary>
/// Checks if a type is a collection by checking if it implements IList{T}, ICollection{T}, or IEnumerable{T}
/// This handles types like NpgsqlPolygon that implement IList{NpgsqlPoint} but aren't named "List"
/// </summary>
public static bool IsCollectionTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return false;
// Skip string (implements IEnumerable<char>)
if (namedType.SpecialType == SpecialType.System_String)
return false;
// Check all interfaces for IList<T>, ICollection<T>, or IEnumerable<T>
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IEnumerable<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
{
return true;
}
}
}
return false;
}
/// <summary>
/// Gets the element type from a collection that implements IList{T}, ICollection{T}, or IEnumerable{T}
/// Returns null if the type is not a collection
/// </summary>
public static ITypeSymbol? GetCollectionElementTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return null;
// Skip string
if (namedType.SpecialType == SpecialType.System_String)
return null;
// Prefer IList<T> over ICollection<T> over IEnumerable<T>
ITypeSymbol? elementType = null;
int priority = 0;
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
int currentPriority = 0;
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>")
currentPriority = 3;
else if (ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
currentPriority = 2;
else if (ifaceName == "System.Collections.Generic.IEnumerable<T>")
currentPriority = 1;
if (currentPriority > priority)
{
priority = currentPriority;
elementType = iface.TypeArguments[0];
}
}
}
return elementType;
}
/// <summary>
/// Collection-internal properties that should be skipped when generating proto messages
/// </summary>
private static readonly System.Collections.Generic.HashSet<string> CollectionInternalProperties = new()
{
"Count", "Capacity", "IsReadOnly", "IsSynchronized", "SyncRoot", "Keys", "Values"
};
/// <summary>
/// Checks if a property name is a collection-internal property that should be skipped
/// </summary>
public static bool IsCollectionInternalProperty(string propertyName)
{
return CollectionInternalProperties.Contains(propertyName);
}
}
@@ -1,130 +0,0 @@
#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// MSBuild task that extracts the auto-generated proto file content from the source generator
/// output and writes it to disk so Grpc.Tools can process it
/// </summary>
public class WriteProtoFileTask : Task
{
/// <summary>
/// The project directory where we should look for generated files
/// </summary>
[Required]
public string ProjectDirectory { get; set; } = string.Empty;
/// <summary>
/// The intermediate output path (typically obj/Debug/net10.0)
/// </summary>
[Required]
public string IntermediateOutputPath { get; set; } = string.Empty;
/// <summary>
/// The output directory where the proto file should be written (typically Protos/)
/// </summary>
[Required]
public string OutputDirectory { get; set; } = string.Empty;
/// <summary>
/// The name of the proto file to generate (typically cqrs_services.proto)
/// </summary>
[Required]
public string ProtoFileName { get; set; } = string.Empty;
public override bool Execute()
{
try
{
Log.LogMessage(MessageImportance.High,
"Svrnty.CQRS.Grpc: Extracting auto-generated proto file...");
// Look for the generated C# file containing the proto content
// Source generators output to obj/Generated, not IntermediateOutputPath/Generated
var generatedFilePath = Path.Combine(
ProjectDirectory,
"obj",
"Generated",
"Svrnty.CQRS.Grpc.Generators",
"Svrnty.CQRS.Grpc.Generators.ProtoFileSourceGenerator",
"GeneratedProtoFile.g.cs"
);
if (!File.Exists(generatedFilePath))
{
Log.LogWarning(
$"Generated proto file not found at {generatedFilePath}. " +
"The proto file may not have been generated yet. This is normal on first build.");
return true; // Don't fail the build, just skip
}
// Read the generated C# file
var csContent = File.ReadAllText(generatedFilePath);
// Extract the proto content using a more robust approach
// Looking for: public const string Content = @"...";
var startMarker = "public const string Content = @\"";
var startIndex = csContent.IndexOf(startMarker);
if (startIndex < 0)
{
Log.LogError($"Could not find Content property in {generatedFilePath}");
return false;
}
startIndex += startMarker.Length;
// Find the closing "; - We need the LAST occurrence because the content contains escaped quotes
// The pattern is: Content = @"...content...";
// where content has "" for literal quotes
var endMarker = "\";";
// Find where the next field starts or class ends to limit our search
var nextFieldOrEnd = csContent.IndexOf("\n }", startIndex); // End of class
if (nextFieldOrEnd < 0)
{
nextFieldOrEnd = csContent.Length;
}
var endIndex = csContent.LastIndexOf(endMarker, nextFieldOrEnd, nextFieldOrEnd - startIndex);
if (endIndex < 0 || endIndex < startIndex)
{
Log.LogError($"Could not find end of Content property in {generatedFilePath}");
return false;
}
// Extract and unescape doubled quotes
var protoContent = csContent.Substring(startIndex, endIndex - startIndex);
protoContent = protoContent.Replace("\"\"", "\"");
Log.LogMessage(MessageImportance.High,
$"Extracted proto content length: {protoContent.Length} characters");
// Ensure output directory exists
var fullOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(fullOutputPath);
// Write the proto file
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
File.WriteAllText(protoFilePath, protoContent);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
return true;
}
catch (Exception ex)
{
Log.LogErrorFromException(ex, true);
return false;
}
}
}
@@ -9,29 +9,48 @@
<!-- Determine the assembly path (different for NuGet package vs project reference) -->
<PropertyGroup>
<_GeneratorsAssemblyPath Condition="Exists('$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_GeneratorsAssemblyPath>
</PropertyGroup>
<!-- Load the WriteProtoFileTask from the generator assembly -->
<UsingTask TaskName="Svrnty.CQRS.Grpc.Generators.WriteProtoFileTask"
<!-- Load the GenerateProtoFileTask from the generator assembly -->
<UsingTask TaskName="Svrnty.CQRS.Grpc.Generators.GenerateProtoFileTask"
AssemblyFile="$(_GeneratorsAssemblyPath)"
Condition="'$(_GeneratorsAssemblyPath)' != ''" />
<!-- This target ensures the Protos directory exists before the generator runs -->
<Target Name="EnsureProtosDirectory" BeforeTargets="CoreCompile">
<!-- This target ensures the Protos directory exists -->
<Target Name="EnsureProtosDirectory" BeforeTargets="SvrntyGenerateProtoFile">
<MakeDir Directories="$(ProtoOutputDirectory)" Condition="!Exists('$(ProtoOutputDirectory)')" />
</Target>
<!-- Extract the proto file from the source generator output BEFORE Grpc.Tools processes protos -->
<!-- Runs before CoreCompile, after source generators have been executed -->
<Target Name="SvrntyExtractProtoFile" BeforeTargets="CoreCompile" AfterTargets="ResolveProjectReferences" DependsOnTargets="EnsureProtosDirectory" Condition="'$(GenerateProtoFile)' == 'true'">
<Message Text="Svrnty.CQRS.Grpc: Extracting auto-generated proto file to $(ProtoOutputDirectory)\$(GeneratedProtoFileName)" Importance="high" />
<!--
Generate the proto file BEFORE Grpc.Tools processes protos and BEFORE CoreCompile.
This runs AFTER ResolveAssemblyReferences so we have access to @(ReferencePath).
<WriteProtoFileTask
Key timing:
- AfterTargets="ResolveAssemblyReferences" ensures we have all references resolved
- BeforeTargets="_gRPC_GetProtoc;CoreCompile" ensures proto is generated before:
1. Grpc.Tools compiles the proto into C# (_gRPC_GetProtoc is Grpc.Tools' entry point)
2. CoreCompile compiles the project
-->
<Target Name="SvrntyGenerateProtoFile"
BeforeTargets="_gRPC_GetProtoc;CoreCompile"
AfterTargets="ResolveAssemblyReferences"
DependsOnTargets="EnsureProtosDirectory"
Condition="'$(GenerateProtoFile)' == 'true' AND '$(_GeneratorsAssemblyPath)' != ''">
<Message Text="Svrnty.CQRS.Grpc: Generating proto file from $(MSBuildProjectName)..." Importance="high" />
<Message Text="Svrnty.CQRS.Grpc: Source files count: @(Compile->Count())" Importance="normal" />
<Message Text="Svrnty.CQRS.Grpc: References count: @(ReferencePath->Count())" Importance="normal" />
<GenerateProtoFileTask
ProjectDirectory="$(MSBuildProjectDirectory)"
IntermediateOutputPath="$(IntermediateOutputPath)"
OutputDirectory="$(ProtoOutputDirectory)"
ProtoFileName="$(GeneratedProtoFileName)" />
ProtoFileName="$(GeneratedProtoFileName)"
SourceFiles="@(Compile)"
References="@(ReferencePath)"
RootNamespace="$(RootNamespace)"
AssemblyName="$(AssemblyName)" />
</Target>
</Project>
BIN
View File
Binary file not shown.
+54
View File
@@ -33,6 +33,7 @@ public static class CqrsBuilderExtensions
{
Console.WriteLine("Warning: AddGrpcFromConfiguration not found. gRPC services were not registered.");
Console.WriteLine("Make sure your project has source generators enabled and references Svrnty.CQRS.Grpc.Generators.");
DiagnoseGeneratedCode();
}
// Register mapping callback for automatic endpoint mapping
@@ -49,6 +50,59 @@ public static class CqrsBuilderExtensions
return builder;
}
private static void DiagnoseGeneratedCode()
{
var entryAsm = Assembly.GetEntryAssembly();
if (entryAsm == null)
{
Console.WriteLine("Diagnostic: Entry assembly is null");
return;
}
Console.WriteLine($"Diagnostic: Entry assembly = {entryAsm.GetName().Name}");
try
{
var allTypes = entryAsm.GetTypes();
Console.WriteLine($"Diagnostic: Total types in entry assembly = {allTypes.Length}");
var grpcTypes = allTypes
.Where(t => t.FullName?.Contains("Grpc") == true)
.ToList();
if (grpcTypes.Any())
{
Console.WriteLine("Diagnostic: Found Grpc-related types:");
foreach (var t in grpcTypes)
{
Console.WriteLine($" - {t.FullName} (IsClass={t.IsClass}, IsSealed={t.IsSealed}, IsPublic={t.IsPublic})");
// Check for our target method
var method = t.GetMethod("AddGrpcFromConfiguration", BindingFlags.Static | BindingFlags.Public);
if (method != null)
Console.WriteLine($" -> HAS AddGrpcFromConfiguration method!");
}
}
else
{
Console.WriteLine("Diagnostic: No Grpc-related types found. Source generator did NOT run.");
}
}
catch (ReflectionTypeLoadException ex)
{
Console.WriteLine($"Diagnostic: ReflectionTypeLoadException - {ex.Message}");
foreach (var le in ex.LoaderExceptions)
{
if (le != null)
Console.WriteLine($" LoaderException: {le.Message}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Diagnostic: Exception - {ex.GetType().Name}: {ex.Message}");
}
}
private static MethodInfo? FindExtensionMethod(string methodName, Type parameterType)
{
// Search through all loaded assemblies for the extension method
+1 -1
View File
@@ -27,7 +27,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.68.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
</ItemGroup>
<ItemGroup>
@@ -19,7 +19,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query")
{
var queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
foreach (var queryMeta in queryDiscovery.GetQueries())
{
@@ -33,8 +32,8 @@ public static class EndpointRouteBuilderExtensions
var route = $"{routePrefix}/{queryMeta.LowerCamelCaseName}";
MapQueryPost(endpoints, route, queryMeta, authorizationService);
MapQueryGet(endpoints, route, queryMeta, authorizationService);
MapQueryPost(endpoints, route, queryMeta);
MapQueryGet(endpoints, route, queryMeta);
}
return endpoints;
@@ -43,13 +42,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapQueryPost(
IEndpointRouteBuilder endpoints,
string route,
IQueryMeta queryMeta,
IQueryAuthorizationService? authorizationService)
IQueryMeta queryMeta)
{
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
@@ -90,13 +89,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapQueryGet(
IEndpointRouteBuilder endpoints,
string route,
IQueryMeta queryMeta,
IQueryAuthorizationService? authorizationService)
IQueryMeta queryMeta)
{
var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType);
endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<IQueryAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken);
@@ -153,7 +152,6 @@ public static class EndpointRouteBuilderExtensions
public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command")
{
var commandDiscovery = endpoints.ServiceProvider.GetRequiredService<ICommandDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<ICommandAuthorizationService>();
foreach (var commandMeta in commandDiscovery.GetCommands())
{
@@ -165,11 +163,11 @@ public static class EndpointRouteBuilderExtensions
if (commandMeta.CommandResultType == null)
{
MapCommandWithoutResult(endpoints, route, commandMeta, authorizationService);
MapCommandWithoutResult(endpoints, route, commandMeta);
}
else
{
MapCommandWithResult(endpoints, route, commandMeta, authorizationService);
MapCommandWithResult(endpoints, route, commandMeta);
}
}
@@ -179,13 +177,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapCommandWithoutResult(
IEndpointRouteBuilder endpoints,
string route,
ICommandMeta commandMeta,
ICommandAuthorizationService? authorizationService)
ICommandMeta commandMeta)
{
var handlerType = typeof(ICommandHandler<>).MakeGenericType(commandMeta.CommandType);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
@@ -221,13 +219,13 @@ public static class EndpointRouteBuilderExtensions
private static void MapCommandWithResult(
IEndpointRouteBuilder endpoints,
string route,
ICommandMeta commandMeta,
ICommandAuthorizationService? authorizationService)
ICommandMeta commandMeta)
{
var handlerType = typeof(ICommandHandler<,>).MakeGenericType(commandMeta.CommandType, commandMeta.CommandResultType!);
endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) =>
{
var authorizationService = serviceProvider.GetService<ICommandAuthorizationService>();
if (authorizationService != null)
{
var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken);
@@ -0,0 +1,18 @@
namespace Svrnty.CQRS.Notifications.Abstractions;
/// <summary>
/// Publishes notifications to all subscribed gRPC clients.
/// </summary>
public interface INotificationPublisher
{
/// <summary>
/// Publish a notification to all subscribers matching the subscription key.
/// The subscription key is extracted from the notification based on the
/// <see cref="StreamingNotificationAttribute.SubscriptionKey"/> property.
/// </summary>
/// <typeparam name="TNotification">The notification type marked with <see cref="StreamingNotificationAttribute"/>.</typeparam>
/// <param name="notification">The notification to publish.</param>
/// <param name="ct">Cancellation token.</param>
Task PublishAsync<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : class;
}
@@ -0,0 +1,15 @@
namespace Svrnty.CQRS.Notifications.Abstractions;
/// <summary>
/// Marks a record as a streaming notification that can be subscribed to via gRPC.
/// The framework will auto-generate proto definitions and service implementations.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class StreamingNotificationAttribute : Attribute
{
/// <summary>
/// The property name used as the subscription key.
/// Subscribers filter notifications by this value.
/// </summary>
public required string SubscriptionKey { get; set; }
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
using System.Collections.Concurrent;
using System.Reflection;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Notifications.Abstractions;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Publishes notifications to subscribed gRPC clients.
/// </summary>
public class NotificationPublisher : INotificationPublisher
{
private readonly NotificationSubscriptionManager _manager;
private readonly ILogger<NotificationPublisher> _logger;
// Cache subscription key property info per notification type
private static readonly ConcurrentDictionary<Type, SubscriptionKeyInfo> _keyCache = new();
public NotificationPublisher(
NotificationSubscriptionManager manager,
ILogger<NotificationPublisher> logger)
{
_manager = manager;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync<TNotification>(TNotification notification, CancellationToken ct = default)
where TNotification : class
{
ArgumentNullException.ThrowIfNull(notification);
var keyInfo = GetSubscriptionKeyInfo(typeof(TNotification));
var subscriptionKey = keyInfo.Property.GetValue(notification);
if (subscriptionKey == null)
{
_logger.LogWarning(
"Subscription key {PropertyName} is null on {NotificationType}, skipping notification",
keyInfo.PropertyName, typeof(TNotification).Name);
return;
}
_logger.LogDebug(
"Publishing {NotificationType} with subscription key {PropertyName}={KeyValue}",
typeof(TNotification).Name, keyInfo.PropertyName, subscriptionKey);
await _manager.NotifyAsync(notification, subscriptionKey, ct);
}
private static SubscriptionKeyInfo GetSubscriptionKeyInfo(Type type)
{
return _keyCache.GetOrAdd(type, t =>
{
var attr = t.GetCustomAttribute<StreamingNotificationAttribute>();
if (attr == null)
{
throw new InvalidOperationException(
$"Type {t.Name} is not marked with [{nameof(StreamingNotificationAttribute)}]. " +
$"Add the attribute with a SubscriptionKey to enable streaming notifications.");
}
var property = t.GetProperty(attr.SubscriptionKey);
if (property == null)
{
throw new InvalidOperationException(
$"Property '{attr.SubscriptionKey}' specified in [{nameof(StreamingNotificationAttribute)}] " +
$"was not found on type {t.Name}.");
}
return new SubscriptionKeyInfo(attr.SubscriptionKey, property);
});
}
private sealed record SubscriptionKeyInfo(string PropertyName, PropertyInfo Property);
}
@@ -0,0 +1,164 @@
using System.Collections.Concurrent;
using Grpc.Core;
using Microsoft.Extensions.Logging;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Manages gRPC stream subscriptions for notifications.
/// Thread-safe singleton that tracks subscriptions and routes notifications to subscribers.
/// </summary>
public class NotificationSubscriptionManager
{
private readonly ConcurrentDictionary<(string TypeName, string Key), ConcurrentBag<object>> _subscriptions = new();
private readonly ILogger<NotificationSubscriptionManager> _logger;
public NotificationSubscriptionManager(ILogger<NotificationSubscriptionManager> logger)
{
_logger = logger;
}
/// <summary>
/// Subscribe to notifications of a specific domain type with a mapper to convert to proto format.
/// </summary>
/// <typeparam name="TDomain">The domain notification type.</typeparam>
/// <typeparam name="TProto">The proto message type.</typeparam>
/// <param name="subscriptionKey">The subscription key value (e.g., inventory ID).</param>
/// <param name="stream">The gRPC server stream writer.</param>
/// <param name="mapper">Function to map domain notification to proto message.</param>
/// <returns>A disposable that removes the subscription when disposed.</returns>
public IDisposable Subscribe<TDomain, TProto>(
object subscriptionKey,
IServerStreamWriter<TProto> stream,
Func<TDomain, TProto> mapper) where TDomain : class
{
var key = (typeof(TDomain).FullName!, subscriptionKey.ToString()!);
var subscriber = new Subscriber<TDomain, TProto>(stream, mapper);
var bag = _subscriptions.GetOrAdd(key, _ => new ConcurrentBag<object>());
bag.Add(subscriber);
_logger.LogInformation(
"Client subscribed to {NotificationType} with key {SubscriptionKey}. Total subscribers: {Count}",
typeof(TDomain).Name, subscriptionKey, bag.Count);
return new SubscriptionHandle(() => Remove(key, subscriber));
}
/// <summary>
/// Notify all subscribers of a specific notification type and subscription key.
/// </summary>
internal async Task NotifyAsync<TDomain>(TDomain notification, object subscriptionKey, CancellationToken ct) where TDomain : class
{
var key = (typeof(TDomain).FullName!, subscriptionKey.ToString()!);
if (!_subscriptions.TryGetValue(key, out var subscribers))
{
_logger.LogDebug(
"No subscribers for {NotificationType} with key {SubscriptionKey}",
typeof(TDomain).Name, subscriptionKey);
return;
}
var deadSubscribers = new List<object>();
foreach (var sub in subscribers)
{
if (sub is INotifiable<TDomain> notifiable)
{
try
{
await notifiable.NotifyAsync(notification, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to notify subscriber for {NotificationType}, marking for removal",
typeof(TDomain).Name);
deadSubscribers.Add(sub);
}
}
}
// Clean up dead subscribers
foreach (var dead in deadSubscribers)
{
Remove(key, dead);
}
_logger.LogDebug(
"Notified {Count} subscribers for {NotificationType} with key {SubscriptionKey}",
subscribers.Count - deadSubscribers.Count, typeof(TDomain).Name, subscriptionKey);
}
private void Remove((string TypeName, string Key) key, object subscriber)
{
if (_subscriptions.TryGetValue(key, out var bag))
{
// ConcurrentBag doesn't support removal, so we rebuild
var remaining = bag.Where(s => !ReferenceEquals(s, subscriber)).ToList();
if (remaining.Count == 0)
{
_subscriptions.TryRemove(key, out _);
}
else
{
var newBag = new ConcurrentBag<object>(remaining);
_subscriptions.TryUpdate(key, newBag, bag);
}
_logger.LogInformation(
"Client unsubscribed from {NotificationType} with key {SubscriptionKey}",
key.TypeName.Split('.').Last(), key.Key);
}
}
}
/// <summary>
/// Internal interface for type-erased notification delivery.
/// </summary>
internal interface INotifiable<in TDomain>
{
Task NotifyAsync(TDomain notification, CancellationToken ct);
}
/// <summary>
/// Wraps a gRPC stream writer with a domain→proto mapper.
/// </summary>
internal sealed class Subscriber<TDomain, TProto> : INotifiable<TDomain>
{
private readonly IServerStreamWriter<TProto> _stream;
private readonly Func<TDomain, TProto> _mapper;
public Subscriber(IServerStreamWriter<TProto> stream, Func<TDomain, TProto> mapper)
{
_stream = stream;
_mapper = mapper;
}
public async Task NotifyAsync(TDomain notification, CancellationToken ct)
{
var proto = _mapper(notification);
await _stream.WriteAsync(proto, ct);
}
}
/// <summary>
/// Handle that removes a subscription when disposed.
/// </summary>
internal sealed class SubscriptionHandle : IDisposable
{
private readonly Action _onDispose;
private bool _disposed;
public SubscriptionHandle(Action onDispose)
{
_onDispose = onDispose;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_onDispose();
}
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Notifications.Abstractions;
namespace Svrnty.CQRS.Notifications.Grpc;
/// <summary>
/// Extension methods for registering streaming notification services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds gRPC streaming notification services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddStreamingNotifications(this IServiceCollection services)
{
// Subscription manager is singleton - shared state for all subscriptions
services.AddSingleton<NotificationSubscriptionManager>();
// Publisher can be singleton since it only depends on the manager
services.AddSingleton<INotificationPublisher, NotificationPublisher>();
return services;
}
}
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Company>Svrnty</Company>
<Authors>Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Notifications.Abstractions\Svrnty.CQRS.Notifications.Abstractions.csproj" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Defines a saga with its steps and compensation logic.
/// </summary>
/// <typeparam name="TData">The saga's data/context type.</typeparam>
public interface ISaga<TData> where TData : class, ISagaData, new()
{
/// <summary>
/// Configures the saga steps using the fluent builder.
/// </summary>
/// <param name="builder">The saga builder for defining steps.</param>
void Configure(ISagaBuilder<TData> builder);
}
@@ -0,0 +1,173 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Fluent builder for defining saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public interface ISagaBuilder<TData> where TData : class, ISagaData
{
/// <summary>
/// Adds a local step that executes synchronously within the orchestrator process.
/// </summary>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the step.</returns>
ISagaStepBuilder<TData> Step(string name);
/// <summary>
/// Adds a step that sends a command to a remote service via messaging.
/// </summary>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the remote step.</returns>
ISagaRemoteStepBuilder<TData, TCommand> SendCommand<TCommand>(string name) where TCommand : class;
/// <summary>
/// Adds a step that sends a command and expects a specific result.
/// </summary>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="name">Unique name for this step.</param>
/// <returns>Builder for configuring the remote step.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> SendCommand<TCommand, TResult>(string name) where TCommand : class;
}
/// <summary>
/// Builder for configuring a local saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public interface ISagaStepBuilder<TData> where TData : class, ISagaData
{
/// <summary>
/// Defines the execution action for this step.
/// </summary>
/// <param name="action">The action to execute.</param>
/// <returns>This builder for chaining.</returns>
ISagaStepBuilder<TData> Execute(Func<TData, ISagaContext, CancellationToken, Task> action);
/// <summary>
/// Defines the compensation action for this step.
/// </summary>
/// <param name="action">The compensation action to execute on rollback.</param>
/// <returns>This builder for chaining.</returns>
ISagaStepBuilder<TData> Compensate(Func<TData, ISagaContext, CancellationToken, Task> action);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
/// <summary>
/// Builder for configuring a remote command saga step (no result).
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type to send.</typeparam>
public interface ISagaRemoteStepBuilder<TData, TCommand>
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Defines how to build the command from saga data.
/// </summary>
/// <param name="commandBuilder">Function to create the command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder);
/// <summary>
/// Defines what to do when the command completes successfully.
/// </summary>
/// <param name="handler">Handler for the response.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> OnResponse(Func<TData, ISagaContext, CancellationToken, Task> handler);
/// <summary>
/// Defines the compensation command to send on rollback.
/// </summary>
/// <typeparam name="TCompensationCommand">The compensation command type.</typeparam>
/// <param name="compensationBuilder">Function to create the compensation command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder) where TCompensationCommand : class;
/// <summary>
/// Sets a timeout for this step.
/// </summary>
/// <param name="timeout">The timeout duration.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithTimeout(TimeSpan timeout);
/// <summary>
/// Configures retry behavior for this step.
/// </summary>
/// <param name="maxRetries">Maximum number of retries.</param>
/// <param name="delay">Delay between retries.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand> WithRetry(int maxRetries, TimeSpan delay);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
/// <summary>
/// Builder for configuring a remote command saga step with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type to send.</typeparam>
/// <typeparam name="TResult">The expected result type.</typeparam>
public interface ISagaRemoteStepBuilder<TData, TCommand, TResult>
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Defines how to build the command from saga data.
/// </summary>
/// <param name="commandBuilder">Function to create the command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder);
/// <summary>
/// Defines what to do when the command completes successfully with a result.
/// </summary>
/// <param name="handler">Handler for the response with result.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> OnResponse(
Func<TData, ISagaContext, TResult, CancellationToken, Task> handler);
/// <summary>
/// Defines the compensation command to send on rollback.
/// </summary>
/// <typeparam name="TCompensationCommand">The compensation command type.</typeparam>
/// <param name="compensationBuilder">Function to create the compensation command.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder) where TCompensationCommand : class;
/// <summary>
/// Sets a timeout for this step.
/// </summary>
/// <param name="timeout">The timeout duration.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithTimeout(TimeSpan timeout);
/// <summary>
/// Configures retry behavior for this step.
/// </summary>
/// <param name="maxRetries">Maximum number of retries.</param>
/// <param name="delay">Delay between retries.</param>
/// <returns>This builder for chaining.</returns>
ISagaRemoteStepBuilder<TData, TCommand, TResult> WithRetry(int maxRetries, TimeSpan delay);
/// <summary>
/// Completes this step definition and returns to the saga builder.
/// </summary>
/// <returns>The saga builder for adding more steps.</returns>
ISagaBuilder<TData> Then();
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Provides context information during saga step execution.
/// </summary>
public interface ISagaContext
{
/// <summary>
/// Unique identifier for this saga instance.
/// </summary>
Guid SagaId { get; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
Guid CorrelationId { get; }
/// <summary>
/// The fully qualified type name of the saga.
/// </summary>
string SagaType { get; }
/// <summary>
/// Index of the current step being executed.
/// </summary>
int CurrentStepIndex { get; }
/// <summary>
/// Name of the current step being executed.
/// </summary>
string CurrentStepName { get; }
/// <summary>
/// Results from completed steps, keyed by step name.
/// </summary>
IReadOnlyDictionary<string, object?> StepResults { get; }
/// <summary>
/// Gets a result from a previous step.
/// </summary>
/// <typeparam name="T">The expected result type.</typeparam>
/// <param name="stepName">The name of the step.</param>
/// <returns>The result, or default if not found.</returns>
T? GetStepResult<T>(string stepName);
/// <summary>
/// Sets a result for the current step (available to subsequent steps).
/// </summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="result">The result value.</param>
void SetStepResult<T>(T result);
}
@@ -0,0 +1,14 @@
using System;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Marker interface for saga data. All saga data classes must implement this interface.
/// </summary>
public interface ISagaData
{
/// <summary>
/// Correlation ID for tracing the saga across services.
/// </summary>
Guid CorrelationId { get; set; }
}
@@ -0,0 +1,52 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Orchestrates saga execution.
/// </summary>
public interface ISagaOrchestrator
{
/// <summary>
/// Starts a new saga instance with a generated correlation ID.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="initialData">The initial saga data.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state.</returns>
Task<SagaState> StartAsync<TSaga, TData>(TData initialData, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new();
/// <summary>
/// Starts a new saga instance with a specific correlation ID.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="initialData">The initial saga data.</param>
/// <param name="correlationId">The correlation ID for tracing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state.</returns>
Task<SagaState> StartAsync<TSaga, TData>(TData initialData, Guid correlationId, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new();
/// <summary>
/// Gets the current state of a saga by its ID.
/// </summary>
/// <param name="sagaId">The saga instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetStateAsync(Guid sagaId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current state of a saga by its correlation ID.
/// </summary>
/// <param name="correlationId">The correlation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetStateByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Abstraction for saga messaging transport.
/// </summary>
public interface ISagaMessageBus
{
/// <summary>
/// Publishes a saga command message to the message bus.
/// </summary>
/// <param name="message">The message to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishAsync(SagaMessage message, CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a saga step response to the message bus.
/// </summary>
/// <param name="response">The response to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task PublishResponseAsync(SagaStepResponse response, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to saga messages for a specific command type.
/// </summary>
/// <typeparam name="TCommand">The command type to subscribe to.</typeparam>
/// <param name="handler">Handler that processes the message and returns a response.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SubscribeAsync<TCommand>(
Func<SagaMessage, TCommand, CancellationToken, Task<SagaStepResponse>> handler,
CancellationToken cancellationToken = default) where TCommand : class;
/// <summary>
/// Subscribes to saga step responses.
/// </summary>
/// <param name="handler">Handler that processes responses.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SubscribeToResponsesAsync(
Func<SagaStepResponse, CancellationToken, Task> handler,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Message envelope for saga commands sent to remote services.
/// </summary>
public record SagaMessage
{
/// <summary>
/// Unique identifier for this message.
/// </summary>
public Guid MessageId { get; init; } = Guid.NewGuid();
/// <summary>
/// The saga instance ID.
/// </summary>
public Guid SagaId { get; init; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; init; }
/// <summary>
/// Name of the saga step that sent this message.
/// </summary>
public string StepName { get; init; } = string.Empty;
/// <summary>
/// Fully qualified type name of the command.
/// </summary>
public string CommandType { get; init; } = string.Empty;
/// <summary>
/// Serialized command payload (JSON).
/// </summary>
public string? Payload { get; init; }
/// <summary>
/// When the message was created.
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Additional headers for the message.
/// </summary>
public Dictionary<string, string> Headers { get; init; } = new();
/// <summary>
/// Whether this is a compensation command.
/// </summary>
public bool IsCompensation { get; init; }
}
@@ -0,0 +1,59 @@
using System;
namespace Svrnty.CQRS.Sagas.Abstractions.Messaging;
/// <summary>
/// Response message from a saga step execution.
/// </summary>
public record SagaStepResponse
{
/// <summary>
/// Unique identifier for this response.
/// </summary>
public Guid MessageId { get; init; } = Guid.NewGuid();
/// <summary>
/// The saga instance ID.
/// </summary>
public Guid SagaId { get; init; }
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; init; }
/// <summary>
/// Name of the saga step that this response is for.
/// </summary>
public string StepName { get; init; } = string.Empty;
/// <summary>
/// Whether the step executed successfully.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Fully qualified type name of the result (if any).
/// </summary>
public string? ResultType { get; init; }
/// <summary>
/// Serialized result payload (JSON).
/// </summary>
public string? ResultPayload { get; init; }
/// <summary>
/// Error message if the step failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Stack trace if the step failed (for debugging).
/// </summary>
public string? StackTrace { get; init; }
/// <summary>
/// When the response was created.
/// </summary>
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Svrnty.CQRS.Sagas.Abstractions.Persistence;
/// <summary>
/// Abstraction for saga state persistence.
/// </summary>
public interface ISagaStateStore
{
/// <summary>
/// Creates a new saga state entry.
/// </summary>
/// <param name="state">The saga state to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created saga state.</returns>
Task<SagaState> CreateAsync(SagaState state, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a saga state by its ID.
/// </summary>
/// <param name="sagaId">The saga instance ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetByIdAsync(Guid sagaId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a saga state by its correlation ID.
/// </summary>
/// <param name="correlationId">The correlation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The saga state, or null if not found.</returns>
Task<SagaState?> GetByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing saga state.
/// </summary>
/// <param name="state">The saga state to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The updated saga state.</returns>
Task<SagaState> UpdateAsync(SagaState state, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all pending (in progress) sagas.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of pending saga states.</returns>
Task<IReadOnlyList<SagaState>> GetPendingSagasAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets all sagas with a specific status.
/// </summary>
/// <param name="status">The status to filter by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of saga states with the specified status.</returns>
Task<IReadOnlyList<SagaState>> GetSagasByStatusAsync(SagaStatus status, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Represents the persistent state of a saga instance.
/// </summary>
public class SagaState
{
/// <summary>
/// Unique identifier for this saga instance.
/// </summary>
public Guid SagaId { get; set; } = Guid.NewGuid();
/// <summary>
/// The fully qualified type name of the saga.
/// </summary>
public string SagaType { get; set; } = string.Empty;
/// <summary>
/// Correlation ID for tracing across services.
/// </summary>
public Guid CorrelationId { get; set; }
/// <summary>
/// Current execution status.
/// </summary>
public SagaStatus Status { get; set; } = SagaStatus.NotStarted;
/// <summary>
/// Index of the current step being executed.
/// </summary>
public int CurrentStepIndex { get; set; }
/// <summary>
/// Name of the current step being executed.
/// </summary>
public string? CurrentStepName { get; set; }
/// <summary>
/// Results from completed steps, keyed by step name.
/// </summary>
public Dictionary<string, object?> StepResults { get; set; } = new();
/// <summary>
/// Names of steps that have been completed.
/// </summary>
public List<string> CompletedSteps { get; set; } = new();
/// <summary>
/// Errors that occurred during saga execution.
/// </summary>
public List<SagaStepError> Errors { get; set; } = new();
/// <summary>
/// Serialized saga data (JSON).
/// </summary>
public string? SerializedData { get; set; }
/// <summary>
/// When the saga was created.
/// </summary>
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// When the saga was last updated.
/// </summary>
public DateTimeOffset? UpdatedAt { get; set; }
/// <summary>
/// When the saga completed (successfully or compensated).
/// </summary>
public DateTimeOffset? CompletedAt { get; set; }
}
/// <summary>
/// Represents an error that occurred during saga step execution.
/// </summary>
public record SagaStepError(
string StepName,
string ErrorMessage,
string? StackTrace,
DateTimeOffset OccurredAt
);
@@ -0,0 +1,37 @@
namespace Svrnty.CQRS.Sagas.Abstractions;
/// <summary>
/// Represents the execution state of a saga.
/// </summary>
public enum SagaStatus
{
/// <summary>
/// Saga has not started execution.
/// </summary>
NotStarted,
/// <summary>
/// Saga is currently executing steps.
/// </summary>
InProgress,
/// <summary>
/// Saga completed successfully.
/// </summary>
Completed,
/// <summary>
/// Saga failed and compensation has not been triggered.
/// </summary>
Failed,
/// <summary>
/// Saga is currently executing compensation steps.
/// </summary>
Compensating,
/// <summary>
/// Saga has been compensated (rolled back) successfully.
/// </summary>
Compensated
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Configuration;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Extensions for adding RabbitMQ saga transport to the CQRS pipeline.
/// </summary>
public static class CqrsBuilderExtensions
{
/// <summary>
/// Uses RabbitMQ as the message transport for sagas.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <param name="configure">Configuration action for RabbitMQ options.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseRabbitMq(this CqrsBuilder builder, Action<RabbitMqSagaOptions> configure)
{
var options = new RabbitMqSagaOptions();
configure(options);
builder.Services.Configure<RabbitMqSagaOptions>(opt =>
{
opt.HostName = options.HostName;
opt.Port = options.Port;
opt.UserName = options.UserName;
opt.Password = options.Password;
opt.VirtualHost = options.VirtualHost;
opt.CommandExchange = options.CommandExchange;
opt.ResponseExchange = options.ResponseExchange;
opt.QueuePrefix = options.QueuePrefix;
opt.DurableQueues = options.DurableQueues;
opt.PrefetchCount = options.PrefetchCount;
opt.ConnectionRetryDelay = options.ConnectionRetryDelay;
opt.MaxConnectionRetries = options.MaxConnectionRetries;
});
// Replace the default message bus with RabbitMQ implementation
builder.Services.RemoveAll<ISagaMessageBus>();
builder.Services.AddSingleton<ISagaMessageBus, RabbitMqSagaMessageBus>();
// Add hosted service for connection management
builder.Services.AddHostedService<RabbitMqSagaHostedService>();
return builder;
}
/// <summary>
/// Uses RabbitMQ as the message transport for sagas with default options.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseRabbitMq(this CqrsBuilder builder)
{
return builder.UseRabbitMq(_ => { });
}
}
@@ -0,0 +1,88 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Hosted service that manages RabbitMQ saga connections and subscriptions.
/// </summary>
public class RabbitMqSagaHostedService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ISagaMessageBus _messageBus;
private readonly ILogger<RabbitMqSagaHostedService> _logger;
/// <summary>
/// Creates a new RabbitMQ saga hosted service.
/// </summary>
public RabbitMqSagaHostedService(
IServiceProvider serviceProvider,
ISagaMessageBus messageBus,
ILogger<RabbitMqSagaHostedService> logger)
{
_serviceProvider = serviceProvider;
_messageBus = messageBus;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting RabbitMQ saga hosted service");
try
{
// Subscribe to saga responses so the orchestrator can process them
await _messageBus.SubscribeToResponsesAsync(
async (response, ct) =>
{
using var scope = _serviceProvider.CreateScope();
var orchestrator = scope.ServiceProvider.GetRequiredService<ISagaOrchestrator>();
// The orchestrator needs to handle responses
// This is a simplified approach - in production you'd want to handle this more robustly
_logger.LogDebug(
"Received response for saga {SagaId}, step {StepName}, success: {Success}",
response.SagaId, response.StepName, response.Success);
// For now, we just log the response
// The orchestrator's HandleResponseAsync method would be called here
// but it requires knowing the saga data type, which we don't have in this context
},
stoppingToken);
_logger.LogInformation("RabbitMQ saga hosted service started successfully");
// Keep the service running
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("RabbitMQ saga hosted service is stopping");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in RabbitMQ saga hosted service");
throw;
}
}
/// <inheritdoc />
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping RabbitMQ saga hosted service");
if (_messageBus is IAsyncDisposable disposable)
{
await disposable.DisposeAsync();
}
await base.StopAsync(cancellationToken);
}
}
@@ -0,0 +1,335 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// RabbitMQ implementation of the saga message bus.
/// </summary>
public class RabbitMqSagaMessageBus : ISagaMessageBus, IAsyncDisposable
{
private readonly RabbitMqSagaOptions _options;
private readonly ILogger<RabbitMqSagaMessageBus> _logger;
private IConnection? _connection;
private IChannel? _publishChannel;
private readonly ConcurrentDictionary<string, IChannel> _subscriptionChannels = new();
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private bool _disposed;
/// <summary>
/// Creates a new RabbitMQ saga message bus.
/// </summary>
public RabbitMqSagaMessageBus(
IOptions<RabbitMqSagaOptions> options,
ILogger<RabbitMqSagaMessageBus> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task PublishAsync(SagaMessage message, CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var routingKey = $"saga.command.{message.CommandType}";
var body = JsonSerializer.SerializeToUtf8Bytes(message);
var properties = new BasicProperties
{
MessageId = message.MessageId.ToString(),
CorrelationId = message.CorrelationId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.DurableQueues ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(message.Timestamp.ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["saga-id"] = message.SagaId.ToString(),
["step-name"] = message.StepName,
["is-compensation"] = message.IsCompensation.ToString()
}
};
await _publishChannel!.BasicPublishAsync(
exchange: _options.CommandExchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published saga command {CommandType} for saga {SagaId}, step {StepName}",
message.CommandType, message.SagaId, message.StepName);
}
/// <inheritdoc />
public async Task PublishResponseAsync(SagaStepResponse response, CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var routingKey = $"saga.response.{response.SagaId}";
var body = JsonSerializer.SerializeToUtf8Bytes(response);
var properties = new BasicProperties
{
MessageId = response.MessageId.ToString(),
CorrelationId = response.CorrelationId.ToString(),
ContentType = "application/json",
DeliveryMode = _options.DurableQueues ? DeliveryModes.Persistent : DeliveryModes.Transient,
Timestamp = new AmqpTimestamp(response.Timestamp.ToUnixTimeSeconds()),
Headers = new Dictionary<string, object?>
{
["saga-id"] = response.SagaId.ToString(),
["step-name"] = response.StepName,
["success"] = response.Success.ToString()
}
};
await _publishChannel!.BasicPublishAsync(
exchange: _options.ResponseExchange,
routingKey: routingKey,
mandatory: false,
basicProperties: properties,
body: body,
cancellationToken: cancellationToken);
_logger.LogDebug(
"Published saga response for saga {SagaId}, step {StepName}, success: {Success}",
response.SagaId, response.StepName, response.Success);
}
/// <inheritdoc />
public async Task SubscribeAsync<TCommand>(
Func<SagaMessage, TCommand, CancellationToken, Task<SagaStepResponse>> handler,
CancellationToken cancellationToken = default)
where TCommand : class
{
await EnsureConnectionAsync(cancellationToken);
var commandTypeName = typeof(TCommand).FullName!;
var queueName = $"{_options.QueuePrefix}.{SanitizeQueueName(commandTypeName)}";
var routingKey = $"saga.command.{commandTypeName}";
var channel = await _connection!.CreateChannelAsync(cancellationToken: cancellationToken);
_subscriptionChannels[commandTypeName] = channel;
// Declare queue
await channel.QueueDeclareAsync(
queue: queueName,
durable: _options.DurableQueues,
exclusive: false,
autoDelete: false,
cancellationToken: cancellationToken);
// Bind to command exchange
await channel.QueueBindAsync(
queue: queueName,
exchange: _options.CommandExchange,
routingKey: routingKey,
cancellationToken: cancellationToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: _options.PrefetchCount, global: false, cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, ea) =>
{
try
{
var messageJson = Encoding.UTF8.GetString(ea.Body.ToArray());
var message = JsonSerializer.Deserialize<SagaMessage>(messageJson);
if (message == null)
{
_logger.LogWarning("Received null saga message");
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
var command = JsonSerializer.Deserialize<TCommand>(message.Payload!);
if (command == null)
{
_logger.LogWarning("Failed to deserialize command {CommandType}", commandTypeName);
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
var response = await handler(message, command, cancellationToken);
await PublishResponseAsync(response, cancellationToken);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing saga command {CommandType}", commandTypeName);
await channel.BasicNackAsync(ea.DeliveryTag, false, true, cancellationToken);
}
};
await channel.BasicConsumeAsync(queueName, false, consumer, cancellationToken);
_logger.LogInformation(
"Subscribed to saga commands of type {CommandType} on queue {QueueName}",
commandTypeName, queueName);
}
/// <inheritdoc />
public async Task SubscribeToResponsesAsync(
Func<SagaStepResponse, CancellationToken, Task> handler,
CancellationToken cancellationToken = default)
{
await EnsureConnectionAsync(cancellationToken);
var queueName = $"{_options.QueuePrefix}.responses";
var routingKey = "saga.response.#";
var channel = await _connection!.CreateChannelAsync(cancellationToken: cancellationToken);
_subscriptionChannels["responses"] = channel;
// Declare queue
await channel.QueueDeclareAsync(
queue: queueName,
durable: _options.DurableQueues,
exclusive: false,
autoDelete: false,
cancellationToken: cancellationToken);
// Bind to response exchange
await channel.QueueBindAsync(
queue: queueName,
exchange: _options.ResponseExchange,
routingKey: routingKey,
cancellationToken: cancellationToken);
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: _options.PrefetchCount, global: false, cancellationToken: cancellationToken);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, ea) =>
{
try
{
var responseJson = Encoding.UTF8.GetString(ea.Body.ToArray());
var response = JsonSerializer.Deserialize<SagaStepResponse>(responseJson);
if (response == null)
{
_logger.LogWarning("Received null saga response");
await channel.BasicNackAsync(ea.DeliveryTag, false, false, cancellationToken);
return;
}
await handler(response, cancellationToken);
await channel.BasicAckAsync(ea.DeliveryTag, false, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing saga response");
await channel.BasicNackAsync(ea.DeliveryTag, false, true, cancellationToken);
}
};
await channel.BasicConsumeAsync(queueName, false, consumer, cancellationToken);
_logger.LogInformation("Subscribed to saga responses on queue {QueueName}", queueName);
}
private async Task EnsureConnectionAsync(CancellationToken cancellationToken)
{
if (_connection?.IsOpen == true && _publishChannel?.IsOpen == true)
{
return;
}
await _connectionLock.WaitAsync(cancellationToken);
try
{
if (_connection?.IsOpen == true && _publishChannel?.IsOpen == true)
{
return;
}
var factory = new ConnectionFactory
{
HostName = _options.HostName,
Port = _options.Port,
UserName = _options.UserName,
Password = _options.Password,
VirtualHost = _options.VirtualHost
};
_connection = await factory.CreateConnectionAsync(cancellationToken);
_publishChannel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
// Declare exchanges
await _publishChannel.ExchangeDeclareAsync(
exchange: _options.CommandExchange,
type: ExchangeType.Topic,
durable: _options.DurableQueues,
autoDelete: false,
cancellationToken: cancellationToken);
await _publishChannel.ExchangeDeclareAsync(
exchange: _options.ResponseExchange,
type: ExchangeType.Topic,
durable: _options.DurableQueues,
autoDelete: false,
cancellationToken: cancellationToken);
_logger.LogInformation(
"Connected to RabbitMQ at {Host}:{Port}",
_options.HostName, _options.Port);
}
finally
{
_connectionLock.Release();
}
}
private static string SanitizeQueueName(string name)
{
return name.Replace(".", "-").Replace("+", "-").ToLowerInvariant();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var channel in _subscriptionChannels.Values)
{
if (channel.IsOpen)
{
await channel.CloseAsync();
}
channel.Dispose();
}
if (_publishChannel?.IsOpen == true)
{
await _publishChannel.CloseAsync();
}
_publishChannel?.Dispose();
if (_connection?.IsOpen == true)
{
await _connection.CloseAsync();
}
_connection?.Dispose();
_connectionLock.Dispose();
}
}
@@ -0,0 +1,69 @@
using System;
namespace Svrnty.CQRS.Sagas.RabbitMQ;
/// <summary>
/// Configuration options for RabbitMQ saga transport.
/// </summary>
public class RabbitMqSagaOptions
{
/// <summary>
/// RabbitMQ host name (default: localhost).
/// </summary>
public string HostName { get; set; } = "localhost";
/// <summary>
/// RabbitMQ port (default: 5672).
/// </summary>
public int Port { get; set; } = 5672;
/// <summary>
/// RabbitMQ user name (default: guest).
/// </summary>
public string UserName { get; set; } = "guest";
/// <summary>
/// RabbitMQ password (default: guest).
/// </summary>
public string Password { get; set; } = "guest";
/// <summary>
/// RabbitMQ virtual host (default: /).
/// </summary>
public string VirtualHost { get; set; } = "/";
/// <summary>
/// Exchange name for saga commands (default: svrnty.sagas.commands).
/// </summary>
public string CommandExchange { get; set; } = "svrnty.sagas.commands";
/// <summary>
/// Exchange name for saga responses (default: svrnty.sagas.responses).
/// </summary>
public string ResponseExchange { get; set; } = "svrnty.sagas.responses";
/// <summary>
/// Queue name prefix for this service (default: saga-service).
/// </summary>
public string QueuePrefix { get; set; } = "saga-service";
/// <summary>
/// Whether to use durable queues (default: true).
/// </summary>
public bool DurableQueues { get; set; } = true;
/// <summary>
/// Prefetch count for consumers (default: 10).
/// </summary>
public ushort PrefetchCount { get; set; } = 10;
/// <summary>
/// Connection retry delay (default: 5 seconds).
/// </summary>
public TimeSpan ConnectionRetryDelay { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum connection retry attempts (default: 10).
/// </summary>
public int MaxConnectionRetries { get; set; } = 10;
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>false</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Sagas\Svrnty.CQRS.Sagas.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RabbitMQ.Client" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,54 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Builder for configuring local saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class LocalSagaStepBuilder<TData> : ISagaStepBuilder<TData>
where TData : class, ISagaData
{
private readonly SagaBuilder<TData> _parent;
private readonly LocalSagaStepDefinition<TData> _definition;
/// <summary>
/// Creates a new local step builder.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public LocalSagaStepBuilder(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new LocalSagaStepDefinition<TData>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaStepBuilder<TData> Execute(Func<TData, ISagaContext, CancellationToken, Task> action)
{
_definition.ExecuteAction = action;
return this;
}
/// <inheritdoc />
public ISagaStepBuilder<TData> Compensate(Func<TData, ISagaContext, CancellationToken, Task> action)
{
_definition.CompensateAction = action;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
@@ -0,0 +1,158 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Builder for configuring remote saga steps (without result).
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
public class RemoteSagaStepBuilder<TData, TCommand> : ISagaRemoteStepBuilder<TData, TCommand>
where TData : class, ISagaData
where TCommand : class
{
private readonly SagaBuilder<TData> _parent;
private readonly RemoteSagaStepDefinition<TData, TCommand> _definition;
/// <summary>
/// Creates a new remote step builder.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public RemoteSagaStepBuilder(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new RemoteSagaStepDefinition<TData, TCommand>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder)
{
_definition.CommandBuilder = commandBuilder;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> OnResponse(Func<TData, ISagaContext, CancellationToken, Task> handler)
{
_definition.ResponseHandler = handler;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder)
where TCompensationCommand : class
{
_definition.CompensationCommandType = typeof(TCompensationCommand);
_definition.CompensationBuilder = (data, ctx) => compensationBuilder(data, ctx);
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithTimeout(TimeSpan timeout)
{
_definition.Timeout = timeout;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> WithRetry(int maxRetries, TimeSpan delay)
{
_definition.MaxRetries = maxRetries;
_definition.RetryDelay = delay;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
/// <summary>
/// Builder for configuring remote saga steps with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
public class RemoteSagaStepBuilderWithResult<TData, TCommand, TResult> : ISagaRemoteStepBuilder<TData, TCommand, TResult>
where TData : class, ISagaData
where TCommand : class
{
private readonly SagaBuilder<TData> _parent;
private readonly RemoteSagaStepDefinition<TData, TCommand, TResult> _definition;
/// <summary>
/// Creates a new remote step builder with result.
/// </summary>
/// <param name="parent">The parent saga builder.</param>
/// <param name="name">The step name.</param>
/// <param name="order">The step order.</param>
public RemoteSagaStepBuilderWithResult(SagaBuilder<TData> parent, string name, int order)
{
_parent = parent;
_definition = new RemoteSagaStepDefinition<TData, TCommand, TResult>
{
Name = name,
Order = order
};
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithCommand(Func<TData, ISagaContext, TCommand> commandBuilder)
{
_definition.CommandBuilder = commandBuilder;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> OnResponse(
Func<TData, ISagaContext, TResult, CancellationToken, Task> handler)
{
_definition.ResponseHandler = handler;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> Compensate<TCompensationCommand>(
Func<TData, ISagaContext, TCompensationCommand> compensationBuilder)
where TCompensationCommand : class
{
_definition.CompensationCommandType = typeof(TCompensationCommand);
_definition.CompensationBuilder = (data, ctx) => compensationBuilder(data, ctx);
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithTimeout(TimeSpan timeout)
{
_definition.Timeout = timeout;
return this;
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> WithRetry(int maxRetries, TimeSpan delay)
{
_definition.MaxRetries = maxRetries;
_definition.RetryDelay = delay;
return this;
}
/// <inheritdoc />
public ISagaBuilder<TData> Then()
{
_parent.AddStep(_definition);
return _parent;
}
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Implementation of the saga builder for defining saga steps.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class SagaBuilder<TData> : ISagaBuilder<TData>
where TData : class, ISagaData
{
private readonly List<SagaStepDefinition> _steps = new();
/// <summary>
/// Gets the defined steps.
/// </summary>
public IReadOnlyList<SagaStepDefinition> Steps => _steps.AsReadOnly();
/// <inheritdoc />
public ISagaStepBuilder<TData> Step(string name)
{
return new LocalSagaStepBuilder<TData>(this, name, _steps.Count);
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand> SendCommand<TCommand>(string name)
where TCommand : class
{
return new RemoteSagaStepBuilder<TData, TCommand>(this, name, _steps.Count);
}
/// <inheritdoc />
public ISagaRemoteStepBuilder<TData, TCommand, TResult> SendCommand<TCommand, TResult>(string name)
where TCommand : class
{
return new RemoteSagaStepBuilderWithResult<TData, TCommand, TResult>(this, name, _steps.Count);
}
/// <summary>
/// Adds a step definition to the builder.
/// </summary>
/// <param name="step">The step definition to add.</param>
internal void AddStep(SagaStepDefinition step)
{
_steps.Add(step);
}
}
@@ -0,0 +1,149 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas.Builders;
/// <summary>
/// Base class for saga step definitions.
/// </summary>
public abstract class SagaStepDefinition
{
/// <summary>
/// Unique name for this step.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Order of the step in the saga.
/// </summary>
public int Order { get; set; }
/// <summary>
/// Whether this step has a compensation action.
/// </summary>
public abstract bool HasCompensation { get; }
/// <summary>
/// Whether this step is a remote step (sends a command).
/// </summary>
public abstract bool IsRemote { get; }
/// <summary>
/// Timeout for this step.
/// </summary>
public TimeSpan? Timeout { get; set; }
/// <summary>
/// Maximum number of retries.
/// </summary>
public int MaxRetries { get; set; }
/// <summary>
/// Delay between retries.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
}
/// <summary>
/// Definition for a local saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
public class LocalSagaStepDefinition<TData> : SagaStepDefinition
where TData : class, ISagaData
{
/// <summary>
/// The execution action.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? ExecuteAction { get; set; }
/// <summary>
/// The compensation action.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? CompensateAction { get; set; }
/// <inheritdoc />
public override bool HasCompensation => CompensateAction != null;
/// <inheritdoc />
public override bool IsRemote => false;
}
/// <summary>
/// Definition for a remote saga step.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
public class RemoteSagaStepDefinition<TData, TCommand> : SagaStepDefinition
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Function to build the command.
/// </summary>
public Func<TData, ISagaContext, TCommand>? CommandBuilder { get; set; }
/// <summary>
/// Handler for successful response.
/// </summary>
public Func<TData, ISagaContext, CancellationToken, Task>? ResponseHandler { get; set; }
/// <summary>
/// Type of the compensation command.
/// </summary>
public Type? CompensationCommandType { get; set; }
/// <summary>
/// Function to build the compensation command.
/// </summary>
public Func<TData, ISagaContext, object>? CompensationBuilder { get; set; }
/// <inheritdoc />
public override bool HasCompensation => CompensationBuilder != null;
/// <inheritdoc />
public override bool IsRemote => true;
}
/// <summary>
/// Definition for a remote saga step with result.
/// </summary>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <typeparam name="TCommand">The command type.</typeparam>
/// <typeparam name="TResult">The result type.</typeparam>
public class RemoteSagaStepDefinition<TData, TCommand, TResult> : SagaStepDefinition
where TData : class, ISagaData
where TCommand : class
{
/// <summary>
/// Function to build the command.
/// </summary>
public Func<TData, ISagaContext, TCommand>? CommandBuilder { get; set; }
/// <summary>
/// Handler for successful response with result.
/// </summary>
public Func<TData, ISagaContext, TResult, CancellationToken, Task>? ResponseHandler { get; set; }
/// <summary>
/// Type of the compensation command.
/// </summary>
public Type? CompensationCommandType { get; set; }
/// <summary>
/// Function to build the compensation command.
/// </summary>
public Func<TData, ISagaContext, object>? CompensationBuilder { get; set; }
/// <summary>
/// The expected result type.
/// </summary>
public Type ResultType => typeof(TResult);
/// <inheritdoc />
public override bool HasCompensation => CompensationBuilder != null;
/// <inheritdoc />
public override bool IsRemote => true;
}
@@ -0,0 +1,39 @@
using System;
namespace Svrnty.CQRS.Sagas.Configuration;
/// <summary>
/// Configuration options for saga orchestration.
/// </summary>
public class SagaOptions
{
/// <summary>
/// Default timeout for saga steps (default: 30 seconds).
/// </summary>
public TimeSpan DefaultStepTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Default number of retries for failed steps (default: 3).
/// </summary>
public int DefaultMaxRetries { get; set; } = 3;
/// <summary>
/// Default delay between retries (default: 1 second).
/// </summary>
public TimeSpan DefaultRetryDelay { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Whether to automatically compensate on failure (default: true).
/// </summary>
public bool AutoCompensateOnFailure { get; set; } = true;
/// <summary>
/// Interval for checking pending/stalled sagas (default: 1 minute).
/// </summary>
public TimeSpan StalledSagaCheckInterval { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Time after which a saga step is considered stalled (default: 5 minutes).
/// </summary>
public TimeSpan StepStalledTimeout { get; set; } = TimeSpan.FromMinutes(5);
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Svrnty.CQRS.Configuration;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
using Svrnty.CQRS.Sagas.Configuration;
using Svrnty.CQRS.Sagas.Persistence;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Extensions for adding saga support to the CQRS pipeline.
/// </summary>
public static class CqrsBuilderExtensions
{
/// <summary>
/// Adds saga orchestration support to the CQRS pipeline.
/// </summary>
/// <param name="builder">The CQRS builder.</param>
/// <param name="configure">Optional configuration action.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder AddSagas(this CqrsBuilder builder, Action<SagaOptions>? configure = null)
{
var options = new SagaOptions();
configure?.Invoke(options);
builder.Services.Configure<SagaOptions>(opt =>
{
opt.DefaultStepTimeout = options.DefaultStepTimeout;
opt.DefaultMaxRetries = options.DefaultMaxRetries;
opt.DefaultRetryDelay = options.DefaultRetryDelay;
opt.AutoCompensateOnFailure = options.AutoCompensateOnFailure;
opt.StalledSagaCheckInterval = options.StalledSagaCheckInterval;
opt.StepStalledTimeout = options.StepStalledTimeout;
});
// Store configuration
builder.Configuration.SetConfiguration(options);
// Register core saga services
builder.Services.TryAddSingleton<ISagaOrchestrator, SagaOrchestrator>();
// Register default in-memory state store if not already registered
builder.Services.TryAddSingleton<ISagaStateStore, InMemorySagaStateStore>();
return builder;
}
/// <summary>
/// Registers a saga type with the CQRS pipeline.
/// </summary>
/// <typeparam name="TSaga">The saga type.</typeparam>
/// <typeparam name="TData">The saga data type.</typeparam>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder AddSaga<TSaga, TData>(this CqrsBuilder builder)
where TSaga : class, ISaga<TData>
where TData : class, ISagaData, new()
{
builder.Services.AddTransient<TSaga>();
builder.Services.AddTransient<ISaga<TData>, TSaga>();
return builder;
}
/// <summary>
/// Uses a custom saga state store implementation.
/// </summary>
/// <typeparam name="TStore">The state store implementation type.</typeparam>
/// <param name="builder">The CQRS builder.</param>
/// <returns>The CQRS builder for chaining.</returns>
public static CqrsBuilder UseSagaStateStore<TStore>(this CqrsBuilder builder)
where TStore : class, ISagaStateStore
{
// Remove existing registration
var descriptor = new ServiceDescriptor(typeof(ISagaStateStore), typeof(TStore), ServiceLifetime.Singleton);
builder.Services.Replace(descriptor);
return builder;
}
}
@@ -0,0 +1,68 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
namespace Svrnty.CQRS.Sagas.Persistence;
/// <summary>
/// In-memory saga state store for development and testing.
/// </summary>
public class InMemorySagaStateStore : ISagaStateStore
{
private readonly ConcurrentDictionary<Guid, SagaState> _states = new();
/// <inheritdoc />
public Task<SagaState> CreateAsync(SagaState state, CancellationToken cancellationToken = default)
{
if (!_states.TryAdd(state.SagaId, state))
{
throw new InvalidOperationException($"Saga with ID {state.SagaId} already exists.");
}
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState?> GetByIdAsync(Guid sagaId, CancellationToken cancellationToken = default)
{
_states.TryGetValue(sagaId, out var state);
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState?> GetByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default)
{
var state = _states.Values.FirstOrDefault(s => s.CorrelationId == correlationId);
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<SagaState> UpdateAsync(SagaState state, CancellationToken cancellationToken = default)
{
state.UpdatedAt = DateTimeOffset.UtcNow;
_states[state.SagaId] = state;
return Task.FromResult(state);
}
/// <inheritdoc />
public Task<IReadOnlyList<SagaState>> GetPendingSagasAsync(CancellationToken cancellationToken = default)
{
var pending = _states.Values
.Where(s => s.Status == SagaStatus.InProgress || s.Status == SagaStatus.Compensating)
.ToList();
return Task.FromResult<IReadOnlyList<SagaState>>(pending);
}
/// <inheritdoc />
public Task<IReadOnlyList<SagaState>> GetSagasByStatusAsync(SagaStatus status, CancellationToken cancellationToken = default)
{
var sagas = _states.Values
.Where(s => s.Status == status)
.ToList();
return Task.FromResult<IReadOnlyList<SagaState>>(sagas);
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using Svrnty.CQRS.Sagas.Abstractions;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Implementation of saga context providing runtime information during step execution.
/// </summary>
public class SagaContext : ISagaContext
{
private readonly SagaState _state;
/// <summary>
/// Creates a new saga context from a saga state.
/// </summary>
/// <param name="state">The saga state.</param>
public SagaContext(SagaState state)
{
_state = state ?? throw new ArgumentNullException(nameof(state));
}
/// <inheritdoc />
public Guid SagaId => _state.SagaId;
/// <inheritdoc />
public Guid CorrelationId => _state.CorrelationId;
/// <inheritdoc />
public string SagaType => _state.SagaType;
/// <inheritdoc />
public int CurrentStepIndex => _state.CurrentStepIndex;
/// <inheritdoc />
public string CurrentStepName => _state.CurrentStepName ?? string.Empty;
/// <inheritdoc />
public IReadOnlyDictionary<string, object?> StepResults => _state.StepResults;
/// <inheritdoc />
public T? GetStepResult<T>(string stepName)
{
if (_state.StepResults.TryGetValue(stepName, out var value) && value is T result)
{
return result;
}
return default;
}
/// <inheritdoc />
public void SetStepResult<T>(T result)
{
_state.StepResults[CurrentStepName] = result;
}
}
+429
View File
@@ -0,0 +1,429 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Svrnty.CQRS.Sagas.Abstractions;
using Svrnty.CQRS.Sagas.Abstractions.Messaging;
using Svrnty.CQRS.Sagas.Abstractions.Persistence;
using Svrnty.CQRS.Sagas.Builders;
using Svrnty.CQRS.Sagas.Configuration;
namespace Svrnty.CQRS.Sagas;
/// <summary>
/// Implementation of saga orchestration.
/// </summary>
public class SagaOrchestrator : ISagaOrchestrator
{
private readonly IServiceProvider _serviceProvider;
private readonly ISagaStateStore _stateStore;
private readonly ISagaMessageBus? _messageBus;
private readonly ILogger<SagaOrchestrator> _logger;
private readonly SagaOptions _options;
/// <summary>
/// Creates a new saga orchestrator.
/// </summary>
public SagaOrchestrator(
IServiceProvider serviceProvider,
ISagaStateStore stateStore,
IOptions<SagaOptions> options,
ILogger<SagaOrchestrator> logger,
ISagaMessageBus? messageBus = null)
{
_serviceProvider = serviceProvider;
_stateStore = stateStore;
_messageBus = messageBus;
_logger = logger;
_options = options.Value;
}
/// <inheritdoc />
public Task<SagaState> StartAsync<TSaga, TData>(TData initialData, CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new()
{
return StartAsync<TSaga, TData>(initialData, Guid.NewGuid(), cancellationToken);
}
/// <inheritdoc />
public async Task<SagaState> StartAsync<TSaga, TData>(
TData initialData,
Guid correlationId,
CancellationToken cancellationToken = default)
where TSaga : ISaga<TData>
where TData : class, ISagaData, new()
{
initialData.CorrelationId = correlationId;
// Get the saga instance and configure it
var saga = _serviceProvider.GetRequiredService<TSaga>();
var builder = new SagaBuilder<TData>();
saga.Configure(builder);
var steps = builder.Steps;
if (steps.Count == 0)
{
throw new InvalidOperationException($"Saga {typeof(TSaga).Name} has no steps configured.");
}
// Create initial state
var state = new SagaState
{
SagaType = typeof(TSaga).FullName!,
CorrelationId = correlationId,
Status = SagaStatus.InProgress,
CurrentStepIndex = 0,
CurrentStepName = steps[0].Name,
SerializedData = JsonSerializer.Serialize(initialData)
};
state = await _stateStore.CreateAsync(state, cancellationToken);
_logger.LogInformation(
"Started saga {SagaType} with ID {SagaId} and CorrelationId {CorrelationId}",
state.SagaType, state.SagaId, state.CorrelationId);
// Execute the first step
await ExecuteNextStepAsync<TData>(state, steps, initialData, cancellationToken);
return state;
}
/// <inheritdoc />
public Task<SagaState?> GetStateAsync(Guid sagaId, CancellationToken cancellationToken = default)
{
return _stateStore.GetByIdAsync(sagaId, cancellationToken);
}
/// <inheritdoc />
public Task<SagaState?> GetStateByCorrelationIdAsync(Guid correlationId, CancellationToken cancellationToken = default)
{
return _stateStore.GetByCorrelationIdAsync(correlationId, cancellationToken);
}
/// <summary>
/// Handles a response from a remote step.
/// </summary>
public async Task HandleResponseAsync<TData>(
SagaStepResponse response,
CancellationToken cancellationToken = default)
where TData : class, ISagaData, new()
{
var state = await _stateStore.GetByIdAsync(response.SagaId, cancellationToken);
if (state == null)
{
_logger.LogWarning("Received response for unknown saga {SagaId}", response.SagaId);
return;
}
var data = JsonSerializer.Deserialize<TData>(state.SerializedData!);
if (data == null)
{
_logger.LogError("Failed to deserialize saga data for {SagaId}", response.SagaId);
return;
}
// Get the saga definition
var sagaType = Type.GetType(state.SagaType);
if (sagaType == null)
{
_logger.LogError("Unknown saga type {SagaType}", state.SagaType);
return;
}
var saga = _serviceProvider.GetService(sagaType) as ISaga<TData>;
if (saga == null)
{
_logger.LogError("Could not resolve saga {SagaType}", state.SagaType);
return;
}
var builder = new SagaBuilder<TData>();
saga.Configure(builder);
var steps = builder.Steps;
if (response.Success)
{
_logger.LogInformation(
"Step {StepName} completed successfully for saga {SagaId}",
response.StepName, response.SagaId);
state.CompletedSteps.Add(response.StepName);
state.CurrentStepIndex++;
if (state.CurrentStepIndex >= steps.Count)
{
// Saga completed
state.Status = SagaStatus.Completed;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogInformation("Saga {SagaId} completed successfully", state.SagaId);
}
else
{
// Move to next step
state.CurrentStepName = steps[state.CurrentStepIndex].Name;
await _stateStore.UpdateAsync(state, cancellationToken);
await ExecuteNextStepAsync(state, steps, data, cancellationToken);
}
}
else
{
_logger.LogError(
"Step {StepName} failed for saga {SagaId}: {Error}",
response.StepName, response.SagaId, response.ErrorMessage);
state.Errors.Add(new SagaStepError(
response.StepName,
response.ErrorMessage ?? "Unknown error",
response.StackTrace,
DateTimeOffset.UtcNow));
if (_options.AutoCompensateOnFailure)
{
await StartCompensationAsync(state, steps, data, cancellationToken);
}
else
{
state.Status = SagaStatus.Failed;
await _stateStore.UpdateAsync(state, cancellationToken);
}
}
}
private async Task ExecuteNextStepAsync<TData>(
SagaState state,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
TData data,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (state.CurrentStepIndex >= steps.Count)
{
state.Status = SagaStatus.Completed;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
return;
}
var step = steps[state.CurrentStepIndex];
var context = new SagaContext(state);
_logger.LogDebug(
"Executing step {StepName} ({StepIndex}/{TotalSteps}) for saga {SagaId}",
step.Name, state.CurrentStepIndex + 1, steps.Count, state.SagaId);
try
{
if (step.IsRemote)
{
await ExecuteRemoteStepAsync(state, step, data, context, cancellationToken);
}
else
{
await ExecuteLocalStepAsync(state, step, data, context, steps, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing step {StepName} for saga {SagaId}", step.Name, state.SagaId);
state.Errors.Add(new SagaStepError(
step.Name,
ex.Message,
ex.StackTrace,
DateTimeOffset.UtcNow));
if (_options.AutoCompensateOnFailure)
{
await StartCompensationAsync(state, steps, data, cancellationToken);
}
else
{
state.Status = SagaStatus.Failed;
await _stateStore.UpdateAsync(state, cancellationToken);
}
}
}
private async Task ExecuteLocalStepAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (step is LocalSagaStepDefinition<TData> localStep && localStep.ExecuteAction != null)
{
await localStep.ExecuteAction(data, context, cancellationToken);
}
// Local step completed, update state and continue
state.CompletedSteps.Add(step.Name);
state.SerializedData = JsonSerializer.Serialize(data);
state.CurrentStepIndex++;
if (state.CurrentStepIndex < steps.Count)
{
state.CurrentStepName = steps[state.CurrentStepIndex].Name;
}
await _stateStore.UpdateAsync(state, cancellationToken);
// Continue to next step
await ExecuteNextStepAsync(state, steps, data, cancellationToken);
}
private async Task ExecuteRemoteStepAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (_messageBus == null)
{
throw new InvalidOperationException(
"Remote saga steps require a message bus. Configure RabbitMQ or another transport.");
}
object? command = null;
string commandType;
// Get the command from the step definition
var stepType = step.GetType();
var commandBuilderProp = stepType.GetProperty("CommandBuilder");
if (commandBuilderProp?.GetValue(step) is Delegate commandBuilder)
{
command = commandBuilder.DynamicInvoke(data, context);
}
if (command == null)
{
throw new InvalidOperationException($"Step {step.Name} did not produce a command.");
}
commandType = command.GetType().FullName!;
var message = new SagaMessage
{
SagaId = state.SagaId,
CorrelationId = state.CorrelationId,
StepName = step.Name,
CommandType = commandType,
Payload = JsonSerializer.Serialize(command, command.GetType())
};
await _messageBus.PublishAsync(message, cancellationToken);
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogDebug(
"Published command {CommandType} for step {StepName} of saga {SagaId}",
commandType, step.Name, state.SagaId);
}
private async Task StartCompensationAsync<TData>(
SagaState state,
System.Collections.Generic.IReadOnlyList<SagaStepDefinition> steps,
TData data,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
_logger.LogInformation("Starting compensation for saga {SagaId}", state.SagaId);
state.Status = SagaStatus.Compensating;
await _stateStore.UpdateAsync(state, cancellationToken);
// Execute compensation in reverse order
var context = new SagaContext(state);
var completedSteps = state.CompletedSteps.ToList();
for (var i = completedSteps.Count - 1; i >= 0; i--)
{
var stepName = completedSteps[i];
var step = steps.FirstOrDefault(s => s.Name == stepName);
if (step == null || !step.HasCompensation)
{
continue;
}
_logger.LogDebug("Compensating step {StepName} for saga {SagaId}", stepName, state.SagaId);
try
{
if (step.IsRemote)
{
await ExecuteRemoteCompensationAsync(state, step, data, context, cancellationToken);
}
else if (step is LocalSagaStepDefinition<TData> localStep && localStep.CompensateAction != null)
{
await localStep.CompensateAction(data, context, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during compensation of step {StepName} for saga {SagaId}",
stepName, state.SagaId);
// Continue with other compensations even if one fails
}
}
state.Status = SagaStatus.Compensated;
state.CompletedAt = DateTimeOffset.UtcNow;
await _stateStore.UpdateAsync(state, cancellationToken);
_logger.LogInformation("Saga {SagaId} compensation completed", state.SagaId);
}
private async Task ExecuteRemoteCompensationAsync<TData>(
SagaState state,
SagaStepDefinition step,
TData data,
SagaContext context,
CancellationToken cancellationToken)
where TData : class, ISagaData
{
if (_messageBus == null)
{
return;
}
var stepType = step.GetType();
var compensationBuilderProp = stepType.GetProperty("CompensationBuilder");
var compensationTypeProp = stepType.GetProperty("CompensationCommandType");
if (compensationBuilderProp?.GetValue(step) is Delegate compensationBuilder &&
compensationTypeProp?.GetValue(step) is Type compensationType)
{
var compensationCommand = compensationBuilder.DynamicInvoke(data, context);
if (compensationCommand != null)
{
var message = new SagaMessage
{
SagaId = state.SagaId,
CorrelationId = state.CorrelationId,
StepName = step.Name,
CommandType = compensationType.FullName!,
Payload = JsonSerializer.Serialize(compensationCommand, compensationType),
IsCompensation = true
};
await _messageBus.PublishAsync(message, cancellationToken);
_logger.LogDebug(
"Published compensation command {CommandType} for step {StepName} of saga {SagaId}",
compensationType.Name, step.Name, state.SagaId);
}
}
}
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsAotCompatible>true</IsAotCompatible>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<Company>Svrnty</Company>
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://git.openharbor.io/svrnty/dotnet-cqrs</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<IncludeSymbols>true</IncludeSymbols>
<IncludeSource>true</IncludeSource>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS\Svrnty.CQRS.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Sagas.Abstractions\Svrnty.CQRS.Sagas.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
</Project>
+103
View File
@@ -31,6 +31,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.Sample", "Svrnty.Sam
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.DynamicQuery.MinimalApi", "Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj", "{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas.Abstractions", "Svrnty.CQRS.Sagas.Abstractions\Svrnty.CQRS.Sagas.Abstractions.csproj", "{13B6608A-596B-495B-9C08-F9B3F0D1915A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas", "Svrnty.CQRS.Sagas\Svrnty.CQRS.Sagas.csproj", "{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Sagas.RabbitMQ", "Svrnty.CQRS.Sagas.RabbitMQ\Svrnty.CQRS.Sagas.RabbitMQ.csproj", "{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.DynamicQuery.EntityFramework", "Svrnty.CQRS.DynamicQuery.EntityFramework\Svrnty.CQRS.DynamicQuery.EntityFramework.csproj", "{25456A0B-69AF-4251-B34D-2A3873CD8D80}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstractions", "Svrnty.CQRS.Events.Abstractions\Svrnty.CQRS.Events.Abstractions.csproj", "{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Tests", "tests\Svrnty.CQRS.Tests\Svrnty.CQRS.Tests.csproj", "{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -173,10 +189,97 @@ Global
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x64.Build.0 = Release|Any CPU
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x86.ActiveCfg = Release|Any CPU
{1D0E3388-5E4B-4C0E-B826-ACF256FF7C84}.Release|x86.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x64.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x64.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x86.ActiveCfg = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Debug|x86.Build.0 = Debug|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|Any CPU.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x64.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x64.Build.0 = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x86.ActiveCfg = Release|Any CPU
{13B6608A-596B-495B-9C08-F9B3F0D1915A}.Release|x86.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x64.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x64.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x86.ActiveCfg = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Debug|x86.Build.0 = Debug|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|Any CPU.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x64.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x64.Build.0 = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x86.ActiveCfg = Release|Any CPU
{8EC9D12F-C8CD-4187-A1ED-47365D1C6B61}.Release|x86.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x64.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x64.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x86.ActiveCfg = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Debug|x86.Build.0 = Debug|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|Any CPU.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x64.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x64.Build.0 = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x86.ActiveCfg = Release|Any CPU
{2EA39D64-B4A8-4A74-A2E6-D8A8E8312B68}.Release|x86.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x64.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x64.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x86.ActiveCfg = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Debug|x86.Build.0 = Debug|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|Any CPU.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x64.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x64.Build.0 = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x86.ActiveCfg = Release|Any CPU
{25456A0B-69AF-4251-B34D-2A3873CD8D80}.Release|x86.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x64.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x64.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x86.ActiveCfg = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Debug|x86.Build.0 = Debug|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|Any CPU.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x64.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x64.Build.0 = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x86.ActiveCfg = Release|Any CPU
{7905A4BB-2462-4FFF-9A29-3E4769D20FFC}.Release|x86.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x64.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x64.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x86.ActiveCfg = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Debug|x86.Build.0 = Debug|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|Any CPU.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x64.ActiveCfg = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x64.Build.0 = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x86.ActiveCfg = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x86.Build.0 = Debug|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|Any CPU.Build.0 = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x64.ActiveCfg = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x64.Build.0 = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x86.ActiveCfg = Release|Any CPU
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D6D431EA-C04F-462B-8033-60F510FEB49E}
EndGlobalSection
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -15,8 +15,8 @@ public sealed class CommandDiscovery : ICommandDiscovery
}
public IEnumerable<ICommandMeta> GetCommands() => _commandMetas;
public ICommandMeta FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name);
public ICommandMeta FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
public ICommandMeta? FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name);
public ICommandMeta? FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
public bool CommandExists(string name) => _commandMetas.Any(t => t.Name == name);
public bool CommandExists(Type commandType) => _commandMetas.Any(t => t.CommandType == commandType);
}
+2 -2
View File
@@ -15,8 +15,8 @@ public sealed class QueryDiscovery : IQueryDiscovery
}
public IEnumerable<IQueryMeta> GetQueries() => _queryMetas;
public IQueryMeta FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name);
public IQueryMeta FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
public IQueryMeta? FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name);
public IQueryMeta? FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
public bool QueryExists(string name) => _queryMetas.Any(t => t.Name == name);
public bool QueryExists(Type queryType) => _queryMetas.Any(t => t.QueryType == queryType);
}
+178
View File
@@ -0,0 +1,178 @@
# Architecture
> Svrnty.CQRS is a modular CQRS/event-sourcing framework for .NET 10, organized as 18 NuGet packages with clear separation of concerns.
## Package Dependency Graph
```
Svrnty.CQRS.Abstractions
(ICommandHandler, IQueryHandler)
|
+-----------------+-----------------+
| |
Svrnty.CQRS Svrnty.CQRS.FluentValidation
(Discovery, Registration, (AbstractValidator<T> binding)
CqrsBuilder, DI) depends on: Abstractions, Core
|
+------------+------------+---------------------------+
| | | |
MinimalApi Grpc DynamicQuery Sagas
(HTTP REST) (gRPC) (Filtering, (Orchestrator,
| Sorting, Paging) Compensation)
| | |
Grpc.Abstractions DQ.Abstractions Sagas.Abstractions
(GrpcIgnore attr) (IQueryableProvider) (ISaga, ISagaBuilder,
| | | ISagaOrchestrator)
Grpc.Generators DQ.MinimalApi | |
(Source gen, (HTTP endpoints | Sagas.RabbitMQ
.proto gen) for DQ) | (RabbitMQ transport)
|
DQ.EntityFramework
(EF Core provider)
Events.Abstractions Notifications.Abstractions
(IDomainEvent, (INotificationPublisher,
IDomainEventPublisher) StreamingNotificationAttribute)
| |
Events.RabbitMQ Notifications.Grpc
(RabbitMQ transport) (gRPC streaming)
```
## Dependency Matrix
| Package | Depends On (internal) |
|---|---|
| `Svrnty.CQRS.Abstractions` | _(none)_ |
| `Svrnty.CQRS` | Abstractions |
| `Svrnty.CQRS.MinimalApi` | Abstractions, Core |
| `Svrnty.CQRS.Grpc` | Core |
| `Svrnty.CQRS.Grpc.Abstractions` | _(none)_ |
| `Svrnty.CQRS.Grpc.Generators` | _(none, Roslyn source gen)_ |
| `Svrnty.CQRS.FluentValidation` | Abstractions, Core |
| `Svrnty.CQRS.DynamicQuery.Abstractions` | _(none)_ |
| `Svrnty.CQRS.DynamicQuery` | DynamicQuery.Abstractions, Core |
| `Svrnty.CQRS.DynamicQuery.MinimalApi` | Abstractions, DynamicQuery.Abstractions, DynamicQuery |
| `Svrnty.CQRS.DynamicQuery.EntityFramework` | DynamicQuery |
| `Svrnty.CQRS.Events.Abstractions` | _(none)_ |
| `Svrnty.CQRS.Events.RabbitMQ` | Events.Abstractions |
| `Svrnty.CQRS.Sagas.Abstractions` | _(none)_ |
| `Svrnty.CQRS.Sagas` | Core, Sagas.Abstractions |
| `Svrnty.CQRS.Sagas.RabbitMQ` | Sagas |
| `Svrnty.CQRS.Notifications.Abstractions` | _(none)_ |
| `Svrnty.CQRS.Notifications.Grpc` | Notifications.Abstractions |
## CQRS Data Flow
### Command Flow
```
Client Request
|
v
[MinimalApi POST /api/command/{name}] or [gRPC CommandService/{name}]
|
v
FluentValidation (if validator registered)
|
|-- Validation fails --> RFC 7807 ProblemDetails (HTTP) / Google Rich Error (gRPC)
|
v
ICommandHandler<TCommand, TResult>.HandleAsync(command, ct)
|
v
Command Result (or void)
|
+--> (optional) IDomainEventPublisher.PublishAsync(event)
+--> (optional) INotificationPublisher.PublishAsync(notification)
```
### Query Flow
```
Client Request
|
v
[MinimalApi POST /api/query/{name}] or [gRPC QueryService/{name}]
|
v
IQueryHandler<TQuery, TResult>.HandleAsync(query, ct)
|
v
Query Result
```
### Dynamic Query Flow
```
Client Request (with filters, sorts, pagination)
|
v
[MinimalApi POST /api/dynamic-query/{entity}]
|
v
IQueryableProvider<TSource>.GetQueryableAsync(query, ct)
|
v
PoweredSoft.DynamicQuery engine (applies filters, sorts, groups, aggregates)
|
v
IAlterQueryableService (optional interception)
|
v
Paged/Grouped result set
```
### Saga Flow
```
ISagaOrchestrator.StartAsync<TSaga, TData>(data)
|
v
ISaga<TData>.Configure(builder) -- defines steps
|
v
Step 1: Execute --> Step 2: Execute --> Step 3: Execute --> Completed
| | |
| | +-- fails -->
| | |
| +-- compensate <-----------------+
| |
+-- compensate <-----------------+
|
v
Compensated (rolled back)
```
## Separation of Concerns
The framework follows a layered architecture:
1. **Abstractions layer** (4 packages) -- Pure interfaces and marker types with zero dependencies. Can be referenced by any project without pulling in implementation details.
- `Svrnty.CQRS.Abstractions`
- `Svrnty.CQRS.DynamicQuery.Abstractions`
- `Svrnty.CQRS.Events.Abstractions`
- `Svrnty.CQRS.Sagas.Abstractions`
- `Svrnty.CQRS.Grpc.Abstractions`
- `Svrnty.CQRS.Notifications.Abstractions`
2. **Core layer** (1 package) -- Handler discovery, DI registration, and the `CqrsBuilder` fluent API.
- `Svrnty.CQRS`
3. **Transport layer** (4 packages) -- Maps commands/queries to HTTP or gRPC endpoints.
- `Svrnty.CQRS.MinimalApi`
- `Svrnty.CQRS.Grpc`
- `Svrnty.CQRS.Grpc.Generators`
- `Svrnty.CQRS.DynamicQuery.MinimalApi`
4. **Feature layer** (4 packages) -- Optional capabilities that can be composed in.
- `Svrnty.CQRS.FluentValidation`
- `Svrnty.CQRS.DynamicQuery`
- `Svrnty.CQRS.DynamicQuery.EntityFramework`
- `Svrnty.CQRS.Sagas`
5. **Infrastructure layer** (3 packages) -- Concrete transport bindings for messaging and streaming.
- `Svrnty.CQRS.Events.RabbitMQ`
- `Svrnty.CQRS.Sagas.RabbitMQ`
- `Svrnty.CQRS.Notifications.Grpc`
This layering ensures that application code depends only on abstractions, while transport and infrastructure concerns remain pluggable.
+514
View File
@@ -0,0 +1,514 @@
# Getting Started
> Step-by-step guide to building a CQRS application with Svrnty.CQRS on .NET 10.
## Prerequisites
- .NET 10 SDK
- A text editor or IDE with C# support
## 1. Create a New Project
```bash
dotnet new web -n MyCqrsApp
cd MyCqrsApp
```
Add the required packages:
```bash
dotnet add package Svrnty.CQRS
dotnet add package Svrnty.CQRS.Abstractions
dotnet add package Svrnty.CQRS.MinimalApi
dotnet add package Svrnty.CQRS.FluentValidation
```
## 2. Define Commands and Queries
### Command with Result
A command represents an action that changes state. Implement `ICommandHandler<TCommand, TResult>` for commands that return a value.
```csharp
using Svrnty.CQRS.Abstractions;
// The command (a plain record/class)
public record CreateUserCommand
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public int Age { get; set; }
}
// The handler
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
public Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken = default)
{
// Your business logic here -- persist to database, etc.
return Task.FromResult(123); // Return the new user ID
}
}
```
### Command without Result
For commands that do not return a value, implement `ICommandHandler<TCommand>`:
```csharp
public record DeleteUserCommand
{
public int UserId { get; set; }
}
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
{
public Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken = default)
{
// Delete the user
return Task.CompletedTask;
}
}
```
### Query
A query retrieves data without side effects. Implement `IQueryHandler<TQuery, TResult>`:
```csharp
public record GetUserQuery
{
public int UserId { get; set; }
}
public record UserDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
public Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken = default)
{
return Task.FromResult(new UserDto
{
Id = query.UserId,
Name = "John Doe",
Email = "john@example.com"
});
}
}
```
## 3. Register Handlers
In `Program.cs`, register your handlers with the DI container:
```csharp
using Svrnty.CQRS;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args);
// Register command and query handlers
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddCommand<DeleteUserCommand, DeleteUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
// Configure CQRS with MinimalApi transport
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddMinimalApi();
});
var app = builder.Build();
// Map all CQRS endpoints
app.UseSvrntyCqrs();
app.Run();
```
This will expose:
- `POST /api/command/CreateUser` -- executes CreateUserCommand
- `POST /api/command/DeleteUser` -- executes DeleteUserCommand
- `POST /api/query/GetUser` -- executes GetUserQuery
## 4. Add FluentValidation
Add validators to enforce business rules before handler execution:
```csharp
using FluentValidation;
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Email must be valid");
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required");
RuleFor(x => x.Age)
.GreaterThan(0).WithMessage("Age must be greater than 0");
}
}
```
Register the command with its validator using the 4-type-parameter overload:
```csharp
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler, CreateUserCommandValidator>();
```
Validation errors are returned as RFC 7807 Problem Details (HTTP) or Google Rich Error Model (gRPC).
## 5. gRPC Setup
Add the gRPC packages:
```bash
dotnet add package Svrnty.CQRS.Grpc
dotnet add package Svrnty.CQRS.Grpc.Generators
dotnet add package Svrnty.CQRS.Grpc.Abstractions
```
Configure Kestrel for dual-protocol support and enable gRPC:
```csharp
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Svrnty.CQRS.Grpc;
var builder = WebApplication.CreateBuilder(args);
// Configure dual ports
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(6000, o => o.Protocols = HttpProtocols.Http2); // gRPC
options.ListenLocalhost(6001, o => o.Protocols = HttpProtocols.Http1); // HTTP API
});
// Register handlers (same as before)
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddQuery<GetUserQuery, UserDto, GetUserQueryHandler>();
// Enable both gRPC and MinimalApi
builder.Services.AddSvrntyCqrs(cqrs =>
{
cqrs.AddGrpc(grpc =>
{
grpc.EnableReflection(); // Enable gRPC reflection for tools like grpcurl
});
cqrs.AddMinimalApi();
});
var app = builder.Build();
app.UseSvrntyCqrs();
app.Run();
```
The `Svrnty.CQRS.Grpc.Generators` package automatically generates `.proto` files and gRPC service implementations from your registered command/query types at build time.
### Excluding Commands from gRPC
Use the `[GrpcIgnore]` attribute to prevent a command or query from being exposed via gRPC:
```csharp
using Svrnty.CQRS.Grpc.Abstractions.Attributes;
[GrpcIgnore]
public record InternalCommand
{
public string Data { get; set; } = string.Empty;
}
```
## 6. DynamicQuery Usage
Dynamic queries provide automatic filtering, sorting, grouping, and pagination for entity collections.
Add the packages:
```bash
dotnet add package Svrnty.CQRS.DynamicQuery
dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions
dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi
```
### Define a Queryable Provider
Implement `IQueryableProvider<T>` to supply the data source:
```csharp
using Svrnty.CQRS.DynamicQuery.Abstractions;
public class UserQueryableProvider : IQueryableProvider<UserDto>
{
private readonly MyDbContext _db;
public UserQueryableProvider(MyDbContext db)
{
_db = db;
}
public Task<IQueryable<UserDto>> GetQueryableAsync(object query, CancellationToken cancellationToken = default)
{
return Task.FromResult(_db.Users.AsQueryable());
}
}
```
### Register the Provider
```csharp
using Svrnty.CQRS.DynamicQuery;
// Register PoweredSoft dependencies
builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, MyAsyncQueryableService>();
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
// Register the dynamic query provider
builder.Services.AddDynamicQueryWithProvider<UserDto, UserQueryableProvider>();
```
This exposes a POST endpoint that accepts filter, sort, group, and pagination parameters, returning paged results automatically.
### Entity Framework Integration
For EF Core projects, add the EF integration package:
```bash
dotnet add package Svrnty.CQRS.DynamicQuery.EntityFramework
```
This provides a ready-made `IAsyncQueryableService` backed by EF Core.
## 7. Domain Events
Domain events allow you to publish side effects after a command completes.
Add the packages:
```bash
dotnet add package Svrnty.CQRS.Events.Abstractions
dotnet add package Svrnty.CQRS.Events.RabbitMQ # or implement your own IDomainEventPublisher
```
### Define an Event
```csharp
using Svrnty.CQRS.Events.Abstractions;
public record UserCreatedEvent : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredAt { get; } = DateTime.UtcNow;
public int UserId { get; init; }
public string Email { get; init; } = string.Empty;
}
```
### Publish from a Command Handler
```csharp
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
private readonly IDomainEventPublisher _events;
public CreateUserCommandHandler(IDomainEventPublisher events)
{
_events = events;
}
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken ct = default)
{
var userId = 123; // persist user
await _events.PublishAsync(new UserCreatedEvent
{
UserId = userId,
Email = command.Email
}, ct);
return userId;
}
}
```
## 8. Saga Pattern
Sagas orchestrate multi-step workflows with automatic compensation (rollback) on failure.
Add the packages:
```bash
dotnet add package Svrnty.CQRS.Sagas
dotnet add package Svrnty.CQRS.Sagas.Abstractions
dotnet add package Svrnty.CQRS.Sagas.RabbitMQ # for distributed sagas
```
### Define Saga Data
```csharp
using Svrnty.CQRS.Sagas.Abstractions;
public class CreateOrderSagaData : ISagaData
{
public Guid CorrelationId { get; set; }
public int OrderId { get; set; }
public int PaymentId { get; set; }
public decimal Amount { get; set; }
}
```
### Define a Saga
```csharp
public class CreateOrderSaga : ISaga<CreateOrderSagaData>
{
public void Configure(ISagaBuilder<CreateOrderSagaData> builder)
{
builder
.Step("CreateOrder")
.Execute(async (data, ctx, ct) =>
{
// Create the order
data.OrderId = 42;
})
.Compensate(async (data, ctx, ct) =>
{
// Cancel the order on rollback
})
.Then()
.Step("ProcessPayment")
.Execute(async (data, ctx, ct) =>
{
// Charge payment
data.PaymentId = 99;
})
.Compensate(async (data, ctx, ct) =>
{
// Refund payment on rollback
})
.Then();
}
}
```
### Execute a Saga
```csharp
using Svrnty.CQRS.Sagas.Abstractions;
public class OrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
private readonly ISagaOrchestrator _orchestrator;
public OrderCommandHandler(ISagaOrchestrator orchestrator)
{
_orchestrator = orchestrator;
}
public async Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken ct = default)
{
var state = await _orchestrator.StartAsync<CreateOrderSaga, CreateOrderSagaData>(
new CreateOrderSagaData { Amount = command.Amount }, ct);
// state.Status will be Completed or Compensated
return state.Status == SagaStatus.Completed ? 1 : 0;
}
}
```
Saga statuses: `NotStarted` -> `InProgress` -> `Completed` (success) or `Failed` -> `Compensating` -> `Compensated` (rolled back).
### Remote Steps (Distributed Sagas)
For steps that execute on remote services via RabbitMQ:
```csharp
builder
.SendCommand<ChargePaymentCommand, PaymentResult>("ChargePayment")
.WithCommand((data, ctx) => new ChargePaymentCommand { Amount = data.Amount })
.OnResponse(async (data, ctx, result, ct) =>
{
data.PaymentId = result.PaymentId;
})
.Compensate<RefundPaymentCommand>((data, ctx) =>
new RefundPaymentCommand { PaymentId = data.PaymentId })
.WithTimeout(TimeSpan.FromSeconds(30))
.WithRetry(maxRetries: 3, delay: TimeSpan.FromSeconds(2))
.Then();
```
## 9. Real-Time Notifications
For pushing real-time updates to clients via gRPC streaming:
```bash
dotnet add package Svrnty.CQRS.Notifications.Abstractions
dotnet add package Svrnty.CQRS.Notifications.Grpc
```
### Define a Notification
```csharp
using Svrnty.CQRS.Notifications.Abstractions;
[StreamingNotification(SubscriptionKey = "user-updates")]
public record UserUpdatedNotification
{
public int UserId { get; init; }
public string NewEmail { get; init; } = string.Empty;
}
```
### Publish a Notification
```csharp
public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand>
{
private readonly INotificationPublisher _notifications;
public UpdateUserCommandHandler(INotificationPublisher notifications)
{
_notifications = notifications;
}
public async Task HandleAsync(UpdateUserCommand command, CancellationToken ct = default)
{
// Update user...
await _notifications.PublishAsync(new UserUpdatedNotification
{
UserId = command.UserId,
NewEmail = command.NewEmail
}, ct);
}
}
```
## Running the Sample App
The repository includes a complete sample application:
```bash
cd Svrnty.Sample
dotnet run
```
This starts:
- gRPC server on `http://localhost:6000` (HTTP/2)
- HTTP API on `http://localhost:6001` (HTTP/1.1)
- Swagger UI at `http://localhost:6001/swagger`
The sample demonstrates commands with validation, queries, gRPC reflection, MinimalApi endpoints, and dynamic queries.
+335
View File
@@ -0,0 +1,335 @@
# Package Index
> Complete reference for all 18 NuGet packages in the Svrnty.CQRS framework.
## Overview
| # | Package | Path | NuGet Package |
|---|---------|------|:---:|
| 1 | [Svrnty.CQRS.Abstractions](#1-svrntycqrsabstractions) | `Svrnty.CQRS.Abstractions/` | Yes |
| 2 | [Svrnty.CQRS](#2-svrntycqrs) | `Svrnty.CQRS/` | Yes |
| 3 | [Svrnty.CQRS.MinimalApi](#3-svrntycqrsminimalapi) | `Svrnty.CQRS.MinimalApi/` | Yes |
| 4 | [Svrnty.CQRS.Grpc](#4-svrntycqrsgrpc) | `Svrnty.CQRS.Grpc/` | Yes |
| 5 | [Svrnty.CQRS.Grpc.Abstractions](#5-svrntycqrsgrpcabstractions) | `Svrnty.CQRS.Grpc.Abstractions/` | Yes |
| 6 | [Svrnty.CQRS.Grpc.Generators](#6-svrntycqrsgrpcgenerators) | `Svrnty.CQRS.Grpc.Generators/` | Yes |
| 7 | [Svrnty.CQRS.FluentValidation](#7-svrntycqrsfluentvalidation) | `Svrnty.CQRS.FluentValidation/` | Yes |
| 8 | [Svrnty.CQRS.DynamicQuery.Abstractions](#8-svrntycqrsdynamicqueryabstractions) | `Svrnty.CQRS.DynamicQuery.Abstractions/` | Yes |
| 9 | [Svrnty.CQRS.DynamicQuery](#9-svrntycqrsdynamicquery) | `Svrnty.CQRS.DynamicQuery/` | Yes |
| 10 | [Svrnty.CQRS.DynamicQuery.MinimalApi](#10-svrntycqrsdynamicqueryminimalapi) | `Svrnty.CQRS.DynamicQuery.MinimalApi/` | Yes |
| 11 | [Svrnty.CQRS.DynamicQuery.EntityFramework](#11-svrntycqrsdynamicqueryentityframework) | `Svrnty.CQRS.DynamicQuery.EntityFramework/` | Yes |
| 12 | [Svrnty.CQRS.Events.Abstractions](#12-svrntycqrseventsabstractions) | `Svrnty.CQRS.Events.Abstractions/` | Yes |
| 13 | [Svrnty.CQRS.Events.RabbitMQ](#13-svrntycqrseventsrabbitmq) | `Svrnty.CQRS.Events.RabbitMQ/` | Yes |
| 14 | [Svrnty.CQRS.Sagas.Abstractions](#14-svrntycqrssagasabstractions) | `Svrnty.CQRS.Sagas.Abstractions/` | Yes |
| 15 | [Svrnty.CQRS.Sagas](#15-svrntycqrssagas) | `Svrnty.CQRS.Sagas/` | Yes |
| 16 | [Svrnty.CQRS.Sagas.RabbitMQ](#16-svrntycqrssagasrabbitmq) | `Svrnty.CQRS.Sagas.RabbitMQ/` | Yes |
| 17 | [Svrnty.CQRS.Notifications.Abstractions](#17-svrntycqrsnotificationsabstractions) | `Svrnty.CQRS.Notifications.Abstractions/` | Yes |
| 18 | [Svrnty.CQRS.Notifications.Grpc](#18-svrntycqrsnotificationsgrpc) | `Svrnty.CQRS.Notifications.Grpc/` | Yes |
---
## Package Details
### 1. Svrnty.CQRS.Abstractions
**Purpose**: Core interfaces that define the CQRS contract. This is the only package your domain/application layer needs to reference.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `ICommandHandler<TCommand>` -- Handler for commands with no return value
- `ICommandHandler<TCommand, TResult>` -- Handler for commands returning a result
- `IQueryHandler<TQuery, TResult>` -- Handler for queries
- `ICommandMeta` / `IQueryMeta` -- Discovery metadata
- `ICommandDiscovery` / `IQueryDiscovery` -- Service discovery interfaces
- `ICommandAuthorizationService<TCommand>` -- Per-command authorization
- `IQueryAuthorizationService<TQuery>` -- Per-query authorization
- `CommandNameAttribute` / `QueryNameAttribute` -- Custom naming
- `IgnoreCommandAttribute` / `IgnoreQueryAttribute` -- Exclude from auto-discovery
**Internal Dependencies**: None
---
### 2. Svrnty.CQRS
**Purpose**: Core registration and discovery engine. Provides the `AddSvrntyCqrs()` fluent API and auto-discovers registered handlers.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `CqrsBuilder` -- Fluent builder for configuring transports and features
- `CqrsConfiguration` -- Configuration state
- `ServiceCollectionExtensions.AddSvrntyCqrs()` -- Entry point
- `ServiceCollectionExtensions.AddCommand<T, TResult, THandler>()` -- Register a command handler
- `ServiceCollectionExtensions.AddQuery<T, TResult, THandler>()` -- Register a query handler
- `CommandDiscovery` / `QueryDiscovery` -- Default discovery implementations
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`
---
### 3. Svrnty.CQRS.MinimalApi
**Purpose**: Maps registered commands and queries to ASP.NET Core Minimal API HTTP endpoints. Includes RFC 7807 Problem Details for validation errors.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- `CqrsBuilderExtensions.AddMinimalApi()` -- Enable HTTP endpoints
- `MinimalApiCqrsOptions` -- Configuration (route prefixes, etc.)
- `ValidationFilter` -- Endpoint filter for FluentValidation
- `WebApplicationExtensions.UseSvrntyCqrs()` -- Map endpoints at startup
- `EndpointRouteBuilderExtensions` -- Route mapping helpers
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`, `Svrnty.CQRS`
**External Dependencies**: `FluentValidation 11.x`, `Microsoft.AspNetCore.App`
---
### 4. Svrnty.CQRS.Grpc
**Purpose**: Maps registered commands and queries to gRPC services. Uses Google Rich Error Model for structured validation errors.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- `CqrsBuilderExtensions.AddGrpc()` -- Enable gRPC endpoints
- `GrpcCqrsOptions` -- Configuration (reflection, etc.)
**Internal Dependencies**: `Svrnty.CQRS`
**External Dependencies**: `Grpc.AspNetCore 2.71.0`
---
### 5. Svrnty.CQRS.Grpc.Abstractions
**Purpose**: Attributes for controlling gRPC code generation behavior.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `GrpcIgnoreAttribute` -- Marks a command/query to be excluded from gRPC service generation
**Internal Dependencies**: None
---
### 6. Svrnty.CQRS.Grpc.Generators
**Purpose**: Roslyn source generator that auto-generates `.proto` files and gRPC service implementations from registered command/query types.
**Target**: `netstandard2.0` (Roslyn component) | **AOT**: N/A
**Key Types**:
- Source generator (analyzer DLL)
- MSBuild `WriteProtoFileTask` -- Writes generated `.proto` files to disk
- Build targets and props for NuGet consumers
**Internal Dependencies**: None (ships as analyzer)
**External Dependencies**: `Microsoft.CodeAnalysis.CSharp 5.0.0`, `Microsoft.Build.Utilities.Core 17.0.0`
---
### 7. Svrnty.CQRS.FluentValidation
**Purpose**: Integrates FluentValidation with command/query registration. Validators are automatically invoked before handler execution.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `ServiceCollectionExtensions.AddCommand<TCmd, TResult, THandler, TValidator>()` -- Register command with validator
- Automatic `AbstractValidator<T>` binding
**Internal Dependencies**: `Svrnty.CQRS`, `Svrnty.CQRS.Abstractions`
**External Dependencies**: `FluentValidation 11.11.0`
---
### 8. Svrnty.CQRS.DynamicQuery.Abstractions
**Purpose**: Interfaces for the dynamic query subsystem. Defines how data sources are provided and queries are intercepted.
**Target**: `netstandard2.1`, `net10.0` (multi-target) | **AOT**: Conditional
**Key Types**:
- `IQueryableProvider<TSource>` -- Provides an `IQueryable<T>` data source
- `IQueryableProviderOverride<TSource>` -- Override default provider
- `IAlterQueryableService<TSource>` -- Intercept/modify queryables
- `IDynamicQuery` / `IDynamicQueryParams` -- Query parameter contracts
- `IDynamicQueryInterceptorProvider` -- Interceptor registration
**Internal Dependencies**: None
**External Dependencies**: `PoweredSoft.DynamicQuery.Core 3.0.1`
---
### 9. Svrnty.CQRS.DynamicQuery
**Purpose**: Implementation of dynamic query execution with filtering, sorting, grouping, pagination, and aggregation.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `ServiceCollectionExtensions.AddDynamicQueryWithProvider<TSource, TProvider>()` -- Register a queryable provider
- Dynamic query handler pipeline
**Internal Dependencies**: `Svrnty.CQRS.DynamicQuery.Abstractions`, `Svrnty.CQRS`
**External Dependencies**: `PoweredSoft.DynamicQuery 3.0.1`, `Pluralize.NET 1.0.2`
---
### 10. Svrnty.CQRS.DynamicQuery.MinimalApi
**Purpose**: HTTP Minimal API endpoints for dynamic queries. Exposes each registered entity as a POST endpoint with filter/sort/page parameters.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- Endpoint mapping for dynamic query routes (`/api/dynamic-query/{entity}`)
**Internal Dependencies**: `Svrnty.CQRS.Abstractions`, `Svrnty.CQRS.DynamicQuery.Abstractions`, `Svrnty.CQRS.DynamicQuery`
**External Dependencies**: `Microsoft.AspNetCore.App`
---
### 11. Svrnty.CQRS.DynamicQuery.EntityFramework
**Purpose**: Entity Framework Core integration for dynamic queries. Provides an EF-backed `IAsyncQueryableService`.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- EF Core queryable service adapter
**Internal Dependencies**: `Svrnty.CQRS.DynamicQuery`
**External Dependencies**: `PoweredSoft.Data.EntityFrameworkCore 3.0.0`
---
### 12. Svrnty.CQRS.Events.Abstractions
**Purpose**: Interfaces for domain event publishing.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `IDomainEvent` -- Marker interface (EventId, OccurredAt)
- `IDomainEventPublisher` -- Publish events to external systems
**Internal Dependencies**: None
---
### 13. Svrnty.CQRS.Events.RabbitMQ
**Purpose**: RabbitMQ-backed implementation of domain event publishing.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- RabbitMQ event publisher implementation
**Internal Dependencies**: `Svrnty.CQRS.Events.Abstractions`
**External Dependencies**: `RabbitMQ.Client 7.0.0`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.Options`
---
### 14. Svrnty.CQRS.Sagas.Abstractions
**Purpose**: Interfaces and types for the saga orchestration pattern with compensation (rollback) support.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `ISaga<TData>` -- Define a saga with steps
- `ISagaBuilder<TData>` -- Fluent builder for local and remote steps
- `ISagaStepBuilder<TData>` -- Configure Execute/Compensate actions
- `ISagaRemoteStepBuilder<TData, TCommand>` -- Remote command steps with timeout/retry
- `ISagaOrchestrator` -- Start sagas, query state
- `ISagaData` -- Marker interface (CorrelationId)
- `SagaState` -- Persistent saga state (status, completed steps, errors)
- `SagaStatus` -- Enum: NotStarted, InProgress, Completed, Failed, Compensating, Compensated
- `ISagaStateStore` -- Persistence abstraction
- `ISagaMessageBus` -- Messaging abstraction
- `SagaMessage` / `SagaStepResponse` -- Message types
- `ISagaContext` -- Step execution context
**Internal Dependencies**: None
---
### 15. Svrnty.CQRS.Sagas
**Purpose**: Default saga orchestrator implementation with step execution, compensation, and state management.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- Saga orchestrator engine
- In-memory state store (default)
**Internal Dependencies**: `Svrnty.CQRS`, `Svrnty.CQRS.Sagas.Abstractions`
**External Dependencies**: `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.Options`
---
### 16. Svrnty.CQRS.Sagas.RabbitMQ
**Purpose**: RabbitMQ-backed message bus for distributed saga step execution across microservices.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- RabbitMQ saga message bus implementation
**Internal Dependencies**: `Svrnty.CQRS.Sagas`
**External Dependencies**: `RabbitMQ.Client 7.0.0`, `Microsoft.Extensions.Hosting.Abstractions`, `Microsoft.Extensions.Options`
---
### 17. Svrnty.CQRS.Notifications.Abstractions
**Purpose**: Interfaces for real-time notification streaming to clients.
**Target**: `net10.0` | **AOT**: Yes
**Key Types**:
- `INotificationPublisher` -- Publish notifications to subscribed clients
- `StreamingNotificationAttribute` -- Marks a type as a streamable notification with a subscription key
**Internal Dependencies**: None
---
### 18. Svrnty.CQRS.Notifications.Grpc
**Purpose**: gRPC server-streaming implementation for real-time notifications.
**Target**: `net10.0` | **AOT**: No
**Key Types**:
- gRPC notification streaming service
**Internal Dependencies**: `Svrnty.CQRS.Notifications.Abstractions`
**External Dependencies**: `Grpc.AspNetCore 2.71.0`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.Extensions.Logging.Abstractions`
---
## Additional Projects (not NuGet packages)
| Project | Path | Purpose |
|---------|------|---------|
| `Svrnty.Sample` | `Svrnty.Sample/` | Sample web application demonstrating commands, queries, gRPC, MinimalApi, DynamicQuery, and validation |
| `Svrnty.CQRS.Tests` | `tests/Svrnty.CQRS.Tests/` | Unit and integration test suite |
+129
View File
@@ -0,0 +1,129 @@
pre-commit:
parallel: true
commands:
check-author:
run: |
EMAIL=$(git config user.email)
ALLOWED="jp@svrnty.io mathias@svrnty.io"
for a in $ALLOWED; do
[ "$EMAIL" = "$a" ] && exit 0
done
echo "BLOCKED: author email '$EMAIL' not in allowed list: $ALLOWED"
exit 1
no-secrets:
run: |
BLOCKED=$(git diff --cached --name-only | grep -E '\.(env|pem|key)$|credentials\.json|id_rsa|id_ed25519' || true)
if [ -n "$BLOCKED" ]; then
echo "BLOCKED: refusing to commit sensitive files:"
echo "$BLOCKED"
exit 1
fi
no-large-files:
run: |
LARGE=$(git diff --cached --name-only -z | xargs -0 -I{} sh -c 'if [ -f "{}" ]; then size=$(wc -c < "{}"); if [ "$size" -gt 5242880 ]; then echo "{} ($(( size / 1048576 ))MB)"; fi; fi' || true)
if [ -n "$LARGE" ]; then
echo "WARNING: large files staged (>5MB):"
echo "$LARGE"
fi
doc-hygiene:
run: |
STAGED=$(git diff --cached --name-only)
# Check if code files are staged (not just docs)
CODE_CHANGED=$(echo "$STAGED" | grep -vE '\.(md|txt|yml|yaml|json|toml|lock)$|^LICENSE$|^\.gitignore$' || true)
if [ -z "$CODE_CHANGED" ]; then
exit 0
fi
# Warn if CHANGELOG.md is not being updated with code changes
if ! echo "$STAGED" | grep -q '^CHANGELOG.md$'; then
echo "WARNING: code changes staged without CHANGELOG.md update"
echo " → Update CHANGELOG.md under [Unreleased] before committing"
echo " → See root CLAUDE.md § Documentation Standards for format"
fi
# Warn if README.md is missing
if [ ! -f "README.md" ]; then
echo "WARNING: README.md is missing — every repo must have one"
echo " → See root CLAUDE.md § README Requirements for structure"
fi
commit-msg:
commands:
validate-message:
run: |
MSG=$(cat "{1}")
if echo "$MSG" | head -1 | grep -qE '^Merge '; then
exit 0
fi
if ! echo "$MSG" | head -1 | grep -qE '^[a-z]+(\([a-zA-Z0-9_-]+\))?: .+'; then
echo "WARNING: commit message does not follow conventional format: type(scope): message"
echo " → Types: feat, fix, refactor, docs, test, chore, ci, perf"
fi
append-coauthor:
run: |
MSG=$(cat "{1}")
if ! echo "$MSG" | grep -qF 'Co-Authored-By: Svrnty Inc. <jp@svrnty.io>'; then
printf '\n\nCo-Authored-By: Svrnty Inc. <jp@svrnty.io>\n' >> "{1}"
fi
post-commit:
commands:
register-repo:
run: |
REPO_NAME=$(basename "$(git rev-parse --show-toplevel)")
ROOT_CLAUDE="$(git rev-parse --show-toplevel)/../CLAUDE.md"
[ -f "$ROOT_CLAUDE" ] || exit 0
if grep -qF "| \`$REPO_NAME\`" "$ROOT_CLAUDE"; then
exit 0
fi
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date +%Y-%m-%d)
TOTAL_LINE=$(grep -n '^\*\*Total:' "$ROOT_CLAUDE" | head -1 | cut -d: -f1)
if [ -z "$TOTAL_LINE" ]; then
exit 0
fi
OLD_COUNT=$(sed -n "${TOTAL_LINE}p" "$ROOT_CLAUDE" | grep -oP '\d+')
NEW_COUNT=$((OLD_COUNT + 1))
sed -i "${TOTAL_LINE}i| \`${REPO_NAME}\` | — | NEW REPO — registered ${DATE} (${COMMIT}). Update stack and purpose. |" "$ROOT_CLAUDE"
NEW_TOTAL_LINE=$((TOTAL_LINE + 1))
sed -i "${NEW_TOTAL_LINE}s/Total: ${OLD_COUNT}/Total: ${NEW_COUNT}/" "$ROOT_CLAUDE"
echo "REGISTRY: added '$REPO_NAME' to root CLAUDE.md (${DATE}, ${COMMIT})"
bootstrap-siblings:
run: |
REPO_ROOT=$(git rev-parse --show-toplevel)
HOOKS_DIR="$REPO_ROOT/../.svrnty-hooks"
[ -d "$HOOKS_DIR" ] || exit 0
[ -f "$HOOKS_DIR/lefthook.yml" ] || exit 0
for sibling in "$REPO_ROOT"/../*/; do
[ -d "$sibling/.git" ] || continue
[ -f "$sibling/lefthook.yml" ] && continue
SNAME=$(basename "$sibling")
# Deploy lefthook
cp "$HOOKS_DIR/lefthook.yml" "$sibling/lefthook.yml"
# Deploy CLAUDE.md
[ -f "$HOOKS_DIR/CLAUDE.md.template" ] && cp "$HOOKS_DIR/CLAUDE.md.template" "$sibling/CLAUDE.md"
# Deploy governance docs
[ -f "$HOOKS_DIR/LICENSE" ] && [ ! -f "$sibling/LICENSE" ] && cp "$HOOKS_DIR/LICENSE" "$sibling/LICENSE"
[ -f "$HOOKS_DIR/CONTRIBUTING.md" ] && [ ! -f "$sibling/CONTRIBUTING.md" ] && cp "$HOOKS_DIR/CONTRIBUTING.md" "$sibling/CONTRIBUTING.md"
[ -f "$HOOKS_DIR/SECURITY.md" ] && [ ! -f "$sibling/SECURITY.md" ] && cp "$HOOKS_DIR/SECURITY.md" "$sibling/SECURITY.md"
[ -f "$HOOKS_DIR/CHANGELOG.md.template" ] && [ ! -f "$sibling/CHANGELOG.md" ] && cp "$HOOKS_DIR/CHANGELOG.md.template" "$sibling/CHANGELOG.md"
# Install lefthook
(cd "$sibling" && lefthook install 2>/dev/null)
echo "BOOTSTRAP: installed lefthook + governance docs in '$SNAME'"
done
pre-push:
commands:
protect-main:
run: |
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
echo "BLOCKED: direct push to $BRANCH is not allowed"
exit 1
fi
check-behind-remote:
run: |
git fetch origin 2>/dev/null || true
BRANCH=$(git rev-parse --abbrev-ref HEAD)
BEHIND=$(git rev-list --count HEAD..origin/"$BRANCH" 2>/dev/null || echo 0)
if [ "$BEHIND" -gt 0 ]; then
echo "WARNING: local branch is $BEHIND commit(s) behind origin/$BRANCH"
fi
@@ -0,0 +1,124 @@
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.Discovery;
namespace Svrnty.CQRS.Tests;
public class CommandDiscoveryTests
{
private static CommandDiscovery CreateDiscovery(params ICommandMeta[] metas)
{
return new CommandDiscovery(metas);
}
[Fact]
public void GetCommands_ReturnsAllRegistered()
{
var meta1 = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var meta2 = new CommandMeta(typeof(DeletePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta1, meta2);
var commands = discovery.GetCommands().ToList();
Assert.Equal(2, commands.Count);
}
[Fact]
public void GetCommands_ReturnsEmpty_WhenNoneRegistered()
{
var discovery = CreateDiscovery();
var commands = discovery.GetCommands().ToList();
Assert.Empty(commands);
}
[Fact]
public void FindCommand_ByName_ReturnsCorrectMeta()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand("CreatePerson");
Assert.NotNull(found);
Assert.Equal(typeof(CreatePersonCommand), found.CommandType);
}
[Fact]
public void FindCommand_ByName_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindCommand("NonExistent");
Assert.Null(found);
}
[Fact]
public void FindCommand_ByType_ReturnsCorrectMeta()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand(typeof(CreatePersonCommand));
Assert.NotNull(found);
Assert.Equal("CreatePerson", found.Name);
}
[Fact]
public void FindCommand_ByType_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindCommand(typeof(CreatePersonCommand));
Assert.Null(found);
}
[Fact]
public void CommandExists_ByName_ReturnsTrue_WhenFound()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.CommandExists("CreatePerson"));
}
[Fact]
public void CommandExists_ByName_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.CommandExists("CreatePerson"));
}
[Fact]
public void CommandExists_ByType_ReturnsTrue_WhenFound()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.CommandExists(typeof(CreatePersonCommand)));
}
[Fact]
public void CommandExists_ByType_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.CommandExists(typeof(CreatePersonCommand)));
}
[Fact]
public void FindCommand_WithCustomName_FindsByAttributeName()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
var discovery = CreateDiscovery(meta);
var found = discovery.FindCommand("customCreate");
Assert.NotNull(found);
Assert.Equal(typeof(CreateWidgetCommand), found.CommandType);
}
}
@@ -0,0 +1,64 @@
using Svrnty.CQRS.Abstractions.Discovery;
namespace Svrnty.CQRS.Tests;
public class CommandMetaTests
{
[Fact]
public void Name_StripsCommandSuffix()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal("CreatePerson", meta.Name);
}
[Fact]
public void Name_UsesCommandNameAttribute_WhenPresent()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
Assert.Equal("customCreate", meta.Name);
}
[Fact]
public void LowerCamelCaseName_ConvertsFirstCharToLower()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal("createPerson", meta.LowerCamelCaseName);
}
[Fact]
public void LowerCamelCaseName_PreservesAlreadyLowerCase()
{
var meta = new CommandMeta(typeof(CreateWidgetCommand), typeof(object));
// customCreate -> already lower first char
Assert.Equal("customCreate", meta.LowerCamelCaseName);
}
[Fact]
public void CommandType_IsSetCorrectly()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object));
Assert.Equal(typeof(CreatePersonCommand), meta.CommandType);
}
[Fact]
public void ServiceType_IsSetCorrectly()
{
var serviceType = typeof(object);
var meta = new CommandMeta(typeof(CreatePersonCommand), serviceType);
Assert.Equal(serviceType, meta.ServiceType);
}
[Fact]
public void CommandResultType_IsSetCorrectly_WithThreeArgConstructor()
{
var meta = new CommandMeta(typeof(CreatePersonCommand), typeof(object), typeof(CreatePersonResult));
Assert.Equal(typeof(CreatePersonResult), meta.CommandResultType);
}
[Fact]
public void CommandResultType_IsNull_WithTwoArgConstructor()
{
var meta = new CommandMeta(typeof(DeletePersonCommand), typeof(object));
Assert.Null(meta.CommandResultType);
}
}
@@ -0,0 +1,106 @@
using Svrnty.CQRS.Configuration;
namespace Svrnty.CQRS.Tests;
public class CqrsConfigurationTests
{
private class TestConfig
{
public string Value { get; set; } = string.Empty;
}
private class OtherConfig
{
public int Number { get; set; }
}
[Fact]
public void SetConfiguration_CanBeRetrieved()
{
var config = new CqrsConfiguration();
var testConfig = new TestConfig { Value = "hello" };
config.SetConfiguration(testConfig);
var retrieved = config.GetConfiguration<TestConfig>();
Assert.NotNull(retrieved);
Assert.Equal("hello", retrieved.Value);
}
[Fact]
public void GetConfiguration_ReturnsNull_WhenNotSet()
{
var config = new CqrsConfiguration();
var retrieved = config.GetConfiguration<TestConfig>();
Assert.Null(retrieved);
}
[Fact]
public void HasConfiguration_ReturnsTrue_WhenSet()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig());
Assert.True(config.HasConfiguration<TestConfig>());
}
[Fact]
public void HasConfiguration_ReturnsFalse_WhenNotSet()
{
var config = new CqrsConfiguration();
Assert.False(config.HasConfiguration<TestConfig>());
}
[Fact]
public void SetConfiguration_OverwritesPrevious()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig { Value = "first" });
config.SetConfiguration(new TestConfig { Value = "second" });
var retrieved = config.GetConfiguration<TestConfig>();
Assert.Equal("second", retrieved!.Value);
}
[Fact]
public void MultipleConfigTypes_AreIndependent()
{
var config = new CqrsConfiguration();
config.SetConfiguration(new TestConfig { Value = "test" });
config.SetConfiguration(new OtherConfig { Number = 42 });
Assert.Equal("test", config.GetConfiguration<TestConfig>()!.Value);
Assert.Equal(42, config.GetConfiguration<OtherConfig>()!.Number);
}
[Fact]
public void ExecuteMappingCallbacks_InvokesAllCallbacks()
{
var config = new CqrsConfiguration();
var callCount = 0;
config.AddMappingCallback(_ => callCount++);
config.AddMappingCallback(_ => callCount++);
config.ExecuteMappingCallbacks(new object());
Assert.Equal(2, callCount);
}
[Fact]
public void ExecuteMappingCallbacks_PassesAppObject()
{
var config = new CqrsConfiguration();
object? receivedApp = null;
config.AddMappingCallback(app => receivedApp = app);
var expected = new object();
config.ExecuteMappingCallbacks(expected);
Assert.Same(expected, receivedApp);
}
}

Some files were not shown because too many files have changed in this diff Show More