Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55f1324286 | |||
|
|
b34bf874b4 | ||
|
|
c6de10b98b | ||
|
|
3945c1a158 | ||
| 7614f68512 | |||
|
|
fdee02c960 | ||
|
|
a4525bad6a |
@ -1,50 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet clean:*)",
|
||||
"Bash(dotnet run)",
|
||||
"Bash(dotnet add:*)",
|
||||
"Bash(timeout 5 dotnet run:*)",
|
||||
"Bash(dotnet remove:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"WebSearch",
|
||||
"Bash(dotnet tool install:*)",
|
||||
"Bash(protogen:*)",
|
||||
"Bash(timeout 15 dotnet run:*)",
|
||||
"Bash(where:*)",
|
||||
"Bash(timeout 30 dotnet run:*)",
|
||||
"Bash(timeout 60 dotnet run:*)",
|
||||
"Bash(timeout 120 dotnet run:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(timeout 3 cmd:*)",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dotnet --list-sdks:*)",
|
||||
"Bash(dotnet sln:*)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(grpcurl:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(xargs kill -9)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(dotnet pack:*)",
|
||||
"Bash(unzip:*)",
|
||||
"WebFetch(domain:andrewlock.net)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:stackoverflow.com)",
|
||||
"WebFetch(domain:www.kenmuse.com)",
|
||||
"WebFetch(domain:blog.rsuter.com)",
|
||||
"WebFetch(domain:natemcmaster.com)",
|
||||
"WebFetch(domain:www.nuget.org)",
|
||||
"Bash(mkdir:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
265
.editorconfig
265
.editorconfig
@ -1,7 +1,5 @@
|
||||
# Top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# All files
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
@ -10,252 +8,89 @@ charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
# XML project files
|
||||
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
|
||||
[*.{csproj,props,targets,xml}]
|
||||
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]
|
||||
# Namespace
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
|
||||
#### Core EditorConfig Options ####
|
||||
# Braces — Allman style
|
||||
csharp_new_line_before_open_brace = all
|
||||
|
||||
# Indentation and spacing
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
tab_width = 4
|
||||
|
||||
#### .NET Coding Conventions ####
|
||||
|
||||
# Organise usings
|
||||
# Usings
|
||||
dotnet_sort_system_directives_first = true
|
||||
dotnet_separate_import_directive_groups = false
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
|
||||
# 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
|
||||
# var preferences — use var when type is apparent
|
||||
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
|
||||
# Expression bodies — prefer for simple members
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_constructors = false:suggestion
|
||||
csharp_style_expression_bodied_operators = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_properties = true:suggestion
|
||||
csharp_style_expression_bodied_accessors = true:suggestion
|
||||
csharp_style_expression_bodied_lambdas = true:suggestion
|
||||
|
||||
# Pattern matching preferences
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
# Pattern matching
|
||||
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
|
||||
csharp_style_pattern_matching_over_as_with_null_check = 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
|
||||
# Null checking
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
csharp_style_conditional_delegate_call = true:suggestion
|
||||
|
||||
# 'using' directive preferences
|
||||
csharp_using_directive_placement = outside_namespace:suggestion
|
||||
# Modifier preferences — exclude interface members (netstandard2.1 compat)
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||
|
||||
# Namespace preferences
|
||||
csharp_style_namespace_declarations = file_scoped:suggestion
|
||||
# Field naming — _camelCase for private fields
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_underscore
|
||||
|
||||
#### C# Formatting Rules ####
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected
|
||||
dotnet_naming_symbols.private_fields.required_modifiers =
|
||||
|
||||
# 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
|
||||
dotnet_naming_style.camel_case_underscore.required_prefix = _
|
||||
dotnet_naming_style.camel_case_underscore.capitalization = camel_case
|
||||
|
||||
# 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
|
||||
# Constants — PascalCase
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
|
||||
dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case
|
||||
|
||||
# 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_symbols.constants.applicable_kinds = field
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
# Interfaces — I prefix
|
||||
dotnet_naming_rule.interfaces_should_begin_with_i.severity = warning
|
||||
dotnet_naming_rule.interfaces_should_begin_with_i.symbols = interfaces
|
||||
dotnet_naming_rule.interfaces_should_begin_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_symbols.interfaces.applicable_kinds = interface
|
||||
|
||||
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
|
||||
# Async methods — Async suffix
|
||||
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_symbols.async_methods.applicable_kinds = method
|
||||
dotnet_naming_symbols.async_methods.required_modifiers = async
|
||||
|
||||
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
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
# 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
35
.github/dependabot.yml
vendored
@ -1,35 +0,0 @@
|
||||
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
37
.github/workflows/ci.yml
vendored
@ -1,37 +0,0 @@
|
||||
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
47
.github/workflows/codeql.yml
vendored
@ -1,47 +0,0 @@
|
||||
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
86
.github/workflows/release.yml
vendored
@ -1,86 +0,0 @@
|
||||
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
34
.github/workflows/security.yml
vendored
@ -1,34 +0,0 @@
|
||||
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
12
.gitignore
vendored
@ -4,7 +4,6 @@
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
.research/
|
||||
.DS_Store
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
@ -340,13 +339,4 @@ ASALocalRun/
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Secrets and credentials
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
*.key
|
||||
secrets/
|
||||
.aws/
|
||||
credentials.json
|
||||
healthchecksdb
|
||||
@ -1,14 +0,0 @@
|
||||
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
22
CHANGELOG.md
@ -1,22 +0,0 @@
|
||||
# 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
438
CLAUDE.md
@ -1,47 +1,413 @@
|
||||
# Development Guidelines
|
||||
# CLAUDE.md
|
||||
|
||||
> **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.
|
||||
This file provides guidance to AI agents when working with code in this repository.
|
||||
|
||||
## Quick Reference
|
||||
## Project Overview
|
||||
|
||||
- **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
|
||||
This is Svrnty.CQRS, a modern implementation of Command Query Responsibility Segregation (CQRS) for .NET 10. It was forked from PoweredSoft.CQRS and provides:
|
||||
|
||||
## Tech Stack
|
||||
- CQRS pattern implementation with command/query handlers exposed via HTTP or gRPC
|
||||
- Automatic HTTP endpoint generation via Minimal API
|
||||
- Automatic gRPC endpoint generation with source generators and Google Rich Error Model validation
|
||||
- Dynamic query capabilities (filtering, sorting, grouping, aggregation)
|
||||
- FluentValidation support with RFC 7807 Problem Details (HTTP) and Google Rich Error Model (gRPC)
|
||||
- AOT (Ahead-of-Time) compilation compatibility for core packages (where dependencies allow)
|
||||
|
||||
| Tool | Version |
|
||||
|------|---------|
|
||||
| C# | 14 |
|
||||
| .NET | 10.0 |
|
||||
| AOT | enabled (IsAotCompatible=true) |
|
||||
| Nullable | enabled |
|
||||
## Solution Structure
|
||||
|
||||
## Commands
|
||||
The solution contains 11 projects organized by responsibility (10 packages + 1 sample project):
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `dotnet build` | Build all 18 projects |
|
||||
| `dotnet test` | Run tests |
|
||||
| `dotnet format` | Format code |
|
||||
**Abstractions (interfaces and contracts only):**
|
||||
- `Svrnty.CQRS.Abstractions` - Core interfaces (ICommandHandler, IQueryHandler, discovery contracts)
|
||||
- `Svrnty.CQRS.DynamicQuery.Abstractions` - Dynamic query interfaces (multi-targets netstandard2.1 and net10.0)
|
||||
- `Svrnty.CQRS.Grpc.Abstractions` - gRPC-specific interfaces and contracts
|
||||
|
||||
## Key Dependencies
|
||||
**Implementation:**
|
||||
- `Svrnty.CQRS` - Core discovery and registration logic
|
||||
- `Svrnty.CQRS.MinimalApi` - Minimal API endpoint mapping for commands/queries (recommended for HTTP)
|
||||
- `Svrnty.CQRS.DynamicQuery` - PoweredSoft.DynamicQuery integration for advanced filtering
|
||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi` - Minimal API endpoint mapping for dynamic queries
|
||||
- `Svrnty.CQRS.FluentValidation` - Validation integration helpers
|
||||
- `Svrnty.CQRS.Grpc` - gRPC service implementation support
|
||||
- `Svrnty.CQRS.Grpc.Generators` - Source generator for .proto files and gRPC service implementations
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| Svrnty.CQRS.Core | Core CQRS abstractions |
|
||||
| Svrnty.CQRS.DynamicQuery | Dynamic query support |
|
||||
| Svrnty.CQRS.gRPC | gRPC transport |
|
||||
| Svrnty.CQRS.Events | Event sourcing |
|
||||
| Svrnty.CQRS.Sagas | Saga orchestration |
|
||||
| Svrnty.CQRS.Notifications | Notification handlers |
|
||||
| Svrnty.CQRS.MinimalApi | Minimal API bindings |
|
||||
**Sample Projects:**
|
||||
- `Svrnty.Sample` - Comprehensive demo project showcasing both HTTP and gRPC endpoints
|
||||
|
||||
## Repo-Specific Notes
|
||||
**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.
|
||||
|
||||
- Solution file: `Svrnty.CQRS.sln` with 18 projects.
|
||||
- Lint is handled by .NET analyzers — AOT compatibility and nullable reference types are enforced.
|
||||
- No Docker or proto files in this repo.
|
||||
- Published under the `svrnty` org (git.openharbor.io/svrnty), not `a-gent`.
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Restore dependencies
|
||||
dotnet restore
|
||||
|
||||
# Build entire solution
|
||||
dotnet build
|
||||
|
||||
# Build in Release mode
|
||||
dotnet build -c Release
|
||||
|
||||
# Create NuGet packages (with version)
|
||||
dotnet pack -c Release -o ./artifacts -p:Version=1.0.0
|
||||
|
||||
# Build specific project
|
||||
dotnet build Svrnty.CQRS/Svrnty.CQRS.csproj
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This repository does not currently contain test projects. When adding tests:
|
||||
- Place them in a `tests/` directory or alongside source projects
|
||||
- Name them with `.Tests` suffix (e.g., `Svrnty.CQRS.Tests`)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core CQRS Pattern
|
||||
|
||||
The framework uses handler interfaces that follow this pattern:
|
||||
|
||||
```csharp
|
||||
// Command with no result
|
||||
ICommandHandler<TCommand>
|
||||
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default)
|
||||
|
||||
// Command with result
|
||||
ICommandHandler<TCommand, TResult>
|
||||
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default)
|
||||
|
||||
// Query (always returns result)
|
||||
IQueryHandler<TQuery, TResult>
|
||||
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default)
|
||||
```
|
||||
|
||||
### Metadata-Driven Discovery
|
||||
|
||||
The framework uses a **metadata pattern** for runtime discovery:
|
||||
|
||||
1. When you register a handler using `services.AddCommand<TCommand, THandler>()`, it:
|
||||
- Registers the handler in DI as `ICommandHandler<TCommand, THandler>`
|
||||
- Creates metadata (`ICommandMeta`) describing the command type, handler type, and result type
|
||||
- Stores metadata as singleton in DI
|
||||
|
||||
2. Discovery services (`ICommandDiscovery`, `IQueryDiscovery`) implemented in `Svrnty.CQRS`:
|
||||
- Query all registered metadata from DI container
|
||||
- Provide lookup methods: `GetCommand(string name)`, `GetCommands()`, etc.
|
||||
|
||||
3. Endpoint mapping (HTTP and gRPC) uses discovery to:
|
||||
- Enumerate all registered commands/queries
|
||||
- Dynamically generate endpoints at application startup
|
||||
- Apply naming conventions (convert to lowerCamelCase)
|
||||
- Generate gRPC service implementations via source generators
|
||||
|
||||
**Key Files:**
|
||||
- `Svrnty.CQRS.Abstractions/Discovery/` - Metadata interfaces
|
||||
- `Svrnty.CQRS/Discovery/` - Discovery implementations
|
||||
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint generation
|
||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - Dynamic query endpoint generation
|
||||
- `Svrnty.CQRS.Grpc.Generators/` - gRPC service generation via source generators
|
||||
|
||||
### Integration Options
|
||||
|
||||
There are two primary integration options for exposing commands and queries:
|
||||
|
||||
#### Option 1: gRPC (Recommended for performance-critical scenarios)
|
||||
|
||||
The **Svrnty.CQRS.Grpc** package with **Svrnty.CQRS.Grpc.Generators** source generator provides high-performance gRPC endpoints:
|
||||
|
||||
**Registration:**
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register CQRS services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
|
||||
// Add your commands and queries
|
||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
|
||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||
|
||||
// Add gRPC support
|
||||
builder.Services.AddGrpc();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map auto-generated gRPC service implementations
|
||||
app.MapGrpcService<CommandServiceImpl>();
|
||||
app.MapGrpcService<QueryServiceImpl>();
|
||||
|
||||
// Enable gRPC reflection for tools like grpcurl
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
1. Define `.proto` files in `Protos/` directory with your commands/queries as messages
|
||||
2. Source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
|
||||
3. Property names in C# commands must match proto field names (case-insensitive)
|
||||
4. FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
||||
5. Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
||||
|
||||
**Features:**
|
||||
- High-performance binary protocol
|
||||
- Automatic service implementation generation at compile time
|
||||
- Google Rich Error Model for structured validation errors
|
||||
- Full FluentValidation integration
|
||||
- gRPC reflection support for development tools
|
||||
- Suitable for microservices, internal APIs, and low-latency scenarios
|
||||
|
||||
**Key Files:**
|
||||
- `Svrnty.CQRS.Grpc/` - Runtime support for gRPC services
|
||||
- `Svrnty.CQRS.Grpc.Generators/` - Source generator for service implementations
|
||||
|
||||
#### Option 2: HTTP via Minimal API (Recommended for web/browser scenarios)
|
||||
|
||||
The **Svrnty.CQRS.MinimalApi** package provides HTTP endpoints for CQRS commands and queries:
|
||||
|
||||
**Registration:**
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register CQRS services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
|
||||
// Add your commands and queries
|
||||
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
|
||||
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
||||
|
||||
// Add Swagger (optional)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map endpoints (this creates routes automatically)
|
||||
app.MapSvrntyCommands(); // Maps all commands to POST /api/command/{name}
|
||||
app.MapSvrntyQueries(); // Maps all queries to POST/GET /api/query/{name}
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
1. Extension methods iterate through `ICommandDiscovery` and `IQueryDiscovery`
|
||||
2. For each command/query, creates Minimal API endpoints using `MapPost()`/`MapGet()`
|
||||
3. Applies naming conventions (lowerCamelCase)
|
||||
4. Respects `[CommandControllerIgnore]` and `[QueryControllerIgnore]` attributes
|
||||
5. Integrates with `ICommandAuthorizationService` and `IQueryAuthorizationService`
|
||||
6. Supports OpenAPI/Swagger documentation
|
||||
|
||||
**Features:**
|
||||
- Queries support both POST (with JSON body) and GET (with query string parameters)
|
||||
- Commands only support POST with JSON body
|
||||
- Authorization via authorization services (returns 401/403 status codes)
|
||||
- Customizable route prefixes: `MapSvrntyCommands("my-prefix")`
|
||||
- Automatic OpenAPI tags: "Commands" and "Queries"
|
||||
- RFC 7807 Problem Details for validation errors
|
||||
- Full Swagger/OpenAPI support
|
||||
|
||||
**Key Files:**
|
||||
- `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs` - Main implementation
|
||||
|
||||
#### Option 3: Both gRPC and HTTP (Dual Protocol Support)
|
||||
|
||||
You can enable both protocols simultaneously, allowing clients to choose their preferred protocol:
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register CQRS services
|
||||
builder.Services.AddSvrntyCQRS();
|
||||
builder.Services.AddDefaultCommandDiscovery();
|
||||
builder.Services.AddDefaultQueryDiscovery();
|
||||
|
||||
// Add commands and queries
|
||||
AddCommands(builder.Services);
|
||||
AddQueries(builder.Services);
|
||||
|
||||
// Add both gRPC and HTTP support
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map both gRPC and HTTP endpoints
|
||||
app.MapGrpcService<CommandServiceImpl>();
|
||||
app.MapGrpcService<QueryServiceImpl>();
|
||||
app.MapGrpcReflectionService();
|
||||
|
||||
app.MapSvrntyCommands();
|
||||
app.MapSvrntyQueries();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single codebase supports multiple protocols
|
||||
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
|
||||
- HTTP for web browsers, legacy clients, and public APIs
|
||||
- Same commands, queries, and validation logic for both protocols
|
||||
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
||||
|
||||
### Dynamic Query System
|
||||
|
||||
Dynamic queries provide OData-like filtering capabilities:
|
||||
|
||||
**Core Components:**
|
||||
- `IDynamicQuery<TSource, TDestination>` - Interface with GetFilters(), GetSorts(), GetGroups(), GetAggregates()
|
||||
- `IQueryableProvider<TSource>` - Provides base IQueryable to query against
|
||||
- `IAlterQueryableService<TSource, TDestination>` - Middleware to modify queries (e.g., security filters)
|
||||
- `DynamicQueryHandler<TSource, TDestination>` - Executes queries using PoweredSoft.DynamicQuery
|
||||
|
||||
**Request Flow:**
|
||||
1. HTTP request with filters/sorts/aggregates
|
||||
2. Minimal API endpoint receives request
|
||||
3. DynamicQueryHandler gets base queryable from IQueryableProvider
|
||||
4. Applies alterations from all registered IAlterQueryableService instances
|
||||
5. Builds PoweredSoft query criteria
|
||||
6. Executes and returns IQueryExecutionResult
|
||||
|
||||
**Registration Example:**
|
||||
```csharp
|
||||
// Register dynamic query
|
||||
services.AddDynamicQuery<Person, PersonDto>()
|
||||
.AddDynamicQueryWithProvider<Person, PersonQueryableProvider>()
|
||||
.AddAlterQueryable<Person, PersonDto, SecurityFilter>();
|
||||
|
||||
// Map dynamic query endpoints
|
||||
app.MapSvrntyDynamicQueries(); // Creates POST/GET /api/query/{queryName} endpoints
|
||||
```
|
||||
|
||||
**Key Files:**
|
||||
- `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs` - Query execution logic
|
||||
- `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs` - HTTP endpoint mapping
|
||||
|
||||
## Package Configuration
|
||||
|
||||
All projects target .NET 10.0 and use C# 14, sharing common configuration:
|
||||
|
||||
- **Target Framework**: `net10.0` (except DynamicQuery.Abstractions which multi-targets `netstandard2.1;net10.0`)
|
||||
- **Language Version**: C# 14
|
||||
- **IsAotCompatible**: Currently set but not enforced (many dependencies are not AOT-compatible yet)
|
||||
- **Symbols**: Portable debug symbols with source, published as `.snupkg`
|
||||
- **NuGet metadata**: Icon, README, license (MIT), and repository URL included in packages
|
||||
- **Authors**: David Lebee, Mathias Beaulieu-Duncan
|
||||
- **Repository**: https://git.openharbor.io/svrnty/dotnet-cqrs
|
||||
|
||||
### Package Dependencies
|
||||
|
||||
**Core Dependencies:**
|
||||
- **Microsoft.Extensions.DependencyInjection.Abstractions**: 10.0.0
|
||||
- **FluentValidation**: 11.11.0
|
||||
- **PoweredSoft.DynamicQuery**: 3.0.1
|
||||
- **Pluralize.NET**: 1.0.2
|
||||
|
||||
**gRPC Dependencies (for Svrnty.CQRS.Grpc):**
|
||||
- **Grpc.AspNetCore**: 2.68.0 or later
|
||||
- **Grpc.AspNetCore.Server.Reflection**: 2.71.0 or later (optional, for reflection)
|
||||
- **Grpc.StatusProto**: 2.71.0 or later (for Rich Error Model validation)
|
||||
- **Grpc.Tools**: 2.76.0 or later (for .proto compilation)
|
||||
|
||||
**Source Generator Dependencies (for Svrnty.CQRS.Grpc.Generators):**
|
||||
- **Microsoft.CodeAnalysis.CSharp**: 5.0.0-2.final
|
||||
- **Microsoft.CodeAnalysis.Analyzers**: 3.11.0
|
||||
- **Microsoft.Build.Utilities.Core**: 17.0.0
|
||||
- Targets: netstandard2.0 (for Roslyn compatibility)
|
||||
|
||||
## Publishing
|
||||
|
||||
NuGet packages are published automatically via GitHub Actions when a release is created:
|
||||
|
||||
**Workflow:** `.github/workflows/publish-nugets.yml`
|
||||
1. Triggered on release publication
|
||||
2. Extracts version from release tag
|
||||
3. Runs `dotnet pack -c Release -p:Version={tag}`
|
||||
4. Pushes to NuGet.org using `NUGET_API_KEY` secret
|
||||
|
||||
**Manual publish:**
|
||||
```bash
|
||||
# Create packages with specific version
|
||||
dotnet pack -c Release -o ./artifacts -p:Version=1.2.3
|
||||
|
||||
# Push to NuGet
|
||||
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key YOUR_KEY
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
**Adding a New Command/Query Handler:**
|
||||
|
||||
1. Create command/query POCO in consumer project
|
||||
2. Implement handler: `ICommandHandler<TCommand, TResult>`
|
||||
3. Register in DI: `services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>()`
|
||||
4. (Optional) Add validator: `services.AddTransient<IValidator<CreatePersonCommand>, Validator>()`
|
||||
5. Controller endpoint is automatically generated
|
||||
|
||||
**Adding a New Feature to Framework:**
|
||||
|
||||
1. Add interface to appropriate Abstractions project
|
||||
2. Implement in corresponding implementation project
|
||||
3. Update ServiceCollectionExtensions with registration method
|
||||
4. Ensure all projects maintain AOT compatibility (unless AspNetCore-specific)
|
||||
5. Update package version and release notes
|
||||
|
||||
**Naming Conventions:**
|
||||
|
||||
- Commands/Queries: Use `[CommandName]` or `[QueryName]` attribute for custom names
|
||||
- Default naming: Strips "Command"/"Query" suffix, converts to lowerCamelCase
|
||||
- Example: `CreatePersonCommand` -> `createPerson` endpoint
|
||||
|
||||
## C# 14 Language Features
|
||||
|
||||
The project now uses C# 14, which introduces several new features. Be aware of these breaking changes:
|
||||
|
||||
**Potential Breaking Changes:**
|
||||
- **`field` keyword**: New contextual keyword in property accessors for implicit backing fields
|
||||
- **`extension` keyword**: Reserved for extension containers; use `@extension` for identifiers
|
||||
- **`partial` return type**: Cannot use `partial` as return type without escaping
|
||||
- **Span<T> overload resolution**: New implicit conversions may select different overloads
|
||||
- **`scoped` as lambda modifier**: Always treated as modifier in lambda parameters
|
||||
|
||||
**New Features Available:**
|
||||
- Extension members (static extension members and extension properties)
|
||||
- Implicit span conversions
|
||||
- Unbound generic types with `nameof`
|
||||
- Lambda parameter modifiers without type specification
|
||||
- Partial instance constructors and events
|
||||
- Null-conditional assignment (`?.=` and `?[]=`)
|
||||
|
||||
The codebase currently compiles without warnings on C# 14.
|
||||
|
||||
## Important Implementation Notes
|
||||
|
||||
1. **AOT Compatibility**: Currently not enforced. The `IsAotCompatible` property is set on some projects but many dependencies (including FluentValidation, PoweredSoft.DynamicQuery) are not AOT-compatible. Future work may address this.
|
||||
|
||||
2. **Async Everywhere**: All handlers are async. Always support CancellationToken.
|
||||
|
||||
3. **Generic Type Safety**: Framework relies heavily on generics for compile-time safety. When adding features, maintain strong typing.
|
||||
|
||||
4. **Metadata Pattern**: When extending discovery, always create corresponding metadata classes (implement ICommandMeta/IQueryMeta).
|
||||
|
||||
5. **Endpoint Mapping Timing**: Endpoints are mapped at application startup. Discovery services must be registered before calling `MapSvrntyCommands()`/`MapSvrntyQueries()` or mapping gRPC services.
|
||||
|
||||
6. **FluentValidation Integration**:
|
||||
- For HTTP: Validation happens automatically in the Minimal API pipeline. Errors return RFC 7807 Problem Details.
|
||||
- For gRPC: Validation happens automatically via source-generated services. Errors return Google Rich Error Model with structured FieldViolations.
|
||||
- The framework REGISTERS validators in DI; actual validation execution is handled by the endpoint implementations.
|
||||
|
||||
7. **DynamicQuery Interceptors**: Support up to 5 interceptors per query type. Interceptors modify PoweredSoft DynamicQuery behavior.
|
||||
|
||||
## Common Code Locations
|
||||
|
||||
- Handler interfaces: `Svrnty.CQRS.Abstractions/ICommandHandler.cs`, `IQueryHandler.cs`
|
||||
- Discovery implementations: `Svrnty.CQRS/Discovery/`
|
||||
- Service registration: `*/ServiceCollectionExtensions.cs` in each project
|
||||
- HTTP endpoint mapping: `Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs`
|
||||
- Dynamic query logic: `Svrnty.CQRS.DynamicQuery/DynamicQueryHandler.cs`
|
||||
- Dynamic query endpoints: `Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs`
|
||||
- gRPC support: `Svrnty.CQRS.Grpc/` runtime, `Svrnty.CQRS.Grpc.Generators/` source generators
|
||||
- Sample application: `Svrnty.Sample/` - demonstrates both HTTP and gRPC integration
|
||||
|
||||
@ -1,52 +0,0 @@
|
||||
# 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.
|
||||
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 svrnty
|
||||
Copyright (c) 2021 Powered Softwares Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
303
README.md
303
README.md
@ -1,81 +1,282 @@
|
||||
# Svrnty.CQRS
|
||||
> 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
|
||||
|
||||
> Modern CQRS framework for .NET with gRPC source generation and HTTP Minimal API support.
|
||||
# CQRS
|
||||
|
||||
Our implementation of query and command responsibility segregation (CQRS).
|
||||
|
||||
## Where This Fits
|
||||
|
||||
**Layer**: libs
|
||||
This is a backend framework of the [Svrnty Agent System](../README.md).
|
||||
|
||||
**Layer**: Framework
|
||||
**Depends on**: Nothing (standalone .NET framework)
|
||||
**Depended on by**: a-gent-app (backend services), flutter-cqrs-datasource (client)
|
||||
**Git**: git.openharbor.io/svrnty/dotnet-cqrs.git
|
||||
**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)
|
||||
|
||||
## Tech Stack
|
||||
## Getting Started
|
||||
|
||||
- **Language**: C# 14 / .NET 10
|
||||
- **Framework**: ASP.NET Core Minimal API, gRPC
|
||||
- **Key Dependencies**: FluentValidation 11.x, Grpc.AspNetCore, PoweredSoft.DynamicQuery
|
||||
> Install nuget package to your awesome project.
|
||||
|
||||
## Quick Start
|
||||
| Package Name | NuGet | NuGet Install |
|
||||
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
|
||||
| Svrnty.CQRS | [](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
|
||||
| Svrnty.CQRS.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
|
||||
| Svrnty.CQRS.FluentValidation | [](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
|
||||
| Svrnty.CQRS.DynamicQuery | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
|
||||
| Svrnty.CQRS.DynamicQuery.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.MinimalApi/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi ``` |
|
||||
| Svrnty.CQRS.Grpc | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
|
||||
| Svrnty.CQRS.Grpc.Generators | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build
|
||||
> Abstractions Packages.
|
||||
|
||||
# Run
|
||||
dotnet run --project Svrnty.Sample
|
||||
| Package Name | NuGet | NuGet Install |
|
||||
| ---------------------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -----------------------------------------------------: |
|
||||
| Svrnty.CQRS.Abstractions | [](https://www.nuget.org/packages/Svrnty.CQRS.Abstractions/) | ```dotnet add package Svrnty.CQRS.Abstractions ``` |
|
||||
| Svrnty.CQRS.DynamicQuery.Abstractions | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.Abstractions/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.Abstractions ``` |
|
||||
| Svrnty.CQRS.Grpc.Abstractions | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Abstractions/) | ```dotnet add package Svrnty.CQRS.Grpc.Abstractions ``` |
|
||||
|
||||
# Test
|
||||
dotnet test
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
18 NuGet packages organized by concern:
|
||||
|
||||
- **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)
|
||||
|
||||
See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for a full dependency diagram and data flow.
|
||||
|
||||
## Configuration
|
||||
## Sample of startup code for gRPC (Recommended)
|
||||
|
||||
```csharp
|
||||
// Register handlers
|
||||
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
||||
builder.Services.AddQuery<GetUserQuery, User, GetUserQueryHandler>();
|
||||
using Svrnty.CQRS;
|
||||
using Svrnty.CQRS.FluentValidation;
|
||||
using Svrnty.CQRS.Grpc;
|
||||
|
||||
// Configure CQRS with gRPC + HTTP
|
||||
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 =>
|
||||
{
|
||||
cqrs.AddGrpc(grpc => grpc.EnableReflection());
|
||||
// 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
|
||||
dotnet add package Grpc.AspNetCore
|
||||
dotnet add package Grpc.AspNetCore.Server.Reflection
|
||||
dotnet add package Grpc.StatusProto # For Rich Error Model validation
|
||||
```
|
||||
|
||||
#### 2. Add the source generator as an analyzer:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
#### 3. Define your C# commands and queries:
|
||||
|
||||
```csharp
|
||||
public record AddUserCommand
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Email { get; init; }
|
||||
public int Age { get; init; }
|
||||
}
|
||||
|
||||
public record RemoveUserCommand
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- The source generator automatically creates:
|
||||
- `.proto` files in the `Protos/` directory from your C# commands and queries
|
||||
- `CommandServiceImpl` and `QueryServiceImpl` implementations
|
||||
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
||||
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
||||
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
|
||||
- No need for protobuf-net attributes - just define your C# types
|
||||
|
||||
## Sample of startup code for Minimal API (HTTP)
|
||||
|
||||
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS;
|
||||
using Svrnty.CQRS.FluentValidation;
|
||||
using Svrnty.CQRS.MinimalApi;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register your commands with validators
|
||||
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
||||
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
||||
|
||||
// Register your queries
|
||||
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
||||
|
||||
// Configure CQRS with Minimal API support
|
||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||
{
|
||||
// Enable Minimal API endpoints
|
||||
cqrs.AddMinimalApi();
|
||||
});
|
||||
|
||||
// Add Swagger (optional)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// Map all configured CQRS endpoints (automatically creates POST /api/command/* and POST/GET /api/query/*)
|
||||
app.UseSvrntyCqrs();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
## Documentation
|
||||
**Notes:**
|
||||
- FluentValidation is automatically integrated with **RFC 7807 Problem Details** for structured validation errors
|
||||
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
|
||||
- Supports both POST and GET (for queries) endpoints
|
||||
- Automatically generates Swagger/OpenAPI documentation
|
||||
|
||||
- [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
|
||||
## Sample enabling both gRPC and HTTP
|
||||
|
||||
## Related Libraries
|
||||
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
|
||||
|
||||
- **[flutter_cqrs_datasource](https://git.openharbor.io/svrnty/flutter_cqrs_datasource)** -- Flutter/Dart counterpart for consuming Svrnty.CQRS services from mobile and desktop apps
|
||||
```csharp
|
||||
using Svrnty.CQRS;
|
||||
using Svrnty.CQRS.FluentValidation;
|
||||
using Svrnty.CQRS.Grpc;
|
||||
using Svrnty.CQRS.MinimalApi;
|
||||
|
||||
## Contributing
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for development guidelines.
|
||||
// Register your commands with validators
|
||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||
|
||||
## License
|
||||
// Register your queries
|
||||
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||
|
||||
MIT OR Apache-2.0
|
||||
// Configure CQRS with both gRPC and Minimal API support
|
||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||
{
|
||||
// Enable gRPC endpoints with reflection
|
||||
cqrs.AddGrpc(grpc =>
|
||||
{
|
||||
grpc.EnableReflection();
|
||||
});
|
||||
|
||||
// Enable Minimal API endpoints
|
||||
cqrs.AddMinimalApi();
|
||||
});
|
||||
|
||||
// Add HTTP support with Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// Map all configured CQRS endpoints (both gRPC and HTTP)
|
||||
app.UseSvrntyCqrs();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single codebase supports multiple protocols
|
||||
- gRPC for high-performance, low-latency scenarios (microservices, internal APIs)
|
||||
- HTTP for web browsers, legacy clients, and public APIs
|
||||
- Same commands, queries, and validation logic for both protocols
|
||||
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
||||
|
||||
# Fluent Validation
|
||||
|
||||
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
|
||||
|
||||
## With Svrnty.CQRS.FluentValidation (Recommended)
|
||||
|
||||
The package exposes extension method overloads that accept the validator as a generic parameter:
|
||||
|
||||
```bash
|
||||
dotnet add package Svrnty.CQRS.FluentValidation
|
||||
```
|
||||
|
||||
```csharp
|
||||
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
|
||||
|
||||
// Command with result - validator as last generic parameter
|
||||
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
||||
|
||||
// Command without result - validator included in generics
|
||||
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Single line registration** - Handler and validator registered together
|
||||
- **Type safety** - Compiler ensures validator matches command type
|
||||
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
|
||||
- **Cleaner code** - Clear intent that validation is part of command pipeline
|
||||
|
||||
## Without Svrnty.CQRS.FluentValidation
|
||||
|
||||
If you prefer not to use the FluentValidation package, you need to register commands and validators separately:
|
||||
|
||||
```csharp
|
||||
using FluentValidation;
|
||||
using Svrnty.CQRS;
|
||||
|
||||
// Register command handler
|
||||
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
||||
|
||||
// Manually register validator
|
||||
builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
||||
```
|
||||
|
||||
# 2024-2025 Roadmap
|
||||
|
||||
| Task | Description | Status |
|
||||
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
|
||||
| Support .NET 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. | ⬜️ |
|
||||
122
SAGAS_ROADMAP.md
Normal file
122
SAGAS_ROADMAP.md
Normal file
@ -0,0 +1,122 @@
|
||||
# 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
52
SECURITY.md
@ -1,52 +0,0 @@
|
||||
# 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 |
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Attributes;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Attributes;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Svrnty.CQRS.Abstractions.Attributes;
|
||||
|
||||
@ -19,7 +19,7 @@ public sealed class CommandMeta : ICommandMeta
|
||||
ServiceType = serviceType;
|
||||
}
|
||||
|
||||
private CommandNameAttribute? NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
|
||||
private CommandNameAttribute NameAttribute => CommandType.GetCustomAttribute<CommandNameAttribute>();
|
||||
|
||||
public string Name
|
||||
{
|
||||
@ -32,7 +32,7 @@ public sealed class CommandMeta : ICommandMeta
|
||||
|
||||
public Type CommandType { get; }
|
||||
public Type ServiceType { get; }
|
||||
public Type? CommandResultType { get; }
|
||||
public Type CommandResultType { get; }
|
||||
|
||||
public string LowerCamelCaseName
|
||||
{
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@ -7,7 +7,7 @@ public interface ICommandMeta
|
||||
string Name { get; }
|
||||
Type CommandType { get; }
|
||||
Type ServiceType { get; }
|
||||
Type? CommandResultType { get; }
|
||||
Type CommandResultType { get; }
|
||||
string LowerCamelCaseName { get; }
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
public interface IQueryDiscovery
|
||||
{
|
||||
IQueryMeta? FindQuery(string name);
|
||||
IQueryMeta? FindQuery(Type queryType);
|
||||
IQueryMeta FindQuery(string name);
|
||||
IQueryMeta FindQuery(Type queryType);
|
||||
IEnumerable<IQueryMeta> GetQueries();
|
||||
bool QueryExists(string name);
|
||||
bool QueryExists(Type queryType);
|
||||
@ -16,8 +16,8 @@ public interface ICommandDiscovery
|
||||
{
|
||||
bool CommandExists(string name);
|
||||
bool CommandExists(Type commandType);
|
||||
ICommandMeta? FindCommand(string name);
|
||||
ICommandMeta? FindCommand(Type commandType);
|
||||
ICommandMeta FindCommand(string name);
|
||||
ICommandMeta FindCommand(Type commandType);
|
||||
IEnumerable<ICommandMeta> GetCommands();
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@ -10,4 +10,4 @@ public interface IQueryMeta
|
||||
Type QueryResultType { get; }
|
||||
string Category { get; }
|
||||
string LowerCamelCaseName { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Svrnty.CQRS.Abstractions.Attributes;
|
||||
|
||||
@ -13,7 +13,7 @@ public class QueryMeta : IQueryMeta
|
||||
QueryResultType = queryResultType;
|
||||
}
|
||||
|
||||
protected virtual QueryNameAttribute? NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
|
||||
protected virtual QueryNameAttribute NameAttribute => QueryType.GetCustomAttribute<QueryNameAttribute>();
|
||||
|
||||
public virtual string Name
|
||||
{
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Threading;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions;
|
||||
@ -13,4 +13,4 @@ public interface ICommandHandler<in TCommand, TCommandResult>
|
||||
where TCommand : class
|
||||
{
|
||||
Task<TCommandResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Threading;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Svrnty.CQRS.Abstractions;
|
||||
@ -7,4 +7,4 @@ public interface IQueryHandler<in TQuery, TQueryResult>
|
||||
where TQuery : class
|
||||
{
|
||||
Task<TQueryResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
namespace Svrnty.CQRS.Abstractions.Security;
|
||||
namespace Svrnty.CQRS.Abstractions.Security;
|
||||
|
||||
public enum AuthorizationResult
|
||||
{
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
Allowed
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
|
||||
public interface ICommandAuthorizationService
|
||||
{
|
||||
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@ -7,4 +7,4 @@ namespace Svrnty.CQRS.Abstractions.Security;
|
||||
public interface IQueryAuthorizationService
|
||||
{
|
||||
Task<AuthorizationResult> IsAllowedAsync(Type queryType, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@ -47,4 +47,4 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@ -13,4 +13,4 @@ public interface IAlterQueryableService<TSource, TDestination, in TParams>
|
||||
where TParams : class
|
||||
{
|
||||
Task<IQueryable<TSource>> AlterQueryableAsync(IQueryable<TSource> query, IDynamicQueryParams<TParams> dynamicQuery, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
@ -15,15 +15,15 @@ public interface IDynamicQuery<TSource, TDestination, out TParams> : IDynamicQue
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
|
||||
public interface IDynamicQuery
|
||||
{
|
||||
List<IFilter>? GetFilters();
|
||||
List<IGroup>? GetGroups();
|
||||
List<ISort>? GetSorts();
|
||||
List<IAggregate>? GetAggregates();
|
||||
List<IFilter> GetFilters();
|
||||
List<IGroup> GetGroups();
|
||||
List<ISort> GetSorts();
|
||||
List<IAggregate> GetAggregates();
|
||||
int? GetPage();
|
||||
int? GetPageSize();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
|
||||
public interface IDynamicQueryInterceptorProvider<TSource, TDestination>
|
||||
{
|
||||
IEnumerable<Type> GetInterceptorsTypes();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
public interface IDynamicQueryParams<out TParams>
|
||||
where TParams : class
|
||||
{
|
||||
TParams? GetParams();
|
||||
TParams GetParams();
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@ -7,4 +7,4 @@ namespace Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
public interface IQueryableProvider<TSource>
|
||||
{
|
||||
Task<IQueryable<TSource>> GetQueryableAsync(object query, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.CQRS.Abstractions.Attributes;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
@ -14,7 +15,6 @@ using Svrnty.CQRS.Abstractions.Security;
|
||||
using Svrnty.CQRS.DynamicQuery;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
using Svrnty.CQRS.DynamicQuery.Discover;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery.MinimalApi;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Pluralize.NET;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
|
||||
@ -7,7 +7,7 @@ namespace Svrnty.CQRS.DynamicQuery.Discover;
|
||||
public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResultType)
|
||||
: QueryMeta(queryType, serviceType, queryResultType)
|
||||
{
|
||||
public Type SourceType => QueryType.GetGenericArguments()[0];
|
||||
public Type SourceType => QueryType.GetGenericArguments()[0];
|
||||
public Type DestinationType => QueryType.GetGenericArguments()[1];
|
||||
public override string Category => "DynamicQuery";
|
||||
public override string Name
|
||||
@ -22,7 +22,7 @@ public class DynamicQueryMeta(Type queryType, Type serviceType, Type queryResult
|
||||
}
|
||||
}
|
||||
|
||||
public Type? ParamsType { get; internal set; }
|
||||
public string? OverridableName { get; internal set; }
|
||||
public Type ParamsType { get; internal set; }
|
||||
public string OverridableName { get; internal set; }
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
using PoweredSoft.DynamicQuery;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
@ -18,9 +18,9 @@ public class DynamicQuery<TSource, TDestination, TParams> : DynamicQuery, IDynam
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
{
|
||||
public TParams? Params { get; set; }
|
||||
public TParams Params { get; set; }
|
||||
|
||||
public TParams? GetParams()
|
||||
public TParams GetParams()
|
||||
{
|
||||
return Params;
|
||||
}
|
||||
@ -30,23 +30,23 @@ public class DynamicQuery : IDynamicQuery
|
||||
{
|
||||
public int? Page { get; set; }
|
||||
public int? PageSize { get; set; }
|
||||
public List<Sort>? Sorts { get; set; }
|
||||
public List<DynamicQueryAggregate>? Aggregates { get; set; }
|
||||
public List<Group>? Groups { get; set; }
|
||||
public List<DynamicQueryFilter>? Filters { get; set; }
|
||||
public List<Sort> Sorts { get; set; }
|
||||
public List<DynamicQueryAggregate> Aggregates { get; set; }
|
||||
public List<Group> Groups { get; set; }
|
||||
public List<DynamicQueryFilter> Filters { get; set; }
|
||||
|
||||
|
||||
public List<IAggregate>? GetAggregates()
|
||||
public List<IAggregate> GetAggregates()
|
||||
{
|
||||
return Aggregates?.Select(t => t.ToAggregate())?.ToList();
|
||||
return Aggregates?.Select(t => t.ToAggregate())?.ToList();//.AsEnumerable<IAggregate>()?.ToList();
|
||||
}
|
||||
|
||||
public List<IFilter>? GetFilters()
|
||||
public List<IFilter> GetFilters()
|
||||
{
|
||||
return Filters?.Select(t => t.ToFilter())?.ToList();
|
||||
}
|
||||
|
||||
public List<IGroup>? GetGroups()
|
||||
public List<IGroup> GetGroups()
|
||||
{
|
||||
return this.Groups?.AsEnumerable<IGroup>()?.ToList();
|
||||
}
|
||||
@ -61,7 +61,7 @@ public class DynamicQuery : IDynamicQuery
|
||||
return this.PageSize;
|
||||
}
|
||||
|
||||
public List<ISort>? GetSorts()
|
||||
public List<ISort> GetSorts()
|
||||
{
|
||||
return this.Sorts?.AsEnumerable<ISort>()?.ToList();
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using PoweredSoft.DynamicQuery;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using System;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
public class DynamicQueryAggregate
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public required string Type { get; set; }
|
||||
public string Path { get; set; }
|
||||
public string Type { get; set; }
|
||||
|
||||
public IAggregate ToAggregate()
|
||||
{
|
||||
|
||||
@ -9,14 +9,14 @@ namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
public class DynamicQueryFilter
|
||||
{
|
||||
public List<DynamicQueryFilter>? Filters { get; set; }
|
||||
public List<DynamicQueryFilter> Filters { get; set; }
|
||||
public bool? And { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string Type { get; set; }
|
||||
public bool? Not { get; set; }
|
||||
public string? Path { get; set; }
|
||||
public object? Value { get; set; }
|
||||
public string Path { get; set; }
|
||||
public object Value { get; set; }
|
||||
|
||||
public string? QueryValue
|
||||
public string QueryValue
|
||||
{
|
||||
get
|
||||
{
|
||||
@ -32,7 +32,7 @@ public class DynamicQueryFilter
|
||||
|
||||
public IFilter ToFilter()
|
||||
{
|
||||
var type = Enum.Parse<FilterType>(Type!);
|
||||
var type = Enum.Parse<FilterType>(Type);
|
||||
if (type == FilterType.Composite)
|
||||
{
|
||||
var compositeFilter = new CompositeFilter
|
||||
@ -44,7 +44,7 @@ public class DynamicQueryFilter
|
||||
return compositeFilter;
|
||||
}
|
||||
|
||||
object? value = Value;
|
||||
object value = Value;
|
||||
if (Value is JsonElement jsonElement)
|
||||
{
|
||||
switch (jsonElement.ValueKind)
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
public class DynamicQueryHandler<TSource, TDestination>
|
||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination>, IQueryExecutionResult<TDestination>>
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
{
|
||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
|
||||
{
|
||||
}
|
||||
@ -29,7 +29,7 @@ public class DynamicQueryHandler<TSource, TDestination>
|
||||
}
|
||||
|
||||
public class DynamicQueryHandler<TSource, TDestination, TParams>
|
||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||
: DynamicQueryHandlerBase<TSource, TDestination>,
|
||||
Svrnty.CQRS.Abstractions.IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
@ -37,10 +37,10 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
|
||||
{
|
||||
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams;
|
||||
|
||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||
public DynamicQueryHandler(IQueryHandlerAsync queryHandlerAsync,
|
||||
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
|
||||
IEnumerable<IAlterQueryableService<TSource, TDestination, TParams>> alterQueryableServicesWithParams,
|
||||
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
||||
IServiceProvider serviceProvider) : base(queryHandlerAsync, queryableProviders, alterQueryableServices, dynamicQueryInterceptorProviders, serviceProvider)
|
||||
{
|
||||
@ -49,7 +49,7 @@ public class DynamicQueryHandler<TSource, TDestination, TParams>
|
||||
|
||||
protected override async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
source = await base.AlterSourceAsync(source, query, cancellationToken);
|
||||
source = await base.AlterSourceAsync(source, query, cancellationToken);
|
||||
|
||||
if (query is IDynamicQueryParams<TParams> withParams)
|
||||
{
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
@ -6,9 +6,9 @@ using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
using PoweredSoft.DynamicQuery;
|
||||
using PoweredSoft.DynamicQuery.Core;
|
||||
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
||||
|
||||
namespace Svrnty.CQRS.DynamicQuery;
|
||||
|
||||
@ -60,10 +60,7 @@ public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
||||
{
|
||||
var types = _dynamicQueryInterceptorProviders.SelectMany(t => t.GetInterceptorsTypes()).Distinct();
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (_serviceProvider.GetService(type) is IQueryInterceptor interceptor)
|
||||
yield return interceptor;
|
||||
}
|
||||
yield return _serviceProvider.GetService(type) as IQueryInterceptor;
|
||||
}
|
||||
|
||||
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using PoweredSoft.Data.Core;
|
||||
@ -26,11 +26,11 @@ public static class ServiceCollectionExtensions
|
||||
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
|
||||
=> AddDynamicQuery<TSourceAndDestination, TSourceAndDestination>(services, name: name);
|
||||
|
||||
public static IServiceCollection AddDynamicQuery<TSource, TDestination>(this IServiceCollection services, string? name = null)
|
||||
public static IServiceCollection AddDynamicQuery<TSource, TDestination>(this IServiceCollection services, string name = null)
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
{
|
||||
@ -51,7 +51,7 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddDynamicQueryWithProvider<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string? name = null)
|
||||
public static IServiceCollection AddDynamicQueryWithProvider<TSource, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string name = null)
|
||||
where TQueryableProvider : class, IQueryableProvider<TSource>
|
||||
where TSource : class
|
||||
{
|
||||
@ -60,7 +60,7 @@ public static class ServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddDynamicQueryWithParamsAndProvider<TSource, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string? name = null)
|
||||
public static IServiceCollection AddDynamicQueryWithParamsAndProvider<TSource, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TQueryableProvider>(this IServiceCollection services, string name = null)
|
||||
where TQueryableProvider : class, IQueryableProvider<TSource>
|
||||
where TParams : class
|
||||
where TSource : class
|
||||
@ -86,15 +86,15 @@ public static class ServiceCollectionExtensions
|
||||
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 TParams : class
|
||||
=> AddDynamicQueryWithParams<TSourceAndDestination, TSourceAndDestination, TParams>(services, name: name);
|
||||
|
||||
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string? name = null)
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
public static IServiceCollection AddDynamicQueryWithParams<TSource, TDestination, TParams>(this IServiceCollection services, string name = null)
|
||||
where TSource : class
|
||||
where TDestination : class
|
||||
where TParams : class
|
||||
{
|
||||
// add query handler.
|
||||
services.AddTransient<IQueryHandler<IDynamicQuery<TSource, TDestination, TParams>, IQueryExecutionResult<TDestination>>, DynamicQueryHandler<TSource, TDestination, TParams>>();
|
||||
@ -133,7 +133,7 @@ public static class ServiceCollectionExtensions
|
||||
where TParams : class
|
||||
where TService : class, IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>
|
||||
{
|
||||
return services.AddTransient<IAlterQueryableService< TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
|
||||
return services.AddTransient<IAlterQueryableService<TSourceAndTDestination, TSourceAndTDestination, TParams>, TService>();
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAlterQueryableWithParams<TSource, TDestination, TParams, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TService>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
@ -39,7 +39,7 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
services.AddQuery<TQuery, TQueryResult, TQueryHandler>()
|
||||
.AddFluentValidator<TQuery, TValidator>();
|
||||
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,102 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Helpers
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Helpers;
|
||||
|
||||
internal static class ProtoTypeMapper
|
||||
{
|
||||
internal static class ProtoTypeMapper
|
||||
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
|
||||
{
|
||||
private static readonly Dictionary<string, string> TypeMap = new Dictionary<string, string>
|
||||
// Primitives
|
||||
{ "System.String", "string" },
|
||||
{ "System.Boolean", "bool" },
|
||||
{ "System.Int32", "int32" },
|
||||
{ "System.Int64", "int64" },
|
||||
{ "System.UInt32", "uint32" },
|
||||
{ "System.UInt64", "uint64" },
|
||||
{ "System.Single", "float" },
|
||||
{ "System.Double", "double" },
|
||||
{ "System.Byte", "uint32" },
|
||||
{ "System.SByte", "int32" },
|
||||
{ "System.Int16", "int32" },
|
||||
{ "System.UInt16", "uint32" },
|
||||
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
|
||||
{ "System.DateTime", "int64" }, // Unix timestamp
|
||||
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
|
||||
{ "System.Guid", "string" },
|
||||
{ "System.TimeSpan", "int64" }, // Ticks
|
||||
|
||||
// Nullable variants
|
||||
{ "System.Boolean?", "bool" },
|
||||
{ "System.Int32?", "int32" },
|
||||
{ "System.Int64?", "int64" },
|
||||
{ "System.UInt32?", "uint32" },
|
||||
{ "System.UInt64?", "uint64" },
|
||||
{ "System.Single?", "float" },
|
||||
{ "System.Double?", "double" },
|
||||
{ "System.Byte?", "uint32" },
|
||||
{ "System.SByte?", "int32" },
|
||||
{ "System.Int16?", "int32" },
|
||||
{ "System.UInt16?", "uint32" },
|
||||
{ "System.Decimal?", "string" },
|
||||
{ "System.DateTime?", "int64" },
|
||||
{ "System.DateTimeOffset?", "int64" },
|
||||
{ "System.Guid?", "string" },
|
||||
{ "System.TimeSpan?", "int64" },
|
||||
};
|
||||
|
||||
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
|
||||
{
|
||||
isRepeated = false;
|
||||
isOptional = false;
|
||||
|
||||
// Handle byte[] as bytes proto type (NOT repeated uint32)
|
||||
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
|
||||
{
|
||||
// Primitives
|
||||
{ "System.String", "string" },
|
||||
{ "System.Boolean", "bool" },
|
||||
{ "System.Int32", "int32" },
|
||||
{ "System.Int64", "int64" },
|
||||
{ "System.UInt32", "uint32" },
|
||||
{ "System.UInt64", "uint64" },
|
||||
{ "System.Single", "float" },
|
||||
{ "System.Double", "double" },
|
||||
{ "System.Byte", "uint32" },
|
||||
{ "System.SByte", "int32" },
|
||||
{ "System.Int16", "int32" },
|
||||
{ "System.UInt16", "uint32" },
|
||||
{ "System.Decimal", "string" }, // Decimal as string to preserve precision
|
||||
{ "System.DateTime", "int64" }, // Unix timestamp
|
||||
{ "System.DateTimeOffset", "int64" }, // Unix timestamp
|
||||
{ "System.Guid", "string" },
|
||||
{ "System.TimeSpan", "int64" }, // Ticks
|
||||
|
||||
// Nullable variants
|
||||
{ "System.Boolean?", "bool" },
|
||||
{ "System.Int32?", "int32" },
|
||||
{ "System.Int64?", "int64" },
|
||||
{ "System.UInt32?", "uint32" },
|
||||
{ "System.UInt64?", "uint64" },
|
||||
{ "System.Single?", "float" },
|
||||
{ "System.Double?", "double" },
|
||||
{ "System.Byte?", "uint32" },
|
||||
{ "System.SByte?", "int32" },
|
||||
{ "System.Int16?", "int32" },
|
||||
{ "System.UInt16?", "uint32" },
|
||||
{ "System.Decimal?", "string" },
|
||||
{ "System.DateTime?", "int64" },
|
||||
{ "System.DateTimeOffset?", "int64" },
|
||||
{ "System.Guid?", "string" },
|
||||
{ "System.TimeSpan?", "int64" },
|
||||
};
|
||||
|
||||
public static string MapToProtoType(string csharpType, out bool isRepeated, out bool isOptional)
|
||||
{
|
||||
isRepeated = false;
|
||||
isOptional = false;
|
||||
|
||||
// Handle byte[] as bytes proto type (NOT repeated uint32)
|
||||
if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]")
|
||||
{
|
||||
return "bytes";
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (csharpType.EndsWith("[]"))
|
||||
{
|
||||
isRepeated = true;
|
||||
var elementType = csharpType.Substring(0, csharpType.Length - 2);
|
||||
return MapToProtoType(elementType, out _, out _);
|
||||
}
|
||||
|
||||
// Handle generic collections
|
||||
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
|
||||
{
|
||||
isRepeated = true;
|
||||
var startIndex = csharpType.IndexOf('<') + 1;
|
||||
var endIndex = csharpType.LastIndexOf('>');
|
||||
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
|
||||
return MapToProtoType(elementType, out _, out _);
|
||||
}
|
||||
|
||||
// Handle nullable value types
|
||||
if (csharpType.EndsWith("?"))
|
||||
{
|
||||
isOptional = true;
|
||||
}
|
||||
|
||||
// Check if it's a known primitive type
|
||||
if (TypeMap.TryGetValue(csharpType, out var protoType))
|
||||
{
|
||||
return protoType;
|
||||
}
|
||||
|
||||
// For unknown types, assume it's a custom message type
|
||||
// Extract just the type name without namespace
|
||||
var lastDot = csharpType.LastIndexOf('.');
|
||||
if (lastDot >= 0)
|
||||
{
|
||||
return csharpType.Substring(lastDot + 1).Replace("?", "");
|
||||
}
|
||||
|
||||
return csharpType.Replace("?", "");
|
||||
return "bytes";
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (csharpType.EndsWith("[]"))
|
||||
{
|
||||
isRepeated = true;
|
||||
var elementType = csharpType.Substring(0, csharpType.Length - 2);
|
||||
return MapToProtoType(elementType, out _, out _);
|
||||
}
|
||||
|
||||
// Handle generic collections
|
||||
if (csharpType.StartsWith("System.Collections.Generic.List<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.IList<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.IEnumerable<") ||
|
||||
csharpType.StartsWith("System.Collections.Generic.ICollection<"))
|
||||
{
|
||||
isRepeated = true;
|
||||
var startIndex = csharpType.IndexOf('<') + 1;
|
||||
var endIndex = csharpType.LastIndexOf('>');
|
||||
var elementType = csharpType.Substring(startIndex, endIndex - startIndex);
|
||||
return MapToProtoType(elementType, out _, out _);
|
||||
}
|
||||
|
||||
// Handle nullable value types
|
||||
if (csharpType.EndsWith("?"))
|
||||
{
|
||||
isOptional = true;
|
||||
}
|
||||
|
||||
// Check if it's a known primitive type
|
||||
if (TypeMap.TryGetValue(csharpType, out var protoType))
|
||||
{
|
||||
return protoType;
|
||||
}
|
||||
|
||||
// For unknown types, assume it's a custom message type
|
||||
// Extract just the type name without namespace
|
||||
var lastDot = csharpType.LastIndexOf('.');
|
||||
if (lastDot >= 0)
|
||||
{
|
||||
return csharpType.Substring(lastDot + 1).Replace("?", "");
|
||||
}
|
||||
|
||||
return csharpType.Replace("?", "");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,83 +1,82 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models;
|
||||
|
||||
public class CommandInfo
|
||||
{
|
||||
public class CommandInfo
|
||||
public string Name { get; set; }
|
||||
public string FullyQualifiedName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
public string? ResultType { get; set; }
|
||||
public string? ResultFullyQualifiedName { get; set; }
|
||||
public bool HasResult => ResultType != null;
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public List<PropertyInfo> ResultProperties { get; set; }
|
||||
public bool IsResultPrimitiveType { get; set; }
|
||||
|
||||
public CommandInfo()
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string FullyQualifiedName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
public string? ResultType { get; set; }
|
||||
public string? ResultFullyQualifiedName { get; set; }
|
||||
public bool HasResult => ResultType != null;
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public List<PropertyInfo> ResultProperties { get; set; }
|
||||
public bool IsResultPrimitiveType { get; set; }
|
||||
|
||||
public CommandInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
Properties = new List<PropertyInfo>();
|
||||
HandlerInterfaceName = string.Empty;
|
||||
ResultProperties = new List<PropertyInfo>();
|
||||
IsResultPrimitiveType = false;
|
||||
}
|
||||
}
|
||||
|
||||
public class PropertyInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string FullyQualifiedType { get; set; }
|
||||
public string ProtoType { get; set; }
|
||||
public int FieldNumber { get; set; }
|
||||
public bool IsComplexType { get; set; }
|
||||
public List<PropertyInfo> NestedProperties { get; set; }
|
||||
|
||||
// Type conversion metadata
|
||||
public bool IsEnum { get; set; }
|
||||
public bool IsList { get; set; }
|
||||
public bool IsNullable { get; set; }
|
||||
public bool IsDecimal { get; set; }
|
||||
public bool IsDateTime { get; set; }
|
||||
public bool IsDateTimeOffset { get; set; }
|
||||
public bool IsGuid { get; set; }
|
||||
public bool IsJsonElement { get; set; }
|
||||
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
|
||||
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
|
||||
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
|
||||
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
|
||||
public string? ElementType { get; set; }
|
||||
public bool IsElementComplexType { get; set; }
|
||||
public bool IsElementGuid { get; set; }
|
||||
public List<PropertyInfo>? ElementNestedProperties { get; set; }
|
||||
|
||||
public PropertyInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
Type = string.Empty;
|
||||
FullyQualifiedType = string.Empty;
|
||||
ProtoType = string.Empty;
|
||||
IsComplexType = false;
|
||||
NestedProperties = new List<PropertyInfo>();
|
||||
IsEnum = false;
|
||||
IsList = false;
|
||||
IsNullable = false;
|
||||
IsDecimal = false;
|
||||
IsDateTime = false;
|
||||
IsDateTimeOffset = false;
|
||||
IsGuid = false;
|
||||
IsJsonElement = false;
|
||||
IsBinaryType = false;
|
||||
IsStream = false;
|
||||
IsReadOnly = false;
|
||||
IsValueTypeCollection = false;
|
||||
IsElementComplexType = false;
|
||||
IsElementGuid = false;
|
||||
}
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
Properties = new List<PropertyInfo>();
|
||||
HandlerInterfaceName = string.Empty;
|
||||
ResultProperties = new List<PropertyInfo>();
|
||||
IsResultPrimitiveType = false;
|
||||
}
|
||||
}
|
||||
|
||||
public class PropertyInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string FullyQualifiedType { get; set; }
|
||||
public string ProtoType { get; set; }
|
||||
public int FieldNumber { get; set; }
|
||||
public bool IsComplexType { get; set; }
|
||||
public List<PropertyInfo> NestedProperties { get; set; }
|
||||
|
||||
// Type conversion metadata
|
||||
public bool IsEnum { get; set; }
|
||||
public bool IsList { get; set; }
|
||||
public bool IsNullable { get; set; }
|
||||
public bool IsDecimal { get; set; }
|
||||
public bool IsDateTime { get; set; }
|
||||
public bool IsDateTimeOffset { get; set; }
|
||||
public bool IsGuid { get; set; }
|
||||
public bool IsJsonElement { get; set; }
|
||||
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
|
||||
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
|
||||
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
|
||||
public bool IsValueTypeCollection { get; set; } // Value types that implement IList<T> (like NpgsqlPolygon)
|
||||
public string? ElementType { get; set; }
|
||||
public bool IsElementComplexType { get; set; }
|
||||
public bool IsElementGuid { get; set; }
|
||||
public List<PropertyInfo>? ElementNestedProperties { get; set; }
|
||||
|
||||
public PropertyInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
Type = string.Empty;
|
||||
FullyQualifiedType = string.Empty;
|
||||
ProtoType = string.Empty;
|
||||
IsComplexType = false;
|
||||
NestedProperties = new List<PropertyInfo>();
|
||||
IsEnum = false;
|
||||
IsList = false;
|
||||
IsNullable = false;
|
||||
IsDecimal = false;
|
||||
IsDateTime = false;
|
||||
IsDateTimeOffset = false;
|
||||
IsGuid = false;
|
||||
IsJsonElement = false;
|
||||
IsBinaryType = false;
|
||||
IsStream = false;
|
||||
IsReadOnly = false;
|
||||
IsValueTypeCollection = false;
|
||||
IsElementComplexType = false;
|
||||
IsElementGuid = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +1,27 @@
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
{
|
||||
public class DynamicQueryInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string SourceType { get; set; }
|
||||
public string SourceTypeFullyQualified { get; set; }
|
||||
public string DestinationType { get; set; }
|
||||
public string DestinationTypeFullyQualified { get; set; }
|
||||
public string? ParamsType { get; set; }
|
||||
public string? ParamsTypeFullyQualified { get; set; }
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public string QueryInterfaceName { get; set; }
|
||||
public bool HasParams { get; set; }
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models;
|
||||
|
||||
public DynamicQueryInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
SourceType = string.Empty;
|
||||
SourceTypeFullyQualified = string.Empty;
|
||||
DestinationType = string.Empty;
|
||||
DestinationTypeFullyQualified = string.Empty;
|
||||
HandlerInterfaceName = string.Empty;
|
||||
QueryInterfaceName = string.Empty;
|
||||
HasParams = false;
|
||||
}
|
||||
public class DynamicQueryInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string SourceType { get; set; }
|
||||
public string SourceTypeFullyQualified { get; set; }
|
||||
public string DestinationType { get; set; }
|
||||
public string DestinationTypeFullyQualified { get; set; }
|
||||
public string? ParamsType { get; set; }
|
||||
public string? ParamsTypeFullyQualified { get; set; }
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public string QueryInterfaceName { get; set; }
|
||||
public bool HasParams { get; set; }
|
||||
|
||||
public DynamicQueryInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
SourceType = string.Empty;
|
||||
SourceTypeFullyQualified = string.Empty;
|
||||
DestinationType = string.Empty;
|
||||
DestinationTypeFullyQualified = string.Empty;
|
||||
HandlerInterfaceName = string.Empty;
|
||||
QueryInterfaceName = string.Empty;
|
||||
HasParams = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered streaming notification type for proto/gRPC generation.
|
||||
/// </summary>
|
||||
public class NotificationInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a discovered streaming notification type for proto/gRPC generation.
|
||||
/// The notification type name (e.g., "InventoryChangeNotification").
|
||||
/// </summary>
|
||||
public class NotificationInfo
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The fully qualified type name including namespace.
|
||||
/// </summary>
|
||||
public string FullyQualifiedName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The namespace of the notification type.
|
||||
/// </summary>
|
||||
public string Namespace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The property name used as the subscription key (from [StreamingNotification] attribute).
|
||||
/// </summary>
|
||||
public string SubscriptionKeyProperty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription key property info.
|
||||
/// </summary>
|
||||
public PropertyInfo SubscriptionKeyInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All properties of the notification type.
|
||||
/// </summary>
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
|
||||
public NotificationInfo()
|
||||
{
|
||||
/// <summary>
|
||||
/// The notification type name (e.g., "InventoryChangeNotification").
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The fully qualified type name including namespace.
|
||||
/// </summary>
|
||||
public string FullyQualifiedName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The namespace of the notification type.
|
||||
/// </summary>
|
||||
public string Namespace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The property name used as the subscription key (from [StreamingNotification] attribute).
|
||||
/// </summary>
|
||||
public string SubscriptionKeyProperty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The subscription key property info.
|
||||
/// </summary>
|
||||
public PropertyInfo SubscriptionKeyInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All properties of the notification type.
|
||||
/// </summary>
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
|
||||
public NotificationInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
SubscriptionKeyProperty = string.Empty;
|
||||
SubscriptionKeyInfo = new PropertyInfo();
|
||||
Properties = new List<PropertyInfo>();
|
||||
}
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
SubscriptionKeyProperty = string.Empty;
|
||||
SubscriptionKeyInfo = new PropertyInfo();
|
||||
Properties = new List<PropertyInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,30 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models
|
||||
{
|
||||
public class QueryInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string FullyQualifiedName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
public string ResultType { get; set; }
|
||||
public string ResultFullyQualifiedName { get; set; }
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public List<PropertyInfo> ResultProperties { get; set; }
|
||||
public bool IsResultPrimitiveType { get; set; }
|
||||
namespace Svrnty.CQRS.Grpc.Generators.Models;
|
||||
|
||||
public QueryInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
Properties = new List<PropertyInfo>();
|
||||
ResultType = string.Empty;
|
||||
ResultFullyQualifiedName = string.Empty;
|
||||
HandlerInterfaceName = string.Empty;
|
||||
ResultProperties = new List<PropertyInfo>();
|
||||
IsResultPrimitiveType = false;
|
||||
}
|
||||
public class QueryInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string FullyQualifiedName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public List<PropertyInfo> Properties { get; set; }
|
||||
public string ResultType { get; set; }
|
||||
public string ResultFullyQualifiedName { get; set; }
|
||||
public string HandlerInterfaceName { get; set; }
|
||||
public List<PropertyInfo> ResultProperties { get; set; }
|
||||
public bool IsResultPrimitiveType { get; set; }
|
||||
|
||||
public QueryInfo()
|
||||
{
|
||||
Name = string.Empty;
|
||||
FullyQualifiedName = string.Empty;
|
||||
Namespace = string.Empty;
|
||||
Properties = new List<PropertyInfo>();
|
||||
ResultType = string.Empty;
|
||||
ResultFullyQualifiedName = string.Empty;
|
||||
HandlerInterfaceName = string.Empty;
|
||||
ResultProperties = new List<PropertyInfo>();
|
||||
IsResultPrimitiveType = false;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Svrnty.CQRS.Grpc/.DS_Store
vendored
Normal file
BIN
Svrnty.CQRS.Grpc/.DS_Store
vendored
Normal file
Binary file not shown.
@ -43,10 +43,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.Abstract
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Events.RabbitMQ", "Svrnty.CQRS.Events.RabbitMQ\Svrnty.CQRS.Events.RabbitMQ.csproj", "{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Svrnty.CQRS.Tests", "tests\Svrnty.CQRS.Tests\Svrnty.CQRS.Tests.csproj", "{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -261,25 +257,10 @@ Global
|
||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3C7412EF-13C2-41F3-9D4C-D2BEC4843C8C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{BCB3DD95-DFDB-452C-A0AD-AA657AE0C049} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {D6D431EA-C04F-462B-8033-60F510FEB49E}
|
||||
EndGlobalSection
|
||||
|
||||
BIN
Svrnty.CQRS/.DS_Store
vendored
Normal file
BIN
Svrnty.CQRS/.DS_Store
vendored
Normal file
Binary file not shown.
@ -1,3 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.CQRS.Discovery;
|
||||
@ -43,7 +44,7 @@ public class CqrsBuilder
|
||||
/// <summary>
|
||||
/// Adds a command handler to the CQRS pipeline
|
||||
/// </summary>
|
||||
public CqrsBuilder AddCommand<TCommand, TCommandHandler>()
|
||||
public CqrsBuilder AddCommand<TCommand, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
|
||||
where TCommand : class
|
||||
where TCommandHandler : class, ICommandHandler<TCommand>
|
||||
{
|
||||
@ -54,7 +55,7 @@ public class CqrsBuilder
|
||||
/// <summary>
|
||||
/// Adds a command handler with result to the CQRS pipeline
|
||||
/// </summary>
|
||||
public CqrsBuilder AddCommand<TCommand, TResult, TCommandHandler>()
|
||||
public CqrsBuilder AddCommand<TCommand, TResult, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TCommandHandler>()
|
||||
where TCommand : class
|
||||
where TCommandHandler : class, ICommandHandler<TCommand, TResult>
|
||||
{
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
@ -15,8 +15,8 @@ public sealed class CommandDiscovery : ICommandDiscovery
|
||||
}
|
||||
|
||||
public IEnumerable<ICommandMeta> GetCommands() => _commandMetas;
|
||||
public ICommandMeta? FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name);
|
||||
public ICommandMeta? FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
|
||||
public ICommandMeta FindCommand(string name) => _commandMetas.FirstOrDefault(t => t.Name == name);
|
||||
public ICommandMeta FindCommand(Type commandType) => _commandMetas.FirstOrDefault(t => t.CommandType == commandType);
|
||||
public bool CommandExists(string name) => _commandMetas.Any(t => t.Name == name);
|
||||
public bool CommandExists(Type commandType) => _commandMetas.Any(t => t.CommandType == commandType);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Svrnty.CQRS.Abstractions.Discovery;
|
||||
@ -15,8 +15,8 @@ public sealed class QueryDiscovery : IQueryDiscovery
|
||||
}
|
||||
|
||||
public IEnumerable<IQueryMeta> GetQueries() => _queryMetas;
|
||||
public IQueryMeta? FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name);
|
||||
public IQueryMeta? FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
|
||||
public IQueryMeta FindQuery(string name) => _queryMetas.FirstOrDefault(t => t.Name == name);
|
||||
public IQueryMeta FindQuery(Type queryType) => _queryMetas.FirstOrDefault(t => t.QueryType == queryType);
|
||||
public bool QueryExists(string name) => _queryMetas.Any(t => t.Name == name);
|
||||
public bool QueryExists(Type queryType) => _queryMetas.Any(t => t.QueryType == queryType);
|
||||
}
|
||||
|
||||
@ -26,6 +26,10 @@
|
||||
<None Include="..\README.md" Pack="true" PackagePath="" CopyToOutputDirectory="Always" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Svrnty.CQRS.Configuration;
|
||||
|
||||
namespace Svrnty.CQRS.MinimalApi;
|
||||
namespace Svrnty.CQRS;
|
||||
|
||||
public static class WebApplicationExtensions
|
||||
{
|
||||
@ -1,11 +1,11 @@
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Svrnty.CQRS;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.CQRS.DynamicQuery;
|
||||
using Svrnty.CQRS.FluentValidation;
|
||||
using Svrnty.CQRS.Grpc;
|
||||
using Svrnty.Sample;
|
||||
using Svrnty.CQRS.MinimalApi;
|
||||
using Svrnty.CQRS.DynamicQuery;
|
||||
using Svrnty.CQRS.Abstractions;
|
||||
using Svrnty.Sample;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
using PoweredSoft.Data.Core;
|
||||
using System.Linq.Expressions;
|
||||
using PoweredSoft.Data.Core;
|
||||
|
||||
namespace Svrnty.Sample;
|
||||
|
||||
|
||||
@ -1,178 +0,0 @@
|
||||
# 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.
|
||||
@ -1,514 +0,0 @@
|
||||
# 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.
|
||||
@ -1,335 +0,0 @@
|
||||
# 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
129
lefthook.yml
@ -1,129 +0,0 @@
|
||||
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
|
||||
@ -1,124 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -1,184 +0,0 @@
|
||||
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>>());
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
global using Xunit;
|
||||
@ -1,64 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,124 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,57 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
<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>
|
||||
Loading…
Reference in New Issue
Block a user