Compare commits

...

21 Commits
main ... JP

Author SHA1 Message Date
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
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
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
41eb5b97cb chore: add bootstrap-siblings post-commit hook
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:32:15 -04:00
c7d9228a88 chore: add post-commit repo registry hook
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:29:59 -04:00
313b8c83ea chore: standardize CLAUDE.md and lefthook hooks
Co-Authored-By: Svrnty Inc. <jp@svrnty.io>
2026-03-08 11:22:05 -04:00
3fa59306c2 docs: add security policy
Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
2026-03-05 05:59:26 -05:00
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
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
49 changed files with 2984 additions and 821 deletions

BIN
.DS_Store vendored

Binary file not shown.

261
.editorconfig Normal file
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
.env.example Normal file
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
.github/dependabot.yml vendored Normal file
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
.github/workflows/ci.yml vendored Normal file
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
.github/workflows/codeql.yml vendored Normal file
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
.github/workflows/release.yml vendored Normal file
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
.github/workflows/security.yml vendored Normal file
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

12
.gitignore vendored
View File

@ -4,6 +4,7 @@
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
.research/ .research/
.DS_Store
# User-specific files # User-specific files
*.rsuser *.rsuser
@ -339,4 +340,13 @@ ASALocalRun/
.localhistory/ .localhistory/
# BeatPulse healthcheck temp database # BeatPulse healthcheck temp database
healthchecksdb healthchecksdb
# Secrets and credentials
.env
.env.local
.env.*
*.key
secrets/
.aws/
credentials.json

14
.library-manifest.yaml Normal file
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
CHANGELOG.md Normal file
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

438
CLAUDE.md
View File

@ -1,413 +1,47 @@
# CLAUDE.md # Development Guidelines
This file provides guidance to AI agents 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 ## Tech Stack
- 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)
## 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):** | Command | Description |
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts) |---------|-------------|
- `Svrnty.CQRS.DynamicQuery.Abstractions` - Dynamic query interfaces (multi-targets netstandard2.1 and net10.0) | `dotnet build` | Build all 18 projects |
- `Svrnty.CQRS.Grpc.Abstractions` - gRPC-specific interfaces and contracts | `dotnet test` | Run tests |
| `dotnet format` | Format code |
**Implementation:** ## Key Dependencies
- `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
**Sample Projects:** | Package | Description |
- `Svrnty.Sample` - Comprehensive demo project showcasing both HTTP and gRPC endpoints |---------|-------------|
| 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 - Solution file: `Svrnty.CQRS.sln` with 18 projects.
- Lint is handled by .NET analyzers — AOT compatibility and nullable reference types are enforced.
```bash - No Docker or proto files in this repo.
# Restore dependencies - Published under the `svrnty` org (git.openharbor.io/svrnty), not `a-gent`.
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

52
CONTRIBUTING.md Normal file
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.

View File

@ -1,6 +1,6 @@
MIT License 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

297
README.md
View File

@ -1,282 +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 ## Where This Fits
This is a backend framework of the [Svrnty Agent System](../README.md). **Layer**: libs
**Layer**: Framework
**Depends on**: Nothing (standalone .NET framework) **Depends on**: Nothing (standalone .NET framework)
**Depended on by**: a-gent-app (backend services), flutter_cqrs_datasource (client) **Depended on by**: a-gent-app (backend services), flutter-cqrs-datasource (client)
**Git**: [git.openharbor.io/svrnty/dotnet-cqrs](https://git.openharbor.io/svrnty/dotnet-cqrs) **Git**: git.openharbor.io/svrnty/dotnet-cqrs.git
## Getting Started ## Tech Stack
> Install nuget package to your awesome project. - **Language**: C# 14 / .NET 10
- **Framework**: ASP.NET Core Minimal API, gRPC
- **Key Dependencies**: FluentValidation 11.x, Grpc.AspNetCore, PoweredSoft.DynamicQuery
| Package Name | NuGet | NuGet Install | ## Quick Start
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
| 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 ``` |
> 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:
```bash ```bash
dotnet add package Grpc.AspNetCore # Build
dotnet add package Grpc.AspNetCore.Server.Reflection dotnet build
dotnet add package Grpc.StatusProto # For Rich Error Model validation
# Run
dotnet run --project Svrnty.Sample
# Test
dotnet test
``` ```
#### 2. Add the source generator as an analyzer: ## Architecture
```bash 18 NuGet packages organized by concern:
dotnet add package Svrnty.CQRS.Grpc.Generators
```
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 ```csharp
public record AddUserCommand // Register handlers
{ builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
public required string Name { get; init; } builder.Services.AddQuery<GetUserQuery, User, GetUserQueryHandler>();
public required string Email { get; init; }
public int Age { get; init; }
}
public record RemoveUserCommand // Configure CQRS with gRPC + HTTP
{
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
builder.Services.AddSvrntyCqrs(cqrs => builder.Services.AddSvrntyCqrs(cqrs =>
{ {
// Enable Minimal API endpoints cqrs.AddGrpc(grpc => grpc.EnableReflection());
cqrs.AddMinimalApi(); 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.UseSvrntyCqrs();
app.Run();
``` ```
**Notes:** ## Documentation
- 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
## 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 - **[flutter_cqrs_datasource](https://git.openharbor.io/svrnty/flutter_cqrs_datasource)** -- Flutter/Dart counterpart for consuming Svrnty.CQRS services from mobile and desktop apps
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.CQRS.MinimalApi;
var builder = WebApplication.CreateBuilder(args); ## Contributing
// Register your commands with validators See [CLAUDE.md](./CLAUDE.md) for development guidelines.
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Register your queries ## License
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// Configure CQRS with both gRPC and Minimal API support MIT OR Apache-2.0
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 10 | .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. | ⬜️ |

View File

@ -1,122 +0,0 @@
# Saga Orchestration Roadmap
## Completed (Phase 1)
- [x] `Svrnty.CQRS.Sagas.Abstractions` - Core interfaces and contracts
- [x] `Svrnty.CQRS.Sagas` - Orchestration engine with fluent builder API
- [x] `Svrnty.CQRS.Sagas.RabbitMQ` - RabbitMQ message transport
---
## Phase 1d: Testing & Sample
### Unit Tests
- [ ] `SagaBuilder` step configuration tests
- [ ] `SagaOrchestrator` execution flow tests
- [ ] `SagaOrchestrator` compensation flow tests
- [ ] `InMemorySagaStateStore` persistence tests
- [ ] `RabbitMqSagaMessageBus` serialization tests
### Integration Tests
- [ ] End-to-end saga execution with RabbitMQ
- [ ] Multi-step saga with compensation scenario
- [ ] Concurrent saga execution tests
- [ ] Connection recovery tests
### Sample Implementation
- [ ] `OrderProcessingSaga` example in WarehouseManagement
- ReserveInventory step
- ProcessPayment step
- CreateShipment step
- Full compensation flow
---
## Phase 2: Persistence
### Svrnty.CQRS.Sagas.EntityFramework
- [ ] `EfCoreSagaStateStore` implementation
- [ ] `SagaState` entity configuration
- [ ] Migration support
- [ ] PostgreSQL/SQL Server compatibility
- [ ] Optimistic concurrency handling
### Configuration
```csharp
cqrs.AddSagas()
.UseEntityFramework<AppDbContext>();
```
---
## Phase 3: Reliability
### Saga Timeout Service
- [ ] `SagaTimeoutHostedService` - background service for stalled sagas
- [ ] Configurable timeout per saga type
- [ ] Automatic compensation trigger on timeout
- [ ] Dead letter handling for failed compensations
### Retry Policies
- [ ] Exponential backoff support
- [ ] Circuit breaker integration
- [ ] Polly integration option
### Idempotency
- [ ] Message deduplication
- [ ] Idempotent step execution
- [ ] Inbox/Outbox pattern support
---
## Phase 4: Observability
### OpenTelemetry Integration
- [ ] Distributed tracing for saga execution
- [ ] Span per saga step
- [ ] Correlation ID propagation
- [ ] Metrics (saga duration, success/failure rates)
### Saga Dashboard (Optional)
- [ ] Web UI for saga monitoring
- [ ] Real-time saga status
- [ ] Manual compensation trigger
- [ ] Saga history and audit log
---
## Phase 5: Flutter Integration
### gRPC Streaming for Saga Status
- [ ] `ISagaStatusStream` service
- [ ] Real-time saga progress updates
- [ ] Step completion notifications
- [ ] Error/compensation notifications
### Flutter Client
- [ ] Dart client for saga status streaming
- [ ] Saga progress widget components
---
## Phase 6: Alternative Transports
### Svrnty.CQRS.Sagas.AzureServiceBus
- [ ] Azure Service Bus message transport
- [ ] Topic/Subscription topology
- [ ] Dead letter queue handling
### Svrnty.CQRS.Sagas.Kafka
- [ ] Kafka message transport
- [ ] Consumer group management
- [ ] Partition key strategies
---
## Future Considerations
- **Event Sourcing**: Saga state as event stream
- **Saga Versioning**: Handle saga definition changes gracefully
- **Saga Composition**: Nested/child sagas
- **Saga Scheduling**: Delayed saga start
- **Multi-tenancy**: Tenant-aware saga execution

52
SECURITY.md Normal file
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 |

View File

@ -19,7 +19,7 @@ public sealed class CommandMeta : ICommandMeta
ServiceType = serviceType; ServiceType = serviceType;
} }
private CommandNameAttribute NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>(); private CommandNameAttribute? NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
public string Name public string Name
{ {
@ -32,7 +32,7 @@ public sealed class CommandMeta : ICommandMeta
public Type CommandType { get; } public Type CommandType { get; }
public Type ServiceType { get; } public Type ServiceType { get; }
public Type CommandResultType { get; } public Type? CommandResultType { get; }
public string LowerCamelCaseName public string LowerCamelCaseName
{ {

View File

@ -7,7 +7,7 @@ public interface ICommandMeta
string Name { get; } string Name { get; }
Type CommandType { get; } Type CommandType { get; }
Type ServiceType { get; } Type ServiceType { get; }
Type CommandResultType { get; } Type? CommandResultType { get; }
string LowerCamelCaseName { get; } string LowerCamelCaseName { get; }
} }

View File

@ -5,8 +5,8 @@ namespace Svrnty.CQRS.Abstractions.Discovery;
public interface IQueryDiscovery public interface IQueryDiscovery
{ {
IQueryMeta FindQuery(string name); IQueryMeta? FindQuery(string name);
IQueryMeta FindQuery(Type queryType); IQueryMeta? FindQuery(Type queryType);
IEnumerable<IQueryMeta> GetQueries(); IEnumerable<IQueryMeta> GetQueries();
bool QueryExists(string name); bool QueryExists(string name);
bool QueryExists(Type queryType); bool QueryExists(Type queryType);
@ -16,8 +16,8 @@ public interface ICommandDiscovery
{ {
bool CommandExists(string name); bool CommandExists(string name);
bool CommandExists(Type commandType); bool CommandExists(Type commandType);
ICommandMeta FindCommand(string name); ICommandMeta? FindCommand(string name);
ICommandMeta FindCommand(Type commandType); ICommandMeta? FindCommand(Type commandType);
IEnumerable<ICommandMeta> GetCommands(); IEnumerable<ICommandMeta> GetCommands();
} }

View File

@ -13,7 +13,7 @@ public class QueryMeta : IQueryMeta
QueryResultType = queryResultType; QueryResultType = queryResultType;
} }
protected virtual QueryNameAttribute NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>(); protected virtual QueryNameAttribute? NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
public virtual string Name public virtual string Name
{ {

View File

@ -20,10 +20,10 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
public interface IDynamicQuery public interface IDynamicQuery
{ {
List<IFilter> GetFilters(); List<IFilter>? GetFilters();
List<IGroup> GetGroups(); List<IGroup>? GetGroups();
List<ISort> GetSorts(); List<ISort>? GetSorts();
List<IAggregate> GetAggregates(); List<IAggregate>? GetAggregates();
int? GetPage(); int? GetPage();
int? GetPageSize(); int? GetPageSize();
} }

View File

@ -3,5 +3,5 @@
public interface IDynamicQueryParams<out TParams> public interface IDynamicQueryParams<out TParams>
where TParams : class where TParams : class
{ {
TParams GetParams(); TParams? GetParams();
} }

View File

@ -22,7 +22,7 @@ public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResult
} }
} }
public Type ParamsType { get; internal set; } public Type? ParamsType { get; internal set; }
public string OverridableName { get; internal set; } public string? OverridableName { get; internal set; }
} }

View File

@ -18,9 +18,9 @@ public class DynamicQuery<TSource, TDestination, TParams> : DynamicQuery, IDynam
where TDestination : class where TDestination : class
where TParams : class where TParams : class
{ {
public TParams Params { get; set; } public TParams? Params { get; set; }
public TParams GetParams() public TParams? GetParams()
{ {
return Params; return Params;
} }
@ -30,23 +30,23 @@ public class DynamicQuery : IDynamicQuery
{ {
public int? Page { get; set; } public int? Page { get; set; }
public int? PageSize { get; set; } public int? PageSize { get; set; }
public List<Sort> Sorts { get; set; } public List<Sort>? Sorts { get; set; }
public List<DynamicQueryAggregate> Aggregates { get; set; } public List<DynamicQueryAggregate>? Aggregates { get; set; }
public List<Group> Groups { get; set; } public List<Group>? Groups { get; set; }
public List<DynamicQueryFilter> Filters { 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(); return Filters?.Select(t => t.ToFilter())?.ToList();
} }
public List<IGroup> GetGroups() public List<IGroup>? GetGroups()
{ {
return this.Groups?.AsEnumerable<IGroup>()?.ToList(); return this.Groups?.AsEnumerable<IGroup>()?.ToList();
} }
@ -61,7 +61,7 @@ public class DynamicQuery : IDynamicQuery
return this.PageSize; return this.PageSize;
} }
public List<ISort> GetSorts() public List<ISort>? GetSorts()
{ {
return this.Sorts?.AsEnumerable<ISort>()?.ToList(); return this.Sorts?.AsEnumerable<ISort>()?.ToList();
} }

View File

@ -6,8 +6,8 @@ namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryAggregate public class DynamicQueryAggregate
{ {
public string Path { get; set; } public required string Path { get; set; }
public string Type { get; set; } public required string Type { get; set; }
public IAggregate ToAggregate() public IAggregate ToAggregate()
{ {

View File

@ -9,14 +9,14 @@ namespace Svrnty.CQRS.DynamicQuery;
public class DynamicQueryFilter public class DynamicQueryFilter
{ {
public List<DynamicQueryFilter> Filters { get; set; } public List<DynamicQueryFilter>? Filters { get; set; }
public bool? And { get; set; } public bool? And { get; set; }
public string Type { get; set; } public string? Type { get; set; }
public bool? Not { get; set; } public bool? Not { get; set; }
public string Path { get; set; } public string? Path { get; set; }
public object Value { get; set; } public object? Value { get; set; }
public string QueryValue public string? QueryValue
{ {
get get
{ {
@ -32,7 +32,7 @@ public class DynamicQueryFilter
public IFilter ToFilter() public IFilter ToFilter()
{ {
var type = Enum.Parse<FilterType>(Type); var type = Enum.Parse<FilterType>(Type!);
if (type == FilterType.Composite) if (type == FilterType.Composite)
{ {
var compositeFilter = new CompositeFilter var compositeFilter = new CompositeFilter
@ -44,7 +44,7 @@ public class DynamicQueryFilter
return compositeFilter; return compositeFilter;
} }
object value = Value; object? value = Value;
if (Value is JsonElement jsonElement) if (Value is JsonElement jsonElement)
{ {
switch (jsonElement.ValueKind) switch (jsonElement.ValueKind)

View File

@ -60,7 +60,10 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
{ {
var types = _dynamicQueryInterceptorProviders.SelectMany(t => t.GetInterceptorsTypes()).Distinct(); var types = _dynamicQueryInterceptorProviders.SelectMany(t => t.GetInterceptorsTypes()).Distinct();
foreach (var type in types) 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, protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,

View File

@ -26,11 +26,11 @@ public static class ServiceCollectionExtensions
return new DynamicQueryServicesBuilder(services); return new DynamicQueryServicesBuilder(services);
} }
public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string name = null) public static IServiceCollection AddDynamicQuery<TSourceAndDestination>(this IServiceCollection services, string? name = null)
where TSourceAndDestination : class where TSourceAndDestination : class
=> AddDynamicQuery<TSourceAndDestination, TSourceAndDestination>(services, name: name); => 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 TSource : class
where TDestination : class where TDestination : class
{ {
@ -51,7 +51,7 @@ public static class ServiceCollectionExtensions
return services; 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 TQueryableProvider : class, IQueryableProvider<TSource>
where TSource : class where TSource : class
{ {
@ -60,7 +60,7 @@ public static class ServiceCollectionExtensions
return services; 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 TQueryableProvider : class, IQueryableProvider<TSource>
where TParams : class where TParams : class
where TSource : class where TSource : class
@ -86,12 +86,12 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string name = null) public static IServiceCollection AddDynamicQueryWithParams<TSourceAndDestination, TParams>(this IServiceCollection services, string? name = null)
where TSourceAndDestination : class where TSourceAndDestination : class
where TParams : class where TParams : class
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name); => 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 TSource : class
where TDestination : class where TDestination : class
where TParams : class where TParams : class

Binary file not shown.

View File

@ -43,6 +43,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
EndProject 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}" 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 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -257,10 +261,25 @@ Global
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D6D431EA-C04F-462B-8033-60F510FEB49E} SolutionGuid = {D6D431EA-C04F-462B-8033-60F510FEB49E}
EndGlobalSection EndGlobalSection

BIN
Svrnty.CQRS/.DS_Store vendored

Binary file not shown.

View File

@ -15,8 +15,8 @@ public sealed class CommandDiscovery : ICommandDiscovery
} }
public IEnumerable<ICommandMeta> GetCommands() => _commandMetas; public IEnumerable<ICommandMeta> GetCommands() => _commandMetas;
public ICommandMeta FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name); 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(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
public bool CommandExists(string name) => _commandMetas.Any(t => t.Name == name); public bool CommandExists(string name) => _commandMetas.Any(t => t.Name == name);
public bool CommandExists(Type commandType) => _commandMetas.Any(t => t.CommandType == commandType); public bool CommandExists(Type commandType) => _commandMetas.Any(t => t.CommandType == commandType);
} }

View File

@ -15,8 +15,8 @@ public sealed class QueryDiscovery : IQueryDiscovery
} }
public IEnumerable<IQueryMeta> GetQueries() => _queryMetas; public IEnumerable<IQueryMeta> GetQueries() => _queryMetas;
public IQueryMeta FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name); 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(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
public bool QueryExists(string name) => _queryMetas.Any(t => t.Name == name); public bool QueryExists(string name) => _queryMetas.Any(t => t.Name == name);
public bool QueryExists(Type queryType) => _queryMetas.Any(t => t.QueryType == queryType); public bool QueryExists(Type queryType) => _queryMetas.Any(t => t.QueryType == queryType);
} }

178
docs/ARCHITECTURE.md Normal file
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
docs/GETTING_STARTED.md Normal file
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
docs/PACKAGE_INDEX.md Normal file
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
lefthook.yml Normal file
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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,81 @@
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Attributes;
namespace Svrnty.CQRS.Tests;
// Commands
public class CreatePersonCommand
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
public class DeletePersonCommand
{
public int Id { get; set; }
}
[CommandName("customCreate")]
public class CreateWidgetCommand
{
public string Name { get; set; } = string.Empty;
}
// Command results
public class CreatePersonResult
{
public int Id { get; set; }
}
// Queries
public class PersonQuery
{
public string? NameFilter { get; set; }
}
[QueryName("customPersonLookup")]
public class PersonLookupQuery
{
public int Id { get; set; }
}
// Handlers
public class CreatePersonCommandHandler : ICommandHandler<CreatePersonCommand, CreatePersonResult>
{
public Task<CreatePersonResult> HandleAsync(CreatePersonCommand command, CancellationToken cancellationToken = default)
{
return Task.FromResult(new CreatePersonResult { Id = 1 });
}
}
public class DeletePersonCommandHandler : ICommandHandler<DeletePersonCommand>
{
public Task HandleAsync(DeletePersonCommand command, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
public class CreateWidgetCommandHandler : ICommandHandler<CreateWidgetCommand>
{
public Task HandleAsync(CreateWidgetCommand command, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
public class PersonQueryHandler : IQueryHandler<PersonQuery, IEnumerable<string>>
{
public Task<IEnumerable<string>> HandleAsync(PersonQuery query, CancellationToken cancellationToken = default)
{
return Task.FromResult<IEnumerable<string>>(["Alice", "Bob"]);
}
}
public class PersonLookupQueryHandler : IQueryHandler<PersonLookupQuery, string>
{
public Task<string> HandleAsync(PersonLookupQuery query, CancellationToken cancellationToken = default)
{
return Task.FromResult("Alice");
}
}

View File

@ -0,0 +1,184 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Discovery;
namespace Svrnty.CQRS.Tests;
// Validator for CreatePersonCommand
public class CreatePersonCommandValidator : AbstractValidator<CreatePersonCommand>
{
public CreatePersonCommandValidator()
{
RuleFor(x => x.FirstName).NotEmpty().WithMessage("FirstName is required");
RuleFor(x => x.LastName).NotEmpty().WithMessage("LastName is required");
}
}
// Validator for PersonQuery
public class PersonQueryValidator : AbstractValidator<PersonQuery>
{
public PersonQueryValidator()
{
RuleFor(x => x.NameFilter).MaximumLength(100).WithMessage("NameFilter too long");
}
}
public class FluentValidationTests
{
[Fact]
public void AddCommand_WithValidator_RegistersHandlerAndValidator()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>(services);
var provider = services.BuildServiceProvider();
var handler = provider.GetService<ICommandHandler<CreatePersonCommand>>();
var validator = provider.GetService<IValidator<CreatePersonCommand>>();
Assert.NotNull(handler);
Assert.NotNull(validator);
Assert.IsType<CreatePersonCommandHandler>(handler);
Assert.IsType<CreatePersonCommandValidator>(validator);
}
[Fact]
public void AddCommand_WithResultAndValidator_RegistersHandlerAndValidator()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler, CreatePersonCommandValidator>(services);
var provider = services.BuildServiceProvider();
var handler = provider.GetService<ICommandHandler<CreatePersonCommand, CreatePersonResult>>();
var validator = provider.GetService<IValidator<CreatePersonCommand>>();
Assert.NotNull(handler);
Assert.NotNull(validator);
}
[Fact]
public void AddCommand_WithResultAndValidator_RegistersCommandMeta()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler, CreatePersonCommandValidator>(services);
var provider = services.BuildServiceProvider();
var metas = provider.GetServices<ICommandMeta>().ToList();
Assert.Single(metas);
Assert.Equal(typeof(CreatePersonCommand), metas[0].CommandType);
Assert.Equal(typeof(CreatePersonResult), metas[0].CommandResultType);
}
[Fact]
public void AddQuery_WithValidator_RegistersHandlerAndValidator()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler, PersonQueryValidator>(services);
var provider = services.BuildServiceProvider();
var handler = provider.GetService<IQueryHandler<PersonQuery, IEnumerable<string>>>();
var validator = provider.GetService<IValidator<PersonQuery>>();
Assert.NotNull(handler);
Assert.NotNull(validator);
Assert.IsType<PersonQueryHandler>(handler);
Assert.IsType<PersonQueryValidator>(validator);
}
[Fact]
public async Task Validator_RejectsInvalidCommand()
{
var validator = new CreatePersonCommandValidator();
var command = new CreatePersonCommand { FirstName = "", LastName = "" };
var result = await validator.ValidateAsync(command);
Assert.False(result.IsValid);
Assert.Equal(2, result.Errors.Count);
}
[Fact]
public async Task Validator_AcceptsValidCommand()
{
var validator = new CreatePersonCommandValidator();
var command = new CreatePersonCommand { FirstName = "John", LastName = "Doe" };
var result = await validator.ValidateAsync(command);
Assert.True(result.IsValid);
Assert.Empty(result.Errors);
}
[Fact]
public async Task Validator_ResolvedFromDI_WorksCorrectly()
{
var services = new ServiceCollection();
Svrnty.CQRS.FluentValidation.ServiceCollectionExtensions
.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>(services);
var provider = services.BuildServiceProvider();
var validator = provider.GetRequiredService<IValidator<CreatePersonCommand>>();
var invalidResult = await validator.ValidateAsync(new CreatePersonCommand { FirstName = "", LastName = "" });
Assert.False(invalidResult.IsValid);
var validResult = await validator.ValidateAsync(new CreatePersonCommand { FirstName = "Jane", LastName = "Doe" });
Assert.True(validResult.IsValid);
}
[Fact]
public void CqrsBuilder_AddCommand_WithValidator_RegistersBoth()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs(builder =>
{
Svrnty.CQRS.FluentValidation.CqrsBuilderExtensions
.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>(builder);
});
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ICommandHandler<CreatePersonCommand>>());
Assert.NotNull(provider.GetService<IValidator<CreatePersonCommand>>());
}
[Fact]
public void CqrsBuilder_AddCommand_WithResultAndValidator_RegistersBoth()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs(builder =>
{
Svrnty.CQRS.FluentValidation.CqrsBuilderExtensions
.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler, CreatePersonCommandValidator>(builder);
});
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ICommandHandler<CreatePersonCommand, CreatePersonResult>>());
Assert.NotNull(provider.GetService<IValidator<CreatePersonCommand>>());
}
[Fact]
public void CqrsBuilder_AddQuery_WithValidator_RegistersBoth()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs(builder =>
{
Svrnty.CQRS.FluentValidation.CqrsBuilderExtensions
.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler, PersonQueryValidator>(builder);
});
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<IQueryHandler<PersonQuery, IEnumerable<string>>>());
Assert.NotNull(provider.GetService<IValidator<PersonQuery>>());
}
}

View File

@ -0,0 +1 @@
global using Xunit;

View File

@ -0,0 +1,64 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
namespace Svrnty.CQRS.Tests;
public class HandlerTests
{
[Fact]
public async Task CommandHandler_WithResult_ExecutesCorrectly()
{
var services = new ServiceCollection();
services.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<ICommandHandler<CreatePersonCommand, CreatePersonResult>>();
var result = await handler.HandleAsync(new CreatePersonCommand
{
FirstName = "John",
LastName = "Doe"
});
Assert.Equal(1, result.Id);
}
[Fact]
public async Task CommandHandler_WithoutResult_ExecutesWithoutException()
{
var services = new ServiceCollection();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<ICommandHandler<DeletePersonCommand>>();
await handler.HandleAsync(new DeletePersonCommand { Id = 1 });
}
[Fact]
public async Task QueryHandler_ExecutesCorrectly()
{
var services = new ServiceCollection();
services.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<IQueryHandler<PersonQuery, IEnumerable<string>>>();
var result = await handler.HandleAsync(new PersonQuery());
Assert.Equal(["Alice", "Bob"], result);
}
[Fact]
public async Task CommandHandler_SupportsCancellationToken()
{
var services = new ServiceCollection();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<ICommandHandler<DeletePersonCommand>>();
using var cts = new CancellationTokenSource();
await handler.HandleAsync(new DeletePersonCommand { Id = 1 }, cts.Token);
}
}

View File

@ -0,0 +1,124 @@
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.Discovery;
namespace Svrnty.CQRS.Tests;
public class QueryDiscoveryTests
{
private static QueryDiscovery CreateDiscovery(params IQueryMeta[] metas)
{
return new QueryDiscovery(metas);
}
[Fact]
public void GetQueries_ReturnsAllRegistered()
{
var meta1 = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var meta2 = new QueryMeta(typeof(PersonLookupQuery), typeof(object), typeof(string));
var discovery = CreateDiscovery(meta1, meta2);
var queries = discovery.GetQueries().ToList();
Assert.Equal(2, queries.Count);
}
[Fact]
public void GetQueries_ReturnsEmpty_WhenNoneRegistered()
{
var discovery = CreateDiscovery();
var queries = discovery.GetQueries().ToList();
Assert.Empty(queries);
}
[Fact]
public void FindQuery_ByName_ReturnsCorrectMeta()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var discovery = CreateDiscovery(meta);
var found = discovery.FindQuery("Person");
Assert.NotNull(found);
Assert.Equal(typeof(PersonQuery), found.QueryType);
}
[Fact]
public void FindQuery_ByName_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindQuery("NonExistent");
Assert.Null(found);
}
[Fact]
public void FindQuery_ByType_ReturnsCorrectMeta()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var discovery = CreateDiscovery(meta);
var found = discovery.FindQuery(typeof(PersonQuery));
Assert.NotNull(found);
Assert.Equal("Person", found.Name);
}
[Fact]
public void FindQuery_ByType_ReturnsNull_WhenNotFound()
{
var discovery = CreateDiscovery();
var found = discovery.FindQuery(typeof(PersonQuery));
Assert.Null(found);
}
[Fact]
public void QueryExists_ByName_ReturnsTrue_WhenFound()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.QueryExists("Person"));
}
[Fact]
public void QueryExists_ByName_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.QueryExists("Person"));
}
[Fact]
public void QueryExists_ByType_ReturnsTrue_WhenFound()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
var discovery = CreateDiscovery(meta);
Assert.True(discovery.QueryExists(typeof(PersonQuery)));
}
[Fact]
public void QueryExists_ByType_ReturnsFalse_WhenNotFound()
{
var discovery = CreateDiscovery();
Assert.False(discovery.QueryExists(typeof(PersonQuery)));
}
[Fact]
public void FindQuery_WithCustomName_FindsByAttributeName()
{
var meta = new QueryMeta(typeof(PersonLookupQuery), typeof(object), typeof(string));
var discovery = CreateDiscovery(meta);
var found = discovery.FindQuery("customPersonLookup");
Assert.NotNull(found);
Assert.Equal(typeof(PersonLookupQuery), found.QueryType);
}
}

View File

@ -0,0 +1,57 @@
using Svrnty.CQRS.Abstractions.Discovery;
namespace Svrnty.CQRS.Tests;
public class QueryMetaTests
{
[Fact]
public void Name_StripsQuerySuffix()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
Assert.Equal("Person", meta.Name);
}
[Fact]
public void Name_UsesQueryNameAttribute_WhenPresent()
{
var meta = new QueryMeta(typeof(PersonLookupQuery), typeof(object), typeof(string));
Assert.Equal("customPersonLookup", meta.Name);
}
[Fact]
public void LowerCamelCaseName_ConvertsFirstCharToLower()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
Assert.Equal("person", meta.LowerCamelCaseName);
}
[Fact]
public void Category_DefaultsToBasicQuery()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
Assert.Equal("BasicQuery", meta.Category);
}
[Fact]
public void QueryType_IsSetCorrectly()
{
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), typeof(IEnumerable<string>));
Assert.Equal(typeof(PersonQuery), meta.QueryType);
}
[Fact]
public void ServiceType_IsSetCorrectly()
{
var serviceType = typeof(object);
var meta = new QueryMeta(typeof(PersonQuery), serviceType, typeof(IEnumerable<string>));
Assert.Equal(serviceType, meta.ServiceType);
}
[Fact]
public void QueryResultType_IsSetCorrectly()
{
var resultType = typeof(IEnumerable<string>);
var meta = new QueryMeta(typeof(PersonQuery), typeof(object), resultType);
Assert.Equal(resultType, meta.QueryResultType);
}
}

View File

@ -0,0 +1,180 @@
using Microsoft.Extensions.DependencyInjection;
using Svrnty.CQRS.Abstractions;
using Svrnty.CQRS.Abstractions.Discovery;
using Svrnty.CQRS.Discovery;
namespace Svrnty.CQRS.Tests;
public class ServiceRegistrationTests
{
[Fact]
public void AddCommand_WithResult_RegistersHandlerInDI()
{
var services = new ServiceCollection();
services.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetService<ICommandHandler<CreatePersonCommand, CreatePersonResult>>();
Assert.NotNull(handler);
Assert.IsType<CreatePersonCommandHandler>(handler);
}
[Fact]
public void AddCommand_WithResult_RegistersCommandMeta()
{
var services = new ServiceCollection();
services.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var metas = provider.GetServices<ICommandMeta>().ToList();
Assert.Single(metas);
Assert.Equal(typeof(CreatePersonCommand), metas[0].CommandType);
Assert.Equal(typeof(CreatePersonResult), metas[0].CommandResultType);
}
[Fact]
public void AddCommand_WithoutResult_RegistersHandlerInDI()
{
var services = new ServiceCollection();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetService<ICommandHandler<DeletePersonCommand>>();
Assert.NotNull(handler);
Assert.IsType<DeletePersonCommandHandler>(handler);
}
[Fact]
public void AddCommand_WithoutResult_RegistersCommandMeta()
{
var services = new ServiceCollection();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
var provider = services.BuildServiceProvider();
var metas = provider.GetServices<ICommandMeta>().ToList();
Assert.Single(metas);
Assert.Equal(typeof(DeletePersonCommand), metas[0].CommandType);
Assert.Null(metas[0].CommandResultType);
}
[Fact]
public void AddQuery_RegistersHandlerInDI()
{
var services = new ServiceCollection();
services.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
var provider = services.BuildServiceProvider();
var handler = provider.GetService<IQueryHandler<PersonQuery, IEnumerable<string>>>();
Assert.NotNull(handler);
Assert.IsType<PersonQueryHandler>(handler);
}
[Fact]
public void AddQuery_RegistersQueryMeta()
{
var services = new ServiceCollection();
services.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
var provider = services.BuildServiceProvider();
var metas = provider.GetServices<IQueryMeta>().ToList();
Assert.Single(metas);
Assert.Equal(typeof(PersonQuery), metas[0].QueryType);
Assert.Equal(typeof(IEnumerable<string>), metas[0].QueryResultType);
}
[Fact]
public void AddDefaultCommandDiscovery_RegistersCommandDiscovery()
{
var services = new ServiceCollection();
services.AddDefaultCommandDiscovery();
var provider = services.BuildServiceProvider();
var discovery = provider.GetService<ICommandDiscovery>();
Assert.NotNull(discovery);
Assert.IsType<CommandDiscovery>(discovery);
}
[Fact]
public void AddDefaultQueryDiscovery_RegistersQueryDiscovery()
{
var services = new ServiceCollection();
services.AddDefaultQueryDiscovery();
var provider = services.BuildServiceProvider();
var discovery = provider.GetService<IQueryDiscovery>();
Assert.NotNull(discovery);
Assert.IsType<QueryDiscovery>(discovery);
}
[Fact]
public void FullPipeline_DiscoveryFindsRegisteredCommands()
{
var services = new ServiceCollection();
services.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>();
services.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>();
services.AddDefaultCommandDiscovery();
var provider = services.BuildServiceProvider();
var discovery = provider.GetRequiredService<ICommandDiscovery>();
Assert.Equal(2, discovery.GetCommands().Count());
Assert.True(discovery.CommandExists("CreatePerson"));
Assert.True(discovery.CommandExists("DeletePerson"));
}
[Fact]
public void FullPipeline_DiscoveryFindsRegisteredQueries()
{
var services = new ServiceCollection();
services.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
services.AddQuery<PersonLookupQuery, string, PersonLookupQueryHandler>();
services.AddDefaultQueryDiscovery();
var provider = services.BuildServiceProvider();
var discovery = provider.GetRequiredService<IQueryDiscovery>();
Assert.Equal(2, discovery.GetQueries().Count());
Assert.True(discovery.QueryExists("Person"));
Assert.True(discovery.QueryExists("customPersonLookup"));
}
[Fact]
public void AddSvrntyCqrs_RegistersDiscoveryServices()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs();
var provider = services.BuildServiceProvider();
Assert.NotNull(provider.GetService<ICommandDiscovery>());
Assert.NotNull(provider.GetService<IQueryDiscovery>());
}
[Fact]
public void AddSvrntyCqrs_WithFluentBuilder_RegistersCommandsAndQueries()
{
var services = new ServiceCollection();
services.AddSvrntyCqrs(builder =>
{
builder
.AddCommand<CreatePersonCommand, CreatePersonResult, CreatePersonCommandHandler>()
.AddCommand<DeletePersonCommand, DeletePersonCommandHandler>()
.AddQuery<PersonQuery, IEnumerable<string>, PersonQueryHandler>();
});
var provider = services.BuildServiceProvider();
var cmdDiscovery = provider.GetRequiredService<ICommandDiscovery>();
var qryDiscovery = provider.GetRequiredService<IQueryDiscovery>();
Assert.Equal(2, cmdDiscovery.GetCommands().Count());
Assert.Single(qryDiscovery.GetQueries());
}
}

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-*" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Svrnty.CQRS\Svrnty.CQRS.csproj" />
<ProjectReference Include="..\..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
<ProjectReference Include="..\..\Svrnty.CQRS.FluentValidation\Svrnty.CQRS.FluentValidation.csproj" />
<ProjectReference Include="..\..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
<ProjectReference Include="..\..\Svrnty.CQRS.DynamicQuery.Abstractions\Svrnty.CQRS.DynamicQuery.Abstractions.csproj" />
</ItemGroup>
</Project>