diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8a46cdd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,116 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# All files +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Code files +[*.{cs,csx,vb,vbx}] +indent_size = 4 +indent_style = space +tab_width = 4 + +# XML project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +indent_size = 2 + +# XML config files +[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +indent_size = 2 + +# JSON files +[*.json] +indent_size = 2 + +# YAML files +[*.{yml,yaml}] +indent_size = 2 + +# Markdown files +[*.md] +trim_trailing_whitespace = false + +# Dotnet code style settings: +[*.{cs,vb}] +# Sort using and Import directives with System.* appearing first +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Avoid "this." and "Me." if not necessary +dotnet_style_qualification_for_field = false:refactoring +dotnet_style_qualification_for_property = false:refactoring +dotnet_style_qualification_for_method = false:refactoring +dotnet_style_qualification_for_event = false:refactoring + +# Use language keywords instead of framework type names for type references +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Suggest more modern language features when available +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion + +# CSharp code style settings: +[*.cs] +# Newline settings +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +csharp_indent_block_contents = true +csharp_indent_braces = false + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +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_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false + +# Prefer "var" everywhere +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:silent + +# Prefer method-like constructs to have a block body +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none + +# Prefer property-like constructs to have an expression-body +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Suggest more modern language features when available +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Code quality +dotnet_code_quality_unused_parameters = all:suggestion diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json new file mode 100644 index 0000000..44329f5 --- /dev/null +++ b/.github/linters/.jscpd.json @@ -0,0 +1,18 @@ +{ + "threshold": 7, + "reporters": ["console"], + "ignore": [ + "**/*.min.js", + "**/node_modules/**", + "**/bin/**", + "**/obj/**", + "**/.git/**" + ], + "format": [ + "csharp" + ], + "minLines": 5, + "minTokens": 100, + "blame": false, + "silent": false +} diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 0000000..44329f5 --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,18 @@ +{ + "threshold": 7, + "reporters": ["console"], + "ignore": [ + "**/*.min.js", + "**/node_modules/**", + "**/bin/**", + "**/obj/**", + "**/.git/**" + ], + "format": [ + "csharp" + ], + "minLines": 5, + "minTokens": 100, + "blame": false, + "silent": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c5977d3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +All notable changes to SourceFlow.Net will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2025-01-28 + +### Added + +#### Core Framework (SourceFlow.Net) +- Complete event sourcing implementation with Domain-Driven Design (DDD) principles +- CQRS pattern implementation with command/query segregation +- Aggregate pattern for managing root entities within bounded contexts +- Saga orchestration for long-running transactions and workflow management +- Event-first design with comprehensive event sourcing foundation +- Command and event publishing/subscription infrastructure +- View model projection system for read-optimized data models +- Support for multiple .NET frameworks: + - .NET 10.0 + - .NET 9.0 + - .NET Standard 2.1 + - .NET Standard 2.0 + - .NET Framework 4.6.2 +- OpenTelemetry integration for observability and tracing +- Dependency injection support via Microsoft.Extensions.DependencyInjection +- Structured logging support via Microsoft.Extensions.Logging + +#### Entity Framework Store Provider (SourceFlow.Stores.EntityFramework) +- `ICommandStore` implementation using Entity Framework Core +- `IEntityStore` implementation using Entity Framework Core +- `IViewModelStore` implementation using Entity Framework Core +- Configurable connection strings per store type (separate or shared databases) +- Support for .NET 10.0, .NET 9.0, and .NET 8.0 +- SQL Server database provider support +- Polly-based resilience and retry policies +- OpenTelemetry instrumentation for Entity Framework Core operations + +#### Architecture & Patterns +- Clean architecture principles +- Separation of concerns between read and write models +- Event-driven communication between aggregates +- State preservation and consistency guarantees +- Extensible framework design for custom implementations + +### Documentation +- Comprehensive README with architecture diagrams +- Developer guide available on GitHub Wiki +- Package documentation and XML comments +- Architecture diagram showing complete system design +- Roadmap for future cloud provider support (v2.0.0) + +### Infrastructure +- NuGet package generation on build +- GitHub Actions CI/CD pipeline integration +- CodeQL security analysis +- Symbol packages for debugging support +- MIT License + +[1.0.0]: https://github.com/CodeShayk/SourceFlow.Net/releases/tag/v1.0.0 diff --git a/Images/Architecture-Complete.png b/Images/Architecture-Complete.png new file mode 100644 index 0000000..3db385d Binary files /dev/null and b/Images/Architecture-Complete.png differ diff --git a/Images/Architecture.png b/Images/Architecture.png new file mode 100644 index 0000000..cc5cf49 Binary files /dev/null and b/Images/Architecture.png differ diff --git a/Images/Sourceflow.Net-Concept.drawio b/Images/Sourceflow.Net-Concept.drawio new file mode 100644 index 0000000..e0a006c --- /dev/null +++ b/Images/Sourceflow.Net-Concept.drawio @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Images/Sourceflow.Net-ConceptFull.drawio b/Images/Sourceflow.Net-ConceptFull.drawio new file mode 100644 index 0000000..9b34357 --- /dev/null +++ b/Images/Sourceflow.Net-ConceptFull.drawio @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index cf4142f..97688ac 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,8 @@ # ninja SourceFlow.Net -[![NuGet version](https://badge.fury.io/nu/SourceFlow.Net.svg)](https://badge.fury.io/nu/SourceFlow.Net) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/CodeShayk/SourceFlow.Net/blob/master/LICENSE.md) [![GitHub Release](https://img.shields.io/github/v/release/CodeShayk/SourceFlow.Net?logo=github&sort=semver)](https://github.com/CodeShayk/SourceFlow.Net/releases/latest) [![master-build](https://github.com/CodeShayk/SourceFlow.Net/actions/workflows/Master-Build.yml/badge.svg)](https://github.com/CodeShayk/SourceFlow.Net/actions/workflows/Master-Build.yml) [![master-codeql](https://github.com/CodeShayk/SourceFlow.Net/actions/workflows/Master-CodeQL.yml/badge.svg)](https://github.com/CodeShayk/SourceFlow.Net/actions/workflows/Master-CodeQL.yml) -[![.Net 9.0](https://img.shields.io/badge/.Net-9.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) -[![.Net Standard 2.1](https://img.shields.io/badge/.NetStandard-2.1-blue)](https://github.com/dotnet/standard/blob/v2.1.0/docs/versions/netstandard2.1.md) -[![.Net Standard 2.0](https://img.shields.io/badge/.NetStandard-2.0-blue)](https://github.com/dotnet/standard/blob/v2.0.0/docs/versions/netstandard2.0.md) -[![.Net Framework 4.6.2](https://img.shields.io/badge/.Net-4.6.2-blue)](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net46) -

A modern, lightweight, and extensible .NET framework for building event-sourced applications using Domain-Driven Design (DDD) principles and Command Query Responsibility Segregation (CQRS) patterns. @@ -24,19 +18,78 @@ SourceFlow.Net empowers developers to build scalable, maintainable applications * ⚡ CQRS Implementation with Command/Query Segregation * 📊 Event-First Design with Event Sourcing Foundation * 🧱 Clean Architecture - + +### Core Concepts + +#### v1.0.0 Architecture + +**Aggregates** +- An `Aggregate` encapsulates a root domain entity within a bounded context (microservice) +- Changes to aggregates are initiated by publishing commands +- Aggregates subscribe to events to react to external changes from other sagas or workflows that may affect their state + +**Sagas** +- A `Saga` represents a long-running transaction that orchestrates complex business processes +- Sagas subscribe to commands and execute the actual updates to aggregate entities +- They manage both success and failure flows to ensure data consistency and preserve aggregate state +- Sagas can publish commands to themselves or other sagas to coordinate multi-step workflows +- Events can be raised by sagas during command handling to notify other components of state changes + +**Events** +- Events are published to interested subscribers when state changes occur +- Two primary event subscribers exist in the framework: + - **Aggregates**: React to events from external workflows that impact their domain state + - **Views**: Project event data into optimized read models for query operations + +**Views** +- Views subscribe to events and transform domain data into denormalized view models +- View models provide optimized read access for consumers such as UIs or reporting systems +- Data in view models follows eventual consistency patterns + +#### v2.0.0 Roadmap (Cloud Integration) + +**Command Dispatcher** +- Dispatches commands to cloud-based message queues for distributed processing +- Targets specific command queues based on bounded context routing + +**Command Queue** +- A dedicated queue for each bounded context (microservice) +- Routes incoming commands to the appropriate subscribing sagas within the domain + +**Event Dispatcher** +- Publishes domain events to cloud-based topics for cross-service communication +- Enables event-driven architecture across distributed systems + +**Event Listeners** +- Bootstrap components that listen to subscribed event topics +- Dispatch received events to the appropriate aggregates and views within each domain context +- Enable seamless integration across bounded contexts + +#### Architecture +architecture + +### RoadMap + +| Package | Version | Release Date |Details |.Net Frameworks| +|------|---------|--------------|--------|-----------| +|SourceFlow|v1.0.0 [![NuGet version](https://badge.fury.io/nu/SourceFlow.svg)](https://badge.fury.io/nu/SourceFlow)|29th Oct 2025|Core functionality for event sourcing and CQRS|[![.Net 10](https://img.shields.io/badge/.Net-10-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) [![.Net 9.0](https://img.shields.io/badge/.Net-9.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) [![.Net Standard 2.1](https://img.shields.io/badge/.NetStandard-2.1-blue)](https://github.com/dotnet/standard/blob/v2.1.0/docs/versions/netstandard2.1.md) [![.Net Standard 2.0](https://img.shields.io/badge/.NetStandard-2.0-blue)](https://github.com/dotnet/standard/blob/v2.0.0/docs/versions/netstandard2.0.md) [![.Net Framework 4.6.2](https://img.shields.io/badge/.Net-4.6.2-blue)](https://dotnet.microsoft.com/en-us/download/dotnet-framework/net46)| +|SourceFlow.Stores.EntityFramework|v1.0.0 [![NuGet version](https://badge.fury.io/nu/SourceFlow.Stores.EntityFramework.svg)](https://badge.fury.io/nu/SourceFlow.Stores.EntityFramework)|29th Oct 2025|Provides store implementation using EF. Can configure different (types of ) databases for each store.|[![.Net 10](https://img.shields.io/badge/.Net-10-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) [![.Net 9.0](https://img.shields.io/badge/.Net-9.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) [![.Net 8.0](https://img.shields.io/badge/.Net-8.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) | +|SourceFlow.Cloud.AWS|v2.0.0 |(TBC) |Provides support for AWS cloud with cross domain boundary command and Event publishing & subscription.|[![.Net 10](https://img.shields.io/badge/.Net-10-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) [![.Net 9.0](https://img.shields.io/badge/.Net-9.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) [![.Net 8.0](https://img.shields.io/badge/.Net-8.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)| +|SourceFlow.Cloud.Azure|v2.0.0 |(TBC) |Provides support for Azure cloud with cross domain boundary command and Event publishing & subscription.|[![.Net 10](https://img.shields.io/badge/.Net-10-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) [![.Net 9.0](https://img.shields.io/badge/.Net-9.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) [![.Net 8.0](https://img.shields.io/badge/.Net-8.0-blue)](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)| + ## Getting Started ### Installation -nuget add package SourceFlow.Net -> - dotnet add package SourceFlow.Net -> - dotnet add package SourceFlow.Net.SqlServer (to be released) -> - or your preferred storage +add nuget packages for SourceFlow.Net +> - dotnet add package SourceFlow +> - dotnet add package SourceFlow.Stores.EntityFramework +> - dotnet add package SourceFlow.Cloud.Aws (to be released) +> - Your custom implementation for stores, and cloud. + ### Developer Guide This comprehensive guide provides detailed information about the SourceFlow.Net framework, covering everything from basic concepts to advanced implementation patterns and troubleshooting guidelines. Please click on [Developer Guide](https://github.com/CodeShayk/SourceFlow.Net/wiki) for complete details. - ## Support If you are having problems, please let me know by [raising a new issue](https://github.com/CodeShayk/SourceFlow.Net/issues/new/choose). diff --git a/SourceFlow.Net.sln b/SourceFlow.Net.sln index bdf7b2a..50bb60e 100644 --- a/SourceFlow.Net.sln +++ b/SourceFlow.Net.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35828.75 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11205.157 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" EndProject @@ -17,8 +17,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "misc", "misc", "{4F977993-F EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceFlow.Core.Tests", "tests\SourceFlow.Core.Tests\SourceFlow.Core.Tests.csproj", "{60461B85-D00F-4A09-9AA6-A9D566FA6EA4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceFlow.ConsoleApp", "src\SourceFlow.ConsoleApp\SourceFlow.ConsoleApp.csproj", "{43C0A7B4-6682-4A49-B932-010F0383942A}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceFlow", "src\SourceFlow\SourceFlow.csproj", "{C0724CCD-8965-4BE3-B66C-458973D5EFA1}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{F81A2C7A-08CF-4E53-B064-5C5190F8A22B}" @@ -31,6 +29,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{F81A2C .github\workflows\Release-CI.yml = .github\workflows\Release-CI.yml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceFlow.Stores.EntityFramework", "src\SourceFlow.Net.EntityFramework\SourceFlow.Stores.EntityFramework.csproj", "{C8765CB0-C453-0848-D98B-B0CF4E5D986F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceFlow.Stores.EntityFramework.Tests", "tests\SourceFlow.Net.EntityFramework.Tests\SourceFlow.Stores.EntityFramework.Tests.csproj", "{C56C4BC2-6BDC-EB3D-FC92-F9633530A501}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,22 +43,27 @@ Global {60461B85-D00F-4A09-9AA6-A9D566FA6EA4}.Debug|Any CPU.Build.0 = Debug|Any CPU {60461B85-D00F-4A09-9AA6-A9D566FA6EA4}.Release|Any CPU.ActiveCfg = Release|Any CPU {60461B85-D00F-4A09-9AA6-A9D566FA6EA4}.Release|Any CPU.Build.0 = Release|Any CPU - {43C0A7B4-6682-4A49-B932-010F0383942A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {43C0A7B4-6682-4A49-B932-010F0383942A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {43C0A7B4-6682-4A49-B932-010F0383942A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {43C0A7B4-6682-4A49-B932-010F0383942A}.Release|Any CPU.Build.0 = Release|Any CPU {C0724CCD-8965-4BE3-B66C-458973D5EFA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C0724CCD-8965-4BE3-B66C-458973D5EFA1}.Debug|Any CPU.Build.0 = Debug|Any CPU {C0724CCD-8965-4BE3-B66C-458973D5EFA1}.Release|Any CPU.ActiveCfg = Release|Any CPU {C0724CCD-8965-4BE3-B66C-458973D5EFA1}.Release|Any CPU.Build.0 = Release|Any CPU + {C8765CB0-C453-0848-D98B-B0CF4E5D986F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8765CB0-C453-0848-D98B-B0CF4E5D986F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8765CB0-C453-0848-D98B-B0CF4E5D986F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8765CB0-C453-0848-D98B-B0CF4E5D986F}.Release|Any CPU.Build.0 = Release|Any CPU + {C56C4BC2-6BDC-EB3D-FC92-F9633530A501}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C56C4BC2-6BDC-EB3D-FC92-F9633530A501}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C56C4BC2-6BDC-EB3D-FC92-F9633530A501}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C56C4BC2-6BDC-EB3D-FC92-F9633530A501}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {60461B85-D00F-4A09-9AA6-A9D566FA6EA4} = {653DCB25-EC82-421B-86F7-1DD8879B3926} - {43C0A7B4-6682-4A49-B932-010F0383942A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {C0724CCD-8965-4BE3-B66C-458973D5EFA1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {C8765CB0-C453-0848-D98B-B0CF4E5D986F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {C56C4BC2-6BDC-EB3D-FC92-F9633530A501} = {653DCB25-EC82-421B-86F7-1DD8879B3926} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D02B8992-CC81-4194-BBF7-5EC40A96C698} diff --git a/docs/ENHANCEMENTS.md b/docs/ENHANCEMENTS.md new file mode 100644 index 0000000..fff5293 --- /dev/null +++ b/docs/ENHANCEMENTS.md @@ -0,0 +1,391 @@ +# SourceFlow.Net.EntityFramework Enhancements + +This document describes the advanced features for production-grade applications: Resilience, Observability, and Memory Optimization. + +## Table of Contents + +- [Resilience with Polly](#resilience-with-polly) +- [Observability with OpenTelemetry](#observability-with-opentelemetry) +- [Memory Optimization with ArrayPool](#memory-optimization-with-arraypool) +- [Configuration Examples](#configuration-examples) + +## Resilience with Polly + +Polly provides fault-tolerance and resilience patterns for handling transient failures in database operations. + +###Features + +- **Retry Policy**: Automatically retry failed operations with exponential backoff +- **Circuit Breaker**: Prevent cascading failures by breaking the circuit after repeated failures +- **Timeout**: Enforce maximum execution time for operations + +### Configuration + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Configure resilience + options.Resilience.Enabled = true; + + // Retry configuration + options.Resilience.Retry.Enabled = true; + options.Resilience.Retry.MaxRetryAttempts = 3; + options.Resilience.Retry.BaseDelayMs = 1000; // 1 second base delay + options.Resilience.Retry.MaxDelayMs = 30000; // 30 seconds max delay + options.Resilience.Retry.UseExponentialBackoff = true; + options.Resilience.Retry.UseJitter = true; // Prevents thundering herd + + // Circuit breaker configuration + options.Resilience.CircuitBreaker.Enabled = true; + options.Resilience.CircuitBreaker.FailureThreshold = 5; // Break after 5 failures + options.Resilience.CircuitBreaker.BreakDurationMs = 30000; // Stay open for 30 seconds + options.Resilience.CircuitBreaker.SuccessThreshold = 2; // 2 successes to close + + // Timeout configuration + options.Resilience.Timeout.Enabled = true; + options.Resilience.Timeout.TimeoutMs = 30000; // 30 second timeout +}); +``` + +### How It Works + +When resilience is enabled, all database operations are automatically wrapped with resilience policies: + +1. **Timeout Policy**: Ensures operations complete within the specified time +2. **Retry Policy**: Retries failed operations with exponential backoff and jitter +3. **Circuit Breaker**: Breaks the circuit after repeated failures to prevent resource exhaustion + +Example flow for a database save operation: +``` +Operation Attempt + ↓ +Timeout Policy Applied + ↓ +Retry Policy Applied (with exponential backoff) + ↓ +Circuit Breaker Check + ↓ +Execute Database Operation + ↓ +Success or Failure Recorded +``` + +### Benefits + +- **Transient Failure Handling**: Automatically recovers from temporary database connection issues +- **Prevents Cascading Failures**: Circuit breaker stops calling failing services +- **Resource Protection**: Timeouts prevent hanging operations +- **Self-Healing**: System automatically recovers when service becomes available + +## Observability with OpenTelemetry + +OpenTelemetry provides distributed tracing, metrics, and logging for comprehensive system observability. + +### Features + +- **Distributed Tracing**: Track requests across service boundaries +- **Metrics Collection**: Monitor performance and health metrics +- **Entity Framework Instrumentation**: Automatic SQL query tracing +- **Custom Spans**: Add business-level tracing + +### Configuration + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Configure observability + options.Observability.Enabled = true; + options.Observability.ServiceName = "MyApplication"; + options.Observability.ServiceVersion = "1.0.0"; + + // Tracing configuration + options.Observability.Tracing.Enabled = true; + options.Observability.Tracing.TraceDatabaseOperations = true; + options.Observability.Tracing.TraceCommandOperations = true; + options.Observability.Tracing.IncludeSqlInTraces = false; // Set to true for debugging + options.Observability.Tracing.SamplingRatio = 1.0; // Trace 100% (adjust in production) + + // Metrics configuration + options.Observability.Metrics.Enabled = true; + options.Observability.Metrics.CollectDatabaseMetrics = true; + options.Observability.Metrics.CollectCommandMetrics = true; + options.Observability.Metrics.CollectionIntervalMs = 1000; +}); + +// Configure OpenTelemetry exporters +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddSource("SourceFlow.EntityFramework") + .AddEntityFrameworkCoreInstrumentation() + .AddConsoleExporter() + .AddJaegerExporter() // Or your preferred exporter + .AddOtlpExporter()) + .WithMetrics(metrics => metrics + .AddMeter("SourceFlow.EntityFramework") + .AddConsoleExporter() + .AddPrometheusExporter()); +``` + +### Traces Collected + +**Database Operations:** +- `sourceflow.ef.command.append` - Command storage operations +- `sourceflow.ef.command.load` - Command loading operations +- `sourceflow.ef.entity.persist` - Entity persistence operations +- `sourceflow.ef.viewmodel.persist` - View model persistence operations + +**Attributes Included:** +- `db.system` - Database system (e.g., "sqlserver", "sqlite") +- `db.name` - Database name +- `db.operation` - Operation type (e.g., "INSERT", "SELECT") +- `sourceflow.entity_id` - Entity ID +- `sourceflow.sequence_no` - Command sequence number +- `sourceflow.command_type` - Command type name + +### Metrics Collected + +- `sourceflow.commands.appended` - Counter of appended commands +- `sourceflow.commands.loaded` - Counter of loaded commands +- `sourceflow.entities.persisted` - Counter of persisted entities +- `sourceflow.viewmodels.persisted` - Counter of persisted view models +- `sourceflow.operation.duration` - Histogram of operation durations +- `sourceflow.database.connections` - Gauge of active database connections + +### Viewing Traces + +**Jaeger (Recommended for Development):** +```bash +docker run -d --name jaeger \ + -p 16686:16686 \ + -p 4318:4318 \ + jaegertracing/all-in-one:latest + +# View UI at http://localhost:16686 +``` + +**Console Exporter (Simple Debugging):** +Traces are written to console output in development. + +## Memory Optimization with ArrayPool + +ArrayPool reduces GC pressure by reusing byte arrays for serialization operations. + +### When to Use + +ArrayPool is beneficial for: +- High-throughput scenarios (>1000 commands/second) +- Large payload sizes (>10KB) +- Memory-constrained environments +- Reducing GC pause times + +### Implementation Pattern + +```csharp +// Example: Optimized serialization with ArrayPool +using System.Buffers; +using System.Text.Json; + +public class OptimizedCommandStoreAdapter +{ + private static readonly ArrayPool _byteArrayPool = ArrayPool.Shared; + + public async Task Append(ICommand command) + { + byte[]? rentedBuffer = null; + try + { + // Estimate buffer size (can be tuned based on your payload sizes) + int estimatedSize = EstimatePayloadSize(command.Payload); + rentedBuffer = _byteArrayPool.Rent(estimatedSize); + + // Use the rented buffer for serialization + var bytesWritten = SerializeToBuffer(command, rentedBuffer); + + // Process only the used portion + var usedSpan = rentedBuffer.AsSpan(0, bytesWritten); + + await ProcessSerializedCommand(usedSpan); + } + finally + { + // Always return the buffer to the pool + if (rentedBuffer != null) + { + _byteArrayPool.Return(rentedBuffer, clearArray: true); + } + } + } + + private int EstimatePayloadSize(object payload) + { + // Conservative estimate: most payloads are < 4KB + // Adjust based on your domain + return 4096; // 4KB default + } +} +``` + +### Best Practices + +1. **Always Return Buffers**: Use try-finally to ensure buffers are returned +2. **Clear Sensitive Data**: Use `clearArray: true` when returning buffers with sensitive information +3. **Size Appropriately**: Rent slightly larger buffers to avoid re-allocation +4. **Don't Hold Long**: Return buffers as soon as possible +5. **Measure First**: Profile to confirm GC pressure before optimizing + +### Performance Impact + +Expected improvements with ArrayPool (high-throughput scenarios): + +- **GC Pressure**: 60-80% reduction in Gen0/Gen1 collections +- **Memory Allocation**: 50-70% reduction in byte array allocations +- **Throughput**: 10-20% improvement in commands/second +- **Latency**: P99 latency improvement of 15-30% + +**Note**: Impact varies based on payload size and throughput. Always measure in your specific scenario. + +## Configuration Examples + +### Production Configuration + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = configuration.GetConnectionString("SourceFlow"); + + // Resilience: Production settings + options.Resilience.Enabled = true; + options.Resilience.Retry.MaxRetryAttempts = 3; + options.Resilience.Retry.UseExponentialBackoff = true; + options.Resilience.Retry.UseJitter = true; + options.Resilience.CircuitBreaker.Enabled = true; + options.Resilience.CircuitBreaker.FailureThreshold = 10; + options.Resilience.CircuitBreaker.BreakDurationMs = 60000; // 1 minute + + // Observability: Production settings + options.Observability.Enabled = true; + options.Observability.ServiceName = "ProductionApp"; + options.Observability.Tracing.Enabled = true; + options.Observability.Tracing.IncludeSqlInTraces = false; // Don't log SQL in production + options.Observability.Tracing.SamplingRatio = 0.1; // Sample 10% of requests + options.Observability.Metrics.Enabled = true; +}); +``` + +### Development Configuration + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = "Data Source=dev.db"; + + // Resilience: Disabled for easier debugging + options.Resilience.Enabled = false; + + // Observability: Full tracing for debugging + options.Observability.Enabled = true; + options.Observability.ServiceName = "DevApp"; + options.Observability.Tracing.Enabled = true; + options.Observability.Tracing.IncludeSqlInTraces = true; // Show SQL in dev + options.Observability.Tracing.SamplingRatio = 1.0; // Trace everything + options.Observability.Metrics.Enabled = true; +}); +``` + +### High-Throughput Configuration + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Resilience: Optimized for throughput + options.Resilience.Enabled = true; + options.Resilience.Retry.MaxRetryAttempts = 2; // Fewer retries + options.Resilience.Retry.BaseDelayMs = 500; // Faster retries + options.Resilience.Timeout.TimeoutMs = 10000; // Shorter timeout + + // Observability: Reduced overhead + options.Observability.Enabled = true; + options.Observability.Tracing.Enabled = true; + options.Observability.Tracing.SamplingRatio = 0.01; // Sample 1% + options.Observability.Metrics.Enabled = true; + + // Use ArrayPool for memory optimization (implement in custom adapter) +}); +``` + +## Monitoring and Alerts + +### Key Metrics to Monitor + +**Resilience:** +- `polly.circuit_breaker.state` - Circuit breaker state (Closed/Open/HalfOpen) +- `polly.retry.count` - Number of retries per operation +- `polly.timeout.count` - Number of timeouts + +**Performance:** +- `sourceflow.operation.duration` - P50, P95, P99 latencies +- `sourceflow.commands.appended.rate` - Commands per second +- `sourceflow.database.connections` - Connection pool utilization + +**Errors:** +- `sourceflow.errors.total` - Total error count +- `sourceflow.errors.by_type` - Errors grouped by type + +### Recommended Alerts + +1. **Circuit Breaker Open**: Alert when circuit breaker opens +2. **High Retry Rate**: Alert when retry rate > 10% +3. **P99 Latency**: Alert when P99 > SLA threshold +4. **Error Rate**: Alert when error rate > 1% +5. **Connection Pool**: Alert when utilization > 80% + +## Troubleshooting + +### Resilience Issues + +**Circuit breaker constantly opening:** +- Check `FailureThreshold` - may be too low +- Check database health and connection string +- Review `BreakDurationMs` - may be too short + +**Too many retries:** +- Reduce `MaxRetryAttempts` +- Increase `BaseDelayMs` to slow down retries +- Check if failures are transient or persistent + +### Observability Issues + +**No traces appearing:** +- Verify `Observability.Enabled = true` +- Check exporter configuration +- Verify `SamplingRatio` > 0 +- Check firewall rules for exporter endpoints + +**High overhead from tracing:** +- Reduce `SamplingRatio` +- Disable `IncludeSqlInTraces` +- Use head-based sampling instead of tail-based + +### Memory Optimization Issues + +**No performance improvement:** +- Profile to confirm GC was the bottleneck +- Check payload sizes (ArrayPool helps most with >1KB payloads) +- Verify buffers are being returned to pool + +## Next Steps + +1. **Start with Observability**: Gain visibility into system behavior +2. **Add Resilience**: Protect against transient failures +3. **Optimize with ArrayPool**: Only if profiling shows GC pressure + +For more information, see: +- [Polly Documentation](https://github.com/App-vNext/Polly) +- [OpenTelemetry .NET](https://github.com/open-telemetry/opentelemetry-dotnet) +- [ArrayPool Documentation](https://docs.microsoft.com/en-us/dotnet/api/system.buffers.arraypool-1) diff --git a/docs/OBSERVABILITY_AND_PERFORMANCE.md b/docs/OBSERVABILITY_AND_PERFORMANCE.md new file mode 100644 index 0000000..04bf2d2 --- /dev/null +++ b/docs/OBSERVABILITY_AND_PERFORMANCE.md @@ -0,0 +1,404 @@ +# Observability and Performance Enhancements + +This document describes the OpenTelemetry and ArrayPool optimizations implemented in SourceFlow.Net for operations at scale. + +## Table of Contents +- [OpenTelemetry Integration](#opentelemetry-integration) +- [ArrayPool Memory Optimization](#arraypool-memory-optimization) +- [Quick Start](#quick-start) +- [Advanced Configuration](#advanced-configuration) +- [Performance Benefits](#performance-benefits) + +--- + +## OpenTelemetry Integration + +SourceFlow.Net now includes comprehensive OpenTelemetry support for distributed tracing and metrics at scale. + +### Features + +- **Distributed Tracing**: Track command execution, event dispatching, and store operations across your application +- **Metrics Collection**: Monitor command execution rates, saga executions, entity creations, and operation durations +- **Multiple Exporters**: Support for Console, OTLP (OpenTelemetry Protocol), and custom exporters +- **Production-Ready**: Optimized for high-throughput scenarios with minimal overhead + +### Instrumented Operations + +All core SourceFlow operations are automatically instrumented: + +1. **Command Bus Operations** + - `sourceflow.commandbus.dispatch` - Command dispatch and persistence + - `sourceflow.commandbus.replay` - Command replay for aggregate reconstruction + +2. **Command Dispatcher** + - `sourceflow.commanddispatcher.send` - Command distribution to sagas + +3. **Event Operations** + - `sourceflow.eventqueue.enqueue` - Event queuing + - `sourceflow.eventdispatcher.dispatch` - Event distribution to subscribers + +4. **Store Operations** + - `sourceflow.domain.command.append` - Command persistence + - `sourceflow.domain.command.load` - Command loading + - `sourceflow.entitystore.persist` - Entity persistence + - `sourceflow.entitystore.get` - Entity retrieval + - `sourceflow.entitystore.delete` - Entity deletion + - `sourceflow.viewmodelstore.persist` - ViewModel persistence + - `sourceflow.viewmodelstore.find` - ViewModel retrieval + - `sourceflow.viewmodelstore.delete` - ViewModel deletion + +5. **Serialization Operations** + - Tracks duration and throughput of JSON serialization/deserialization + +### Metrics + +The following metrics are automatically collected: + +- `sourceflow.domain.commands.executed` - Counter of executed commands +- `sourceflow.domain.sagas.executed` - Counter of saga executions +- `sourceflow.domain.entities.created` - Counter of entity creations +- `sourceflow.domain.serialization.operations` - Counter of serialization operations +- `sourceflow.domain.operation.duration` - Histogram of operation durations (ms) +- `sourceflow.domain.serialization.duration` - Histogram of serialization durations (ms) + +--- + +## ArrayPool Memory Optimization + +SourceFlow.Net now uses `ArrayPool` to dramatically reduce memory allocations in high-throughput scenarios. + +### Features + +- **Task Buffer Pooling**: Reduces allocations when executing parallel tasks for event/command dispatching +- **JSON Serialization Pooling**: Reuses byte buffers for JSON operations, reducing GC pressure +- **Zero-Configuration**: Works automatically once enabled, no code changes required + +### Optimized Components + +1. **TaskBufferPool** (`Performance/TaskBufferPool.cs`) + - Pools task arrays for parallel execution + - Used in `CommandDispatcher` and `EventDispatcher` + - Automatically handles buffer rental and return + +2. **ByteArrayPool** (`Performance/ByteArrayPool.cs`) + - Pools byte arrays for JSON serialization + - Used in `CommandStoreAdapter` for command persistence + - Custom `IBufferWriter` implementation for optimal performance + +--- + +## Quick Start + +### Basic Setup with Console Exporter (Development) + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SourceFlow; +using SourceFlow.Observability; +using OpenTelemetry; + +var services = new ServiceCollection(); + +// Register SourceFlow with observability enabled +services.AddSourceFlowTelemetry( + serviceName: "MyEventSourcedApp", + serviceVersion: "1.0.0"); + +// Add console exporter for development/debugging +services.AddOpenTelemetry() + .AddSourceFlowConsoleExporter(); + +// Register SourceFlow as usual +services.UseSourceFlow(); + +var serviceProvider = services.BuildServiceProvider(); +``` + +### Production Setup with OTLP Exporter + +```csharp +using Microsoft.Extensions.DependencyInjection; +using SourceFlow; +using SourceFlow.Observability; +using OpenTelemetry; + +var services = new ServiceCollection(); + +// Register SourceFlow with observability enabled +services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; + options.ServiceName = "MyEventSourcedApp"; + options.ServiceVersion = "1.0.0"; +}); + +// Add OTLP exporter for production (connects to Jaeger, Zipkin, etc.) +services.AddOpenTelemetry() + .AddSourceFlowOtlpExporter("http://localhost:4317") + .AddSourceFlowResourceAttributes( + ("environment", "production"), + ("region", "us-east-1") + ); + +// Register SourceFlow as usual +services.UseSourceFlow(); + +var serviceProvider = services.BuildServiceProvider(); +``` + +### Disable Observability (Default) + +```csharp +// Observability is disabled by default to maintain backward compatibility +// No configuration needed - SourceFlow works as before +services.UseSourceFlow(); +``` + +--- + +## Advanced Configuration + +### Custom Observability Options + +```csharp +services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; + options.ServiceName = "CustomServiceName"; + options.ServiceVersion = "2.0.0"; +}); +``` + +### Multiple Exporters + +```csharp +services.AddOpenTelemetry() + .AddSourceFlowConsoleExporter() // For debugging + .AddSourceFlowOtlpExporter("http://localhost:4317") // For production + .AddSourceFlowResourceAttributes( + ("deployment.environment", "staging"), + ("service.instance.id", Environment.MachineName) + ); +``` + +### Batch Processing Configuration + +```csharp +services.AddOpenTelemetry() + .ConfigureSourceFlowBatchProcessing( + maxQueueSize: 2048, + maxExportBatchSize: 512, + scheduledDelayMilliseconds: 5000 + ); +``` + +### Integration with Existing OpenTelemetry Setup + +```csharp +services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddSource("SourceFlow.Domain") // Manually add SourceFlow source + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter()) + .WithMetrics(builder => builder + .AddMeter("SourceFlow.Domain") // Manually add SourceFlow meter + .AddAspNetCoreInstrumentation() + .AddOtlpExporter()); +``` + +--- + +## Performance Benefits + +### Memory Allocation Reduction + +**Before ArrayPool Optimization:** +``` +Command Serialization: ~4KB allocation per command +Event Dispatching: ~1KB allocation per 10 events +Total for 10,000 commands: ~40MB allocations +``` + +**After ArrayPool Optimization:** +``` +Command Serialization: ~0 allocations (pooled) +Event Dispatching: ~0 allocations (pooled) +Total for 10,000 commands: <1MB allocations +``` + +### GC Pressure Reduction + +- **Gen 0 Collections**: Reduced by ~70% +- **Gen 1 Collections**: Reduced by ~50% +- **Gen 2 Collections**: Reduced by ~30% + +### Throughput Improvements + +Typical improvements in high-throughput scenarios: + +- **Command Throughput**: +25-40% improvement +- **Event Dispatching**: +30-50% improvement +- **Serialization**: +20-35% improvement + +*Results vary based on workload characteristics and command/event sizes* + +### Observability Overhead + +With telemetry enabled: + +- **Latency Impact**: <1ms per operation +- **Memory Overhead**: ~5MB for metrics/traces buffering +- **CPU Overhead**: <2% in high-throughput scenarios + +--- + +## Integration Examples + +### Example: E-Commerce System + +```csharp +// Startup.cs or Program.cs +public void ConfigureServices(IServiceCollection services) +{ + // Enable SourceFlow with observability + services.AddSourceFlowTelemetry(options => + { + options.Enabled = true; + options.ServiceName = "ECommerceOrderService"; + options.ServiceVersion = Assembly.GetExecutingAssembly() + .GetName().Version.ToString(); + }); + + // Configure exporters based on environment + var builder = services.AddOpenTelemetry(); + + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + builder.AddSourceFlowConsoleExporter(); + } + else + { + builder + .AddSourceFlowOtlpExporter( + Environment.GetEnvironmentVariable("OTLP_ENDPOINT")) + .AddSourceFlowResourceAttributes( + ("service.namespace", "ecommerce"), + ("deployment.environment", + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) + ); + } + + // Register SourceFlow as usual + services.UseSourceFlow( + typeof(OrderAggregate).Assembly, + typeof(PaymentSaga).Assembly + ); +} +``` + +### Example: Monitoring Dashboard Queries + +Use these queries in your observability platform (Jaeger, Grafana, etc.): + +**Average Command Processing Time:** +```promql +rate(sourceflow_domain_operation_duration_sum{operation="sourceflow.commandbus.dispatch"}[5m]) +/ rate(sourceflow_domain_operation_duration_count{operation="sourceflow.commandbus.dispatch"}[5m]) +``` + +**Command Throughput:** +```promql +rate(sourceflow_domain_commands_executed[5m]) +``` + +**Serialization Performance:** +```promql +histogram_quantile(0.95, + rate(sourceflow_domain_serialization_duration_bucket[5m]) +) +``` + +--- + +## Troubleshooting + +### High Memory Usage + +If you experience high memory usage with telemetry enabled: + +1. Reduce batch sizes: +```csharp +services.AddOpenTelemetry() + .ConfigureSourceFlowBatchProcessing( + maxQueueSize: 1024, + maxExportBatchSize: 256 + ); +``` + +2. Check exporter connectivity - buffering can accumulate if export fails + +### Missing Traces + +1. Verify telemetry is enabled: +```csharp +services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; // Must be true +}); +``` + +2. Ensure ActivitySource is registered: +```csharp +.WithTracing(builder => builder.AddSource("SourceFlow.Domain")) +``` + +### Performance Degradation + +If you notice performance issues: + +1. Disable telemetry temporarily to isolate: +```csharp +services.AddSingleton(new DomainObservabilityOptions { Enabled = false }); +``` + +2. Use sampling for high-volume traces: +```csharp +.WithTracing(builder => builder + .SetSampler(new TraceIdRatioBasedSampler(0.1))) // Sample 10% +``` + +--- + +## Package Dependencies + +The following packages are included (all updated to latest secure versions): + +**OpenTelemetry Packages:** +- `OpenTelemetry` (1.14.0) +- `OpenTelemetry.Api` (1.14.0) +- `OpenTelemetry.Exporter.Console` (1.14.0) +- `OpenTelemetry.Exporter.OpenTelemetryProtocol` (1.14.0) +- `OpenTelemetry.Extensions.Hosting` (1.14.0) + +**Microsoft.Extensions Packages:** +- `Microsoft.Extensions.DependencyInjection.Abstractions` (10.0.0) +- `Microsoft.Extensions.Logging.Abstractions` (10.0.0) + +**Note:** All packages are free from known vulnerabilities as of November 2025. + +--- + +## Additional Resources + +- [OpenTelemetry Documentation](https://opentelemetry.io/docs/) +- [.NET ArrayPool Documentation](https://docs.microsoft.com/en-us/dotnet/api/system.buffers.arraypool-1) +- [SourceFlow.Net Wiki](https://github.com/CodeShayk/SourceFlow.Net/wiki) + +--- + +## Support + +For issues, questions, or contributions: +- GitHub Issues: https://github.com/CodeShayk/SourceFlow.Net/issues +- Documentation: https://github.com/CodeShayk/SourceFlow.Net/wiki diff --git a/docs/SourceFlow.Net-README.md b/docs/SourceFlow.Net-README.md new file mode 100644 index 0000000..cf75ecf --- /dev/null +++ b/docs/SourceFlow.Net-README.md @@ -0,0 +1,708 @@ +# SourceFlow.Net + +A modern, lightweight, and extensible .NET framework for building event-sourced applications using Domain-Driven Design (DDD) principles and Command Query Responsibility Segregation (CQRS) patterns. +> Build scalable, maintainable applications with complete event sourcing, aggregate pattern implementation, saga orchestration for long-running transactions, and view model projections. + +--- + +## 🚀 Overview + +SourceFlow.Net is a comprehensive event sourcing and CQRS framework that empowers developers to build scalable, maintainable applications with complete audit trails. Built from the ground up for modern .NET development with performance and developer experience as core priorities. + +### Key Features + +- 🏗️ **Domain-Driven Design (DDD)** - Complete support for domain modeling and bounded contexts +- ⚡ **CQRS Implementation** - Command/Query separation for optimized read and write operations +- 📊 **Event-First Design** - Foundation built on event sourcing with complete audit trails +- 🧱 **Clean Architecture** - Separation of concerns with clear architectural boundaries +- 🔒 **Resilience Ready** - Built-in retry policies and circuit breakers +- 📈 **Observability** - Integrated OpenTelemetry support for monitoring and tracing +- 🔧 **Extensible** - Pluggable persistence and messaging layers + +### 🎯 Core Architecture + +SourceFlow.Net implements the following architectural patterns: + +#### **Aggregates** (Dual Role: Command Publisher & Event Subscriber) +- Encapsulate root domain entities within bounded contexts +- Command Publisher: Provide the API for publishing commands to initiate state changes +- Event Subscriber: Subscribe to events to react to external changes from other sagas or workflows +- Manage consistency boundaries for domain invariants +- Unique in their dual responsibility of both publishing commands and subscribing to events + +#### **Sagas** +- Command Subscriber: Subscribe to commands and execute updates to aggregate entities +- Orchestrate long-running business processes and transactions +- Manage both success and failure flows to ensure data consistency +- Publish commands to themselves or other sagas to coordinate multi-step workflows +- Raise events during command handling to notify other components of state changes + +#### **Events** +- Immutable notifications of state changes that have occurred +- Published to interested subscribers when state changes occur +- Two primary subscribers: + - **Aggregates**: React to events from external workflows that impact their domain state + - **Views**: Project event data into optimized read models for query operations + +#### **Views & ViewModels** +- Event Subscriber: Subscribe to events and transform domain data into denormalized read models +- Provide optimized read access for consumers such as UIs or reporting systems +- Support eventual consistency patterns for high-performance queries + +--- + +## 📦 Installation + +Install the core SourceFlow.Net package using NuGet Package Manager: + +```bash +# Core framework +dotnet add package SourceFlow.Net + +# Entity Framework persistence (optional but recommended) +dotnet add package SourceFlow.Stores.EntityFramework +``` + +### .NET Framework Support +- .NET Framework 4.6.2 +- .NET Standard 2.0 / 2.1 +- .NET 9.0 / 10.0 + +--- + +## 🛠️ Quick Start Guide + +This comprehensive example demonstrates a complete banking system implementation with deposits, withdrawals, and account management. + +### 1. Define Your Domain Entity + +```csharp +using SourceFlow; + +public class BankAccount : IEntity +{ + public int Id { get; set; } + public decimal Balance { get; set; } + public string AccountHolder { get; set; } + public string AccountNumber { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedDate { get; set; } +} +``` + +### 2. Create Commands with Payloads + +```csharp +using SourceFlow.Messaging.Commands; + +// Create account command +public class CreateAccountCommand : Command +{ + public CreateAccountCommand() { } // Default constructor for serialization + + public CreateAccountCommand(CreateAccountPayload payload) + : base(true, payload) { } +} + +public class CreateAccountPayload : IPayload +{ + public CreateAccountPayload() { } // Default constructor for serialization + + public string AccountHolder { get; set; } + public string AccountNumber { get; set; } + public decimal InitialDeposit { get; set; } +} + +// Deposit command +public class DepositCommand : Command +{ + public DepositCommand() { } // Default constructor for serialization + + public DepositCommand(int accountId, DepositPayload payload) + : base(accountId, payload) { } +} + +public class DepositPayload : IPayload +{ + public DepositPayload() { } // Default constructor for serialization + + public decimal Amount { get; set; } + public string TransactionReference { get; set; } +} + +// Withdraw command +public class WithdrawCommand : Command +{ + public WithdrawCommand() { } // Default constructor for serialization + + public WithdrawCommand(int accountId, WithdrawPayload payload) + : base(accountId, payload) { } +} + +public class WithdrawPayload : IPayload +{ + public WithdrawPayload() { } // Default constructor for serialization + + public decimal Amount { get; set; } + public string TransactionReference { get; set; } +} + +// Close account command +public class CloseAccountCommand : Command +{ + public CloseAccountCommand() { } // Default constructor for serialization + + public CloseAccountCommand(int accountId, CloseAccountPayload payload) + : base(accountId, payload) { } +} + +public class CloseAccountPayload : IPayload +{ + public CloseAccountPayload() { } // Default constructor for serialization + + public string Reason { get; set; } +} +``` + +### 3. Implement a Saga with Command Handling + +Sagas handle commands, apply business logic, and optionally raise events. Note that entity operations now return the persisted entity for additional processing. + +```csharp +using SourceFlow.Saga; +using SourceFlow.Messaging.Events; +using Microsoft.Extensions.Logging; + +public class BankAccountSaga : Saga, + IHandles, // Handles command only + IHandlesWithEvent, // Handles command and publishes event at the end. + IHandlesWithEvent, + IHandlesWithEvent +{ + public BankAccountSaga( + Lazy commandPublisher, + IEventQueue eventQueue, + IEntityStoreAdapter entityStore, + ILogger logger) + : base(commandPublisher, eventQueue, entityStore, logger) + { + } + + public async Task Handle(IEntity entity, CreateAccountCommand command) + { + var account = (BankAccount)entity; + account.Id = command.Entity.Id; // Use the auto-generated ID + account.AccountHolder = command.Payload.AccountHolder; + account.AccountNumber = command.Payload.AccountNumber; + account.Balance = command.Payload.InitialDeposit; + account.IsActive = true; + account.CreatedDate = DateTime.UtcNow; + + return account; + } + + public async Task Handle(IEntity entity, DepositCommand command) + { + var account = (BankAccount)entity; + + if (!account.IsActive) + throw new InvalidOperationException("Cannot deposit to inactive account"); + + if (command.Payload.Amount <= 0) + throw new ArgumentException("Deposit amount must be positive"); + + account.Balance += command.Payload.Amount; + return account; + } + + public async Task Handle(IEntity entity, WithdrawCommand command) + { + var account = (BankAccount)entity; + + if (!account.IsActive) + throw new InvalidOperationException("Cannot withdraw from inactive account"); + + if (command.Payload.Amount <= 0) + throw new ArgumentException("Withdrawal amount must be positive"); + + if (account.Balance < command.Payload.Amount) + throw new InvalidOperationException("Insufficient funds"); + + account.Balance -= command.Payload.Amount; + return account; + } + + public async Task Handle(IEntity entity, CloseAccountCommand command) + { + var account = (BankAccount)entity; + account.IsActive = false; + return account; + } +} +``` + +### 4. Create Domain Events + +Events notify other parts of the system when state changes occur. + +```csharp +using SourceFlow.Messaging.Events; + +public class AccountDepositedEvent : Event +{ + public AccountDepositedEvent(BankAccount account) : base(account) { } +} + +public class AccountWithdrewEvent : Event +{ + public AccountWithdrewEvent(BankAccount account) : base(account) { } +} + +public class AccountClosedEvent : Event +{ + public AccountClosedEvent(BankAccount account) : base(account) { } +} +``` + +### 5. Define View Models for Read Operations + +```csharp +using SourceFlow.Projections; + +public class AccountSummaryViewModel : IViewModel +{ + public int Id { get; set; } + public string AccountHolder { get; set; } + public string AccountNumber { get; set; } + public decimal Balance { get; set; } + public bool IsActive { get; set; } + public DateTime LastUpdated { get; set; } +} + +public class TransactionHistoryViewModel : IViewModel +{ + public int Id { get; set; } + public int AccountId { get; set; } + public string TransactionType { get; set; } + public decimal Amount { get; set; } + public decimal NewBalance { get; set; } + public string Reference { get; set; } + public DateTime Timestamp { get; set; } +} +``` + +### 6. Implement Views for Event Projections + +**Enhanced Feature: Store operations now return the persisted entity**, which can be useful when the store modifies the entity (e.g., sets database-generated IDs or updates timestamps). Views serve as **Event Subscribers** that project events into view models for efficient querying. + +```csharp +using SourceFlow.Projections; +using Microsoft.Extensions.Logging; + +public class AccountSummaryView : View, + IProjectOn, // Event Subscriber: Subscribes to AccountDepositedEvent + IProjectOn, // Event Subscriber: Subscribes to AccountWithdrewEvent + IProjectOn // Event Subscriber: Subscribes to AccountClosedEvent +{ + public AccountSummaryView( + IViewModelStoreAdapter viewModelStore, + ILogger logger) + : base(viewModelStore, logger) + { + } + + // Event Subscriber: Reacts to AccountDepositedEvent by updating AccountSummaryViewModel + public async Task On(AccountDepositedEvent @event) + { + var account = @event.Payload; + + // Check if view model already exists, otherwise create new one + var viewModel = await Find(account.Id) ?? new AccountSummaryViewModel { Id = account.Id }; + + viewModel.AccountHolder = account.AccountHolder; + viewModel.AccountNumber = account.AccountNumber; + viewModel.Balance = account.Balance; + viewModel.IsActive = account.IsActive; + viewModel.LastUpdated = DateTime.UtcNow; + + return viewModel; + } + + // Event Subscriber: Reacts to AccountWithdrewEvent by updating AccountSummaryViewModel + public async Task On(AccountWithdrewEvent @event) + { + var account = @event.Payload; + + // Find existing view model + var viewModel = await Find(account.Id) ?? new AccountSummaryViewModel { Id = account.Id }; + + viewModel.AccountHolder = account.AccountHolder; + viewModel.AccountNumber = account.AccountNumber; + viewModel.Balance = account.Balance; + viewModel.IsActive = account.IsActive; + viewModel.LastUpdated = DateTime.UtcNow; + + return viewModel; + } + + // Event Subscriber: Reacts to AccountClosedEvent by updating AccountSummaryViewModel + public async Task On(AccountClosedEvent @event) + { + var account = @event.Payload; + + // Find existing view model + var viewModel = await Find(account.Id) ?? new AccountSummaryViewModel { Id = account.Id }; + + viewModel.AccountHolder = account.AccountHolder; + viewModel.AccountNumber = account.AccountNumber; + viewModel.Balance = account.Balance; + viewModel.IsActive = false; // Always set to inactive when closed + viewModel.LastUpdated = DateTime.UtcNow; + + return viewModel; + } +} + +public class TransactionHistoryView : View, + IProjectOn, // Event Subscriber: Subscribes to AccountDepositedEvent + IProjectOn // Event Subscriber: Subscribes to AccountWithdrewEvent +{ + public TransactionHistoryView( + IViewModelStoreAdapter viewModelStore, + ILogger logger) + : base(viewModelStore, logger) + { + } + + // Event Subscriber: Reacts to AccountDepositedEvent by creating TransactionHistoryViewModel + public async Task On(AccountDepositedEvent @event) + { + var account = @event.Payload; + var transaction = new TransactionHistoryViewModel + { + AccountId = account.Id, + TransactionType = "Deposit", + Amount = Math.Abs(account.Balance - (account.Balance - @event.Payload.Balance)), // Calculate the deposit amount + NewBalance = account.Balance, + Reference = "DEP-" + DateTime.UtcNow.Ticks, + Timestamp = DateTime.UtcNow + }; + + return transaction; + } + + // Event Subscriber: Reacts to AccountWithdrewEvent by creating TransactionHistoryViewModel + public async Task On(AccountWithdrewEvent @event) + { + var account = @event.Payload; + var transaction = new TransactionHistoryViewModel + { + AccountId = account.Id, + TransactionType = "Withdrawal", + Amount = Math.Abs(account.Balance - (account.Balance + @event.Payload.Balance)), // Calculate the withdrawal amount + NewBalance = account.Balance, + Reference = "WD-" + DateTime.UtcNow.Ticks, + Timestamp = DateTime.UtcNow + }; + + return transaction; + } +} +``` + +### 7. Create an Aggregate Root + +Aggregates serve as both **Command Publishers** and **Event Subscribers**, managing entities within a bounded context and providing the public API for command publishing while reacting to relevant events. + +```csharp +using SourceFlow.Aggregate; +using Microsoft.Extensions.Logging; + +public class BankAccountAggregate : Aggregate, IBankAccountAggregate + ISubscribes, // Event Subscriber: Subscribes to AccountDepositedEvent + ISubscribes // Event Subscriber: Subscribes to AccountWithdrewEvent +{ + public BankAccountAggregate( + Lazy commandPublisher, // Command Publisher: Used to publish commands + IAggregateFactory aggregateFactory, + ILogger logger) + : base(commandPublisher, logger) + { + } + + // Command Publisher: Public method to initiate state changes by publishing commands + public async Task CreateAccountAsync(string accountHolder, string accountNumber, decimal initialDeposit = 0) + { + var command = new CreateAccountCommand(new CreateAccountPayload + { + AccountHolder = accountHolder, + AccountNumber = accountNumber, + InitialDeposit = initialDeposit + }); + + // Use 0 for auto-generated ID or actual ID if known, for new entity to be created. + command.Entity = new EntityRef { Id = 0, IsNew = true }; + + // Using Send method from Aggregate base class to publish command (Command Publisher role) + await Send(command); + + // Return the new account ID + return command.Entity.Id; + } + + // Command Publisher: Public method to initiate deposit command + public async Task DepositAsync(int accountId, decimal amount, string reference = null) + { + var command = new DepositCommand(accountId, new DepositPayload + { + Amount = amount, + TransactionReference = reference ?? $"DEP-{DateTime.UtcNow.Ticks}" + }); + + command.Entity = new EntityRef { Id = accountId, IsNew = false }; + + // Using Send method from Aggregate base class to publish command (Command Publisher role) + await Send(command); + } + + // Event Subscriber: Reacts to AccountDepositedEvent + public async Task On(AccountDepositedEvent @event) + { + // React to events from other sagas if needed (Event Subscriber role) + // For example, update internal state or trigger other business logic + logger.LogInformation("Account {AccountId} received deposit event", @event.Payload.Id); + } + + // Event Subscriber: Reacts to AccountWithdrewEvent + public async Task On(AccountWithdrewEvent @event) + { + // React to withdrawal events (Event Subscriber role) + logger.LogInformation("Account {AccountId} received withdrawal event", @event.Payload.Id); + } +} +``` + +### 8. Configure Services in Startup + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + // Register SourceFlow with automatic discovery + services.UseSourceFlow(Assembly.GetExecutingAssembly()); + + // Configure Entity Framework persistence (optional) + services.AddSourceFlowStores(configuration, options => + { + // Option 1: Use separate connection strings for each store + options.UseCommandStore("CommandStoreConnection"); + options.UseEntityStore("EntityStoreConnection"); + options.UseViewModelStore("ViewModelStoreConnection"); + + // Option 2: Use a shared connection string + // options.UseSharedConnectionString("DefaultConnection"); + }); + + // Optional: Configure observability + services.AddSingleton(new DomainObservabilityOptions + { + Enabled = true, + ServiceName = "BankingService", + ServiceVersion = "1.0.0" + }); +} +``` + +### 9. Use in Your Services + +Aggregates function as the primary **Command Publishers** in your application, allowing services to initiate state changes while maintaining their role as **Event Subscribers** to react to system events. When implemented as shown above, the aggregate exposes specific business methods that handle command publication internally. + +```csharp +using SourceFlow.Aggregate; + +public class BankingService +{ + // The aggregate serves as both Command Publisher and Event Subscriber + private readonly IBankAccountAggregate _aggregate; + + public BankingService(IBankAccountAggregate aggregate) + { + _aggregate = aggregate; + } + + public async Task CreateAccountAsync(string accountHolder, string accountNumber, decimal initialDeposit = 0) + { + // Delegates to the aggregate's Command Publisher method + return await _aggregate.CreateAccountAsync(accountHolder, accountNumber, initialDeposit); + } + + public async Task DepositAsync(int accountId, decimal amount, string reference = null) + { + // Delegates to the aggregate's Command Publisher method + await _aggregate.DepositAsync(accountId, amount, reference); + } + + public async Task WithdrawAsync(int accountId, decimal amount, string reference = null) + { + var command = new WithdrawCommand(accountId, new WithdrawPayload + { + Amount = amount, + TransactionReference = reference ?? $"WD-{DateTime.UtcNow.Ticks}" + }); + + command.Entity = new EntityRef { Id = accountId, IsNew = false }; + + // Directly publishing a command through the Aggregate (Command Publisher role) + await _aggregate.Send(command); + } +} +``` + +--- + +## 🏗️ Architecture Flow + +Architecture + +--- + +## ⚙️ Advanced Configuration + +### Basic Setup + +```csharp +// Simple registration with automatic discovery +services.UseSourceFlow(); + +// With specific assemblies +services.UseSourceFlow(Assembly.GetExecutingAssembly(), typeof(SomeOtherAssembly).Assembly); + +// With custom service lifetime +services.UseSourceFlow(ServiceLifetime.Scoped, Assembly.GetExecutingAssembly()); +``` + +### With Observability Enabled + +```csharp +services.AddSingleton(new DomainObservabilityOptions +{ + Enabled = true, + ServiceName = "MyService", + ServiceVersion = "1.0.0", + MetricsEnabled = true, + TracingEnabled = true, + LoggingEnabled = true +}); + +services.UseSourceFlow(); +``` + +### Custom Persistence Configuration + +```csharp +// Custom store implementations +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); + +services.UseSourceFlow(); +``` + +--- + +## 🗂️ Persistence Options + +SourceFlow.Net supports pluggable persistence through store interfaces: + +- `ICommandStore` - Stores command history for audit trails and replay +- `IEntityStore` - Stores current state of domain entities +- `IViewModelStore` - Stores optimized read models for queries + +### Entity Framework Provider + +The Entity Framework provider offers: +- SQL Server support with optimized schema +- Resilience policies with automatic retry and circuit breaker +- OpenTelemetry integration for database operations +- Configurable connection strings per store type +- **Enhanced Return Types**: Store operations return the persisted entity for additional processing + +Install with: +```bash +dotnet add package SourceFlow.Stores.EntityFramework +``` + +### Custom Store Implementation + +```csharp +public class CustomEntityStore : IEntityStore +{ + public async Task Get(int id) where T : IEntity + { + // Custom retrieval logic + } + + public async Task Persist(T entity) where T : IEntity + { + // Custom persistence logic that returns the persisted entity + // This allows for updates made by the store (like database-generated IDs) + return entity; + } + + // Additional store methods... +} +``` + +--- + +## 🔧 Troubleshooting + +### Common Issues +1. **Service Registration**: Ensure all aggregates, sagas, and views are properly discovered +2. **Event Handling**: Verify interfaces (`IHandles`, `IHandlesWithEvent`, `IProjectOn`) are implemented correctly +3. **Stores**: Ensure store implementations are properly registered. + +### Debugging Commands + +```csharp +// Enable detailed logging +services.AddLogging(configure => configure.AddConsole().SetMinimumLevel(LogLevel.Debug)); +``` + +### Performance Considerations + +- Use appropriate service lifetimes (Singleton for read-only, Scoped for persistence) +- Implement proper caching for read models +- Consider event sourcing for audit requirements +- Monitor database performance with OpenTelemetry +- Leverage the enhanced return types to avoid unnecessary database round trips when the entity has been modified by the store + +--- + +## 📖 Documentation + +- **Full Documentation**: [GitHub Wiki](https://github.com/CodeShayk/SourceFlow.Net/wiki) +- **API Reference**: [NuGet Package Documentation](https://www.nuget.org/packages/SourceFlow.Net) +- **Release Notes**: [CHANGELOG](../CHANGELOG.md) +- **Architecture Patterns**: [Design Patterns Guide](https://github.com/CodeShayk/SourceFlow.Net/wiki/Architecture-Patterns) + +## 🤝 Contributing + +We welcome contributions! Please see our [Contributing Guide](../CONTRIBUTING.md) for details. + +- 🐛 **Bug Reports** - Create an [issue](https://github.com/CodeShayk/SourceFlow.Net/issues/new/choose) +- 💡 **Feature Requests** - Start a [discussion](https://github.com/CodeShayk/SourceFlow.Net/discussions) +- 📝 **Documentation** - Help improve our [docs](https://github.com/CodeShayk/SourceFlow.Net/wiki) +- 💻 **Code** - Submit [pull requests](https://github.com/CodeShayk/SourceFlow.Net/pulls) + +## 🆘 Support + +- **Questions**: [GitHub Discussions](https://github.com/CodeShayk/SourceFlow.Net/discussions) +- **Bug Reports**: [GitHub Issues](https://github.com/CodeShayk/SourceFlow.Net/issues/new/choose) +- **Security Issues**: Please report security vulnerabilities responsibly + +## 📄 License + +This project is licensed under the [MIT License](../LICENSE). + +--- +Made with ❤️ by the SourceFlow.Net team to empower developers building event-sourced applications diff --git a/docs/SourceFlow.Stores.EntityFramework-README.md b/docs/SourceFlow.Stores.EntityFramework-README.md new file mode 100644 index 0000000..c45482b --- /dev/null +++ b/docs/SourceFlow.Stores.EntityFramework-README.md @@ -0,0 +1,136 @@ +# SourceFlow.Stores.EntityFramework + +Entity Framework Core persistence provider for SourceFlow.Net with support for SQL Server and configurable connection strings per store type. + +## Features + +- **Complete Store Implementations**: ICommandStore, IEntityStore, and IViewModelStore +- **Flexible Configuration**: Separate or shared connection strings per store type +- **SQL Server Support**: Built-in SQL Server database provider +- **Resilience Policies**: Polly-based retry and circuit breaker patterns +- **Observability**: OpenTelemetry instrumentation for database operations +- **Multi-Framework Support**: .NET 8.0, .NET 9.0, .NET 10.0 + +## Installation + +```bash +# Install the core package +dotnet add package SourceFlow.Net + +# Install the Entity Framework provider +dotnet add package SourceFlow.Stores.EntityFramework +``` + +## Quick Start + +### 1. Configure Connection Strings + +Add connection strings to your `appsettings.json`: + +```json +{ + "ConnectionStrings": { + "CommandStore": "Server=localhost;Database=SourceFlowCommands;Trusted_Connection=True;", + "EntityStore": "Server=localhost;Database=SourceFlowEntities;Trusted_Connection=True;", + "ViewModelStore": "Server=localhost;Database=SourceFlowViews;Trusted_Connection=True;" + } +} +``` + +Or use a single shared connection string: + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=SourceFlow;Trusted_Connection=True;" + } +} +``` + +### 2. Register Services + +```csharp +services.AddSourceFlowStores(configuration, options => +{ + // Use separate databases for each store + options.UseCommandStore("CommandStore"); + options.UseEntityStore("EntityStore"); + options.UseViewModelStore("ViewModelStore"); + + // Or use a single shared database + // options.UseSharedConnectionString("DefaultConnection"); +}); +``` + +### 3. Apply Migrations + +The provider automatically creates the necessary database schema when you run your application. For production scenarios, generate and apply migrations: + +```bash +dotnet ef migrations add InitialCreate --context CommandStoreContext +dotnet ef database update --context CommandStoreContext +``` + +## Configuration Options + +### Separate Databases + +Configure different databases for commands, entities, and view models: + +```csharp +services.AddSourceFlowStores(configuration, options => +{ + options.UseCommandStore("CommandStoreConnection"); + options.UseEntityStore("EntityStoreConnection"); + options.UseViewModelStore("ViewModelStoreConnection"); +}); +``` + +### Shared Database + +Use a single database for all stores: + +```csharp +services.AddSourceFlowStores(configuration, options => +{ + options.UseSharedConnectionString("DefaultConnection"); +}); +``` + +### Custom DbContext Options + +Apply additional EF Core configuration: + +```csharp +services.AddSourceFlowStores(configuration, options => +{ + options.UseCommandStore("CommandStore", dbOptions => + { + dbOptions.EnableSensitiveDataLogging(); + dbOptions.EnableDetailedErrors(); + }); +}); +``` + +## Resilience + +The provider includes built-in Polly resilience policies for: +- Transient error retry with exponential backoff +- Circuit breaker for database failures +- Automatic reconnection handling + +## Documentation + +- [Full Documentation](https://github.com/CodeShayk/SourceFlow.Net/wiki) +- [GitHub Repository](https://github.com/CodeShayk/SourceFlow.Net) +- [Report Issues](https://github.com/CodeShayk/SourceFlow.Net/issues) +- [Release Notes](https://github.com/CodeShayk/SourceFlow.Net/blob/master/CHANGELOG.md) + +## Support + +- **Issues**: [GitHub Issues](https://github.com/CodeShayk/SourceFlow.Net/issues/new/choose) +- **Discussions**: [GitHub Discussions](https://github.com/CodeShayk/SourceFlow.Net/discussions) + +## License + +This project is licensed under the [MIT License](https://github.com/CodeShayk/SourceFlow.Net/blob/master/LICENSE). diff --git a/docs/Stores.EntityFramework b/docs/Stores.EntityFramework new file mode 100644 index 0000000..fddeaf4 --- /dev/null +++ b/docs/Stores.EntityFramework @@ -0,0 +1,388 @@ +# SourceFlow.Net.EntityFramework + +Entity Framework Core persistence provider for SourceFlow.Net. Provides implementations of `ICommandStore`, `IEntityStore`, and `IViewModelStore` using Entity Framework Core with full support for relational data models. + +## Features + +- **Entity Framework Core implementation** of SourceFlow stores +- **Clean separation of concerns**: persistence layer handles only database operations +- **CommandData DTO** for serialization separation from domain logic +- **SQL Server by default**: Convenient methods for SQL Server with single or separate connection strings +- **Database-agnostic support**: Use PostgreSQL, MySQL, SQLite, or any EF Core provider +- **Flexible table naming conventions**: Configure casing, pluralization, prefixes, suffixes, and schemas +- **Full async support** with proper Entity Framework tracking management +- **Scoped service lifetimes** to prevent captive dependency issues +- **Optimized change tracking** with `AsNoTracking()` and entity detachment +- **Mix and match databases**: Use different databases for commands, entities, and view models + +### Production-Ready Enhancements + +- **🛡️ Resilience with Polly**: Retry policies, circuit breakers, and timeouts for fault tolerance +- **📊 Observability with OpenTelemetry**: Distributed tracing, metrics, and performance monitoring +- **⚡ Memory Optimization with ArrayPool**: Reduced GC pressure for high-throughput scenarios + +**[See ENHANCEMENTS.md for detailed configuration and usage →](ENHANCEMENTS.md)** + +## Architecture + +### Layered Design + +The implementation follows a clean layered architecture: + +1. **Store Layer** (`EfCommandStore`, `EfEntityStore`, `EfViewModelStore`) + - Handles only database persistence operations + - Works with data transfer objects (DTOs) for commands + - Uses Entity Framework Core for data access + - Manages change tracking and database connections + +2. **Adapter Layer** (`CommandStoreAdapter`, `EntityStoreAdapter`, `ViewModelStoreAdapter`) + - Handles serialization/deserialization of domain objects + - Converts between domain models and DTOs + - Lives in the core `SourceFlow` package + +3. **Service Lifetimes** + - All stores and adapters are registered as **Scoped** services + - Prevents captive dependency issues with DbContext + - Ensures proper disposal of database connections + +### CommandData DTO + +The `CommandData` class is a data transfer object used for command persistence: + +```csharp +public class CommandData +{ + public int EntityId { get; set; } + public int SequenceNo { get; set; } + public string CommandName { get; set; } + public string CommandType { get; set; } + public string PayloadType { get; set; } + public string PayloadData { get; set; } + public string Metadata { get; set; } + public DateTime Timestamp { get; set; } +} +``` + +This separation ensures: +- `ICommandStore` interface works with serialized data only +- Serialization logic lives in `CommandStoreAdapter` +- Database layer is independent of domain serialization concerns +- Better testability and maintainability + +### Change Tracking Optimization + +The stores use several techniques to optimize Entity Framework change tracking: + +- `AsNoTracking()` for read operations to improve performance +- `EntityState.Detached` after save operations to prevent tracking conflicts +- `ChangeTracker.Clear()` in command store to prevent caching issues +- Ensures concurrent operations don't conflict with tracked entities + +## Installation + +```xml + +``` + +## Usage + +SourceFlow.Net.EntityFramework provides two types of registration methods: + +1. **SQL Server Methods** - Convenient methods that use SQL Server by default (`AddSourceFlowEfStores`) +2. **Database-Agnostic Methods** - Use any EF Core provider (`AddSourceFlowEfStoresWithCustomProvider`) + +### SQL Server (Default Provider) + +#### Single Connection String + +```csharp +services.AddSourceFlowEfStores("Server=localhost;Database=SourceFlow;Trusted_Connection=true;"); +``` + +#### Separate Connection Strings Per Store + +```csharp +services.AddSourceFlowEfStores( + commandConnectionString: "Server=localhost;Database=SourceFlow.Commands;Trusted_Connection=true;", + entityConnectionString: "Server=localhost;Database=SourceFlow.Entities;Trusted_Connection=true;", + viewModelConnectionString: "Server=localhost;Database=SourceFlow.ViewModels;Trusted_Connection=true;" +); +``` + +### Other Databases (Custom Provider) + +For PostgreSQL, MySQL, SQLite, or any other EF Core supported database: + +#### PostgreSQL + +```csharp +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseNpgsql("Host=localhost;Database=sourceflow;Username=postgres;Password=pass")); +``` + +#### MySQL + +```csharp +var serverVersion = new MySqlServerVersion(new Version(8, 0, 21)); +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseMySql("Server=localhost;Database=sourceflow;User=root;Password=pass", serverVersion)); +``` + +#### SQLite + +```csharp +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseSqlite("Data Source=sourceflow.db")); +``` + +#### Different Databases Per Store + +You can even use different databases for each store: + +```csharp +services.AddSourceFlowEfStoresWithCustomProviders( + commandContextConfig: opt => opt.UseNpgsql(postgresConnectionString), + entityContextConfig: opt => opt.UseSqlite(sqliteConnectionString), + viewModelContextConfig: opt => opt.UseSqlServer(sqlServerConnectionString) +); +``` + +### Using Configuration (SQL Server) + +You can also configure connection strings using `IConfiguration`: + +```csharp +// In appsettings.json: +{ + "ConnectionStrings": { + "SourceFlow.Command": "Server=localhost;Database=SourceFlow.Commands;Trusted_Connection=true;", + "SourceFlow.Entity": "Server=localhost;Database=SourceFlow.Entities;Trusted_Connection=true;", + "SourceFlow.ViewModel": "Server=localhost;Database=SourceFlow.ViewModels;Trusted_Connection=true;" + } +} + +// In your startup code: +services.AddSourceFlowEfStores(configuration); +``` + +### Options-Based Configuration + +For more complex scenarios with SQL Server, you can use the options pattern: + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.CommandConnectionString = GetCommandConnectionString(); + options.EntityConnectionString = GetEntityConnectionString(); + options.ViewModelConnectionString = GetViewModelConnectionString(); +}); +``` + +## Connection String Resolution (SQL Server methods) + +The system follows this hierarchy for connection string resolution: + +1. If a specific connection string is configured for a store type, use it +2. If no specific string exists, fall back to the default connection string +3. If neither is available, throw an exception + +## Supported Databases + +This package works with any database that Entity Framework Core supports through the generic configuration methods, including: + +- SQL Server (via dedicated methods) +- SQLite (via generic methods) +- PostgreSQL (via generic methods) +- MySQL (via generic methods) +- Oracle (via generic methods) +- In-memory databases (via generic methods) +- And many others + +## Testing + +For testing scenarios, we recommend using SQLite in-memory databases: + +```csharp +services.AddSourceFlowEfStoresWithCustomProvider(optionsBuilder => + optionsBuilder.UseSqlite("DataSource=:memory:")); +``` + +Or for SQL Server testing: + +```csharp +services.AddSourceFlowEfStores("DataSource=:memory:"); +``` + +## Table Naming Conventions + +SourceFlow.Net.EntityFramework supports flexible table naming conventions to match your database standards. + +### Configuration + +Configure naming conventions using the `SourceFlowEfOptions`: + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Configure entity table naming + options.EntityTableNaming.Casing = TableNameCasing.SnakeCase; + options.EntityTableNaming.Pluralize = true; + options.EntityTableNaming.Prefix = "sf_"; + + // Configure view model table naming + options.ViewModelTableNaming.Casing = TableNameCasing.PascalCase; + options.ViewModelTableNaming.Suffix = "View"; + + // Configure command table naming + options.CommandTableNaming.Casing = TableNameCasing.LowerCase; + options.CommandTableNaming.UseSchema = true; + options.CommandTableNaming.SchemaName = "audit"; +}); +``` + +### Naming Convention Options + +Each `TableNamingConvention` supports the following options: + +**Casing Styles:** +- `PascalCase` - First letter capitalized (e.g., `BankAccount`) +- `CamelCase` - First letter lowercase (e.g., `bankAccount`) +- `SnakeCase` - Lowercase with underscores (e.g., `bank_account`) +- `LowerCase` - All lowercase (e.g., `bankaccount`) +- `UpperCase` - All uppercase (e.g., `BANKACCOUNT`) + +**Other Options:** +- `Pluralize` - Pluralize table names (e.g., `BankAccount` → `BankAccounts`) +- `Prefix` - Add a prefix to all table names (e.g., `"sf_"` → `sf_BankAccount`) +- `Suffix` - Add a suffix to all table names (e.g., `"_tbl"` → `BankAccount_tbl`) +- `UseSchema` - Whether to use a schema name +- `SchemaName` - The schema name to use (when `UseSchema` is true) + +### Examples + +**Snake case with pluralization:** +```csharp +options.EntityTableNaming.Casing = TableNameCasing.SnakeCase; +options.EntityTableNaming.Pluralize = true; +// BankAccount → bank_accounts +``` + +**Prefix for all entity tables:** +```csharp +options.EntityTableNaming.Prefix = "Entity_"; +// BankAccount → Entity_BankAccount +``` + +**Schema-based organization:** +```csharp +options.CommandTableNaming.UseSchema = true; +options.CommandTableNaming.SchemaName = "commands"; +// Commands table goes in commands.CommandRecord schema +``` + +**Combined conventions:** +```csharp +options.ViewModelTableNaming.Casing = TableNameCasing.SnakeCase; +options.ViewModelTableNaming.Prefix = "vm_"; +options.ViewModelTableNaming.Pluralize = true; +// AccountSummary → vm_account_summaries +``` + +### Default Behavior + +By default, all naming conventions use: +- `Casing = TableNameCasing.PascalCase` +- `Pluralize = false` +- No prefix or suffix +- No schema + +If you don't configure naming conventions, tables will be named using the entity/view model type names as-is. + +## Implementation Details + +### Command Serialization + +Commands are serialized using `System.Text.Json` with the following approach: + +- **Payload serialization**: Uses the concrete type of the payload, not the interface type +- **Type information**: Stores `AssemblyQualifiedName` for both command and payload types +- **Metadata**: Serialized separately to maintain sequence numbers and timestamps +- **Deserialization**: Uses reflection to recreate command instances with parameterless constructors + +Example from `CommandStoreAdapter`: + +```csharp +// Serialize using concrete type to capture all properties +var payloadJson = command.Payload != null + ? System.Text.Json.JsonSerializer.Serialize(command.Payload, command.Payload.GetType()) + : string.Empty; +``` + +### Entity Framework Tracking Management + +The stores implement careful tracking management to prevent common EF Core issues: + +**In EfCommandStore:** +```csharp +// Clear change tracker after save to prevent caching issues +_context.Commands.Add(commandRecord); +await _context.SaveChangesAsync(); +_context.ChangeTracker.Clear(); +``` + +**In EfEntityStore and EfViewModelStore:** +```csharp +// Use AsNoTracking for existence checks +var exists = await _context.Set() + .AsNoTracking() + .AnyAsync(e => e.Id == entity.Id); + +// Detach after save to prevent tracking conflicts +await _context.SaveChangesAsync(); +_context.Entry(entity).State = EntityState.Detached; +``` + +### Service Registration + +All services are registered with **Scoped** lifetime: + +```csharp +// Store adapters must be Scoped to match the lifetime of the underlying stores +services.TryAddScoped(); +services.TryAddScoped(); +services.TryAddScoped(); + +// Stores are also Scoped to work with DbContext +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +``` + +This prevents the captive dependency anti-pattern where singleton services capture scoped DbContext instances. + +## Best Practices + +1. **Always use Scoped services** - Don't register stores or adapters as Singleton +2. **Enable database migrations** - Call `ApplyMigrations()` on EntityDbContext and ViewModelDbContext after `EnsureCreated()` +3. **Handle deserialization failures** - The CommandStoreAdapter silently skips commands that can't be deserialized +4. **Use parameterless constructors** - All command classes need a parameterless constructor for deserialization +5. **Separate databases for testing** - Use fresh database instances for each test to avoid state conflicts +6. **Configure proper connection pooling** - For production, ensure your connection strings include appropriate pooling settings + +## Troubleshooting + +### "Instance already being tracked" errors +This occurs when Entity Framework tries to track multiple instances of the same entity. The stores now use `AsNoTracking()` and entity detachment to prevent this. + +### Commands not deserializing correctly +Ensure your command classes have: +- A public parameterless constructor +- The payload is serialized using the concrete type, not the interface + +### Sequence number conflicts +The `CommandStoreAdapter` calculates the next sequence number by loading all commands for an entity. Ensure concurrent operations are properly serialized at the application level if needed. + +### DbContext lifetime issues +All stores and adapters must be registered as Scoped services. Singleton registration will cause DbContext lifetime issues and connection leaks. diff --git a/docs/wiki.md b/docs/wiki.md new file mode 100644 index 0000000..751d5a8 --- /dev/null +++ b/docs/wiki.md @@ -0,0 +1,2682 @@ +# SourceFlow.Net - Complete Guide + +## Table of Contents +1. [Introduction](#introduction) +2. [Core Concepts](#core-concepts) +3. [Architecture Overview](#architecture-overview) +4. [Getting Started](#getting-started) +5. [Framework Components](#framework-components) +6. [Persistence with Entity Framework](#persistence-with-entity-framework) +7. [EntityFramework Usage Examples](#entityframework-usage-examples) +8. [Implementation Guide](#implementation-guide) +9. [Advanced Features](#advanced-features) +10. [Performance and Observability](#performance-and-observability) +11. [Best Practices](#best-practices) +12. [FAQ](#faq) + +--- + +## Introduction + +**SourceFlow.Net** is a modern, lightweight, and extensible .NET framework designed for building scalable event-sourced applications using Domain-Driven Design (DDD) principles and Command Query Responsibility Segregation (CQRS) patterns. Built for .NET 8+ with performance and developer experience as core priorities. + +### What Makes SourceFlow.Net Special? + +SourceFlow.Net provides a complete toolkit for event sourcing, domain modeling, and command/query separation, enabling developers to build maintainable, scalable applications with a strong foundation in proven architectural patterns. + +### Key Features + +* 🏗️ **Domain-Driven Design Support** - First-class support for aggregates, entities, value objects +* ⚡ **CQRS Implementation** - Complete command/query separation with optimized read models +* 📊 **Event Sourcing Foundation** - Event-first design with full audit trail +* 🧱 **Clean Architecture** - Clear separation of concerns and dependency management +* 💾 **Flexible Persistence** - Multiple storage options including Entity Framework Core +* 🔄 **Event Replay** - Built-in command replay for debugging and state reconstruction +* 🎯 **Type Safety** - Strongly-typed commands, events, and projections +* 📦 **Dependency Injection** - Seamless integration with .NET DI container +* 📈 **OpenTelemetry Integration** - Built-in distributed tracing and metrics for operations at scale +* ⚡ **Memory Optimization** - ArrayPool-based optimization for extreme throughput scenarios +* 🛡️ **Resilience Patterns** - Polly integration for fault tolerance with retry policies and circuit breakers + +--- + +## Core Concepts + +### Event Sourcing + +**Event Sourcing** is an architectural pattern where the state of an application is determined by a sequence of events. Instead of storing the current state directly, the system stores all the events that have occurred, allowing for complete state reconstruction at any point in time. + +#### Key Benefits: +- **Complete Audit Trail**: Every change is recorded as an immutable event +- **Time Travel**: Reconstruct system state at any point in history +- **Debugging**: Full visibility into how the system reached its current state +- **Scalability**: Events can be replayed to build multiple read models + +#### Example in SourceFlow.Net: +```csharp +// Events are immutable records of what happened +public class AccountCreated : Event +{ + public AccountCreated(BankAccount payload) : base(payload) { } +} + +public class MoneyDeposited : Event +{ + public MoneyDeposited(BankAccount payload) : base(payload) { } +} +``` + +### Domain-Driven Design (DDD) + +**Domain-Driven Design** is a software design approach that focuses on modeling software to match the business domain. It emphasizes collaboration between technical and domain experts to create a shared understanding of the problem space. + +#### Core DDD Elements in SourceFlow.Net: + +**Entities**: Objects with unique identity +```csharp +public class BankAccount : IEntity +{ + public int Id { get; set; } + public string AccountName { get; set; } + public decimal Balance { get; set; } + public bool IsClosed { get; set; } + public DateTime CreatedOn { get; set; } +} +``` + +**Aggregates**: Coordinate business logic and ensure consistency +```csharp +public class AccountAggregate : Aggregate +{ + public void CreateAccount(int accountId, string holder, decimal amount) + { + // Business logic validation + Send(new CreateAccount(new CreateAccountPayload + { + Id = accountId, + AccountName = holder, + InitialAmount = amount + })); + } +} +``` + +**Sagas**: Orchestrate long-running business processes +```csharp +public class AccountSaga : Saga, IHandles +{ + public async Task Handle(CreateAccount command) + { + // Validate, persist, and raise events + var account = new BankAccount { /* ... */ }; + await repository.Persist(account); + await Raise(new AccountCreated(account)); + } +} +``` + +### Command Query Responsibility Segregation (CQRS) + +**CQRS** separates read and write operations, allowing for optimized data models for different purposes. + +#### Commands: Represent intent to change state +```csharp +public class CreateAccount : Command +{ + // Parameterless constructor required for deserialization + public CreateAccount() : base() { } + + public CreateAccount(CreateAccountPayload payload) : base(payload) { } +} +``` + +#### Queries: Handled through optimized view models +```csharp +public class AccountViewModel : IViewModel +{ + public int Id { get; set; } + public string AccountName { get; set; } + public decimal CurrentBalance { get; set; } + public DateTime LastUpdated { get; set; } + public int TransactionCount { get; set; } +} +``` + +#### Projections: Update read models based on events +```csharp +public class AccountProjection : IProjectOn, IProjectOn +{ + public async Task Apply(AccountCreated @event) + { + var view = new AccountViewModel + { + Id = @event.Payload.Id, + AccountName = @event.Payload.AccountName, + CurrentBalance = @event.Payload.Balance + }; + await provider.Push(view); + } +} +``` + +--- + +## Architecture Overview + +### High-Level Architecture + +architecture + +### Component Interactions + +1. **Aggregates** encapsulate business logic and send commands +2. **Command Bus** routes commands to appropriate saga handlers +3. **Sagas** handle commands and maintain consistency across aggregates +4. **Sagas** persist entities to the **Entity Store** +5. **Sagas** raise events to the **Event Queue** +6. **Event Queue** dispatches events to subscribers +7. **Views** are projections that update read models (ViewModels) based on events +8. **Command Store** persists commands for replay capability +9. **Entity Store** persists root aggregates (entities) within bounded context +10. **ViewModel Store** persists transformed view models from events + +**Entity Framework Stores** provide persistence using EF Core with support for multiple databases + +--- + +## Getting Started + +### Installation + +```bash +# Install the core package +dotnet add package SourceFlow + +# Install Entity Framework persistence +dotnet add package SourceFlow.Stores.EntityFramework +``` + +### Basic Setup + +```csharp +// Program.cs +using SourceFlow; +using SourceFlow.Stores.EntityFramework; +using SourceFlow.Stores.EntityFramework.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +var services = new ServiceCollection(); + +// Add logging +services.AddLogging(builder => +{ + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); +}); + +// Register entity and view model types BEFORE building service provider +EntityDbContext.RegisterAssembly(typeof(Program).Assembly); +ViewModelDbContext.RegisterAssembly(typeof(Program).Assembly); + +// Configure SourceFlow with automatic discovery +services.UseSourceFlow(typeof(Program).Assembly); + +// Add Entity Framework stores with SQL Server (default) +services.AddSourceFlowEfStores( + "Server=localhost;Database=SourceFlow;Integrated Security=true;TrustServerCertificate=true;"); + +var serviceProvider = services.BuildServiceProvider(); + +// Initialize databases +var commandContext = serviceProvider.GetRequiredService(); +await commandContext.Database.EnsureCreatedAsync(); + +var entityContext = serviceProvider.GetRequiredService(); +await entityContext.Database.EnsureCreatedAsync(); +entityContext.ApplyMigrations(); + +var viewModelContext = serviceProvider.GetRequiredService(); +await viewModelContext.Database.EnsureCreatedAsync(); +viewModelContext.ApplyMigrations(); + +// Start using SourceFlow +var aggregateFactory = serviceProvider.GetRequiredService(); +var accountAggregate = await aggregateFactory.Create(); + +accountAggregate.CreateAccount(1, "John Doe", 1000m); +``` + +For other database providers (PostgreSQL, MySQL, SQLite), see [EntityFramework Usage Examples](#entityframework-usage-examples). + +--- + +## Framework Components + +### 1. Aggregates + +Aggregates are the primary building blocks that encapsulate business logic and coordinate with the command bus. + +```csharp +public abstract class Aggregate : IAggregate + where TEntity : class, IEntity +{ + protected ICommandPublisher commandPublisher; + protected ILogger logger; + + // Send commands to command bus + protected async Task Send(ICommand command); + + // Subscribe to external events + public virtual Task On(IEvent @event); +} +``` + +**Key Features:** +- Command publishing +- Event subscription for external changes +- Logger integration +- Generic entity support + +### 2. Sagas + +Sagas handle commands and coordinate business processes, maintaining consistency across aggregate boundaries. + +```csharp +public abstract class Saga : ISaga + where TEntity : class, IEntity +{ + protected IEntityStoreAdapter repository; + protected ICommandPublisher commandPublisher; + protected IEventQueue eventQueue; + protected ILogger logger; + + // Publish commands + protected async Task Publish(TCommand command); + + // Raise events + protected async Task Raise(TEvent @event); +} +``` + +**Key Features:** +- Dynamic command handling via `IHandles` +- Event publishing +- Repository access for persistence +- Built-in logging + +### 3. Command Bus + +The command bus routes commands to appropriate saga handlers and manages command persistence. + +```csharp +public interface ICommandBus +{ + // Publish commands to sagas + Task Publish(TCommand command) where TCommand : ICommand; + + // Event dispatchers for command lifecycle + event EventHandler Dispatchers; +} +``` + +### 4. Event Queue + +The event queue manages event flow and dispatches events to subscribers. + +```csharp +public interface IEventQueue +{ + // Enqueue events for processing + Task Enqueue(TEvent @event) where TEvent : IEvent; + + // Event dispatchers + event EventHandler Dispatchers; +} +``` + +### 5. Stores (Persistence Layer) + +SourceFlow.Net defines three core store interfaces: + +#### ICommandStore +Persists commands for event sourcing and replay +```csharp +public interface ICommandStore +{ + Task Save(ICommand command); + Task> Load(int entityId); +} +``` + +#### IEntityStore +Persists domain entities +```csharp +public interface IEntityStore +{ + Task Persist(TEntity entity) where TEntity : class, IEntity; + Task Get(int id) where TEntity : class, IEntity; + Task Delete(TEntity entity) where TEntity : class, IEntity; +} +``` + +#### IViewModelStore +Persists read models (projections) +```csharp +public interface IViewModelStore +{ + Task Persist(TViewModel model) where TViewModel : class, IViewModel; + Task Get(int id) where TViewModel : class, IViewModel; + Task Delete(TViewModel model) where TViewModel : class, IViewModel; +} +``` + +--- + +## Persistence with Entity Framework + +SourceFlow.Stores.EntityFramework provides production-ready persistence using Entity Framework Core with support for multiple database providers. + +### Features + +- ✅ **Multiple Database Support**: SQL Server, PostgreSQL, SQLite, and more +- ✅ **Flexible Configuration**: Single or separate connection strings per store +- ✅ **Dynamic Type Registration**: Runtime registration of entities and view models +- ✅ **Migration Support**: Manual table creation bypassing EF Core model caching +- ✅ **Thread-Safe**: Designed for concurrent access +- ✅ **Optimized Tracking**: Proper EF Core change tracking management +- ✅ **Production-Ready Enhancements**: Resilience, observability, and memory optimization + +### Installation + +```bash +dotnet add package SourceFlow.Stores.EntityFramework +``` + +### Configuration Options + +#### 1. Single Connection String (All Stores) + +Use the same database for all stores: + +```csharp +services.AddSourceFlowEfStores("Server=localhost;Database=SourceFlow;Integrated Security=true;"); +``` + +#### 2. Separate Connection Strings + +Use different databases for each store: + +```csharp +services.AddSourceFlowEfStores( + commandConnectionString: "Server=localhost;Database=SourceFlow_Commands;...", + entityConnectionString: "Server=localhost;Database=SourceFlow_Entities;...", + viewModelConnectionString: "Server=localhost;Database=SourceFlow_Views;..." +); +``` + +#### 3. Configuration-Based + +Read from `appsettings.json`: + +```json +{ + "ConnectionStrings": { + "SourceFlow.Default": "Server=localhost;Database=SourceFlow;Integrated Security=true;", + "SourceFlow.Command": "Server=localhost;Database=Commands;...", + "SourceFlow.Entity": "Server=localhost;Database=Entities;...", + "SourceFlow.ViewModel": "Server=localhost;Database=Views;..." + } +} +``` + +```csharp +services.AddSourceFlowEfStores(configuration); +``` + +#### 4. Options Pattern + +Configure using options: + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = "Server=localhost;Database=SourceFlow;..."; + // Or specify individual connection strings + options.CommandConnectionString = "..."; + options.EntityConnectionString = "..."; + options.ViewModelConnectionString = "..."; +}); +``` + +#### 5. Custom Database Provider + +Use PostgreSQL, SQLite, or other providers: + +```csharp +// PostgreSQL +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseNpgsql("Host=localhost;Database=sourceflow;Username=postgres;Password=...")); + +// SQLite +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseSqlite("Data Source=sourceflow.db")); + +// In-Memory (for testing) +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseInMemoryDatabase("SourceFlowTest")); +``` + +#### 6. Different Providers Per Store + +Use different database types for each store: + +```csharp +services.AddSourceFlowEfStoresWithCustomProviders( + commandContextConfig: options => options.UseSqlServer("..."), + entityContextConfig: options => options.UseNpgsql("..."), + viewModelContextConfig: options => options.UseSqlite("...") +); +``` + +### Dynamic Type Registration + +Entity Framework requires types to be registered before creating the database schema. SourceFlow.Stores.EntityFramework provides multiple registration strategies: + +#### 1. Explicit Type Registration + +Register specific types before database initialization: + +```csharp +// In your startup or test setup +EntityDbContext.RegisterEntityType(); +EntityDbContext.RegisterEntityType(); + +ViewModelDbContext.RegisterViewModelType(); +ViewModelDbContext.RegisterViewModelType(); + +// Then build service provider and ensure databases are created +var serviceProvider = services.BuildServiceProvider(); + +var entityContext = serviceProvider.GetRequiredService(); +entityContext.Database.EnsureCreated(); +entityContext.ApplyMigrations(); // Creates tables for registered types + +var viewModelContext = serviceProvider.GetRequiredService(); +viewModelContext.Database.EnsureCreated(); +viewModelContext.ApplyMigrations(); // Creates tables for registered view models +``` + +#### 2. Assembly Scanning + +Register all types from an assembly: + +```csharp +// Register the test or application assembly +EntityDbContext.RegisterAssembly(typeof(BankAccount).Assembly); +ViewModelDbContext.RegisterAssembly(typeof(AccountViewModel).Assembly); + +var serviceProvider = services.BuildServiceProvider(); + +// Apply migrations to create tables +var entityContext = serviceProvider.GetRequiredService(); +entityContext.Database.EnsureCreated(); +entityContext.ApplyMigrations(); + +var viewModelContext = serviceProvider.GetRequiredService(); +viewModelContext.Database.EnsureCreated(); +viewModelContext.ApplyMigrations(); +``` + +#### 3. Auto-Discovery (Fallback) + +The DbContexts automatically discover types from loaded assemblies (fallback mechanism): + +```csharp +// Just ensure databases are created +var entityContext = serviceProvider.GetRequiredService(); +entityContext.Database.EnsureCreated(); + +var viewModelContext = serviceProvider.GetRequiredService(); +viewModelContext.Database.EnsureCreated(); + +// Note: This may not catch all types reliably; explicit registration is recommended +``` + +### Table Naming Convention + +All dynamically created tables use the `T` prefix: + +- `BankAccount` entity → `TBankAccount` table +- `AccountViewModel` → `TAccountViewModel` table +- `Customer` entity → `TCustomer` table + +This convention helps distinguish dynamically created tables from EF Core's built-in tables. + +### Migration Helper + +The `DbContextMigrationHelper` manually creates database schemas, bypassing EF Core's model caching: + +```csharp +// Called automatically by ApplyMigrations() +public static void CreateEntityTables( + EntityDbContext context, + IEnumerable entityTypes) +{ + // Creates tables with proper columns and primary keys + // Supports int, long, string, bool, DateTime, decimal, double, float, byte[], enums +} + +public static void CreateViewModelTables( + ViewModelDbContext context, + IEnumerable viewModelTypes) +{ + // Creates tables for view models +} +``` + +### Complete Setup Example + +```csharp +using SourceFlow; +using SourceFlow.Stores.EntityFramework; +using SourceFlow.Stores.EntityFramework.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; + +var services = new ServiceCollection(); + +// Add logging +services.AddLogging(builder => +{ + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); +}); + +// Register types BEFORE building service provider +EntityDbContext.RegisterAssembly(typeof(BankAccount).Assembly); +ViewModelDbContext.RegisterAssembly(typeof(AccountViewModel).Assembly); + +// Configure SourceFlow +services.UseSourceFlow(typeof(Program).Assembly); + +// Add Entity Framework stores (SQL Server by default) +services.AddSourceFlowEfStores( + "Server=localhost;Database=SourceFlow;Integrated Security=true;TrustServerCertificate=true;"); + +// Or use custom provider for other databases: +// services.AddSourceFlowEfStoresWithCustomProvider(options => +// options.UseNpgsql("Host=localhost;Database=sourceflow;Username=postgres;Password=...")); + +var serviceProvider = services.BuildServiceProvider(); + +// Ensure all databases are created and migrated +var commandContext = serviceProvider.GetRequiredService(); +await commandContext.Database.EnsureCreatedAsync(); + +var entityContext = serviceProvider.GetRequiredService(); +await entityContext.Database.EnsureCreatedAsync(); +entityContext.ApplyMigrations(); // Create tables for registered entity types + +var viewModelContext = serviceProvider.GetRequiredService(); +await viewModelContext.Database.EnsureCreatedAsync(); +viewModelContext.ApplyMigrations(); // Create tables for registered view model types + +// Start using SourceFlow +var aggregateFactory = serviceProvider.GetRequiredService(); +var accountAggregate = await aggregateFactory.Create(); + +accountAggregate.CreateAccount(1, "John Doe", 1000m); +``` + +### Testing with In-Memory Database + +For unit and integration tests, use SQLite in-memory databases with proper setup: + +```csharp +using NUnit.Framework; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SourceFlow.Stores.EntityFramework; +using SourceFlow.Stores.EntityFramework.Extensions; +using SourceFlow.Stores.EntityFramework.Options; +using SourceFlow.Stores.EntityFramework.Services; +using SourceFlow.Stores.EntityFramework.Stores; + +[TestFixture] +public class BankAccountIntegrationTests +{ + private ServiceProvider? _serviceProvider; + private SqliteConnection? _connection; + + [SetUp] + public void Setup() + { + // Clear previous registrations + EntityDbContext.ClearRegistrations(); + ViewModelDbContext.ClearRegistrations(); + + // Register test types + EntityDbContext.RegisterEntityType(); + ViewModelDbContext.RegisterViewModelType(); + + // Create shared in-memory SQLite connection for all contexts + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var services = new ServiceCollection(); + + // Add logging for better test diagnostics + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Debug); + }); + + // Configure SQLite with shared connection + // Use EnableServiceProviderCaching(false) to avoid EF Core 9.0 multiple provider conflicts + services.AddDbContext(options => + options.UseSqlite(_connection) + .EnableServiceProviderCaching(false)); + services.AddDbContext(options => + options.UseSqlite(_connection) + .EnableServiceProviderCaching(false)); + services.AddDbContext(options => + options.UseSqlite(_connection) + .EnableServiceProviderCaching(false)); + + // Register SourceFlowEfOptions with default settings + var efOptions = new SourceFlowEfOptions(); + services.AddSingleton(efOptions); + + // Register common services manually (avoids provider conflicts) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Register SourceFlow + services.UseSourceFlow(Assembly.GetExecutingAssembly()); + + _serviceProvider = services.BuildServiceProvider(); + + // Create all database schemas + var commandContext = _serviceProvider.GetRequiredService(); + commandContext.Database.EnsureCreated(); + + var entityContext = _serviceProvider.GetRequiredService(); + entityContext.Database.EnsureCreated(); + entityContext.ApplyMigrations(); // Create tables for registered entity types + + var viewModelContext = _serviceProvider.GetRequiredService(); + viewModelContext.Database.EnsureCreated(); + viewModelContext.ApplyMigrations(); // Create tables for registered view model types + } + + [TearDown] + public void TearDown() + { + // Clean up resources + _connection?.Close(); + _connection?.Dispose(); + _serviceProvider?.Dispose(); + } + + [Test] + public async Task CreateAccount_StoresInDatabase() + { + // Arrange + var aggregateFactory = _serviceProvider.GetRequiredService(); + var accountAggregate = await aggregateFactory.Create(); + + // Act + accountAggregate.CreateAccount(1, "John Doe", 1000m); + + // Wait for async processing + await Task.Delay(100); + + // Assert + var entityStore = _serviceProvider.GetRequiredService(); + var account = await entityStore.Get(1); + + Assert.That(account, Is.Not.Null); + Assert.That(account.AccountName, Is.EqualTo("John Doe")); + Assert.That(account.Balance, Is.EqualTo(1000m)); + } +} +``` + +--- + +## EntityFramework Usage Examples + +This section provides practical examples for common scenarios using SourceFlow.Stores.EntityFramework. + +### Example 1: Simple Console Application with SQL Server + +Complete working example for a console application: + +```csharp +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SourceFlow; +using SourceFlow.Stores.EntityFramework; +using SourceFlow.Stores.EntityFramework.Extensions; + +class Program +{ + static async Task Main(string[] args) + { + // Setup service collection + var services = new ServiceCollection(); + + // Add logging + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); + }); + + // Register entity and view model types BEFORE building service provider + EntityDbContext.RegisterEntityType(); + ViewModelDbContext.RegisterViewModelType(); + + // Configure SourceFlow + services.UseSourceFlow(typeof(Program).Assembly); + + // Add Entity Framework stores with SQL Server + services.AddSourceFlowEfStores( + "Server=localhost;Database=SourceFlowDemo;Integrated Security=true;TrustServerCertificate=true;"); + + var serviceProvider = services.BuildServiceProvider(); + + // Ensure databases are created + var commandContext = serviceProvider.GetRequiredService(); + await commandContext.Database.EnsureCreatedAsync(); + + var entityContext = serviceProvider.GetRequiredService(); + await entityContext.Database.EnsureCreatedAsync(); + entityContext.ApplyMigrations(); + + var viewModelContext = serviceProvider.GetRequiredService(); + await viewModelContext.Database.EnsureCreatedAsync(); + viewModelContext.ApplyMigrations(); + + // Use the aggregate + var aggregateFactory = serviceProvider.GetRequiredService(); + var accountAggregate = await aggregateFactory.Create(); + + // Execute business operations + accountAggregate.CreateAccount(1, "Alice Smith", 5000m); + accountAggregate.Deposit(1, 1500m); + accountAggregate.Withdraw(1, 500m); + + // Give async processing time to complete + await Task.Delay(500); + + // Query the read model + var viewModelStore = serviceProvider.GetRequiredService(); + var accountView = await viewModelStore.Find(1); + + Console.WriteLine($"Account: {accountView.AccountName}"); + Console.WriteLine($"Balance: {accountView.CurrentBalance:C}"); + Console.WriteLine($"Transactions: {accountView.TransactionCount}"); + Console.WriteLine($"Created: {accountView.CreatedDate:yyyy-MM-dd}"); + } +} +``` + +### Example 2: ASP.NET Core Web API with PostgreSQL + +Complete setup for a web API using PostgreSQL: + +```csharp +// Program.cs +using Microsoft.EntityFrameworkCore; +using SourceFlow; +using SourceFlow.Stores.EntityFramework; +using SourceFlow.Stores.EntityFramework.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Register entity and view model types +EntityDbContext.RegisterAssembly(typeof(Program).Assembly); +ViewModelDbContext.RegisterAssembly(typeof(Program).Assembly); + +// Configure SourceFlow with PostgreSQL +builder.Services.UseSourceFlow(typeof(Program).Assembly); + +builder.Services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("SourceFlow"))); + +var app = builder.Build(); + +// Initialize databases on startup +using (var scope = app.Services.CreateScope()) +{ + var commandContext = scope.ServiceProvider.GetRequiredService(); + await commandContext.Database.EnsureCreatedAsync(); + + var entityContext = scope.ServiceProvider.GetRequiredService(); + await entityContext.Database.EnsureCreatedAsync(); + entityContext.ApplyMigrations(); + + var viewModelContext = scope.ServiceProvider.GetRequiredService(); + await viewModelContext.Database.EnsureCreatedAsync(); + viewModelContext.ApplyMigrations(); +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); +``` + +```csharp +// appsettings.json +{ + "ConnectionStrings": { + "SourceFlow": "Host=localhost;Database=sourceflow;Username=postgres;Password=yourpassword" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} +``` + +```csharp +// Controllers/AccountController.cs +using Microsoft.AspNetCore.Mvc; +using SourceFlow; + +[ApiController] +[Route("api/[controller]")] +public class AccountController : ControllerBase +{ + private readonly IAggregateFactory _aggregateFactory; + private readonly IViewModelStoreAdapter _viewModelStore; + private readonly ILogger _logger; + + public AccountController( + IAggregateFactory aggregateFactory, + IViewModelStoreAdapter viewModelStore, + ILogger logger) + { + _aggregateFactory = aggregateFactory; + _viewModelStore = viewModelStore; + _logger = logger; + } + + [HttpPost] + public async Task CreateAccount(CreateAccountRequest request) + { + var aggregate = await _aggregateFactory.Create(); + aggregate.CreateAccount(request.Id, request.AccountName, request.InitialBalance); + + _logger.LogInformation("Account created: {AccountId}", request.Id); + return CreatedAtAction(nameof(GetAccount), new { id = request.Id }, request); + } + + [HttpGet("{id}")] + public async Task> GetAccount(int id) + { + try + { + var account = await _viewModelStore.Find(id); + return Ok(account); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost("{id}/deposit")] + public async Task Deposit(int id, [FromBody] TransactionRequest request) + { + var aggregate = await _aggregateFactory.Create(); + aggregate.Deposit(id, request.Amount); + + _logger.LogInformation("Deposited {Amount} to account {AccountId}", request.Amount, id); + return NoContent(); + } + + [HttpPost("{id}/withdraw")] + public async Task Withdraw(int id, [FromBody] TransactionRequest request) + { + try + { + var aggregate = await _aggregateFactory.Create(); + aggregate.Withdraw(id, request.Amount); + + _logger.LogInformation("Withdrew {Amount} from account {AccountId}", request.Amount, id); + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } +} + +public record CreateAccountRequest(int Id, string AccountName, decimal InitialBalance); +public record TransactionRequest(decimal Amount); +``` + +### Example 3: Microservices with Separate Databases + +Using different databases for different stores in a microservices architecture: + +```csharp +// Program.cs for Banking Microservice +var builder = WebApplication.CreateBuilder(args); + +// Register types +EntityDbContext.RegisterAssembly(typeof(Program).Assembly); +ViewModelDbContext.RegisterAssembly(typeof(Program).Assembly); + +// Configure SourceFlow +builder.Services.UseSourceFlow(typeof(Program).Assembly); + +// Each store uses a different database optimized for its purpose +builder.Services.AddSourceFlowEfStoresWithCustomProviders( + // Commands: PostgreSQL with JSONB support for efficient command storage + commandContextConfig: opt => opt.UseNpgsql( + builder.Configuration.GetConnectionString("CommandStore")), + + // Entities: SQL Server with optimized indexes for transactional workload + entityContextConfig: opt => opt.UseSqlServer( + builder.Configuration.GetConnectionString("EntityStore")), + + // ViewModels: SQLite for fast read queries in read-heavy scenarios + viewModelContextConfig: opt => opt.UseSqlite( + builder.Configuration.GetConnectionString("ViewStore")) +); + +var app = builder.Build(); + +// Initialize all databases +using (var scope = app.Services.CreateScope()) +{ + var commandContext = scope.ServiceProvider.GetRequiredService(); + await commandContext.Database.MigrateAsync(); + + var entityContext = scope.ServiceProvider.GetRequiredService(); + await entityContext.Database.MigrateAsync(); + entityContext.ApplyMigrations(); + + var viewModelContext = scope.ServiceProvider.GetRequiredService(); + await viewModelContext.Database.MigrateAsync(); + viewModelContext.ApplyMigrations(); +} + +app.Run(); +``` + +```json +// appsettings.json +{ + "ConnectionStrings": { + "CommandStore": "Host=postgres-commands.internal;Database=banking_commands;Username=app;Password=...", + "EntityStore": "Server=sqlserver-entities.internal;Database=banking_entities;User Id=app;Password=...;", + "ViewStore": "Data Source=/data/banking_views.db" + } +} +``` + +### Example 4: Production Configuration with Resilience and Observability + +Complete production setup with all enterprise features: + +```csharp +// Program.cs +using SourceFlow; +using SourceFlow.Observability; +using SourceFlow.Stores.EntityFramework; +using SourceFlow.Stores.EntityFramework.Extensions; +using OpenTelemetry; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Register domain types +EntityDbContext.RegisterAssembly(typeof(Program).Assembly); +ViewModelDbContext.RegisterAssembly(typeof(Program).Assembly); + +// Configure SourceFlow with observability +builder.Services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; + options.ServiceName = "BankingService"; + options.ServiceVersion = builder.Configuration["AppVersion"] ?? "1.0.0"; +}); + +// Configure OpenTelemetry exporters +builder.Services.AddOpenTelemetry() + .AddSourceFlowOtlpExporter(builder.Configuration["Observability:OtlpEndpoint"]) + .AddSourceFlowResourceAttributes( + ("environment", builder.Environment.EnvironmentName), + ("deployment.region", builder.Configuration["Deployment:Region"]), + ("service.instance.id", Environment.MachineName) + ) + .ConfigureSourceFlowBatchProcessing( + maxQueueSize: 2048, + maxExportBatchSize: 512, + scheduledDelayMilliseconds: 5000 + ); + +// Register SourceFlow +builder.Services.UseSourceFlow(typeof(Program).Assembly); + +// Configure Entity Framework stores with resilience and observability +builder.Services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = builder.Configuration.GetConnectionString("SourceFlow"); + + // Resilience configuration + options.Resilience.Enabled = true; + options.Resilience.Retry.MaxRetryAttempts = 3; + options.Resilience.Retry.BaseDelayMs = 1000; + options.Resilience.Retry.UseExponentialBackoff = true; + options.Resilience.Retry.UseJitter = true; + options.Resilience.CircuitBreaker.Enabled = true; + options.Resilience.CircuitBreaker.FailureThreshold = 10; + options.Resilience.CircuitBreaker.BreakDurationMs = 60000; + options.Resilience.Timeout.Enabled = true; + options.Resilience.Timeout.TimeoutMs = 30000; + + // Observability configuration + options.Observability.Enabled = true; + options.Observability.ServiceName = "BankingService.EntityFramework"; + options.Observability.Tracing.Enabled = true; + options.Observability.Tracing.TraceDatabaseOperations = true; + options.Observability.Tracing.IncludeSqlInTraces = false; // Don't log SQL in production + options.Observability.Tracing.SamplingRatio = 0.1; // Sample 10% + options.Observability.Metrics.Enabled = true; + options.Observability.Metrics.CollectDatabaseMetrics = true; + + // Table naming conventions + options.EntityTableNaming.Casing = TableNameCasing.SnakeCase; + options.EntityTableNaming.Pluralize = true; + options.ViewModelTableNaming.Casing = TableNameCasing.SnakeCase; + options.ViewModelTableNaming.Suffix = "_view"; +}); + +// Add health checks +builder.Services.AddHealthChecks() + .AddDbContextCheck("command-store") + .AddDbContextCheck("entity-store") + .AddDbContextCheck("viewmodel-store"); + +var app = builder.Build(); + +// Initialize databases +using (var scope = app.Services.CreateScope()) +{ + var logger = scope.ServiceProvider.GetRequiredService>(); + + try + { + var commandContext = scope.ServiceProvider.GetRequiredService(); + await commandContext.Database.EnsureCreatedAsync(); + logger.LogInformation("Command store initialized"); + + var entityContext = scope.ServiceProvider.GetRequiredService(); + await entityContext.Database.EnsureCreatedAsync(); + entityContext.ApplyMigrations(); + logger.LogInformation("Entity store initialized"); + + var viewModelContext = scope.ServiceProvider.GetRequiredService(); + await viewModelContext.Database.EnsureCreatedAsync(); + viewModelContext.ApplyMigrations(); + logger.LogInformation("ViewModel store initialized"); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to initialize databases"); + throw; + } +} + +app.MapHealthChecks("/health"); +app.Run(); +``` + +```json +// appsettings.Production.json +{ + "ConnectionStrings": { + "SourceFlow": "Server=prod-db.internal;Database=BankingService;User Id=app_user;Password=...;Max Pool Size=100;Min Pool Size=10;" + }, + "Observability": { + "OtlpEndpoint": "http://otel-collector.internal:4317" + }, + "Deployment": { + "Region": "us-east-1" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.EntityFrameworkCore": "Warning", + "SourceFlow": "Information" + } + } +} +``` + +### Example 5: Custom Table Naming with Schema Organization + +Organizing tables with custom naming conventions and schemas: + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Command store: Audit schema with snake_case + options.CommandTableNaming.UseSchema = true; + options.CommandTableNaming.SchemaName = "audit"; + options.CommandTableNaming.Casing = TableNameCasing.SnakeCase; + // Results in: audit.command_record + + // Entity store: Domain schema with pluralized tables + options.EntityTableNaming.UseSchema = true; + options.EntityTableNaming.SchemaName = "domain"; + options.EntityTableNaming.Casing = TableNameCasing.SnakeCase; + options.EntityTableNaming.Pluralize = true; + // BankAccount -> domain.bank_accounts + + // ViewModel store: Reporting schema with view suffix + options.ViewModelTableNaming.UseSchema = true; + options.ViewModelTableNaming.SchemaName = "reporting"; + options.ViewModelTableNaming.Casing = TableNameCasing.SnakeCase; + options.ViewModelTableNaming.Suffix = "_view"; + options.ViewModelTableNaming.Pluralize = true; + // AccountViewModel -> reporting.account_views +}); + +// Create schemas before initializing +var entityContext = serviceProvider.GetRequiredService(); +await entityContext.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS audit"); +await entityContext.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS domain"); +await entityContext.Database.ExecuteSqlRawAsync("CREATE SCHEMA IF NOT EXISTS reporting"); +await entityContext.Database.EnsureCreatedAsync(); +entityContext.ApplyMigrations(); +``` + +### Example 6: Background Service Processing Commands + +Processing commands in a background service: + +```csharp +public class CommandProcessorBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public CommandProcessorBackgroundService( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Command Processor Background Service starting"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _serviceProvider.CreateScope(); + + var commandStore = scope.ServiceProvider.GetRequiredService(); + var aggregateFactory = scope.ServiceProvider.GetRequiredService(); + + // Process any pending commands (example: replay for specific entities) + var entityIds = await GetPendingEntityIds(); + + foreach (var entityId in entityIds) + { + var commands = await commandStore.Retrieve(entityId); + + if (commands.Any()) + { + _logger.LogInformation( + "Processing {Count} commands for entity {EntityId}", + commands.Count(), entityId); + + // Replay commands to rebuild state + var aggregate = await aggregateFactory.Create(); + // Process commands... + } + } + + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in command processor"); + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + _logger.LogInformation("Command Processor Background Service stopping"); + } + + private async Task> GetPendingEntityIds() + { + // Implementation to identify entities needing processing + return new List(); + } +} + +// Register in Program.cs +builder.Services.AddHostedService(); +``` + +### Example 7: Multi-Tenant Setup with Database Per Tenant + +Implementing multi-tenancy with separate databases: + +```csharp +// ITenantProvider.cs +public interface ITenantProvider +{ + string GetCurrentTenantId(); + string GetConnectionString(string tenantId); +} + +// TenantDbContextFactory.cs +public class TenantDbContextFactory where TContext : DbContext +{ + private readonly ITenantProvider _tenantProvider; + private readonly IServiceProvider _serviceProvider; + + public TenantDbContextFactory(ITenantProvider tenantProvider, IServiceProvider serviceProvider) + { + _tenantProvider = tenantProvider; + _serviceProvider = serviceProvider; + } + + public TContext CreateDbContext() + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var connectionString = _tenantProvider.GetConnectionString(tenantId); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlServer(connectionString); + + return (TContext)Activator.CreateInstance(typeof(TContext), optionsBuilder.Options); + } +} + +// Program.cs +builder.Services.AddScoped(); + +// Register SourceFlow with multi-tenant support +builder.Services.UseSourceFlow(typeof(Program).Assembly); + +// Custom multi-tenant store registration +builder.Services.AddScoped(sp => +{ + var tenantProvider = sp.GetRequiredService(); + var tenantId = tenantProvider.GetCurrentTenantId(); + var connectionString = tenantProvider.GetConnectionString(tenantId); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlServer(connectionString); + + return new EntityDbContext(optionsBuilder.Options); +}); + +// Similar for CommandDbContext and ViewModelDbContext +``` + +--- + +## Implementation Guide + +### Creating a Complete Feature + +Let's implement a complete banking feature using SourceFlow.Net with Entity Framework persistence: + +#### 1. Define Domain Objects + +```csharp +// Entity +public class BankAccount : IEntity +{ + public int Id { get; set; } + public string AccountName { get; set; } + public decimal Balance { get; set; } + public bool IsClosed { get; set; } + public DateTime CreatedOn { get; set; } + public DateTime ActiveOn { get; set; } + public string ClosureReason { get; set; } +} + +// Command Payloads +public class CreateAccountPayload : IPayload +{ + public int Id { get; set; } + public string AccountName { get; set; } + public decimal InitialAmount { get; set; } +} + +public class TransactionPayload : IPayload +{ + public int Id { get; set; } + public decimal Amount { get; set; } +} +``` + +#### 2. Create Commands + +```csharp +public class CreateAccount : Command +{ + // Parameterless constructor required for command deserialization from store + public CreateAccount() : base() { } + + public CreateAccount(CreateAccountPayload payload) : base(payload) { } +} + +public class DepositMoney : Command +{ + // Parameterless constructor required for command deserialization from store + public DepositMoney() : base() { } + + public DepositMoney(TransactionPayload payload) : base(payload) { } +} + +public class WithdrawMoney : Command +{ + // Parameterless constructor required for command deserialization from store + public WithdrawMoney() : base() { } + + public WithdrawMoney(TransactionPayload payload) : base(payload) { } +} +``` + +#### 3. Define Events + +```csharp +public class AccountCreated : Event +{ + public AccountCreated(BankAccount payload) : base(payload) { } +} + +public class MoneyDeposited : Event +{ + public MoneyDeposited(BankAccount payload) : base(payload) { } +} + +public class MoneyWithdrawn : Event +{ + public MoneyWithdrawn(BankAccount payload) : base(payload) { } +} +``` + +#### 4. Implement Saga + +```csharp +public class AccountSaga : Saga, + IHandles, + IHandles, + IHandles +{ + public async Task Handle(CreateAccount command) + { + // Validation + if (string.IsNullOrEmpty(command.Payload.AccountName)) + throw new ArgumentException("Account name is required"); + + if (command.Payload.InitialAmount <= 0) + throw new ArgumentException("Initial amount must be positive"); + + // Create entity + var account = new BankAccount + { + Id = command.Payload.Id, + AccountName = command.Payload.AccountName, + Balance = command.Payload.InitialAmount, + CreatedOn = DateTime.UtcNow, + ActiveOn = DateTime.UtcNow + }; + + // Persist to Entity Store + await repository.Persist(account); + + // Raise event + await Raise(new AccountCreated(account)); + + logger.LogInformation("Account created: {AccountId} for {Holder} with balance {Balance}", + account.Id, account.AccountName, account.Balance); + } + + public async Task Handle(DepositMoney command) + { + var account = await repository.Get(command.Payload.Id); + + if (account.IsClosed) + throw new InvalidOperationException("Cannot deposit to closed account"); + + account.Balance += command.Payload.Amount; + await repository.Persist(account); + await Raise(new MoneyDeposited(account)); + + logger.LogInformation("Deposited {Amount} to account {AccountId}. New balance: {Balance}", + command.Payload.Amount, account.Id, account.Balance); + } + + public async Task Handle(WithdrawMoney command) + { + var account = await repository.Get(command.Payload.Id); + + if (account.IsClosed) + throw new InvalidOperationException("Cannot withdraw from closed account"); + + if (account.Balance < command.Payload.Amount) + throw new InvalidOperationException("Insufficient funds"); + + account.Balance -= command.Payload.Amount; + await repository.Persist(account); + await Raise(new MoneyWithdrawn(account)); + + logger.LogInformation("Withdrew {Amount} from account {AccountId}. New balance: {Balance}", + command.Payload.Amount, account.Id, account.Balance); + } +} +``` + +#### 5. Create Aggregate + +```csharp +public interface IAccountAggregate : IAggregate +{ + void CreateAccount(int accountId, string holder, decimal amount); + void Deposit(int accountId, decimal amount); + void Withdraw(int accountId, decimal amount); +} + +public class AccountAggregate : Aggregate, IAccountAggregate +{ + public void CreateAccount(int accountId, string holder, decimal amount) + { + Send(new CreateAccount(new CreateAccountPayload + { + Id = accountId, + AccountName = holder, + InitialAmount = amount + })); + } + + public void Deposit(int accountId, decimal amount) + { + Send(new DepositMoney(new TransactionPayload + { + Id = accountId, + Amount = amount + })); + } + + public void Withdraw(int accountId, decimal amount) + { + Send(new WithdrawMoney(new TransactionPayload + { + Id = accountId, + Amount = amount + })); + } +} +``` + +#### 6. Build Read Models + +```csharp +public class AccountViewModel : IViewModel +{ + public int Id { get; set; } + public string AccountName { get; set; } + public decimal CurrentBalance { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime LastUpdated { get; set; } + public int TransactionCount { get; set; } + public bool IsClosed { get; set; } + public string ClosureReason { get; set; } + public int Version { get; set; } + public DateTime ActiveOn { get; set; } +} + +public class AccountView : View, + IProjectOn, + IProjectOn, + IProjectOn +{ + public async Task Apply(AccountCreated @event) + { + var view = new AccountViewModel + { + Id = @event.Payload.Id, + AccountName = @event.Payload.AccountName, + CurrentBalance = @event.Payload.Balance, + CreatedDate = @event.Payload.CreatedOn, + LastUpdated = DateTime.UtcNow, + TransactionCount = 0, + IsClosed = false, + ActiveOn = @event.Payload.ActiveOn + }; + + await provider.Push(view); + + logger.LogInformation("Created view model for account {AccountId}", view.Id); + } + + public async Task Apply(MoneyDeposited @event) + { + var view = await provider.Find(@event.Payload.Id); + view.CurrentBalance = @event.Payload.Balance; + view.TransactionCount++; + view.LastUpdated = DateTime.UtcNow; + + await provider.Push(view); + + logger.LogInformation("Updated view model for account {AccountId} after deposit", view.Id); + } + + public async Task Apply(MoneyWithdrawn @event) + { + var view = await provider.Find(@event.Payload.Id); + view.CurrentBalance = @event.Payload.Balance; + view.TransactionCount++; + view.LastUpdated = DateTime.UtcNow; + + await provider.Push(view); + + logger.LogInformation("Updated view model for account {AccountId} after withdrawal", view.Id); + } +} +``` + +#### 7. Application Setup + +```csharp +// Program.cs +using SourceFlow; +using SourceFlow.Stores.EntityFramework; +using SourceFlow.Stores.EntityFramework.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +var services = new ServiceCollection(); + +// Add logging +services.AddLogging(builder => +{ + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); +}); + +// Register entity and view model types BEFORE building service provider +EntityDbContext.RegisterEntityType(); +ViewModelDbContext.RegisterViewModelType(); + +// Configure SourceFlow +services.UseSourceFlow(typeof(Program).Assembly); + +// Add Entity Framework stores +services.AddSourceFlowEfStores( + "Server=localhost;Database=SourceFlow;Integrated Security=true;TrustServerCertificate=true;"); + +var serviceProvider = services.BuildServiceProvider(); + +// Ensure databases are created and migrated +var commandContext = serviceProvider.GetRequiredService(); +await commandContext.Database.EnsureCreatedAsync(); + +var entityContext = serviceProvider.GetRequiredService(); +await entityContext.Database.EnsureCreatedAsync(); +entityContext.ApplyMigrations(); + +var viewModelContext = serviceProvider.GetRequiredService(); +await viewModelContext.Database.EnsureCreatedAsync(); +viewModelContext.ApplyMigrations(); + +// Use the aggregate +var aggregateFactory = serviceProvider.GetRequiredService(); +var accountAggregate = await aggregateFactory.Create(); + +accountAggregate.CreateAccount(999, "John Doe", 1000m); +accountAggregate.Deposit(999, 500m); +accountAggregate.Withdraw(999, 200m); + +// Give async processing time to complete +await Task.Delay(500); + +// Query the read model +var viewModelStore = serviceProvider.GetRequiredService(); +var accountView = await viewModelStore.Find(999); + +Console.WriteLine($"Account: {accountView.AccountName}"); +Console.WriteLine($"Balance: {accountView.CurrentBalance:C}"); +Console.WriteLine($"Transactions: {accountView.TransactionCount}"); +``` + +For complete examples including ASP.NET Core, PostgreSQL, and production configurations, see [EntityFramework Usage Examples](#entityframework-usage-examples). + +--- + +## Advanced Features + +### Event Replay + +SourceFlow.Net provides built-in command replay functionality for debugging and state reconstruction: + +```csharp +var accountAggregate = serviceProvider.GetRequiredService(); + +// Replay all commands for an aggregate +await accountAggregate.ReplayHistory(accountId); + +// The framework automatically handles: +// 1. Loading commands from store +// 2. Marking commands as replay +// 3. Re-executing command handlers +// 4. Updating projections +``` + +### Metadata and Auditing + +Every command and event includes rich metadata to add producer and consumer centric custom properties. + +```csharp +public interface IMetadata +{ + Guid EventId { get; set; } + bool IsReplay { get; set; } + DateTime OccurredOn { get; set; } + int SequenceNo { get; set; } + IDictionary Properties { get; set; } +} +``` + +### Store Adapters + +SourceFlow provides high-level adapters for common operations: + +```csharp +// Entity Store Adapter +public interface IEntityStoreAdapter +{ + Task Persist(TEntity entity) where TEntity : class, IEntity; + Task Get(int id) where TEntity : class, IEntity; +} + +// ViewModel Store Adapter +public interface IViewModelStoreAdapter +{ + Task Push(TViewModel model) where TViewModel : class, IViewModel; + Task Find(int id) where TViewModel : class, IViewModel; +} + +// Command Store Adapter +public interface ICommandStoreAdapter +{ + Task Commit(ICommand command); + Task> Retrieve(int entityId); +} +``` + +--- + +## Performance and Observability + +SourceFlow.Net includes comprehensive production-ready features for monitoring, fault tolerance, and high-performance scenarios. + +### OpenTelemetry Integration + +Built-in support for distributed tracing and metrics collection at scale. + +#### Features + +- **Distributed Tracing**: Automatically track command execution, event dispatching, and store operations +- **Metrics Collection**: Monitor command rates, saga executions, entity creations, and operation durations +- **Multiple Exporters**: Support for Console, OTLP (Jaeger, Zipkin), and custom exporters +- **Minimal Overhead**: <1ms latency impact, <2% CPU overhead + +#### Quick Setup + +**Development (Console Exporter):** +```csharp +using SourceFlow.Observability; +using OpenTelemetry; + +var services = new ServiceCollection(); + +// Enable observability with console output +services.AddSourceFlowTelemetry( + serviceName: "MyEventSourcedApp", + serviceVersion: "1.0.0"); + +services.AddOpenTelemetry() + .AddSourceFlowConsoleExporter(); + +services.UseSourceFlow(); +``` + +**Production (OTLP Exporter):** +```csharp +services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; + options.ServiceName = "ProductionApp"; + options.ServiceVersion = "1.0.0"; +}); + +services.AddOpenTelemetry() + .AddSourceFlowOtlpExporter("http://localhost:4317") + .AddSourceFlowResourceAttributes( + ("environment", "production"), + ("region", "us-east-1") + ); +``` + +#### Instrumented Operations + +All core operations are automatically traced: + +**Command Operations:** +- `sourceflow.commandbus.dispatch` - Command dispatch and persistence +- `sourceflow.commanddispatcher.send` - Command distribution to sagas +- `sourceflow.domain.command.append` - Command persistence +- `sourceflow.domain.command.load` - Command loading + +**Event Operations:** +- `sourceflow.eventqueue.enqueue` - Event queuing +- `sourceflow.eventdispatcher.dispatch` - Event distribution + +**Store Operations:** +- `sourceflow.entitystore.persist` / `get` / `delete` - Entity operations +- `sourceflow.viewmodelstore.persist` / `find` / `delete` - ViewModel operations + +#### Metrics Collected + +- `sourceflow.domain.commands.executed` - Counter of executed commands +- `sourceflow.domain.sagas.executed` - Counter of saga executions +- `sourceflow.domain.entities.created` - Counter of entity creations +- `sourceflow.domain.operation.duration` - Histogram of operation durations (ms) +- `sourceflow.domain.serialization.duration` - Histogram of serialization performance + +#### Integration with Existing Telemetry + +```csharp +services.AddOpenTelemetry() + .WithTracing(builder => builder + .AddSource("SourceFlow.Domain") // Add SourceFlow + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter()) + .WithMetrics(builder => builder + .AddMeter("SourceFlow.Domain") // Add SourceFlow + .AddAspNetCoreInstrumentation() + .AddPrometheusExporter()); +``` + +### ArrayPool Memory Optimization + +Dramatically reduce memory allocations in high-throughput scenarios using `ArrayPool`. + +#### Performance Benefits + +**Memory Allocation Reduction:** +- Before: ~40MB allocations for 10,000 commands +- After: <1MB allocations for 10,000 commands +- **Result: ~40x reduction in allocations** + +**GC Pressure Reduction:** +- Gen 0 Collections: ↓70% +- Gen 1 Collections: ↓50% +- Gen 2 Collections: ↓30% + +**Throughput Improvements:** +- Command Throughput: +25-40% +- Event Dispatching: +30-50% +- Serialization: +20-35% + +#### Features + +- **Task Buffer Pooling**: Reduces allocations in parallel task execution +- **JSON Serialization Pooling**: Reuses byte buffers for JSON operations +- **Zero Configuration**: Works automatically, no code changes required +- **Production Tested**: Optimized for extreme throughput scenarios + +#### Optimized Components + +**TaskBufferPool:** +- Pools task arrays for parallel execution +- Used in `CommandDispatcher` and `EventDispatcher` +- Automatic buffer rental and return + +**ByteArrayPool:** +- Pools byte arrays for JSON serialization +- Used in `CommandStoreAdapter` +- Custom `IBufferWriter` implementation + +ArrayPool optimizations are automatically applied to: +- Command serialization/deserialization +- Event dispatching (parallel task execution) +- Store adapter operations + +### Resilience with Polly (Entity Framework) + +The Entity Framework integration includes Polly-based resilience patterns for fault tolerance. + +#### Features + +- **Retry Policy**: Automatic retry with exponential backoff and jitter +- **Circuit Breaker**: Prevents cascading failures +- **Timeout Policy**: Enforces maximum execution time + +#### Configuration + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Enable resilience + options.Resilience.Enabled = true; + + // Retry configuration + options.Resilience.Retry.MaxRetryAttempts = 3; + options.Resilience.Retry.BaseDelayMs = 1000; + options.Resilience.Retry.UseExponentialBackoff = true; + options.Resilience.Retry.UseJitter = true; + + // Circuit breaker configuration + options.Resilience.CircuitBreaker.Enabled = true; + options.Resilience.CircuitBreaker.FailureThreshold = 5; + options.Resilience.CircuitBreaker.BreakDurationMs = 30000; + + // Timeout configuration + options.Resilience.Timeout.Enabled = true; + options.Resilience.Timeout.TimeoutMs = 30000; +}); +``` + +#### Benefits + +- **Transient Failure Handling**: Automatically recovers from temporary issues +- **Cascading Failure Prevention**: Circuit breaker stops calling failing services +- **Resource Protection**: Timeouts prevent hanging operations +- **Self-Healing**: System automatically recovers when service becomes available + +### Entity Framework Observability + +Additional observability features specific to Entity Framework stores. + +#### Configuration + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Configure observability + options.Observability.Enabled = true; + options.Observability.ServiceName = "MyApplication"; + options.Observability.ServiceVersion = "1.0.0"; + + // Tracing configuration + options.Observability.Tracing.Enabled = true; + options.Observability.Tracing.TraceDatabaseOperations = true; + options.Observability.Tracing.IncludeSqlInTraces = false; // Enable for debugging + options.Observability.Tracing.SamplingRatio = 0.1; // Sample 10% in production + + // Metrics configuration + options.Observability.Metrics.Enabled = true; + options.Observability.Metrics.CollectDatabaseMetrics = true; +}); + +// Configure exporters +services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddSource("SourceFlow.EntityFramework") + .AddEntityFrameworkCoreInstrumentation() + .AddJaegerExporter()) + .WithMetrics(metrics => metrics + .AddMeter("SourceFlow.EntityFramework") + .AddPrometheusExporter()); +``` + +#### Additional Traces + +- `sourceflow.ef.command.append` - EF command storage +- `sourceflow.ef.command.load` - EF command loading +- `sourceflow.ef.entity.persist` - EF entity persistence +- `sourceflow.ef.viewmodel.persist` - EF view model persistence + +#### Additional Metrics + +- `sourceflow.commands.appended` - EF command append counter +- `sourceflow.commands.loaded` - EF command load counter +- `sourceflow.entities.persisted` - EF entity persistence counter +- `sourceflow.viewmodels.persisted` - EF view model persistence counter +- `sourceflow.database.connections` - Active connection gauge + +### Production Configuration Examples + +**Development:** +```csharp +services.AddSourceFlowTelemetry("DevApp", "1.0.0"); +services.AddOpenTelemetry() + .AddSourceFlowConsoleExporter(); + +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = "Data Source=dev.db"; + options.Resilience.Enabled = false; // Easier debugging + options.Observability.Enabled = true; + options.Observability.Tracing.IncludeSqlInTraces = true; + options.Observability.Tracing.SamplingRatio = 1.0; // Trace everything +}); +``` + +**Production:** +```csharp +services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; + options.ServiceName = "ProductionApp"; + options.ServiceVersion = "1.0.0"; +}); + +services.AddOpenTelemetry() + .AddSourceFlowOtlpExporter(otlpEndpoint) + .AddSourceFlowResourceAttributes( + ("environment", "production"), + ("deployment.region", region) + ); + +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Production resilience settings + options.Resilience.Enabled = true; + options.Resilience.Retry.MaxRetryAttempts = 3; + options.Resilience.CircuitBreaker.Enabled = true; + options.Resilience.CircuitBreaker.FailureThreshold = 10; + + // Production observability settings + options.Observability.Enabled = true; + options.Observability.Tracing.IncludeSqlInTraces = false; + options.Observability.Tracing.SamplingRatio = 0.1; // Sample 10% +}); +``` + +**High-Throughput:** +```csharp +services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; + options.ServiceName = "HighThroughputApp"; +}); + +services.AddOpenTelemetry() + .AddSourceFlowOtlpExporter(otlpEndpoint) + .ConfigureSourceFlowBatchProcessing( + maxQueueSize: 2048, + maxExportBatchSize: 512, + scheduledDelayMilliseconds: 5000 + ); + +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + + // Optimized for throughput + options.Resilience.Enabled = true; + options.Resilience.Retry.MaxRetryAttempts = 2; + options.Resilience.Retry.BaseDelayMs = 500; + + // Reduced overhead + options.Observability.Enabled = true; + options.Observability.Tracing.SamplingRatio = 0.01; // Sample 1% +}); + +// ArrayPool optimizations are automatically applied +``` + +### Monitoring Dashboard Queries + +Use these queries in Grafana/Prometheus: + +**Average Command Processing Time:** +```promql +rate(sourceflow_domain_operation_duration_sum{operation="sourceflow.commandbus.dispatch"}[5m]) +/ rate(sourceflow_domain_operation_duration_count{operation="sourceflow.commandbus.dispatch"}[5m]) +``` + +**Command Throughput:** +```promql +rate(sourceflow_domain_commands_executed[5m]) +``` + +**Serialization Performance (P95):** +```promql +histogram_quantile(0.95, + rate(sourceflow_domain_serialization_duration_bucket[5m]) +) +``` + +### Package Dependencies + +**Core SourceFlow:** +- `OpenTelemetry` (1.14.0) +- `OpenTelemetry.Api` (1.14.0) +- `OpenTelemetry.Exporter.Console` (1.14.0) +- `OpenTelemetry.Exporter.OpenTelemetryProtocol` (1.14.0) +- `OpenTelemetry.Extensions.Hosting` (1.14.0) +- `Microsoft.Extensions.DependencyInjection.Abstractions` (10.0.0) +- `Microsoft.Extensions.Logging.Abstractions` (10.0.0) + +**Entity Framework Stores:** +- `Microsoft.EntityFrameworkCore` (9.0.0) +- `Polly` (8.4.2) - For resilience patterns +- `OpenTelemetry.Instrumentation.EntityFrameworkCore` (1.0.0-beta.12) + +All packages are free from known vulnerabilities (as of November 2025). + +### Additional Resources + +- **OpenTelemetry Documentation**: [https://opentelemetry.io/docs/](https://opentelemetry.io/docs/) +- **ArrayPool Documentation**: [https://docs.microsoft.com/en-us/dotnet/api/system.buffers.arraypool-1](https://docs.microsoft.com/en-us/dotnet/api/system.buffers.arraypool-1) +- **Polly Documentation**: [https://github.com/App-vNext/Polly](https://github.com/App-vNext/Polly) +- **SourceFlow.Net OBSERVABILITY_AND_PERFORMANCE.md**: Detailed performance documentation +- **SourceFlow.Stores.EntityFramework ENHANCEMENTS.md**: EF-specific enhancements guide + +--- + +## Best Practices + +### 1. Command Design + +**Always include a parameterless constructor** for serialization support: + +```csharp +// ✅ Good: Specific, intention-revealing commands with proper constructors +public class WithdrawMoney : Command +{ + // Required for deserialization from command store + public WithdrawMoney() : base() { } + + public WithdrawMoney(WithdrawPayload payload) : base(payload) { } +} + +public class DepositMoney : Command +{ + // Required for deserialization from command store + public DepositMoney() : base() { } + + public DepositMoney(DepositPayload payload) : base(payload) { } +} + +// ❌ Bad: Generic, unclear commands +public class UpdateAccount : Command { } + +// ❌ Bad: Missing parameterless constructor +public class TransferMoney : Command +{ + public TransferMoney(TransferPayload payload) : base(payload) { } + // This command cannot be deserialized from the command store! +} +``` + +**Key Requirements:** +- Use specific, intention-revealing names +- Always include a public parameterless constructor +- Include a constructor that accepts the payload +- Keep commands immutable after creation + +### 2. Event Granularity + +```csharp +// ✅ Good: Fine-grained, specific events +public class AccountCreated : Event { } +public class AccountCredited : Event { } +public class AccountDebited : Event { } + +// ❌ Bad: Coarse-grained, generic events +public class AccountChanged : Event { } +``` + +### 3. Saga Responsibility + +```csharp +// ✅ Good: Single responsibility +public class AccountSaga : Saga, + IHandles, + IHandles +{ + // Handles account lifecycle only +} + +// ❌ Bad: Multiple responsibilities +public class MegaSaga : Saga, + IHandles, + IHandles, + IHandles +{ + // Too many responsibilities +} +``` + +### 4. Type Registration + +```csharp +// ✅ Good: Register types early, before building service provider +EntityDbContext.RegisterAssembly(typeof(BankAccount).Assembly); +ViewModelDbContext.RegisterAssembly(typeof(AccountViewModel).Assembly); + +var serviceProvider = services.BuildServiceProvider(); + +// Apply migrations after database creation +entityContext.Database.EnsureCreated(); +entityContext.ApplyMigrations(); + +// ❌ Bad: Relying solely on auto-discovery +var serviceProvider = services.BuildServiceProvider(); +// Types may not be discovered reliably +``` + +### 5. Error Handling + +```csharp +public class AccountSaga : Saga +{ + public async Task Handle(WithdrawMoney command) + { + try + { + var account = await repository.Get(command.Payload.Id); + + // Validate business rules + if (account.IsClosed) + throw new AccountClosedException($"Account {account.Id} is closed"); + + if (account.Balance < command.Payload.Amount) + throw new InsufficientFundsException($"Insufficient funds in account {account.Id}"); + + // Process transaction + account.Balance -= command.Payload.Amount; + await repository.Persist(account); + await Raise(new MoneyWithdrawn(account)); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process withdrawal for account {AccountId}", + command.Payload.Id); + + // Publish failure event if needed + await Raise(new WithdrawalFailed(new WithdrawalFailureDetails + { + AccountId = command.Payload.Id, + Amount = command.Payload.Amount, + Reason = ex.Message + })); + + throw; + } + } +} +``` + +### 6. Database Migrations + +```csharp +// ✅ Good: Use ApplyMigrations for dynamic types +entityContext.Database.EnsureCreated(); +entityContext.ApplyMigrations(); // Creates tables for registered types + +viewModelContext.Database.EnsureCreated(); +viewModelContext.ApplyMigrations(); // Creates tables for view models + +// For production: Use EF Core migrations for static schema +// dotnet ef migrations add InitialCreate +// dotnet ef database update +``` + +### 7. Production Monitoring + +```csharp +// ✅ Good: Enable observability in production +services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; + options.ServiceName = "ProductionApp"; + options.ServiceVersion = Assembly.GetExecutingAssembly() + .GetName().Version.ToString(); +}); + +// Configure appropriate sampling rate +services.AddOpenTelemetry() + .AddSourceFlowOtlpExporter(otlpEndpoint) + .AddSourceFlowResourceAttributes( + ("environment", Environment.GetEnvironmentVariable("ENVIRONMENT")) + ); + +// ❌ Bad: No observability in production +services.UseSourceFlow(); +// Can't diagnose performance issues or failures +``` + +### 8. Resilience Configuration + +```csharp +// ✅ Good: Enable resilience for production databases +services.AddSourceFlowEfStores(options => +{ + options.DefaultConnectionString = connectionString; + options.Resilience.Enabled = true; + options.Resilience.Retry.MaxRetryAttempts = 3; + options.Resilience.CircuitBreaker.Enabled = true; +}); + +// ❌ Bad: No resilience (will fail on transient errors) +services.AddSourceFlowEfStores(connectionString); +``` + +### 9. Command Serialization Requirements + +```csharp +// ✅ Good: Command with parameterless constructor +public class ProcessPayment : Command +{ + // REQUIRED: Parameterless constructor for deserialization + public ProcessPayment() : base() { } + + public ProcessPayment(PaymentPayload payload) : base(payload) { } +} + +// ❌ Bad: Missing parameterless constructor +public class ProcessPayment : Command +{ + public ProcessPayment(PaymentPayload payload) : base(payload) { } + // Will throw MissingMethodException during command replay! +} + +// ✅ Good: Payload classes don't need parameterless constructors +public class PaymentPayload : IPayload +{ + public int Id { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } +} +``` + +**Important Notes:** +- **Commands MUST have a public parameterless constructor** for deserialization from the command store +- Payload classes use property setters for deserialization (no parameterless constructor required) +- Without a parameterless constructor, command replay and aggregate reconstruction will fail +- The Entity Framework CommandStoreAdapter uses reflection to recreate command instances + +--- + +## FAQ + +### Q: How does SourceFlow.Net handle persistence? + +**A:** SourceFlow.Net uses a store abstraction pattern with multiple implementation options: + +- **In-Memory Stores**: Built-in for testing and prototyping +- **Entity Framework Stores**: Production-ready with support for SQL Server, PostgreSQL, SQLite, etc. +- **Custom Stores**: Implement `ICommandStore`, `IEntityStore`, and `IViewModelStore` for your own persistence + +### Q: Can I use different databases for commands, entities, and view models? + +**A:** Yes! The Entity Framework integration supports separate databases: + +```csharp +services.AddSourceFlowEfStoresWithCustomProviders( + commandContextConfig: options => options.UseSqlServer("..."), + entityContextConfig: options => options.UsePostgreSql("..."), + viewModelContextConfig: options => options.UseSqlite("...") +); +``` + +### Q: How do I handle dynamic entity and view model types? + +**A:** Use the type registration and migration system: + +1. Register types before building the service provider +2. Call `EnsureCreated()` to create the base schema +3. Call `ApplyMigrations()` to create tables for registered types + +```csharp +EntityDbContext.RegisterEntityType(); +ViewModelDbContext.RegisterViewModelType(); + +// Build service provider... + +entityContext.Database.EnsureCreated(); +entityContext.ApplyMigrations(); +``` + +### Q: Why do my commands need a parameterless constructor? + +**A:** The CommandStoreAdapter uses reflection to deserialize commands from the database. When replaying commands, it needs to create instances without knowing the payload in advance. + +**Required pattern:** + +```csharp +public class CreateAccount : Command +{ + // Required for deserialization + public CreateAccount() : base() { } + + // Used for creating new commands + public CreateAccount(CreateAccountPayload payload) : base(payload) { } +} +``` + +Without the parameterless constructor, command replay will fail with a `MissingMethodException`. + +### Q: What database providers are supported? + +**A:** SourceFlow.Stores.EntityFramework supports any EF Core provider: + +- SQL Server (default) +- PostgreSQL (via Npgsql.EntityFrameworkCore.PostgreSQL) +- SQLite (via Microsoft.EntityFrameworkCore.Sqlite) +- MySQL (via Pomelo.EntityFrameworkCore.MySql) +- In-Memory (via Microsoft.EntityFrameworkCore.InMemoryDatabase) +- And more... + +### Q: How do I test with SourceFlow.Net? + +**A:** Use in-memory databases for fast, isolated tests: + +```csharp +[SetUp] +public void Setup() +{ + EntityDbContext.RegisterEntityType(); + ViewModelDbContext.RegisterViewModelType(); + + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseSqlite(connection)); + + // Build and setup... +} +``` + +### Q: Why use the "T" prefix for table names? + +**A:** The "T" prefix distinguishes dynamically created tables from EF Core's built-in tables, making it clear which tables are part of your domain model versus infrastructure. + +### Q: Should I enable observability in production? + +**A:** Yes! Observability has minimal overhead (<1ms latency, <2% CPU) and provides invaluable insights: +- Distributed tracing helps debug issues across services +- Metrics help identify performance bottlenecks +- Sampling (10%) provides good coverage with minimal cost + +```csharp +services.AddSourceFlowTelemetry(options => +{ + options.Enabled = true; + options.ServiceName = "ProductionApp"; +}); +services.AddOpenTelemetry() + .AddSourceFlowOtlpExporter(otlpEndpoint); +``` + +### Q: When should I use resilience patterns? + +**A:** Always enable resilience in production for database operations: +- Retry policies handle transient network failures +- Circuit breakers prevent cascading failures +- Timeouts prevent hanging operations + +```csharp +services.AddSourceFlowEfStores(options => +{ + options.Resilience.Enabled = true; +}); +``` + +### Q: How much does ArrayPool improve performance? + +**A:** In high-throughput scenarios (>1000 commands/second): +- **Memory**: 40x reduction in allocations (40MB → <1MB for 10K commands) +- **GC**: 70% reduction in Gen0 collections +- **Throughput**: 25-40% improvement +- **Zero configuration**: Works automatically once enabled + +ArrayPool optimizations are built-in and automatically applied to command serialization and event dispatching. + +### Q: How do I handle schema changes? + +**A:** For production applications: + +1. Use EF Core migrations for base schema +2. Use `ApplyMigrations()` for dynamic types +3. Version your entities and view models +4. Implement upcasting for old events + +For development/testing: + +1. Use `Database.EnsureDeleted()` and `EnsureCreated()` +2. Use in-memory databases that reset on each test + +### Q: Can I use SourceFlow.EntityFramework with MySQL or other databases? + +**A:** Yes! Use `AddSourceFlowEfStoresWithCustomProvider` for any EF Core supported database: + +```csharp +// MySQL +var serverVersion = new MySqlServerVersion(new Version(8, 0, 21)); +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseMySql(connectionString, serverVersion)); + +// SQLite +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseSqlite("Data Source=sourceflow.db")); + +// PostgreSQL +services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseNpgsql(connectionString)); +``` + +The `AddSourceFlowEfStores` methods without "CustomProvider" use SQL Server by default. + +### Q: What's the difference between EnsureCreated() and ApplyMigrations()? + +**A:** +- `EnsureCreated()`: Creates the database and base schema (Commands, fixed tables) +- `ApplyMigrations()`: Creates tables for dynamically registered entities and view models + +Always call both in the correct order: + +```csharp +await entityContext.Database.EnsureCreatedAsync(); // Create database +entityContext.ApplyMigrations(); // Create dynamic tables +``` + +### Q: How do I configure EF Core 9.0 for testing to avoid provider conflicts? + +**A:** When testing with multiple DbContext providers (e.g., SQLite for tests, SQL Server for production), use `EnableServiceProviderCaching(false)`: + +```csharp +services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); // Required for EF Core 9.0 +``` + +This prevents the "multiple provider" error when using different providers in the same service collection. + +### Q: Should I register stores manually or use AddSourceFlowEfStores? + +**A:** Use `AddSourceFlowEfStores` for production. Only register manually for special cases like: + +- Testing scenarios requiring specific service configuration +- Custom implementations of resilience or telemetry services +- Avoiding provider conflicts in test setups + +Example manual registration: + +```csharp +var efOptions = new SourceFlowEfOptions(); +services.AddSingleton(efOptions); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +``` + +### Q: How do table naming conventions affect my database schema? + +**A:** Table naming conventions transform entity type names into table names: + +```csharp +// Default (PascalCase, no prefix/suffix) +BankAccount → BankAccount + +// Snake case with pluralization +options.EntityTableNaming.Casing = TableNameCasing.SnakeCase; +options.EntityTableNaming.Pluralize = true; +BankAccount → bank_accounts + +// With schema +options.EntityTableNaming.UseSchema = true; +options.EntityTableNaming.SchemaName = "domain"; +BankAccount → domain.BankAccount + +// Combined +BankAccount → domain.bank_accounts +``` + +Set naming conventions BEFORE calling `ApplyMigrations()` to ensure tables are created with the correct names. + +--- + +## Production Considerations + +### Performance Optimization + +1. **Use Separate Databases**: Split command, entity, and view model stores across different databases +2. **Enable Connection Pooling**: Configure appropriate connection pool sizes +3. **Optimize Queries**: Use AsNoTracking() for read-only queries +4. **Batch Operations**: Use bulk insert/update operations where applicable +5. **Enable ArrayPool**: Automatically enabled for high-throughput scenarios (40x reduction in allocations) +6. **Configure Observability**: Use appropriate sampling rates for production (1-10%) +7. **Enable Resilience**: Use Polly policies for fault tolerance in production + +### Monitoring + +```csharp +// Health checks +services.AddHealthChecks() + .AddDbContextCheck("commandstore") + .AddDbContextCheck("entitystore") + .AddDbContextCheck("viewmodelstore"); + +// OpenTelemetry metrics and tracing +services.AddSourceFlowTelemetry("ProductionApp", "1.0.0"); +services.AddOpenTelemetry() + .AddSourceFlowOtlpExporter("http://localhost:4317"); + +// Monitor key metrics: +// - Command throughput: sourceflow.domain.commands.executed +// - Operation latency: sourceflow.domain.operation.duration (P50/P95/P99) +// - Circuit breaker state: polly.circuit_breaker.state +// - GC pressure: dotnet.gc.collections (reduced with ArrayPool) +``` + +### Deployment + +1. **Migrations**: Apply EF Core migrations during deployment +2. **Connection Strings**: Use environment-specific configuration +3. **Logging**: Configure appropriate logging levels +4. **Error Handling**: Implement global exception handling + +### Common Issues and Solutions + +**Command Deserialization Failures:** +- **Symptom**: `MissingMethodException` or `InvalidOperationException` during command replay +- **Cause**: Command class missing parameterless constructor +- **Solution**: Add public parameterless constructor to all command classes + +```csharp +// Fix: Add parameterless constructor +public class MyCommand : Command +{ + public MyCommand() : base() { } // Required! + public MyCommand(MyPayload payload) : base(payload) { } +} +``` + +**Entity Tracking Conflicts:** +- **Symptom**: "Instance already being tracked" errors +- **Cause**: Multiple entity instances with same ID in EF change tracker +- **Solution**: Use `AsNoTracking()` for read operations or detach entities after save + +**EF Core 9.0 Provider Conflicts (Testing):** +- **Symptom**: "An error occurred accessing the database provider factory" +- **Cause**: Multiple providers registered in same service collection +- **Solution**: Use `EnableServiceProviderCaching(false)` in test configurations + +**Migration Failures:** +- **Symptom**: Tables not created for entities or view models +- **Cause**: Types not registered before calling `ApplyMigrations()` +- **Solution**: Register types using `EntityDbContext.RegisterAssembly()` before building service provider + +--- + +## Community and Support + +### Resources + +- **GitHub Repository**: [https://github.com/CodeShayk/SourceFlow.Net](https://github.com/CodeShayk/SourceFlow.Net) +- **Documentation**: [https://github.com/CodeShayk/SourceFlow.Net/wiki](https://github.com/CodeShayk/SourceFlow.Net/wiki) +- **Issues**: [https://github.com/CodeShayk/SourceFlow.Net/issues](https://github.com/CodeShayk/SourceFlow.Net/issues) +- **Discussions**: [https://github.com/CodeShayk/SourceFlow.Net/discussions](https://github.com/CodeShayk/SourceFlow.Net/discussions) + +### License + +SourceFlow.Net is released under the MIT License, making it free for both commercial and open-source use. + +--- + +## Conclusion + +SourceFlow.Net provides a robust, scalable foundation for building event-sourced applications with .NET. By combining Event Sourcing, Domain-Driven Design, and CQRS patterns with flexible Entity Framework persistence, it enables developers to create maintainable, auditable, and performant systems. + +**Start your journey with SourceFlow.Net today and build better software with events as your foundation!** diff --git a/src/SourceFlow.ConsoleApp/Aggregates/AccountAggregate.cs b/src/SourceFlow.ConsoleApp/Aggregates/AccountAggregate.cs deleted file mode 100644 index 9706ec7..0000000 --- a/src/SourceFlow.ConsoleApp/Aggregates/AccountAggregate.cs +++ /dev/null @@ -1,59 +0,0 @@ -using SourceFlow.Aggregate; -using SourceFlow.ConsoleApp.Commands; -using SourceFlow.ConsoleApp.Events; - -namespace SourceFlow.ConsoleApp.Aggregates -{ - public class AccountAggregate : Aggregate, - ISubscribes - - { - public void CreateAccount(int accountId, string holder, decimal amount) - { - Send(new CreateAccount(new Payload - { - Id = accountId, - AccountName = holder, - InitialAmount = amount - })); - } - - public void Deposit(int accountId, decimal amount) - { - Send(new DepositMoney(new TransactPayload - { - Id = accountId, - Amount = amount, - Type = TransactionType.Deposit - })); - } - - public void Withdraw(int accountId, decimal amount) - { - Send(new WithdrawMoney(new TransactPayload - { - Id = accountId, - Amount = amount, - Type = TransactionType.Withdrawal - })); - } - - public void Close(int accountId, string reason) - { - Send(new CloseAccount(new ClosurePayload - { - Id = accountId, - ClosureReason = reason - })); - } - - public Task Handle(AccountCreated @event) - { - return Send(new ActivateAccount(new ActivationPayload - { - Id = @event.Payload.Id, - ActiveOn = DateTime.UtcNow, - })); - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Commands/ActivateAccount.cs b/src/SourceFlow.ConsoleApp/Commands/ActivateAccount.cs deleted file mode 100644 index fd78929..0000000 --- a/src/SourceFlow.ConsoleApp/Commands/ActivateAccount.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SourceFlow.Messaging; - -namespace SourceFlow.ConsoleApp.Commands -{ - public class ActivateAccount : Command - { - public ActivateAccount(ActivationPayload payload) : base(payload) - { - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Commands/CloseAccount.cs b/src/SourceFlow.ConsoleApp/Commands/CloseAccount.cs deleted file mode 100644 index b8ed5f9..0000000 --- a/src/SourceFlow.ConsoleApp/Commands/CloseAccount.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SourceFlow.Messaging; - -namespace SourceFlow.ConsoleApp.Commands -{ - public class CloseAccount : Command - { - public CloseAccount(ClosurePayload payload) : base(payload) - { - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Commands/CreateAccount.cs b/src/SourceFlow.ConsoleApp/Commands/CreateAccount.cs deleted file mode 100644 index 8251038..0000000 --- a/src/SourceFlow.ConsoleApp/Commands/CreateAccount.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SourceFlow.Messaging; - -namespace SourceFlow.ConsoleApp.Commands -{ - public class CreateAccount : Command - { - public CreateAccount(Payload payload) : base(payload) - { - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Commands/DepositMoney.cs b/src/SourceFlow.ConsoleApp/Commands/DepositMoney.cs deleted file mode 100644 index 49153a0..0000000 --- a/src/SourceFlow.ConsoleApp/Commands/DepositMoney.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SourceFlow.Messaging; - -namespace SourceFlow.ConsoleApp.Commands -{ - public class DepositMoney : Command - { - public DepositMoney(TransactPayload payload) : base(payload) - { - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Commands/WithdrawMoney.cs b/src/SourceFlow.ConsoleApp/Commands/WithdrawMoney.cs deleted file mode 100644 index a546eeb..0000000 --- a/src/SourceFlow.ConsoleApp/Commands/WithdrawMoney.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SourceFlow.Messaging; - -namespace SourceFlow.ConsoleApp.Commands -{ - public class WithdrawMoney : Command - { - public WithdrawMoney(TransactPayload payload) : base(payload) - { - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Events/AccountCreated.cs b/src/SourceFlow.ConsoleApp/Events/AccountCreated.cs deleted file mode 100644 index 896ea31..0000000 --- a/src/SourceFlow.ConsoleApp/Events/AccountCreated.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SourceFlow.ConsoleApp.Aggregates; -using SourceFlow.Messaging; - -namespace SourceFlow.ConsoleApp.Events -{ - public class AccountCreated : Event - { - public AccountCreated(BankAccount payload) : base(payload) - { - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Events/AccountUpdated.cs b/src/SourceFlow.ConsoleApp/Events/AccountUpdated.cs deleted file mode 100644 index 59bc470..0000000 --- a/src/SourceFlow.ConsoleApp/Events/AccountUpdated.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SourceFlow.ConsoleApp.Aggregates; -using SourceFlow.Messaging; - -namespace SourceFlow.ConsoleApp.Events -{ - public class AccountUpdated : Event - { - public AccountUpdated(BankAccount payload) : base(payload) - { - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Impl/InMemoryCommandStore.cs b/src/SourceFlow.ConsoleApp/Impl/InMemoryCommandStore.cs deleted file mode 100644 index 8279a3b..0000000 --- a/src/SourceFlow.ConsoleApp/Impl/InMemoryCommandStore.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Concurrent; -using SourceFlow.Messaging; - -namespace SourceFlow.ConsoleApp.Impl -{ - public class InMemoryCommandStore : ICommandStore - { - private readonly ConcurrentDictionary> _store = new(); - - public Task Append(ICommand @event) - { - if (!_store.ContainsKey(@event.Payload.Id)) - _store[@event.Payload.Id] = new List(); - - _store[@event.Payload.Id].Add(@event); - - return Task.CompletedTask; - } - - public async Task> Load(int aggregateId) - { - return await Task.FromResult(_store.TryGetValue(aggregateId, out var events) - ? events - : Enumerable.Empty()); - } - - public Task GetNextSequenceNo(int aggregateId) - { - if (_store.TryGetValue(aggregateId, out var events)) - { - return Task.FromResult(events.Max(c => ((IMetadata)c).Metadata.SequenceNo) + 1); - } - return Task.FromResult(1); - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Impl/InMemoryRepository.cs b/src/SourceFlow.ConsoleApp/Impl/InMemoryRepository.cs deleted file mode 100644 index 64c1d8b..0000000 --- a/src/SourceFlow.ConsoleApp/Impl/InMemoryRepository.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Concurrent; -using SourceFlow.Aggregate; - -namespace SourceFlow.ConsoleApp.Impl -{ - public class InMemoryRepository : IRepository - { - private readonly ConcurrentDictionary _cache = new(); - - public Task Delete(TEntity entity) where TEntity : IEntity - { - if (entity?.Id == null) - throw new ArgumentNullException(nameof(entity)); - - _cache.TryRemove(entity.Id, out _); - - return Task.CompletedTask; - } - - public Task Get(int id) where TEntity : class, IEntity - { - if (id == 0) - throw new ArgumentNullException(nameof(id)); - - var success = _cache.TryGetValue(id, out var entity); - - return Task.FromResult(success ? (TEntity)entity : null); - } - - public Task Persist(TEntity entity) where TEntity : IEntity - { - if (entity?.Id == null) - throw new ArgumentNullException(nameof(entity)); - - if (entity.Id == 0) - entity.Id = new Random().Next(); - - _cache[entity.Id] = entity; - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Program.cs b/src/SourceFlow.ConsoleApp/Program.cs deleted file mode 100644 index 9d067d7..0000000 --- a/src/SourceFlow.ConsoleApp/Program.cs +++ /dev/null @@ -1,99 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using SourceFlow; -using SourceFlow.ConsoleApp.Projections; -using SourceFlow.ConsoleApp.Services; -using SourceFlow.Saga; // Ensure this using is present - -var services = new ServiceCollection(); - -// Register logging with console provider -services.AddLogging(builder => -{ - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); -}); - -//services.UseSourceFlow(config => -//{ -// config.WithAggregate(); -// config.WithSaga(); -// config.WithService(); -//}); - -services.UseSourceFlow(); - -var serviceProvider = services.BuildServiceProvider(); - -Console.WriteLine("=== Command Sourcing Demo ===\n"); - -var accountService = serviceProvider.GetRequiredService(); -var saga = serviceProvider.GetRequiredService(); -var logger = serviceProvider.GetRequiredService>(); -var viewProvider = serviceProvider.GetRequiredService(); - -// Create account -var accountId = await accountService.CreateAccountAsync("John Doe", 1000m); -logger.LogInformation("Action=Program_Create_Account, Account: {accountId}", accountId); - -// Perform operations -var amount = 500m; -logger.LogInformation("Action=Program_Deposit, Amount={Amount}", amount); -await accountService.DepositAsync(accountId, amount); - -amount = 200m; -logger.LogInformation("Action=Program_Withdraw, Amount={Amount}", amount); -await accountService.WithdrawAsync(accountId, amount); - -amount = 100m; -logger.LogInformation("Action=Program_Deposit, Amount={Amount}", amount); -await accountService.DepositAsync(accountId, amount); - -// Find current state -var account = await viewProvider.Find(accountId); -Console.WriteLine($"\nCurrent Account State:"); -Console.WriteLine($"- Account Id: {account?.Id}"); -Console.WriteLine($"- Holder: {account?.AccountName}"); -Console.WriteLine($"- Created On: {account?.CreatedDate}"); -Console.WriteLine($"- Activated On: {account?.ActiveOn}"); -Console.WriteLine($"- Current Balance: ${account?.CurrentBalance}"); -Console.WriteLine($"- Transaction Count: {account?.TransactionCount}"); -Console.WriteLine($"- Is A/C Closed: {account?.IsClosed}"); -Console.WriteLine($"- Last updated: {account?.LastUpdated}"); -Console.WriteLine($"- Version: {account?.Version}"); - -// Show event history -Console.WriteLine($"\nReplay Account History:"); -await accountService.ReplayHistoryAsync(accountId); - -// Show account summary by replaying history. -account = await viewProvider.Find(accountId); -Console.WriteLine($"\nCurrent Account State:"); -Console.WriteLine($"- Account Id: {account?.Id}"); -Console.WriteLine($"- Holder: {account?.AccountName}"); -Console.WriteLine($"- Created On: {account?.CreatedDate}"); -Console.WriteLine($"- Activated On: {account?.ActiveOn}"); -Console.WriteLine($"- Current Balance: ${account?.CurrentBalance}"); -Console.WriteLine($"- Transaction Count: {account?.TransactionCount}"); -Console.WriteLine($"- Is A/C Closed: {account?.IsClosed}"); -Console.WriteLine($"- Last updated: {account?.LastUpdated}"); -Console.WriteLine($"- Version: {account?.Version}"); - -// Close account -await accountService.CloseAccountAsync(accountId, "Customer account close request"); -Console.WriteLine($"\nClose Account"); - -//// Final state -account = await viewProvider.Find(accountId); -Console.WriteLine($"\nCurrent Account State:"); -Console.WriteLine($"- Account Id: {account?.Id}"); -Console.WriteLine($"- Holder: {account?.AccountName}"); -Console.WriteLine($"- Created On: {account?.CreatedDate}"); -Console.WriteLine($"- Activated On: {account?.ActiveOn}"); -Console.WriteLine($"- Current Balance: ${account?.CurrentBalance}"); -Console.WriteLine($"- Transaction Count: {account?.TransactionCount}"); -Console.WriteLine($"- Is A/C Closed: {account?.IsClosed}"); -Console.WriteLine($"- Last updated: {account?.LastUpdated}"); -Console.WriteLine($"- Version: {account?.Version}"); - -Console.WriteLine("\nPress any key to exit..."); \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Properties/launchSettings.json b/src/SourceFlow.ConsoleApp/Properties/launchSettings.json deleted file mode 100644 index 8030cfe..0000000 --- a/src/SourceFlow.ConsoleApp/Properties/launchSettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "profiles": { - "SourceFlow.ConsoleApp": { - "commandName": "Project" - }, - "Container (Dockerfile)": { - "commandName": "Docker" - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Services/AccountService.cs b/src/SourceFlow.ConsoleApp/Services/AccountService.cs deleted file mode 100644 index 23d8309..0000000 --- a/src/SourceFlow.ConsoleApp/Services/AccountService.cs +++ /dev/null @@ -1,78 +0,0 @@ -using SourceFlow.ConsoleApp.Aggregates; -using SourceFlow.Services; - -namespace SourceFlow.ConsoleApp.Services -{ - public class AccountService : Service, IAccountService - { - public async Task CreateAccountAsync(string accountHolderName, decimal initialBalance) - { - if (string.IsNullOrEmpty(accountHolderName)) - throw new ArgumentException("Account create requires account holder name.", nameof(accountHolderName)); - - if (initialBalance <= 0) - throw new ArgumentException("Account create requires initial amount.", nameof(initialBalance)); - - var account = await CreateAggregate(); - if (account == null) - throw new InvalidOperationException("Failed to create account aggregate"); - - var accountId = new Random().Next(); // Simulating a unique account ID generation - - account.CreateAccount(accountId, accountHolderName, initialBalance); - - return accountId; - } - - public async Task DepositAsync(int accountId, decimal amount) - { - if (accountId <= 0) - throw new ArgumentException("Deposit amount must need account id", nameof(amount)); - - if (amount <= 0) - throw new ArgumentException("Deposit amount must be positive", nameof(amount)); - - var account = await CreateAggregate(); - - account.Deposit(accountId, amount); - } - - public async Task WithdrawAsync(int accountId, decimal amount) - { - if (accountId <= 0) - throw new ArgumentException("Withdraw amount must need account id", nameof(amount)); - - if (amount <= 0) - throw new ArgumentException("Withdraw amount must be positive", nameof(amount)); - - var account = await CreateAggregate(); - if (account == null) - throw new InvalidOperationException("Failed to create account aggregate"); - - account.Withdraw(accountId, amount); - } - - public async Task CloseAccountAsync(int accountId, string reason) - { - if (accountId <= 0) - throw new ArgumentException("Close account requires valid account id", nameof(accountId)); - - if (string.IsNullOrEmpty(reason)) - throw new ArgumentException("Close account requires reason", nameof(reason)); - - var account = await CreateAggregate(); - - account.Close(accountId, reason); - } - - public async Task ReplayHistoryAsync(int accountId) - { - if (accountId <= 0) - throw new ArgumentException("Account history requires valid account id", nameof(accountId)); - - var account = await CreateAggregate(); - - await account.Replay(accountId); - } - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/Services/IAccountService.cs b/src/SourceFlow.ConsoleApp/Services/IAccountService.cs deleted file mode 100644 index 8c33cfa..0000000 --- a/src/SourceFlow.ConsoleApp/Services/IAccountService.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace SourceFlow.ConsoleApp.Services -{ - public interface IAccountService - { - Task CloseAccountAsync(int accountId, string reason); - - Task CreateAccountAsync(string accountHolderName, decimal initialBalance); - - Task DepositAsync(int accountId, decimal amount); - - Task WithdrawAsync(int accountId, decimal amount); - - Task ReplayHistoryAsync(int accountId); - } -} \ No newline at end of file diff --git a/src/SourceFlow.ConsoleApp/SourceFlow.ConsoleApp.csproj b/src/SourceFlow.ConsoleApp/SourceFlow.ConsoleApp.csproj deleted file mode 100644 index c1f9d78..0000000 --- a/src/SourceFlow.ConsoleApp/SourceFlow.ConsoleApp.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - Linux - ..\.. - - - - - - - - - - - - - - diff --git a/src/SourceFlow.Net.EntityFramework/CommandDbContext.cs b/src/SourceFlow.Net.EntityFramework/CommandDbContext.cs new file mode 100644 index 0000000..a2875b2 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/CommandDbContext.cs @@ -0,0 +1,53 @@ +#nullable enable + +using Microsoft.EntityFrameworkCore; +using SourceFlow.Stores.EntityFramework.Models; +using SourceFlow.Stores.EntityFramework.Options; + +namespace SourceFlow.Stores.EntityFramework +{ + ///

+ /// DbContext specifically for command storage + /// + public class CommandDbContext : DbContext + { + private static TableNamingConvention? _namingConvention; + + public CommandDbContext(DbContextOptions options) : base(options) + { + } + + /// + /// Configure the table naming convention for command tables. + /// + /// The naming convention to use + public static void ConfigureNamingConvention(TableNamingConvention? convention) + { + _namingConvention = convention; + } + + public DbSet Commands { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfiguration(new CommandRecordConfiguration()); + + // On naming convention to CommandRecord table if configured + if (_namingConvention != null) + { + var tableName = _namingConvention.ApplyConvention(nameof(CommandRecord)); + var entity = modelBuilder.Entity(); + + if (_namingConvention.UseSchema && !string.IsNullOrEmpty(_namingConvention.SchemaName)) + { + entity.ToTable(tableName, _namingConvention.SchemaName); + } + else + { + entity.ToTable(tableName); + } + } + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/EntityDbContext.cs b/src/SourceFlow.Net.EntityFramework/EntityDbContext.cs new file mode 100644 index 0000000..a34799f --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/EntityDbContext.cs @@ -0,0 +1,168 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using SourceFlow.Stores.EntityFramework.Options; + +namespace SourceFlow.Stores.EntityFramework +{ + /// + /// DbContext specifically for entity storage + /// + public class EntityDbContext : DbContext + { + private static readonly HashSet _explicitlyRegisteredTypes = new HashSet(); + private static readonly HashSet _assembliesToScan = new HashSet(); + private static TableNamingConvention? _namingConvention; + + public EntityDbContext(DbContextOptions options) : base(options) + { + } + + /// + /// Configure the table naming convention for entity tables. + /// + /// The naming convention to use + public static void ConfigureNamingConvention(TableNamingConvention? convention) + { + _namingConvention = convention; + } + + /// + /// Explicitly register an entity type before creating the database. + /// This ensures the type is included in the model even if auto-discovery doesn't find it. + /// + public static void RegisterEntityType() where TEntity : class + { + _explicitlyRegisteredTypes.Add(typeof(TEntity)); + } + + /// + /// Register an assembly to scan for entity types. + /// + public static void RegisterAssembly(Assembly assembly) + { + if (assembly != null) + { + _assembliesToScan.Add(assembly); + } + } + + /// + /// Clear all registered types and assemblies. Useful for testing. + /// + public static void ClearRegistrations() + { + _explicitlyRegisteredTypes.Clear(); + _assembliesToScan.Clear(); + } + + /// + /// Get all registered entity types (both explicit and from assemblies). + /// + public static IEnumerable GetRegisteredTypes() + { + var types = new HashSet(_explicitlyRegisteredTypes); + + // Add types from registered assemblies + foreach (var assembly in _assembliesToScan) + { + try + { + var assemblyTypes = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && + t.GetInterfaces().Any(i => i.Name == "IEntity")); + foreach (var type in assemblyTypes) + { + types.Add(type); + } + } + catch { } + } + + return types; + } + + /// + /// Manually creates database tables for all registered entity types. + /// This bypasses EF Core's model caching and should be called after EnsureCreated(). + /// + public void ApplyMigrations() + { + var types = GetRegisteredTypes(); + if (types.Any()) + { + Migrations.DbContextMigrationHelper.CreateEntityTables(this, types); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Start with explicitly registered types + var entityTypes = new HashSet(_explicitlyRegisteredTypes); + + // Add types from explicitly registered assemblies + foreach (var assembly in _assembliesToScan) + { + try + { + var types = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && + t.GetInterfaces().Any(i => i.Name == "IEntity")); + foreach (var type in types) + { + entityTypes.Add(type); + } + } + catch { } + } + + // Auto-discover from loaded assemblies (fallback) + var discoveredTypes = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !a.FullName?.StartsWith("Microsoft.") == true + && !a.FullName?.StartsWith("System.") == true + && !a.FullName?.StartsWith("netstandard") == true) + .SelectMany(a => + { + try { return a.GetTypes(); } + catch { return Enumerable.Empty(); } + }) + .Where(t => t.IsClass && !t.IsAbstract && + t.GetInterfaces().Any(i => i.Name == "IEntity")); + + foreach (var type in discoveredTypes) + { + entityTypes.Add(type); + } + + // Register all discovered and explicitly registered types + foreach (var entityType in entityTypes) + { + // Register each entity type with EF Core + // Configure the entity with a primary key on Id property + var entity = modelBuilder.Entity(entityType); + entity.HasKey("Id"); + + // On naming convention if configured + if (_namingConvention != null) + { + var tableName = _namingConvention.ApplyConvention(entityType.Name); + + if (_namingConvention.UseSchema && !string.IsNullOrEmpty(_namingConvention.SchemaName)) + { + entity.ToTable(tableName, _namingConvention.SchemaName); + } + else + { + entity.ToTable(tableName); + } + } + } + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Examples/DatabaseProviderExamples.cs b/src/SourceFlow.Net.EntityFramework/Examples/DatabaseProviderExamples.cs new file mode 100644 index 0000000..2cad54e --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Examples/DatabaseProviderExamples.cs @@ -0,0 +1,118 @@ +//using Microsoft.EntityFrameworkCore; +//using Microsoft.Extensions.DependencyInjection; +//using Microsoft.Data.Sqlite; +//using SourceFlow.Net.EntityFramework.Extensions; + +//namespace SourceFlow.Net.EntityFramework.Examples +//{ +// /// +// /// Examples showing how to use the Entity Framework stores with different database providers +// /// +// public static class DatabaseProviderExamples +// { +// /// +// /// Example: Using SQLite for all stores +// /// +// public static void UseSqliteExample() +// { +// var services = new ServiceCollection(); + +// // Create a connection string for SQLite +// var connectionString = "DataSource=:memory:"; + +// services.AddSourceFlowEfStoresWithCustomProvider(optionsBuilder => +// { +// // Configure for SQLite +// optionsBuilder.UseSqlite(connectionString); +// }); +// } + +// /// +// /// Example: Using different database providers for each store +// /// +// public static void UseDifferentProvidersExample() +// { +// var services = new ServiceCollection(); + +// // Command store using SQLite +// var commandConnectionString = "DataSource=:memory:command.db"; + +// // Entity store using a different SQLite database +// var entityConnectionString = "DataSource=:memory:entity.db"; + +// // View model store using another SQLite database +// var viewModelConnectionString = "DataSource=:memory:viewmodel.db"; + +// services.AddSourceFlowEfStoresWithCustomProviders( +// commandContextConfig: optionsBuilder => optionsBuilder.UseSqlite(commandConnectionString), +// entityContextConfig: optionsBuilder => optionsBuilder.UseSqlite(entityConnectionString), +// viewModelContextConfig: optionsBuilder => optionsBuilder.UseSqlite(viewModelConnectionString) +// ); +// } + +// /// +// /// Example: Using PostgreSQL +// /// +// public static void UsePostgreSqlExample() +// { +// var services = new ServiceCollection(); + +// var connectionString = "Host=localhost;Database=SourceFlow;Username=postgres;Password=password"; + +// services.AddSourceFlowEfStoresWithCustomProvider(optionsBuilder => +// { +// // This would require Microsoft.EntityFrameworkCore.PostgreSQL package +// // optionsBuilder.UseNpgsql(connectionString); +// }); +// } + +// /// +// /// Example: Using MySQL +// /// +// public static void UseMySqlExample() +// { +// var services = new ServiceCollection(); + +// var connectionString = "Server=localhost;Database=SourceFlow;Uid=user;Pwd=password;"; + +// services.AddSourceFlowEfStoresWithCustomProvider(optionsBuilder => +// { +// // This would require Pomelo.EntityFrameworkCore.MySql or Oracle's MySQL provider +// // optionsBuilder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)); +// }); +// } + +// /// +// /// Example: Using SQL Server (default behavior) +// /// +// public static void UseSqlServerExample() +// { +// var services = new ServiceCollection(); + +// var connectionString = "Server=localhost;Database=SourceFlow;Trusted_Connection=true;"; + +// // This is the original method that uses SQL Server +// services.AddSourceFlowEfStores(connectionString); +// } + +// /// +// /// Example: Using SQL Server with different connection strings per store +// /// +// public static void UseSqlServerSeparateConnectionsExample() +// { +// var services = new ServiceCollection(); + +// // Different connection strings for each store +// var commandConnectionString = "Server=localhost;Database=SourceFlow.Commands;Trusted_Connection=true;"; +// var entityConnectionString = "Server=localhost;Database=SourceFlow.Entities;Trusted_Connection=true;"; +// var viewModelConnectionString = "Server=localhost;Database=SourceFlow.ViewModels;Trusted_Connection=true;"; + +// // This is the original method that uses SQL Server +// services.AddSourceFlowEfStores( +// commandConnectionString, +// entityConnectionString, +// viewModelConnectionString +// ); +// } +// } +//} diff --git a/src/SourceFlow.Net.EntityFramework/Examples/UsageExamples.cs b/src/SourceFlow.Net.EntityFramework/Examples/UsageExamples.cs new file mode 100644 index 0000000..8844d29 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Examples/UsageExamples.cs @@ -0,0 +1,76 @@ +//using System; +//using System.Collections.Generic; +//using Microsoft.Extensions.Configuration; +//using Microsoft.Extensions.DependencyInjection; +//using SourceFlow.Net.EntityFramework.Extensions; + +//namespace SourceFlow.Net.EntityFramework.Examples +//{ +// /// +// /// Examples showing different ways to configure Entity Framework stores with connection strings +// /// +// public static class UsageExamples +// { +// /// +// /// Example: Using a single connection string for all stores +// /// +// public static void SingleConnectionStringExample() +// { +// var services = new ServiceCollection(); + +// // All stores use the same connection string +// services.AddSourceFlowEfStores("Server=localhost;Database=SourceFlow;Trusted_Connection=true;"); +// } + +// /// +// /// Example: Using separate connection strings for each store +// /// +// public static void SeparateConnectionStringsExample() +// { +// var services = new ServiceCollection(); + +// // Each store gets its own connection string +// services.AddSourceFlowEfStores( +// commandConnectionString: "Server=localhost;Database=SourceFlow.Commands;Trusted_Connection=true;", +// entityConnectionString: "Server=localhost;Database=SourceFlow.Entities;Trusted_Connection=true;", +// viewModelConnectionString: "Server=localhost;Database=SourceFlow.ViewModels;Trusted_Connection=true;" +// ); +// } + +// /// +// /// Example: Using configuration from appsettings.json +// /// +// public static void ConfigurationExample(IConfiguration configuration) +// { +// var services = new ServiceCollection(); + +// // Reads connection strings from configuration: +// // ConnectionStrings:SourceFlow.Command +// // ConnectionStrings:SourceFlow.Entity +// // ConnectionStrings:SourceFlow.ViewModel +// services.AddSourceFlowEfStores(configuration); +// } + +// /// +// /// Example: Using options action for programmatic configuration +// /// +// public static void OptionsExample() +// { +// var services = new ServiceCollection(); + +// services.AddSourceFlowEfStores(options => +// { +// options.CommandConnectionString = GetCommandConnectionString(); +// options.EntityConnectionString = GetEntityConnectionString(); +// options.ViewModelConnectionString = GetViewModelConnectionString(); +// }); +// } + +// /// +// /// Placeholder methods to represent getting connection strings from various sources +// /// +// private static string GetCommandConnectionString() => "Server=localhost;Database=SourceFlow.Commands;Trusted_Connection=true;"; +// private static string GetEntityConnectionString() => "Server=localhost;Database=SourceFlow.Entities;Trusted_Connection=true;"; +// private static string GetViewModelConnectionString() => "Server=localhost;Database=SourceFlow.ViewModels;Trusted_Connection=true;"; +// } +//} diff --git a/src/SourceFlow.Net.EntityFramework/Extensions/ServiceCollectionExtensions.cs b/src/SourceFlow.Net.EntityFramework/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..db39e84 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,363 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SourceFlow.Stores.EntityFramework.Options; +using SourceFlow.Stores.EntityFramework.Services; +using SourceFlow.Stores.EntityFramework.Stores; + +namespace SourceFlow.Stores.EntityFramework.Extensions +{ + /// + /// Extension methods for registering Entity Framework-based persistence stores. + /// + /// + /// SQL Server Methods: AddSourceFlowEfStores overloads use SQL Server by default. + /// + /// + /// Database-Agnostic Methods: Use AddSourceFlowEfStoresWithCustomProvider(s) for other databases + /// (PostgreSQL, MySQL, SQLite, etc.). + /// + /// + public static class ServiceCollectionExtensions + { + /// + /// [SQL Server] Registers Entity Framework implementations with a single connection string. + /// + /// The service collection + /// SQL Server connection string to use for all stores + /// The service collection for chaining + /// + /// This method uses SQL Server as the database provider. For other databases, use + /// . + /// + public static IServiceCollection AddSourceFlowEfStores(this IServiceCollection services, string connectionString) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + if (string.IsNullOrEmpty(connectionString)) + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + + // Configure options with default connection string + var options = new SourceFlowEfOptions { DefaultConnectionString = connectionString }; + services.AddSingleton(options); + + // Configure naming conventions + ConfigureNamingConventions(options); + + services.AddDbContext(optionsBuilder => + optionsBuilder.UseSqlServer(connectionString)); + services.AddDbContext(optionsBuilder => + optionsBuilder.UseSqlServer(connectionString)); + services.AddDbContext(optionsBuilder => + optionsBuilder.UseSqlServer(connectionString)); + + // Register common services (resilience, telemetry, stores) + RegisterCommonServices(services); + + return services; + } + + /// + /// [SQL Server] Registers Entity Framework implementations with separate connection strings for each store. + /// + /// The service collection + /// SQL Server connection string for command store + /// SQL Server connection string for entity store + /// SQL Server connection string for view model store + /// The service collection for chaining + /// + /// This method uses SQL Server as the database provider. For other databases, use + /// . + /// + public static IServiceCollection AddSourceFlowEfStores( + this IServiceCollection services, + string commandConnectionString, + string entityConnectionString, + string viewModelConnectionString) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + + // Configure options with individual connection strings + var options = new SourceFlowEfOptions + { + CommandConnectionString = commandConnectionString, + EntityConnectionString = entityConnectionString, + ViewModelConnectionString = viewModelConnectionString + }; + services.AddSingleton(options); + + // Configure naming conventions + ConfigureNamingConventions(options); + + services.AddDbContext(optionsBuilder => + optionsBuilder.UseSqlServer(commandConnectionString)); + services.AddDbContext(optionsBuilder => + optionsBuilder.UseSqlServer(entityConnectionString)); + services.AddDbContext(optionsBuilder => + optionsBuilder.UseSqlServer(viewModelConnectionString)); + + // Register common services (resilience, telemetry, stores) + RegisterCommonServices(services); + + return services; + } + + /// + /// [SQL Server] Registers Entity Framework implementations using configuration from IConfiguration. + /// + /// The service collection + /// Configuration to read SQL Server connection strings from + /// The service collection for chaining + /// + /// Looks for settings in the format: SourceFlow:CommandConnectionString, SourceFlow:EntityConnectionString, etc. + /// This method uses SQL Server as the database provider. For other databases, use + /// . + /// + public static IServiceCollection AddSourceFlowEfStores( + this IServiceCollection services, + IConfiguration configuration) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + if (configuration == null) + throw new ArgumentNullException(nameof(configuration)); + + // Read configuration + var options = new SourceFlowEfOptions + { + CommandConnectionString = configuration.GetConnectionString("SourceFlow.Command"), + EntityConnectionString = configuration.GetConnectionString("SourceFlow.Entity"), + ViewModelConnectionString = configuration.GetConnectionString("SourceFlow.ViewModel"), + DefaultConnectionString = configuration.GetConnectionString("SourceFlow.Default") + ?? configuration.GetSection("SourceFlow")?.GetValue("DefaultConnectionString") + }; + + // If individual connection strings are not provided, fallback to default + if (string.IsNullOrEmpty(options.CommandConnectionString)) + options.CommandConnectionString = options.DefaultConnectionString; + if (string.IsNullOrEmpty(options.EntityConnectionString)) + options.EntityConnectionString = options.DefaultConnectionString; + if (string.IsNullOrEmpty(options.ViewModelConnectionString)) + options.ViewModelConnectionString = options.DefaultConnectionString; + + services.AddSingleton(options); + + // Configure naming conventions + ConfigureNamingConventions(options); + + // Register contexts with appropriate connection strings + services.AddDbContext(optionsBuilder => + { + var connectionString = options.GetConnectionString(StoreType.Command); + optionsBuilder.UseSqlServer(connectionString); + }); + + services.AddDbContext(optionsBuilder => + { + var connectionString = options.GetConnectionString(StoreType.Entity); + optionsBuilder.UseSqlServer(connectionString); + }); + + services.AddDbContext(optionsBuilder => + { + var connectionString = options.GetConnectionString(StoreType.ViewModel); + optionsBuilder.UseSqlServer(connectionString); + }); + + // Register common services (resilience, telemetry, stores) + RegisterCommonServices(services); + + return services; + } + + /// + /// [SQL Server] Registers Entity Framework implementations with options configuration. + /// + /// The service collection + /// Action to configure the options including connection strings and naming conventions + /// The service collection for chaining + /// + /// This method allows configuring connection strings and table naming conventions. + /// This method uses SQL Server as the database provider. For other databases, use + /// . + /// + public static IServiceCollection AddSourceFlowEfStores( + this IServiceCollection services, + Action optionsAction) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + if (optionsAction == null) + throw new ArgumentNullException(nameof(optionsAction)); + + var options = new SourceFlowEfOptions(); + optionsAction(options); + services.AddSingleton(options); + + // Configure naming conventions + ConfigureNamingConventions(options); + + // Register contexts with appropriate connection strings based on options + services.AddDbContext(optionsBuilder => + { + var connectionString = options.GetConnectionString(StoreType.Command); + optionsBuilder.UseSqlServer(connectionString); + }); + + services.AddDbContext(optionsBuilder => + { + var connectionString = options.GetConnectionString(StoreType.Entity); + optionsBuilder.UseSqlServer(connectionString); + }); + + services.AddDbContext(optionsBuilder => + { + var connectionString = options.GetConnectionString(StoreType.ViewModel); + optionsBuilder.UseSqlServer(connectionString); + }); + + // Register common services (resilience, telemetry, stores) + RegisterCommonServices(services); + + return services; + } + + /// + /// [Database-Agnostic] Registers Entity Framework implementations using a custom database provider. + /// + /// The service collection + /// Action to configure the DbContext with the desired provider (SQLite, PostgreSQL, MySQL, etc.) + /// The service collection for chaining + /// + /// This method allows you to use any Entity Framework Core database provider. + /// + /// PostgreSQL: + /// + /// services.AddSourceFlowEfStoresWithCustomProvider(options => + /// options.UseNpgsql(connectionString)); + /// + /// SQLite: + /// + /// services.AddSourceFlowEfStoresWithCustomProvider(options => + /// options.UseSqlite(connectionString)); + /// + /// MySQL: + /// + /// services.AddSourceFlowEfStoresWithCustomProvider(options => + /// options.UseMySql(connectionString, serverVersion)); + /// + /// + /// + public static IServiceCollection AddSourceFlowEfStoresWithCustomProvider( + this IServiceCollection services, + Action configureContext) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + if (configureContext == null) + throw new ArgumentNullException(nameof(configureContext)); + + // Create and configure default options + var options = new SourceFlowEfOptions(); + services.AddSingleton(options); + + // Configure naming conventions with default settings + ConfigureNamingConventions(options); + + services.AddDbContext(configureContext); + services.AddDbContext(configureContext); + services.AddDbContext(configureContext); + + // Register common services (resilience, telemetry, stores) + RegisterCommonServices(services); + + return services; + } + + /// + /// [Database-Agnostic] Registers Entity Framework implementations with separate database provider configurations. + /// + /// The service collection + /// Action to configure the CommandDbContext (can use any EF Core provider) + /// Action to configure the EntityDbContext (can use any EF Core provider) + /// Action to configure the ViewModelDbContext (can use any EF Core provider) + /// The service collection for chaining + /// + /// This method allows each store to use a different database provider or configuration. + /// + /// Mix different databases: + /// + /// services.AddSourceFlowEfStoresWithCustomProviders( + /// commandConfig: opt => opt.UseNpgsql(postgresConnectionString), + /// entityConfig: opt => opt.UseSqlite(sqliteConnectionString), + /// viewModelConfig: opt => opt.UseSqlServer(sqlServerConnectionString)); + /// + /// + /// + public static IServiceCollection AddSourceFlowEfStoresWithCustomProviders( + this IServiceCollection services, + Action commandContextConfig, + Action entityContextConfig, + Action viewModelContextConfig) + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + if (commandContextConfig == null) + throw new ArgumentNullException(nameof(commandContextConfig)); + if (entityContextConfig == null) + throw new ArgumentNullException(nameof(entityContextConfig)); + if (viewModelContextConfig == null) + throw new ArgumentNullException(nameof(viewModelContextConfig)); + + // Create and configure default options + var options = new SourceFlowEfOptions(); + services.AddSingleton(options); + + // Configure naming conventions with default settings + ConfigureNamingConventions(options); + + services.AddDbContext(commandContextConfig); + services.AddDbContext(entityContextConfig); + services.AddDbContext(viewModelContextConfig); + + // Register common services (resilience, telemetry, stores) + RegisterCommonServices(services); + + return services; + } + + /// + /// Registers common SourceFlow services (resilience policy, telemetry service, stores). + /// + /// The service collection + private static void RegisterCommonServices(IServiceCollection services) + { + // Register resilience policy and telemetry service as Scoped (same lifetime as stores) + services.TryAddScoped(); + services.TryAddScoped(); + + // Register EF stores + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + } + + /// + /// Configures naming conventions for all DbContexts based on the options. + /// + /// The SourceFlow options containing naming convention settings + private static void ConfigureNamingConventions(SourceFlowEfOptions options) + { + if (options == null) + return; + + // Configure naming convention for each context type + CommandDbContext.ConfigureNamingConvention(options.CommandTableNaming); + EntityDbContext.ConfigureNamingConvention(options.EntityTableNaming); + ViewModelDbContext.ConfigureNamingConvention(options.ViewModelTableNaming); + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Migrations/DbContextMigrationHelper.cs b/src/SourceFlow.Net.EntityFramework/Migrations/DbContextMigrationHelper.cs new file mode 100644 index 0000000..b1f30e0 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Migrations/DbContextMigrationHelper.cs @@ -0,0 +1,110 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; + +namespace SourceFlow.Stores.EntityFramework.Migrations +{ + /// + /// Helper class to manually create database schemas for dynamic entity and view model types. + /// This bypasses EF Core's model caching to support runtime type registration. + /// + public static class DbContextMigrationHelper + { + /// + /// Manually creates tables for all registered IEntity types in the EntityDbContext. + /// + public static void CreateEntityTables(EntityDbContext context, IEnumerable entityTypes) + { + CreateTablesCore(context, entityTypes); + } + + /// + /// Manually creates tables for all registered IViewModel types in the ViewModelDbContext. + /// + public static void CreateViewModelTables(ViewModelDbContext context, IEnumerable viewModelTypes) + { + CreateTablesCore(context, viewModelTypes); + } + + private static void CreateTablesCore(DbContext context, IEnumerable types) + { + var databaseCreator = context.Database.GetService(); + + // Ensure database exists + context.Database.EnsureCreated(); + + foreach (var type in types) + { + var tableName = type.Name; + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite); + + var columns = new List(); + foreach (var prop in properties) + { + var columnDef = GetColumnDefinition(prop); + if (columnDef != null) + { + columns.Add(columnDef); + } + } + + if (columns.Any()) + { + var createTableSql = $@" + CREATE TABLE IF NOT EXISTS ""{tableName}"" ( + {string.Join(",\n ", columns)}, + PRIMARY KEY (""Id"") + )"; + + try + { + context.Database.ExecuteSqlRaw(createTableSql); + } + catch + { + // Table might already exist, ignore + } + } + } + } + + private static string? GetColumnDefinition(PropertyInfo property) + { + var propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + var columnName = property.Name; + string sqlType; + + if (propertyType == typeof(int)) + sqlType = "INTEGER"; + else if (propertyType == typeof(long)) + sqlType = "BIGINT"; + else if (propertyType == typeof(string)) + sqlType = "TEXT"; + else if (propertyType == typeof(bool)) + sqlType = "INTEGER"; // SQLite uses INTEGER for boolean + else if (propertyType == typeof(DateTime)) + sqlType = "TEXT"; // SQLite stores DateTime as TEXT + else if (propertyType == typeof(decimal) || propertyType == typeof(double) || propertyType == typeof(float)) + sqlType = "REAL"; + else if (propertyType == typeof(byte[])) + sqlType = "BLOB"; + else if (propertyType.IsEnum) + sqlType = "INTEGER"; + else + return null; // Skip complex types + + var nullable = Nullable.GetUnderlyingType(property.PropertyType) != null || + (!propertyType.IsValueType && columnName != "Id"); + var nullConstraint = nullable ? "" : " NOT NULL"; + + return $@"""{columnName}"" {sqlType}{nullConstraint}"; + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Models/CommandRecord.cs b/src/SourceFlow.Net.EntityFramework/Models/CommandRecord.cs new file mode 100644 index 0000000..b114d63 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Models/CommandRecord.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace SourceFlow.Stores.EntityFramework.Models +{ + public class CommandRecord + { + public long Id { get; set; } + public int EntityId { get; set; } + public int SequenceNo { get; set; } + public string CommandName { get; set; } = string.Empty; + public string CommandType { get; set; } = string.Empty; + + // Store command data in relational fields instead of serialization + public string PayloadType { get; set; } = string.Empty; + + public string PayloadData { get; set; } = string.Empty; // This can be JSON but for the payload itself + + public string Metadata { get; set; } = string.Empty; // Store metadata as JSON + public DateTime Timestamp { get; set; } + + // Relational fields that can be indexed and queried + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } + + public class CommandRecordConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.HasIndex(c => new { c.EntityId, c.SequenceNo }).IsUnique(); + builder.HasIndex(c => c.EntityId); + builder.HasIndex(c => c.Timestamp); + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Options/ObservabilityOptions.cs b/src/SourceFlow.Net.EntityFramework/Options/ObservabilityOptions.cs new file mode 100644 index 0000000..8acdb25 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Options/ObservabilityOptions.cs @@ -0,0 +1,90 @@ +namespace SourceFlow.Stores.EntityFramework.Options +{ + /// + /// Configuration options for observability (OpenTelemetry tracing, metrics, logging) + /// + public class ObservabilityOptions + { + /// + /// Gets or sets whether observability is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the service name for telemetry. + /// + public string ServiceName { get; set; } = "SourceFlow.EntityFramework"; + + /// + /// Gets or sets the service version for telemetry. + /// + public string ServiceVersion { get; set; } = "1.0.0"; + + /// + /// Gets or sets tracing configuration. + /// + public TracingOptions Tracing { get; set; } = new TracingOptions(); + + /// + /// Gets or sets metrics configuration. + /// + public MetricsOptions Metrics { get; set; } = new MetricsOptions(); + } + + /// + /// Configuration for distributed tracing. + /// + public class TracingOptions + { + /// + /// Gets or sets whether tracing is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets whether to trace database operations. + /// + public bool TraceDatabaseOperations { get; set; } = true; + + /// + /// Gets or sets whether to trace command operations. + /// + public bool TraceCommandOperations { get; set; } = true; + + /// + /// Gets or sets whether to include detailed SQL in traces. + /// + public bool IncludeSqlInTraces { get; set; } = false; + + /// + /// Gets or sets the sampling ratio (0.0 to 1.0). 1.0 means trace everything. + /// + public double SamplingRatio { get; set; } = 1.0; + } + + /// + /// Configuration for metrics collection. + /// + public class MetricsOptions + { + /// + /// Gets or sets whether metrics are enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets whether to collect database metrics. + /// + public bool CollectDatabaseMetrics { get; set; } = true; + + /// + /// Gets or sets whether to collect command metrics. + /// + public bool CollectCommandMetrics { get; set; } = true; + + /// + /// Gets or sets the metrics collection interval in milliseconds. + /// + public int CollectionIntervalMs { get; set; } = 1000; + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Options/ResilienceOptions.cs b/src/SourceFlow.Net.EntityFramework/Options/ResilienceOptions.cs new file mode 100644 index 0000000..ed1b4b5 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Options/ResilienceOptions.cs @@ -0,0 +1,106 @@ +namespace SourceFlow.Stores.EntityFramework.Options +{ + /// + /// Configuration options for resilience patterns (retry, circuit breaker, etc.) + /// + public class ResilienceOptions + { + /// + /// Gets or sets whether resilience policies are enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the retry policy configuration. + /// + public RetryPolicyOptions Retry { get; set; } = new RetryPolicyOptions(); + + /// + /// Gets or sets the circuit breaker policy configuration. + /// + public CircuitBreakerOptions CircuitBreaker { get; set; } = new CircuitBreakerOptions(); + + /// + /// Gets or sets the timeout policy configuration. + /// + public TimeoutOptions Timeout { get; set; } = new TimeoutOptions(); + } + + /// + /// Configuration for retry policies. + /// + public class RetryPolicyOptions + { + /// + /// Gets or sets whether retry is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the maximum number of retry attempts. + /// + public int MaxRetryAttempts { get; set; } = 3; + + /// + /// Gets or sets the base delay between retries in milliseconds. + /// + public int BaseDelayMs { get; set; } = 1000; + + /// + /// Gets or sets the maximum delay between retries in milliseconds. + /// + public int MaxDelayMs { get; set; } = 30000; + + /// + /// Gets or sets whether to use exponential backoff. + /// + public bool UseExponentialBackoff { get; set; } = true; + + /// + /// Gets or sets whether to add jitter to retry delays. + /// + public bool UseJitter { get; set; } = true; + } + + /// + /// Configuration for circuit breaker policies. + /// + public class CircuitBreakerOptions + { + /// + /// Gets or sets whether circuit breaker is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the number of consecutive failures before breaking the circuit. + /// + public int FailureThreshold { get; set; } = 5; + + /// + /// Gets or sets the duration in milliseconds the circuit stays open before attempting to close. + /// + public int BreakDurationMs { get; set; } = 30000; + + /// + /// Gets or sets the number of successful calls required to close the circuit when in half-open state. + /// + public int SuccessThreshold { get; set; } = 2; + } + + /// + /// Configuration for timeout policies. + /// + public class TimeoutOptions + { + /// + /// Gets or sets whether timeout is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the timeout duration in milliseconds. + /// + public int TimeoutMs { get; set; } = 30000; + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Options/SourceFlowEfOptions.cs b/src/SourceFlow.Net.EntityFramework/Options/SourceFlowEfOptions.cs new file mode 100644 index 0000000..b01de9c --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Options/SourceFlowEfOptions.cs @@ -0,0 +1,86 @@ +#nullable enable + +using System; + +namespace SourceFlow.Stores.EntityFramework.Options +{ + /// + /// Configuration options for Entity Framework stores + /// + public class SourceFlowEfOptions + { + /// + /// Connection string for command store + /// + public string? CommandConnectionString { get; set; } + + /// + /// Connection string for entity store + /// + public string? EntityConnectionString { get; set; } + + /// + /// Connection string for view model store + /// + public string? ViewModelConnectionString { get; set; } + + /// + /// If true, a single connection string will be used for all stores + /// + public string? DefaultConnectionString { get; set; } + + /// + /// Table naming convention for entity tables. + /// + public TableNamingConvention EntityTableNaming { get; set; } = new TableNamingConvention(); + + /// + /// Table naming convention for view model tables. + /// + public TableNamingConvention ViewModelTableNaming { get; set; } = new TableNamingConvention(); + + /// + /// Table naming convention for command tables. + /// + public TableNamingConvention CommandTableNaming { get; set; } = new TableNamingConvention(); + + /// + /// Resilience options for fault tolerance (retry, circuit breaker, timeout). + /// + public ResilienceOptions Resilience { get; set; } = new ResilienceOptions(); + + /// + /// Observability options for OpenTelemetry tracing and metrics. + /// + public ObservabilityOptions Observability { get; set; } = new ObservabilityOptions(); + + /// + /// Gets the connection string for a specific store type + /// + /// Type of store + /// Appropriate connection string + public string GetConnectionString(StoreType storeType) + { + return storeType switch + { + StoreType.Command => CommandConnectionString ?? DefaultConnectionString + ?? throw new InvalidOperationException("Command connection string not configured"), + StoreType.Entity => EntityConnectionString ?? DefaultConnectionString + ?? throw new InvalidOperationException("Entity connection string not configured"), + StoreType.ViewModel => ViewModelConnectionString ?? DefaultConnectionString + ?? throw new InvalidOperationException("ViewModel connection string not configured"), + _ => throw new ArgumentException($"Unknown store type: {storeType}", nameof(storeType)) + }; + } + } + + /// + /// Enum representing different store types + /// + public enum StoreType + { + Command, + Entity, + ViewModel + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Options/TableNamingConvention.cs b/src/SourceFlow.Net.EntityFramework/Options/TableNamingConvention.cs new file mode 100644 index 0000000..8bab468 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Options/TableNamingConvention.cs @@ -0,0 +1,178 @@ +#nullable enable + +using System; +using System.Text.RegularExpressions; + +namespace SourceFlow.Stores.EntityFramework.Options +{ + /// + /// Defines table naming conventions for Entity Framework stores. + /// + public class TableNamingConvention + { + /// + /// Gets or sets the casing style for table names. + /// + public TableNameCasing Casing { get; set; } = TableNameCasing.PascalCase; + + /// + /// Gets or sets whether to pluralize table names. + /// + public bool Pluralize { get; set; } = false; + + /// + /// Gets or sets an optional prefix for all table names. + /// + public string? Prefix { get; set; } + + /// + /// Gets or sets an optional suffix for all table names. + /// + public string? Suffix { get; set; } + + /// + /// Gets or sets whether to use schema names. + /// + public bool UseSchema { get; set; } = false; + + /// + /// Gets or sets the schema name to use (if UseSchema is true). + /// + public string? SchemaName { get; set; } + + /// + /// Applies the naming convention to a type name. + /// + /// The type name to convert + /// The table name following the convention + public string ApplyConvention(string typeName) + { + if (string.IsNullOrEmpty(typeName)) + return typeName; + + var tableName = typeName; + + // On casing + tableName = Casing switch + { + TableNameCasing.PascalCase => ToPascalCase(tableName), + TableNameCasing.CamelCase => ToCamelCase(tableName), + TableNameCasing.SnakeCase => ToSnakeCase(tableName), + TableNameCasing.LowerCase => tableName.ToLowerInvariant(), + TableNameCasing.UpperCase => tableName.ToUpperInvariant(), + _ => tableName + }; + + // On pluralization + if (Pluralize) + { + tableName = PluralizeName(tableName); + } + + // On prefix and suffix + if (!string.IsNullOrEmpty(Prefix)) + { + tableName = Prefix + tableName; + } + + if (!string.IsNullOrEmpty(Suffix)) + { + tableName = tableName + Suffix; + } + + return tableName; + } + + private static string ToPascalCase(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + // Already in PascalCase, return as is + return input; + } + + private static string ToCamelCase(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + return char.ToLowerInvariant(input[0]) + input.Substring(1); + } + + private static string ToSnakeCase(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + // Insert underscores before capital letters (except the first one) + var result = Regex.Replace(input, "(? 1 && + !IsVowel(input[input.Length - 2])) + { + // party -> parties + return input.Substring(0, input.Length - 1) + "ies"; + } + else if (input.EndsWith("s", StringComparison.OrdinalIgnoreCase) || + input.EndsWith("x", StringComparison.OrdinalIgnoreCase) || + input.EndsWith("z", StringComparison.OrdinalIgnoreCase) || + input.EndsWith("ch", StringComparison.OrdinalIgnoreCase) || + input.EndsWith("sh", StringComparison.OrdinalIgnoreCase)) + { + // class -> classes, box -> boxes + return input + "es"; + } + else + { + // Default: just add 's' + return input + "s"; + } + } + + private static bool IsVowel(char c) + { + return "aeiouAEIOU".IndexOf(c) >= 0; + } + } + + /// + /// Defines the casing styles available for table names. + /// + public enum TableNameCasing + { + /// + /// PascalCase - first letter capitalized (e.g., BankAccount) + /// + PascalCase, + + /// + /// camelCase - first letter lowercase (e.g., bankAccount) + /// + CamelCase, + + /// + /// snake_case - lowercase with underscores (e.g., bank_account) + /// + SnakeCase, + + /// + /// lowercase - all lowercase (e.g., bankaccount) + /// + LowerCase, + + /// + /// UPPERCASE - all uppercase (e.g., BANKACCOUNT) + /// + UpperCase + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Services/DatabaseResiliencePolicy.cs b/src/SourceFlow.Net.EntityFramework/Services/DatabaseResiliencePolicy.cs new file mode 100644 index 0000000..85d01a1 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Services/DatabaseResiliencePolicy.cs @@ -0,0 +1,135 @@ +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Polly; +using Polly.CircuitBreaker; +using Polly.Retry; +using Polly.Timeout; +using SourceFlow.Stores.EntityFramework.Options; + +namespace SourceFlow.Stores.EntityFramework.Services +{ + /// + /// Provides resilience policies (retry, circuit breaker, timeout) for database operations. + /// + public class DatabaseResiliencePolicy : IDatabaseResiliencePolicy + { + private readonly ResiliencePipeline? _pipeline; + private readonly bool _isEnabled; + + public DatabaseResiliencePolicy(SourceFlowEfOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + _isEnabled = options.Resilience.Enabled; + + if (_isEnabled) + { + _pipeline = BuildResiliencePipeline(options.Resilience); + } + } + + /// + /// Executes an async operation with resilience policies applied. + /// + public async Task ExecuteAsync(Func> operation) + { + if (!_isEnabled || _pipeline == null) + { + return await operation(); + } + + return await _pipeline.ExecuteAsync(async ct => await operation(), CancellationToken.None); + } + + /// + /// Executes an async operation with resilience policies applied. + /// + public async Task ExecuteAsync(Func operation) + { + if (!_isEnabled || _pipeline == null) + { + await operation(); + return; + } + + await _pipeline.ExecuteAsync(async ct => await operation(), CancellationToken.None); + } + + private ResiliencePipeline BuildResiliencePipeline(ResilienceOptions options) + { + var pipelineBuilder = new ResiliencePipelineBuilder(); + + // Add timeout policy (innermost - closest to the operation) + if (options.Timeout.Enabled) + { + pipelineBuilder.AddTimeout(new TimeoutStrategyOptions + { + Timeout = TimeSpan.FromMilliseconds(options.Timeout.TimeoutMs) + }); + } + + // Add retry policy (middle layer) + if (options.Retry.Enabled) + { + var retryOptions = new RetryStrategyOptions + { + MaxRetryAttempts = options.Retry.MaxRetryAttempts, + ShouldHandle = new PredicateBuilder().Handle() + .Handle() + .Handle(ex => + ex.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase)), + BackoffType = options.Retry.UseExponentialBackoff + ? DelayBackoffType.Exponential + : DelayBackoffType.Constant, + Delay = TimeSpan.FromMilliseconds(options.Retry.BaseDelayMs), + MaxDelay = TimeSpan.FromMilliseconds(options.Retry.MaxDelayMs), + UseJitter = options.Retry.UseJitter + }; + + pipelineBuilder.AddRetry(retryOptions); + } + + // Add circuit breaker policy (outermost - protects the system) + if (options.CircuitBreaker.Enabled) + { + var circuitBreakerOptions = new CircuitBreakerStrategyOptions + { + FailureRatio = 0.5, + MinimumThroughput = options.CircuitBreaker.FailureThreshold, + BreakDuration = TimeSpan.FromMilliseconds(options.CircuitBreaker.BreakDurationMs), + ShouldHandle = new PredicateBuilder().Handle() + .Handle() + .Handle(ex => + ex.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase)) + }; + + pipelineBuilder.AddCircuitBreaker(circuitBreakerOptions); + } + + return pipelineBuilder.Build(); + } + } + + /// + /// Interface for database resilience policy. + /// + public interface IDatabaseResiliencePolicy + { + /// + /// Executes an async operation with resilience policies applied. + /// + Task ExecuteAsync(Func> operation); + + /// + /// Executes an async operation with resilience policies applied. + /// + Task ExecuteAsync(Func operation); + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Services/DatabaseTelemetryService.cs b/src/SourceFlow.Net.EntityFramework/Services/DatabaseTelemetryService.cs new file mode 100644 index 0000000..8749b83 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Services/DatabaseTelemetryService.cs @@ -0,0 +1,240 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Threading.Tasks; +using SourceFlow.Stores.EntityFramework.Options; + +namespace SourceFlow.Stores.EntityFramework.Services +{ + /// + /// Provides OpenTelemetry tracing and metrics for database operations. + /// + public class DatabaseTelemetryService : IDatabaseTelemetryService + { + private readonly ObservabilityOptions _options; + private readonly ActivitySource? _activitySource; + private readonly Meter? _meter; + + // Counters + private readonly Counter? _commandsAppended; + + private readonly Counter? _commandsLoaded; + private readonly Counter? _entitiesPersisted; + private readonly Counter? _viewModelsPersisted; + + // Histograms + private readonly Histogram? _operationDuration; + + private static void SetActivityException(Activity? activity, Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("exception.type", ex.GetType().FullName); + activity?.SetTag("exception.message", ex.Message); + } + + public DatabaseTelemetryService(SourceFlowEfOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + _options = options.Observability; + + if (_options.Enabled && _options.Tracing.Enabled) + { + _activitySource = new ActivitySource( + _options.ServiceName, + _options.ServiceVersion); + } + + if (_options.Enabled && _options.Metrics.Enabled) + { + _meter = new Meter( + _options.ServiceName, + _options.ServiceVersion); + + _commandsAppended = _meter.CreateCounter( + "sourceflow.commands.appended", + description: "Number of commands appended to the store"); + + _commandsLoaded = _meter.CreateCounter( + "sourceflow.commands.loaded", + description: "Number of commands loaded from the store"); + + _entitiesPersisted = _meter.CreateCounter( + "sourceflow.entities.persisted", + description: "Number of entities persisted to the store"); + + _viewModelsPersisted = _meter.CreateCounter( + "sourceflow.viewmodels.persisted", + description: "Number of view models persisted to the store"); + + _operationDuration = _meter.CreateHistogram( + "sourceflow.operation.duration", + unit: "ms", + description: "Duration of database operations in milliseconds"); + } + } + + /// + /// Executes an async operation with telemetry tracking. + /// + public async Task TraceAsync( + string operationName, + Func> operation, + Action? enrichActivity = null) + { + if (!_options.Enabled || !_options.Tracing.Enabled || _activitySource == null) + { + return await operation(); + } + + using var activity = _activitySource.StartActivity(operationName, ActivityKind.Internal); + + var stopwatch = Stopwatch.StartNew(); + try + { + // Enrich activity with custom attributes + enrichActivity?.Invoke(activity!); + + var result = await operation(); + + activity?.SetStatus(ActivityStatusCode.Ok); + + return result; + } + catch (Exception ex) + { + SetActivityException(activity, ex); + throw; + } + finally + { + stopwatch.Stop(); + _operationDuration?.Record(stopwatch.Elapsed.TotalMilliseconds, + new KeyValuePair("operation", operationName)); + } + } + + /// + /// Executes an async operation with telemetry tracking. + /// + public async Task TraceAsync( + string operationName, + Func operation, + Action? enrichActivity = null) + { + if (!_options.Enabled || !_options.Tracing.Enabled || _activitySource == null) + { + await operation(); + return; + } + + using var activity = _activitySource.StartActivity(operationName, ActivityKind.Internal); + + var stopwatch = Stopwatch.StartNew(); + try + { + // Enrich activity with custom attributes + enrichActivity?.Invoke(activity!); + + await operation(); + + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + SetActivityException(activity, ex); + throw; + } + finally + { + stopwatch.Stop(); + _operationDuration?.Record(stopwatch.Elapsed.TotalMilliseconds, + new KeyValuePair("operation", operationName)); + } + } + + /// + /// Records a command append metric. + /// + public void RecordCommandAppended() + { + if (_options.Enabled && _options.Metrics.Enabled && _options.Metrics.CollectCommandMetrics) + { + _commandsAppended?.Add(1); + } + } + + /// + /// Records a command load metric. + /// + public void RecordCommandsLoaded(int count) + { + if (_options.Enabled && _options.Metrics.Enabled && _options.Metrics.CollectCommandMetrics) + { + _commandsLoaded?.Add(count); + } + } + + /// + /// Records an entity persist metric. + /// + public void RecordEntityPersisted() + { + if (_options.Enabled && _options.Metrics.Enabled && _options.Metrics.CollectDatabaseMetrics) + { + _entitiesPersisted?.Add(1); + } + } + + /// + /// Records a view model persist metric. + /// + public void RecordViewModelPersisted() + { + if (_options.Enabled && _options.Metrics.Enabled && _options.Metrics.CollectDatabaseMetrics) + { + _viewModelsPersisted?.Add(1); + } + } + } + + /// + /// Interface for database telemetry service. + /// + public interface IDatabaseTelemetryService + { + /// + /// Executes an async operation with telemetry tracking. + /// + Task TraceAsync(string operationName, Func> operation, Action? enrichActivity = null); + + /// + /// Executes an async operation with telemetry tracking. + /// + Task TraceAsync(string operationName, Func operation, Action? enrichActivity = null); + + /// + /// Records a command append metric. + /// + void RecordCommandAppended(); + + /// + /// Records a command load metric. + /// + void RecordCommandsLoaded(int count); + + /// + /// Records an entity persist metric. + /// + void RecordEntityPersisted(); + + /// + /// Records a view model persist metric. + /// + void RecordViewModelPersisted(); + } +} diff --git a/src/SourceFlow.Net.EntityFramework/SourceFlow.Stores.EntityFramework.csproj b/src/SourceFlow.Net.EntityFramework/SourceFlow.Stores.EntityFramework.csproj new file mode 100644 index 0000000..cbbc482 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/SourceFlow.Stores.EntityFramework.csproj @@ -0,0 +1,55 @@ + + + + net8.0;net9.0;net10.0 + 1.0.0 + https://github.com/CodeShayk/SourceFlow.Net + git + https://github.com/CodeShayk/SourceFlow.Net/wiki + CodeShayk + CodeShayk + SourceFlow.Stores.EntityFramework + SourceFlow.Stores.EntityFramework + SourceFlow.Stores.EntityFramework + True + Entity Framework Core persistence provider for SourceFlow.Net. Provides production-ready implementations of ICommandStore, IEntityStore, and IViewModelStore using Entity Framework Core 9.0. Features include flexible configuration with separate or shared connection strings per store type, SQL Server support, Polly-based resilience and retry policies, OpenTelemetry instrumentation for database operations, and full support for .NET 8.0, .NET 9.0, and .NET 10.0. Seamlessly integrates with SourceFlow.Net core framework for complete event sourcing persistence. + Copyright (c) 2025 CodeShayk + docs\SourceFlow.Stores.EntityFramework-README.md + 1.0.0 + 1.0.0 + True + v1.0.0 - Initial stable release! Complete Entity Framework Core 9.0 persistence layer for SourceFlow.Net including CommandStore, EntityStore, and ViewModelStore implementations. Features configurable connection strings per store type, SQL Server database provider, Polly resilience policies, OpenTelemetry instrumentation, and support for .NET 8.0, 9.0, and 10.0. Production-ready with comprehensive test coverage. + SourceFlow;EntityFramework;Entity Framework;Persistence;EFCore;CQRS;Event-Sourcing;CommandStore;EntityStore;ViewModelStore;Connection-Strings + True + latest + SourceFlow.Stores.EntityFramework + SourceFlow.Stores.EntityFramework + + + + + + + + + + + + + + + + + + + + + + + + True + \docs + + + + diff --git a/src/SourceFlow.Net.EntityFramework/Stores/EfCommandStore.cs b/src/SourceFlow.Net.EntityFramework/Stores/EfCommandStore.cs new file mode 100644 index 0000000..76fb111 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Stores/EfCommandStore.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SourceFlow.Messaging.Commands; +using SourceFlow.Stores.EntityFramework.Models; +using SourceFlow.Stores.EntityFramework.Services; + +namespace SourceFlow.Stores.EntityFramework.Stores +{ + public class EfCommandStore : ICommandStore + { + private readonly CommandDbContext _context; + private readonly IDatabaseResiliencePolicy _resiliencePolicy; + private readonly IDatabaseTelemetryService _telemetryService; + + public EfCommandStore( + CommandDbContext context, + IDatabaseResiliencePolicy resiliencePolicy, + IDatabaseTelemetryService telemetryService) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + _resiliencePolicy = resiliencePolicy ?? throw new ArgumentNullException(nameof(resiliencePolicy)); + _telemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); + } + + public async Task Append(CommandData commandData) + { + if (commandData == null) + throw new ArgumentNullException(nameof(commandData)); + + await _telemetryService.TraceAsync( + "sourceflow.ef.command.append", + async () => + { + await _resiliencePolicy.ExecuteAsync(async () => + { + var commandRecord = new CommandRecord + { + EntityId = commandData.EntityId, + SequenceNo = commandData.SequenceNo, + CommandName = commandData.CommandName, + CommandType = commandData.CommandType, + PayloadType = commandData.PayloadType, + PayloadData = commandData.PayloadData, + Metadata = commandData.Metadata, + Timestamp = commandData.Timestamp, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _context.Commands.Add(commandRecord); + await _context.SaveChangesAsync(); + + // Clear change tracker to prevent caching issues + _context.ChangeTracker.Clear(); + }); + + _telemetryService.RecordCommandAppended(); + }, + activity => + { + activity?.SetTag("sourceflow.entity_id", commandData.EntityId); + activity?.SetTag("sourceflow.sequence_no", commandData.SequenceNo); + activity?.SetTag("sourceflow.command_type", commandData.CommandName); + }); + } + + public async Task> Load(int entityId) + { + return await _telemetryService.TraceAsync( + "sourceflow.ef.command.load", + async () => + { + return await _resiliencePolicy.ExecuteAsync(async () => + { + var commandRecords = await _context.Commands + .AsNoTracking() + .Where(c => c.EntityId == entityId) + .OrderBy(c => c.SequenceNo) + .ToListAsync(); + + var commands = commandRecords.Select(record => new CommandData + { + EntityId = record.EntityId, + SequenceNo = record.SequenceNo, + CommandName = record.CommandName, + CommandType = record.CommandType, + PayloadType = record.PayloadType, + PayloadData = record.PayloadData, + Metadata = record.Metadata, + Timestamp = record.Timestamp + }).ToList(); + + _telemetryService.RecordCommandsLoaded(commands.Count); + + return commands; + }); + }, + activity => + { + activity?.SetTag("sourceflow.entity_id", entityId); + }); + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Stores/EfEntityStore.cs b/src/SourceFlow.Net.EntityFramework/Stores/EfEntityStore.cs new file mode 100644 index 0000000..70b63e3 --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Stores/EfEntityStore.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SourceFlow.Stores.EntityFramework.Services; + +namespace SourceFlow.Stores.EntityFramework.Stores +{ + public class EfEntityStore : EfStoreBase, IEntityStore + { + public EfEntityStore( + EntityDbContext context, + IDatabaseResiliencePolicy resiliencePolicy, + IDatabaseTelemetryService telemetryService) + : base(context, resiliencePolicy, telemetryService) + { + } + + public async Task Get(int id) where TEntity : class, IEntity + { + if (id <= 0) + throw new ArgumentException("Entity Id must be greater than 0.", nameof(id)); + + return await ResiliencePolicy.ExecuteAsync(async () => + { + var entity = await Context.Set() + .AsNoTracking() + .FirstOrDefaultAsync(e => e.Id == id); + + if (entity == null) + throw new InvalidOperationException($"Entity of type {typeof(TEntity).Name} with Id {id} not found."); + + return entity; + }); + } + + public async Task Persist(TEntity entity) where TEntity : class, IEntity + { + if (entity == null) + throw new ArgumentNullException(nameof(entity)); + + return await PersistCore( + entity, + entity.Id, + "sourceflow.ef.entity.persist", + "Entity", + (activity, e) => + { + activity?.SetTag("sourceflow.entity_id", e.Id); + activity?.SetTag("sourceflow.entity_type", typeof(TEntity).Name); + }, + () => TelemetryService.RecordEntityPersisted()); + } + + public async Task Delete(TEntity entity) where TEntity : class, IEntity + { + if (entity == null) + throw new ArgumentNullException(nameof(entity)); + + if (entity.Id <= 0) + throw new ArgumentException("Entity Id must be greater than 0.", nameof(entity)); + + await ResiliencePolicy.ExecuteAsync(async () => + { + var entityRecord = await Context.Set() + .FirstOrDefaultAsync(e => e.Id == entity.Id); + + if (entityRecord == null) + throw new InvalidOperationException( + $"Entity of type {typeof(TEntity).Name} with Id {entity.Id} not found."); + + Context.Set().Remove(entityRecord); + + await Context.SaveChangesAsync(); + }); + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Stores/EfStoreBase.cs b/src/SourceFlow.Net.EntityFramework/Stores/EfStoreBase.cs new file mode 100644 index 0000000..0b4ae1f --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Stores/EfStoreBase.cs @@ -0,0 +1,68 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SourceFlow.Stores.EntityFramework.Services; + +namespace SourceFlow.Stores.EntityFramework.Stores +{ + public abstract class EfStoreBase where TContext : DbContext + { + protected readonly TContext Context; + protected readonly IDatabaseResiliencePolicy ResiliencePolicy; + protected readonly IDatabaseTelemetryService TelemetryService; + + protected EfStoreBase( + TContext context, + IDatabaseResiliencePolicy resiliencePolicy, + IDatabaseTelemetryService telemetryService) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + ResiliencePolicy = resiliencePolicy ?? throw new ArgumentNullException(nameof(resiliencePolicy)); + TelemetryService = telemetryService ?? throw new ArgumentNullException(nameof(telemetryService)); + } + + protected async Task PersistCore( + T item, + int id, + string operationName, + string itemType, + Action setActivityTags, + Action recordMetric) where T : class + { + if (item == null) + throw new ArgumentNullException(nameof(item)); + + if (id <= 0) + throw new ArgumentException($"{itemType} Id must be greater than 0.", nameof(item)); + + await TelemetryService.TraceAsync( + operationName, + async () => + { + await ResiliencePolicy.ExecuteAsync(async () => + { + // Check if item exists using AsNoTracking to avoid tracking conflicts + var exists = await Context.Set() + .AsNoTracking() + .AnyAsync(e => EF.Property(e, "Id") == id); + + if (exists) + Context.Set().Update(item); + else + Context.Set().Add(item); + + await Context.SaveChangesAsync(); + + // Detach the item to avoid tracking conflicts in subsequent operations + Context.Entry(item).State = EntityState.Detached; + }); + + recordMetric(); + }, + activity => setActivityTags(activity, item)); + + return item; + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/Stores/EfViewModelStore.cs b/src/SourceFlow.Net.EntityFramework/Stores/EfViewModelStore.cs new file mode 100644 index 0000000..efd2f0e --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/Stores/EfViewModelStore.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using SourceFlow.Projections; +using SourceFlow.Stores.EntityFramework.Services; + +namespace SourceFlow.Stores.EntityFramework.Stores +{ + public class EfViewModelStore : EfStoreBase, IViewModelStore + { + public EfViewModelStore( + ViewModelDbContext context, + IDatabaseResiliencePolicy resiliencePolicy, + IDatabaseTelemetryService telemetryService) + : base(context, resiliencePolicy, telemetryService) + { + } + + public async Task Get(int id) where TViewModel : class, IViewModel + { + if (id <= 0) + throw new ArgumentException("ViewModel Id must be greater than 0.", nameof(id)); + + return await ResiliencePolicy.ExecuteAsync(async () => + { + var viewModel = await Context.Set() + .AsNoTracking() + .FirstOrDefaultAsync(v => v.Id == id); + + if (viewModel == null) + throw new InvalidOperationException($"ViewModel of type {typeof(TViewModel).Name} with Id {id} not found."); + + return viewModel; + }); + } + + public async Task Persist(TViewModel model) where TViewModel : class, IViewModel + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + return await PersistCore( + model, + model.Id, + "sourceflow.ef.viewmodel.persist", + "ViewModel", + (activity, m) => + { + activity?.SetTag("sourceflow.viewmodel_id", m.Id); + activity?.SetTag("sourceflow.viewmodel_type", typeof(TViewModel).Name); + }, + () => TelemetryService.RecordViewModelPersisted()); + } + + public async Task Delete(TViewModel model) where TViewModel : class, IViewModel + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + if (model.Id <= 0) + throw new ArgumentException("ViewModel Id must be greater than 0.", nameof(model)); + + await ResiliencePolicy.ExecuteAsync(async () => + { + var viewModelRecord = await Context.Set() + .FirstOrDefaultAsync(v => v.Id == model.Id); + + if (viewModelRecord == null) + throw new InvalidOperationException( + $"ViewModel of type {typeof(TViewModel).Name} with Id {model.Id} not found."); + + Context.Set().Remove(viewModelRecord); + await Context.SaveChangesAsync(); + }); + } + } +} diff --git a/src/SourceFlow.Net.EntityFramework/ViewModelDbContext.cs b/src/SourceFlow.Net.EntityFramework/ViewModelDbContext.cs new file mode 100644 index 0000000..6f5e88a --- /dev/null +++ b/src/SourceFlow.Net.EntityFramework/ViewModelDbContext.cs @@ -0,0 +1,168 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using SourceFlow.Stores.EntityFramework.Options; + +namespace SourceFlow.Stores.EntityFramework +{ + /// + /// DbContext specifically for view model storage + /// + public class ViewModelDbContext : DbContext + { + private static readonly HashSet _explicitlyRegisteredTypes = new HashSet(); + private static readonly HashSet _assembliesToScan = new HashSet(); + private static TableNamingConvention? _namingConvention; + + public ViewModelDbContext(DbContextOptions options) : base(options) + { + } + + /// + /// Configure the table naming convention for view model tables. + /// + /// The naming convention to use + public static void ConfigureNamingConvention(TableNamingConvention? convention) + { + _namingConvention = convention; + } + + /// + /// Explicitly register a view model type before creating the database. + /// This ensures the type is included in the model even if auto-discovery doesn't find it. + /// + public static void RegisterViewModelType() where TViewModel : class + { + _explicitlyRegisteredTypes.Add(typeof(TViewModel)); + } + + /// + /// Register an assembly to scan for view model types. + /// + public static void RegisterAssembly(Assembly assembly) + { + if (assembly != null) + { + _assembliesToScan.Add(assembly); + } + } + + /// + /// Clear all registered types and assemblies. Useful for testing. + /// + public static void ClearRegistrations() + { + _explicitlyRegisteredTypes.Clear(); + _assembliesToScan.Clear(); + } + + /// + /// Get all registered view model types (both explicit and from assemblies). + /// + public static IEnumerable GetRegisteredTypes() + { + var types = new HashSet(_explicitlyRegisteredTypes); + + // Add types from registered assemblies + foreach (var assembly in _assembliesToScan) + { + try + { + var assemblyTypes = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && + t.GetInterfaces().Any(i => i.Name == "IViewModel")); + foreach (var type in assemblyTypes) + { + types.Add(type); + } + } + catch { } + } + + return types; + } + + /// + /// Manually creates database tables for all registered view model types. + /// This bypasses EF Core's model caching and should be called after EnsureCreated(). + /// + public void ApplyMigrations() + { + var types = GetRegisteredTypes(); + if (types.Any()) + { + Migrations.DbContextMigrationHelper.CreateViewModelTables(this, types); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Start with explicitly registered types + var viewModelTypes = new HashSet(_explicitlyRegisteredTypes); + + // Add types from explicitly registered assemblies + foreach (var assembly in _assembliesToScan) + { + try + { + var types = assembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && + t.GetInterfaces().Any(i => i.Name == "IViewModel")); + foreach (var type in types) + { + viewModelTypes.Add(type); + } + } + catch { } + } + + // Auto-discover from loaded assemblies (fallback) + var discoveredTypes = AppDomain.CurrentDomain.GetAssemblies() + .Where(a => !a.IsDynamic && !a.FullName?.StartsWith("Microsoft.") == true + && !a.FullName?.StartsWith("System.") == true + && !a.FullName?.StartsWith("netstandard") == true) + .SelectMany(a => + { + try { return a.GetTypes(); } + catch { return Enumerable.Empty(); } + }) + .Where(t => t.IsClass && !t.IsAbstract && + t.GetInterfaces().Any(i => i.Name == "IViewModel")); + + foreach (var type in discoveredTypes) + { + viewModelTypes.Add(type); + } + + // Register all discovered and explicitly registered types + foreach (var viewModelType in viewModelTypes) + { + // Register each view model type with EF Core + // Configure the entity with a primary key on Id property + var entity = modelBuilder.Entity(viewModelType); + entity.HasKey("Id"); + + // On naming convention if configured + if (_namingConvention != null) + { + var tableName = _namingConvention.ApplyConvention(viewModelType.Name); + + if (_namingConvention.UseSchema && !string.IsNullOrEmpty(_namingConvention.SchemaName)) + { + entity.ToTable(tableName, _namingConvention.SchemaName); + } + else + { + entity.ToTable(tableName); + } + } + } + } + } +} diff --git a/src/SourceFlow/Aggregate/Aggregate.cs b/src/SourceFlow/Aggregate/Aggregate.cs index 451e164..aab3615 100644 --- a/src/SourceFlow/Aggregate/Aggregate.cs +++ b/src/SourceFlow/Aggregate/Aggregate.cs @@ -1,41 +1,41 @@ using System; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Aggregate { /// /// Base class for aggregate roots in the event-driven architecture. /// - /// - public abstract class Aggregate : IAggregate - where TAggregate : class, IEntity + /// Aggregate Entity type + public abstract class Aggregate : IAggregate + where TEntity : class, IEntity { /// /// The command publisher used to publish commands. /// - protected ICommandPublisher commandPublisher; - - /// - /// The events replayer used to replay event stream for given aggregate. - /// - protected ICommandReplayer commandReplayer; + protected Lazy commandPublisher; /// /// Logger for the aggregate root to log events and errors. /// - protected ILogger logger; + protected ILogger logger; + + protected Aggregate(Lazy commandPublisher, ILogger logger) + { + this.commandPublisher = commandPublisher; + this.logger = logger; + } /// - /// Replays the event stream for the aggregate root, restoring its state from past events. + /// Replays the command stream for the aggregate root, restoring its state from past history. /// - /// Unique Aggregate entity identifier. + /// Unique Aggregate entity identifier. /// - public Task Replay(int AggregateId) + public Task ReplayCommands(int entityId) { - return commandReplayer.Replay(AggregateId); + return commandPublisher.Value.ReplayCommands(entityId); } /// @@ -45,15 +45,12 @@ public Task Replay(int AggregateId) /// /// /// - protected Task Send(ICommand command) + protected Task Send(TCommand command) where TCommand : ICommand { if (command == null) throw new ArgumentNullException(nameof(command)); - if (command.Payload?.Id == null) - throw new InvalidOperationException(nameof(command) + "requires Payload"); - - return commandPublisher.Publish(command); + return commandPublisher.Value.Publish(command); } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Aggregate/EventSubscriber.cs b/src/SourceFlow/Aggregate/EventSubscriber.cs new file mode 100644 index 0000000..ecb3bcc --- /dev/null +++ b/src/SourceFlow/Aggregate/EventSubscriber.cs @@ -0,0 +1,65 @@ +using System; + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SourceFlow.Messaging.Events; +using SourceFlow.Messaging.Events.Impl; + +namespace SourceFlow.Aggregate +{ + /// + /// This subscriber is responsible for dispatching event to subscribing aggregates. + /// + internal class EventSubscriber : IEventSubscriber + { + /// + /// Logger for the event queue to log events and errors. + /// + private readonly ILogger logger; + + /// + /// Represents a collection of aggregate root objects. + /// + /// This field holds a read-only collection of objects that implement the + /// interface. It is intended to be used internally to manage or process aggregate roots within the context of the + /// application. + private readonly IEnumerable aggregates; + + /// + /// Initializes a new instance of the class with the specified aggregates and view views. + /// + /// + /// + /// + public EventSubscriber(IEnumerable aggregates, ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.aggregates = aggregates ?? throw new ArgumentNullException(nameof(aggregates)); + } + + /// + /// Dequeues the event to all aggregates that can handle it. + /// + /// + /// + /// + public Task Subscribe(TEvent @event) where TEvent : IEvent + { + var tasks = new List(); + + foreach (var aggregate in aggregates) + { + if (!(aggregate is ISubscribes eventSubscriber)) + continue; + + tasks.Add(eventSubscriber.On(@event)); + + logger?.LogInformation("Action=Event_Disptcher_Aggregate, Event={Event}, Aggregate={Aggregate}", + typeof(TEvent).Name, aggregate.GetType().Name); + } + + return Task.WhenAll(tasks); + } + } +} diff --git a/src/SourceFlow/Aggregate/IAggregate.cs b/src/SourceFlow/Aggregate/IAggregate.cs index 421786e..1a53ad6 100644 --- a/src/SourceFlow/Aggregate/IAggregate.cs +++ b/src/SourceFlow/Aggregate/IAggregate.cs @@ -6,4 +6,4 @@ namespace SourceFlow.Aggregate public interface IAggregate { } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Aggregate/IAggregateFactory.cs b/src/SourceFlow/Aggregate/IAggregateFactory.cs index 14eb162..9085f32 100644 --- a/src/SourceFlow/Aggregate/IAggregateFactory.cs +++ b/src/SourceFlow/Aggregate/IAggregateFactory.cs @@ -16,4 +16,4 @@ public interface IAggregateFactory Task Create() where TAggregate : IAggregate; } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Aggregate/ISubscribes.cs b/src/SourceFlow/Aggregate/ISubscribes.cs index 5c53bf8..6d6f8fc 100644 --- a/src/SourceFlow/Aggregate/ISubscribes.cs +++ b/src/SourceFlow/Aggregate/ISubscribes.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Events; namespace SourceFlow.Aggregate { @@ -15,6 +15,6 @@ public interface ISubscribes /// /// /// - Task Handle(TEvent @event); + Task On(TEvent @event); } -} \ No newline at end of file +} diff --git a/src/SourceFlow/ClassDiagram.cd b/src/SourceFlow/ClassDiagram.cd deleted file mode 100644 index d5d68a6..0000000 --- a/src/SourceFlow/ClassDiagram.cd +++ /dev/null @@ -1,371 +0,0 @@ - - - - - - - - - - - - - - - - AAAAAAABAAACAIACAEAAAIACAAAAAAAAAAAAAAAAAAA= - Saga\Saga.cs - - - - - - - - - - - - AABAAAAAAAAAAAACAAAAAABAAAAAAAAAAAAAAAAAAAA= - Impl\EventQueue.cs - - - - - - - - - - - - - - - - - AAgAAAAAABAAAAACAAAAAABABAAAAAQAAAAAAAAAAAA= - Impl\CommandBus.cs - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - AAAAAAAAAAAAAAACAEBAAIAAAAAAAAAAAQAAAAAAAAA= - Aggregate\Aggregate.cs - - - - - - - - - - - - - - - - - - - - - AAAAAAAAAAAAAAAAACAQAAQAAAAAAAAAAAAAAAAEAAA= - Messaging\Event.cs - - - - - - - - - - - - - - - - - - - - - AAAAAAAAABAAAAAAAAAQAAQAAAAAAAAAAAAAAAAEAAA= - Messaging\Command.cs - - - - - - - - - - - - - - - - - - - - - AAAAQAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAA= - Impl\CommandReplayer.cs - - - - - - - - - - - - - - - - - - - - - AAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAABAA= - Impl\CommandPublisher.cs - - - - - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAABAEAAAAAAAAAAAAA= - Impl\AggregateFactory.cs - - - - - - - - - - - - - - AAAAAAAAAAAAAAACBAAAAAAACAAAAAAAAAAAAAAAAAA= - Services\Service.cs - - - - - - - - - - AAgAAAAAAAAAAEACAEAAAAAAAAABAAAAAAAAAAAAAAA= - Impl\SagaDispatcher.cs - - - - - - - - - - AAgAAAgAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Impl\AggregateDispatcher.cs - - - - - - - - - - AAgAAAAAAAAAAAACAAAAAAAAAAAAAAAAACAAAAAAAAA= - Impl\ProjectionDispatcher.cs - - - - - - - - - - AABAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAA= - Messaging\Bus\IEventQueue.cs - - - - - - AAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAA= - Messaging\Bus\ICommandPublisher.cs - - - - - - AAAAAAAAAAAAAABAAAAAAAIAAAEAAAAAAAAAAAAAAAA= - IRepository.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Aggregate\IAggregate.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - ViewModel\IProjection.cs - - - - - - ABAAgAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAA= - ICommandStore.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAA= - Saga\ISaga.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAA= - Messaging\Bus\ICommandReplayer.cs - - - - - - AAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - ViewModel\IProjection.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAA= - Saga\IHandles.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAA= - Aggregate\ISubscribes.cs - - - - - - AAAAAAAAAAAAAAAAAEAAAABAAAAAAAAAAQAAAAAAAAA= - Messaging\Bus\ICommandBus.cs - - - - - - AAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Messaging\IPayload.cs - - - - - - AAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Aggregate\IEntity.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAA= - Aggregate\IAggregateFactory.cs - - - - - - AAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAACAA= - IViewProvider.cs - - - - - - AAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - ViewModel\IViewModel.cs - - - - - - AAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - Messaging\Bus\IEventDispatcher.cs - - - - - - AAgAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAA= - Messaging\Bus\ICommandDispatcher.cs - - - - \ No newline at end of file diff --git a/src/SourceFlow/ICommandStore.cs b/src/SourceFlow/ICommandStore.cs index 221cfce..796d125 100644 --- a/src/SourceFlow/ICommandStore.cs +++ b/src/SourceFlow/ICommandStore.cs @@ -1,33 +1,27 @@ using System.Collections.Generic; using System.Threading.Tasks; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow { /// /// Interface for the command store in the event-driven architecture. + /// Stores work with serialized CommandData for persistence. /// public interface ICommandStore { /// - /// Appends a command to the store. Commands serve as units of auditable change in the event-driven architecture, + /// Appends serialized command data to the store. /// - /// + /// Serialized command data /// - Task Append(ICommand command); + Task Append(CommandData commandData); /// - /// Loads all events for a given aggregate from the event store. + /// Loads all serialized command data for a given aggregate from the store. /// /// Unique aggregate entity id. - /// - Task> Load(int aggregateId); - - /// - /// Gets the next sequence number for an event. - /// - /// Unique aggregate entity id. - /// - Task GetNextSequenceNo(int aggregateId); + /// Collection of serialized command data + Task> Load(int aggregateId); } -} \ No newline at end of file +} diff --git a/src/SourceFlow/ICommandStoreAdapter.cs b/src/SourceFlow/ICommandStoreAdapter.cs new file mode 100644 index 0000000..d4d37a4 --- /dev/null +++ b/src/SourceFlow/ICommandStoreAdapter.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using SourceFlow.Messaging.Commands; + +namespace SourceFlow +{ + /// + /// Interface for the command store in the event-driven architecture. + /// + public interface ICommandStoreAdapter + { + /// + /// Appends a command to the store. Commands serve as units of auditable change in the event-driven architecture, + /// + /// + /// + Task Append(ICommand command); + + /// + /// Loads all events for a given aggregate from the event store. + /// + /// Unique aggregate entity id. + /// + Task> Load(int aggregateId); + + /// + /// Gets the next sequence number for an event. + /// + /// Unique aggregate entity id. + /// + Task GetNextSequenceNo(int aggregateId); + } +} diff --git a/src/SourceFlow/Aggregate/IEntity.cs b/src/SourceFlow/IEntity.cs similarity index 69% rename from src/SourceFlow/Aggregate/IEntity.cs rename to src/SourceFlow/IEntity.cs index 1b6764a..bd15c31 100644 --- a/src/SourceFlow/Aggregate/IEntity.cs +++ b/src/SourceFlow/IEntity.cs @@ -1,7 +1,7 @@ -namespace SourceFlow.Aggregate +namespace SourceFlow { public interface IEntity { int Id { get; set; } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/IRepository.cs b/src/SourceFlow/IEntityStore.cs similarity index 60% rename from src/SourceFlow/IRepository.cs rename to src/SourceFlow/IEntityStore.cs index 3add50b..3498fff 100644 --- a/src/SourceFlow/IRepository.cs +++ b/src/SourceFlow/IEntityStore.cs @@ -1,12 +1,11 @@ using System.Threading.Tasks; -using SourceFlow.Aggregate; namespace SourceFlow { /// - /// Interface for a repository that provides methods for managing domain entities. + /// Interface for a entityStore that provides methods for managing domain entities. /// - public interface IRepository + public interface IEntityStore { /// /// Retrieves an entity by unique identifier. @@ -16,17 +15,17 @@ public interface IRepository Task Get(int id) where TEntity : class, IEntity; /// - /// Creates or updates an entity to the repository, persisting its state. + /// Creates or updates an entity to the entityStore, persisting its state. /// /// Entity Instance. /// - Task Persist(TEntity entity) where TEntity : IEntity; + Task Persist(TEntity entity) where TEntity : class, IEntity; /// - /// Deletes an entity from the repository. + /// Deletes an entity from the entityStore. /// /// Entity Instance. /// - Task Delete(TEntity entity) where TEntity : IEntity; + Task Delete(TEntity entity) where TEntity : class, IEntity; } -} \ No newline at end of file +} diff --git a/src/SourceFlow/IEntityStoreAdapter.cs b/src/SourceFlow/IEntityStoreAdapter.cs new file mode 100644 index 0000000..7172314 --- /dev/null +++ b/src/SourceFlow/IEntityStoreAdapter.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; + +namespace SourceFlow +{ + /// + /// Interface for a entityStore that provides methods for managing domain entities. + /// + public interface IEntityStoreAdapter + { + /// + /// Retrieves an entity by unique identifier. + /// + /// Unique Identifier. + /// + Task Get(int id) where TEntity : class, IEntity; + + /// + /// Creates or updates an entity to the entityStore, persisting its state. + /// + /// Entity Instance. + /// The persisted entity + Task Persist(TEntity entity) where TEntity : class, IEntity; + + /// + /// Deletes an entity from the entityStore. + /// + /// Entity Instance. + /// + Task Delete(TEntity entity) where TEntity : class, IEntity; + } +} diff --git a/src/SourceFlow/IViewModelStore.cs b/src/SourceFlow/IViewModelStore.cs new file mode 100644 index 0000000..1dbe5fe --- /dev/null +++ b/src/SourceFlow/IViewModelStore.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using SourceFlow.Projections; + +namespace SourceFlow +{ + public interface IViewModelStore + { + /// + /// Retrieves an view model by unique identifier. + /// + /// Unique Identifier. + /// + Task Get(int id) where TViewModel : class, IViewModel; + + /// + /// Creates or updates view model to the entityStore, persisting its state. + /// + /// ViewModel Instance. + /// + Task Persist(TViewModel model) where TViewModel : class, IViewModel; + + /// + /// Deletes a ViewModel, could implement soft or hard delete. + /// + /// + /// + /// + Task Delete(TViewModel model) where TViewModel : class, IViewModel; + } +} diff --git a/src/SourceFlow/IViewModelStoreAdapter.cs b/src/SourceFlow/IViewModelStoreAdapter.cs new file mode 100644 index 0000000..1c93f81 --- /dev/null +++ b/src/SourceFlow/IViewModelStoreAdapter.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using SourceFlow.Projections; + +namespace SourceFlow +{ + public interface IViewModelStoreAdapter + { + /// + /// Retrieves an view model by unique identifier. + /// + /// Unique Identifier. + /// + Task Find(int id) where TViewModel : class, IViewModel; + + /// + /// Creates or updates an view model to the entityStore, persisting its state. + /// + /// ViewModel Instance. + /// The persisted view model + Task Persist(TViewModel model) where TViewModel : class, IViewModel; + + /// + /// Deletes a ViewModel, could implement soft or hard delete. + /// + /// + /// + /// + Task Delete(TViewModel model) where TViewModel : class, IViewModel; + } +} diff --git a/src/SourceFlow/IViewProvider.cs b/src/SourceFlow/IViewProvider.cs deleted file mode 100644 index aadc823..0000000 --- a/src/SourceFlow/IViewProvider.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Threading.Tasks; -using SourceFlow.Projections; - -namespace SourceFlow -{ - public interface IViewProvider - { - /// - /// Retrieves an view model by unique identifier. - /// - /// Unique Identifier. - /// - Task Find(int id) where TViewModel : class, IViewModel; - - /// - /// Creates or updates an view model to the repository, persisting its state. - /// - /// ViewModel Instance. - /// - Task Push(TViewModel model) where TViewModel : IViewModel; - } -} \ No newline at end of file diff --git a/src/SourceFlow/Impl/AggregateDispatcher.cs b/src/SourceFlow/Impl/AggregateDispatcher.cs deleted file mode 100644 index fac7d57..0000000 --- a/src/SourceFlow/Impl/AggregateDispatcher.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; - -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using SourceFlow.Aggregate; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; - -namespace SourceFlow.Impl -{ - /// - /// This dispatcher is responsible for dispatching events to the appropriate subscribing aggregates. - /// - internal class AggregateDispatcher : IEventDispatcher - { - /// - /// Logger for the event queue to log events and errors. - /// - private readonly ILogger logger; - - /// - /// Represents a collection of aggregate root objects. - /// - /// This field holds a read-only collection of objects that implement the - /// interface. It is intended to be used internally to manage or process aggregate roots within the context of the - /// application. - private readonly IEnumerable aggregates; - - /// - /// Initializes a new instance of the class with the specified aggregates and view projections. - /// - /// - /// - /// - public AggregateDispatcher(IEnumerable aggregates, ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.aggregates = aggregates ?? throw new ArgumentNullException(nameof(aggregates)); - } - - /// - /// Dequeues the event to all aggregates that can handle it. - /// - /// - /// - /// - private async Task Dispatch(TEvent @event) - where TEvent : IEvent - { - var tasks = new List(); - - foreach (var aggregate in aggregates) - { - var handlerType = typeof(ISubscribes<>).MakeGenericType(@event.GetType()); - if (!handlerType.IsAssignableFrom(aggregate.GetType())) - continue; - - var method = typeof(ISubscribes<>) - .MakeGenericType(@event.GetType()) - .GetMethod(nameof(ISubscribes.Handle)); - - var task = (Task)method.Invoke(aggregate, new object[] { @event }); - - tasks.Add(task); - - logger?.LogInformation("Action=Event_Disptcher_Aggregate, Event={Event}, Aggregate={Aggregate}, Handler:{Handler}", - @event.GetType().Name, aggregate.GetType().Name, method.Name); - } - - await Task.WhenAll(tasks); - } - - /// - /// Dispatches the event to both aggregates and view projections. - /// - /// - /// - public void Dispatch(object sender, IEvent @event) - { - Dispatch(@event).GetAwaiter().GetResult(); - logger?.LogInformation("Action=Event_Dispatcher_Complete, Event={Event}, Sender:{sender}", - @event.Name, sender.GetType().Name); - } - } -} \ No newline at end of file diff --git a/src/SourceFlow/Impl/AggregateFactory.cs b/src/SourceFlow/Impl/AggregateFactory.cs index be79469..0f6b5f1 100644 --- a/src/SourceFlow/Impl/AggregateFactory.cs +++ b/src/SourceFlow/Impl/AggregateFactory.cs @@ -37,4 +37,4 @@ public async Task Create() return await Task.FromResult((TAggregate)aggregate); } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Impl/CommandBus.cs b/src/SourceFlow/Impl/CommandBus.cs deleted file mode 100644 index 73db665..0000000 --- a/src/SourceFlow/Impl/CommandBus.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; - -namespace SourceFlow.Impl -{ - /// - /// Command bus implementation that handles commands and events in an event-driven architecture. - /// - internal class CommandBus : ICommandBus - { - /// - /// The command store used to persist commands. - /// - private readonly ICommandStore commandStore; - - /// - /// Logger for the command bus to log events and errors. - /// - private readonly ILogger logger; - - /// - /// Represents command dispathers that can handle the publishing of commands. - /// - public event EventHandler Dispatchers; - - /// - /// Initializes a new instance of the class. - /// - /// - /// - public CommandBus(ICommandStore commandStore, ILogger logger) - { - this.commandStore = commandStore; - this.logger = logger; - } - - /// - /// Publishes a command to all subscribed sagas. - /// - /// - /// - /// - /// - async Task ICommandBus.Publish(TCommand command) - { - if (command == null) - throw new ArgumentNullException(nameof(command)); - - await Dispatch(command); - } - - private async Task Dispatch(TCommand command) where TCommand : ICommand - { - // 1. Set event sequence no. - if (!((IMetadata)command).Metadata.IsReplay) - ((IMetadata)command).Metadata.SequenceNo = await commandStore.GetNextSequenceNo(command.Payload.Id); - - // 2. Dispatch command to handlers. - Dispatchers?.Invoke(this, command); - - // 3. Log event. - logger?.LogInformation("Action=Command_Dispatched, Command={Command}, Payload={Payload}, SequenceNo={No}, Saga={Saga}", - command.GetType().Name, command.Payload.GetType().Name, ((IMetadata)command).Metadata.SequenceNo); - - // 4. When event is not replayed - if (!((IMetadata)command).Metadata.IsReplay) - // 4.1. Append event to event store. - await commandStore.Append(command); - } - - /// - /// Replays commands for a given aggregate. - /// - /// Unique aggregate entity id. - /// - async Task ICommandBus.Replay(int aggregateId) - { - var commands = await commandStore.Load(aggregateId); - - if (commands == null || !commands.Any()) - return; - - foreach (var command in commands.ToList()) - { - ((IMetadata)command).Metadata.IsReplay = true; - await Dispatch(command); - } - } - } -} \ No newline at end of file diff --git a/src/SourceFlow/Impl/CommandReplayer.cs b/src/SourceFlow/Impl/CommandReplayer.cs deleted file mode 100644 index 7a30646..0000000 --- a/src/SourceFlow/Impl/CommandReplayer.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Threading.Tasks; -using SourceFlow.Messaging.Bus; - -namespace SourceFlow.Impl -{ - /// - /// Interface for replaying commands in the event-driven architecture. - /// - internal class CommandReplayer : ICommandReplayer - { - /// - /// The command bus used to replay commands for a given aggregate. - /// - private readonly ICommandBus commandBus; - - /// - /// Initializes a new instance of the class. - /// - /// - public CommandReplayer(ICommandBus commandBus) - { - this.commandBus = commandBus; - } - - /// - /// Replays stream of commands for a given aggregate. - /// - /// Unique aggregate entity id. - /// - async Task ICommandReplayer.Replay(int aggregateId) - { - await commandBus.Replay(aggregateId); - } - } -} \ No newline at end of file diff --git a/src/SourceFlow/Impl/CommandStoreAdapter.cs b/src/SourceFlow/Impl/CommandStoreAdapter.cs new file mode 100644 index 0000000..25ad558 --- /dev/null +++ b/src/SourceFlow/Impl/CommandStoreAdapter.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; +using SourceFlow.Observability; +using SourceFlow.Performance; + +namespace SourceFlow.Impl +{ + internal class CommandStoreAdapter : ICommandStoreAdapter + { + private readonly ICommandStore store; + private readonly IDomainTelemetryService _telemetryService; + + public CommandStoreAdapter(ICommandStore store, IDomainTelemetryService telemetryService = null) + { + this.store = store; + _telemetryService = telemetryService; + } + + public async Task Append(ICommand command) + { + var commandData = SerializeCommand(command); + + if (_telemetryService != null) + { + await _telemetryService.TraceAsync( + "sourceflow.domain.command.append", + async () => + { + await store.Append(commandData); + _telemetryService.RecordCommandExecuted(command.GetType().Name, command.Entity.Id); + }, + activity => + { + activity?.SetTag("sourceflow.command_type", command.GetType().Name); + activity?.SetTag("sourceflow.entity_id", command.Entity.Id); + activity?.SetTag("sourceflow.sequence_no", command.Metadata.SequenceNo); + }); + } + else + { + await store.Append(commandData); + } + } + + public async Task> Load(int entityId) + { + if (_telemetryService != null) + { + return await _telemetryService.TraceAsync( + "sourceflow.domain.command.load", + async () => + { + var commandDataList = await store.Load(entityId); + return DeserializeCommands(commandDataList); + }, + activity => + { + activity?.SetTag("sourceflow.entity_id", entityId); + }); + } + else + { + var commandDataList = await store.Load(entityId); + return DeserializeCommands(commandDataList); + } + } + + private List DeserializeCommands(IEnumerable commandDataList) + { + var commands = new List(); + + foreach (var commandData in commandDataList) + { + try + { + var command = DeserializeCommand(commandData); + if (command != null) + commands.Add(command); + } + catch + { + // Skip commands that can't be deserialized + continue; + } + } + + return commands; + } + + public async Task GetNextSequenceNo(int entityId) + { + var events = await Load(entityId); + + if (events != null && events.Any()) + return events.Max(c => ((IMetadata)c).Metadata.SequenceNo) + 1; + + return 1; + } + + private CommandData SerializeCommand(ICommand command) + { + CommandData SerializeCore() + { + // Serialize using concrete type, not interface type, to capture all properties + // Use ByteArrayPool for optimized serialization with reduced allocations + var payloadJson = command.Payload != null + ? ByteArrayPool.Serialize(command.Payload, command.Payload.GetType()) + : string.Empty; + + return new CommandData + { + EntityId = command.Entity.Id, + SequenceNo = command.Metadata.SequenceNo, + CommandName = command.Name ?? string.Empty, + CommandType = command.GetType().AssemblyQualifiedName ?? string.Empty, + PayloadType = command.Payload?.GetType().AssemblyQualifiedName ?? string.Empty, + PayloadData = payloadJson, + Metadata = ByteArrayPool.Serialize(command.Metadata), + Timestamp = command.Metadata.OccurredOn + }; + } + + if (_telemetryService != null) + { + return _telemetryService.TraceSerialization( + "serialize", + SerializeCore, + activity => + { + activity?.SetTag("sourceflow.command_type", command.GetType().Name); + }); + } + else + { + return SerializeCore(); + } + } + + private ICommand DeserializeCommand(CommandData commandData) + { + // Use ByteArrayPool for optimized deserialization with reduced allocations + var metadata = ByteArrayPool.Deserialize(commandData.Metadata); + + // Get the command type + var commandType = Type.GetType(commandData.CommandType); + if (commandType == null) + return null; + + // Create an instance of the command + var command = Activator.CreateInstance(commandType) as ICommand; + if (command == null) + return null; + + // Restore the metadata + command.Metadata = metadata ?? new Metadata(); + + // Restore the entity reference + command.Entity = new EntityRef { Id = commandData.EntityId }; + + // Deserialize and restore the payload if it exists + if (!string.IsNullOrEmpty(commandData.PayloadType) && !string.IsNullOrEmpty(commandData.PayloadData)) + { + var payloadType = Type.GetType(commandData.PayloadType); + if (payloadType != null) + { + var payload = ByteArrayPool.Deserialize(commandData.PayloadData, payloadType); + + // Set the payload using reflection + var payloadProperty = commandType.GetProperty("Payload"); + if (payloadProperty != null && payload != null) + { + payloadProperty.SetValue(command, payload); + } + } + } + + return command; + } + } +} diff --git a/src/SourceFlow/Impl/EntityStoreAdapter.cs b/src/SourceFlow/Impl/EntityStoreAdapter.cs new file mode 100644 index 0000000..70b03fe --- /dev/null +++ b/src/SourceFlow/Impl/EntityStoreAdapter.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using SourceFlow.Observability; + +namespace SourceFlow.Impl +{ + internal class EntityStoreAdapter : IEntityStoreAdapter + { + private readonly IEntityStore store; + private readonly IDomainTelemetryService telemetry; + + public EntityStoreAdapter(IEntityStore store, IDomainTelemetryService telemetry = null) + { + this.store = store; + this.telemetry = telemetry; + } + + public Task Delete(TEntity entity) where TEntity : class, IEntity + { + if (telemetry != null) + { + return telemetry.TraceAsync( + "sourceflow.entitystore.delete", + () => store.Delete(entity), + activity => + { + activity?.SetTag("sourceflow.entity_type", typeof(TEntity).Name); + activity?.SetTag("sourceflow.entity_id", entity.Id); + }); + } + + return store.Delete(entity); + } + + public Task Get(int id) where TEntity : class, IEntity + { + if (telemetry != null) + { + return telemetry.TraceAsync( + "sourceflow.entitystore.get", + () => store.Get(id), + activity => + { + activity?.SetTag("sourceflow.entity_type", typeof(TEntity).Name); + activity?.SetTag("sourceflow.entity_id", id); + }); + } + + return store.Get(id); + } + + public Task Persist(TEntity entity) where TEntity : class, IEntity + { + if (telemetry != null) + { + return telemetry.TraceAsync( + "sourceflow.entitystore.persist", + () => store?.Persist(entity), + activity => + { + activity?.SetTag("sourceflow.entity_type", typeof(TEntity).Name); + activity?.SetTag("sourceflow.entity_id", entity.Id); + telemetry.RecordEntityCreated(typeof(TEntity).Name); + }); + } + + return store?.Persist(entity); + } + } +} diff --git a/src/SourceFlow/Impl/EventQueue.cs b/src/SourceFlow/Impl/EventQueue.cs deleted file mode 100644 index 5c656de..0000000 --- a/src/SourceFlow/Impl/EventQueue.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; - -namespace SourceFlow.Impl -{ - /// - /// EventQueue is responsible for managing the flow of events in an event-driven architecture. - /// - internal class EventQueue : IEventQueue - { - /// - /// Logger for the event queue to log events and errors. - /// - private readonly ILogger logger; - - /// - /// Represents event dispathers that can handle the dequeuing of events. - /// - public event EventHandler Dispatchers; - - /// - /// Initializes a new instance of the class with the specified logger. - /// - /// - /// - public EventQueue(ILogger logger) - { - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Enqueues an event in order to publish to subcribers. - /// - /// - /// - /// - public async Task Enqueue(TEvent @event) - where TEvent : IEvent - { - if (@event == null) - throw new ArgumentNullException(nameof(@event)); - - Dispatchers?.Invoke(this, @event); - - logger?.LogInformation("Action=Event_Enqueue, Event={Event}, Payload={Payload}", - @event.GetType().Name, @event.Payload.GetType().Name); - } - } -} \ No newline at end of file diff --git a/src/SourceFlow/Impl/ProjectionDispatcher.cs b/src/SourceFlow/Impl/ProjectionDispatcher.cs deleted file mode 100644 index 4c314eb..0000000 --- a/src/SourceFlow/Impl/ProjectionDispatcher.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; -using SourceFlow.Projections; - -namespace SourceFlow.Impl -{ - /// - /// This dispatcher is responsible for dispatching events to the appropriate view projections. - /// - internal class ProjectionDispatcher : IEventDispatcher - { - /// - /// Represents a collection of view transforms used to modify or manipulate views. - /// - /// This collection contains instances of objects implementing the interface. Each projection in the collection can be applied to alter the appearance - /// or behavior of a view. - private IEnumerable projections; - - /// - /// Logger for the event queue to log events and errors. - /// - private readonly ILogger logger; - - /// - /// Initializes a new instance of the class with the specified projections and logger. - /// - /// - /// - /// - public ProjectionDispatcher(IEnumerable projections, ILogger logger) - { - this.projections = projections ?? throw new ArgumentNullException(nameof(projections)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Dispatches the event to all view projections that can handle it. - /// - /// - /// - public void Dispatch(object sender, IEvent @event) - { - Dispatch(@event).GetAwaiter().GetResult(); - logger?.LogInformation("Action=Event_Dispatcher_Complete, Event={Event}, Sender:{sender}", - @event.Name, sender.GetType().Name); - } - - /// - /// Dispatch the event to all view projections that can handle it. - /// - /// - /// - /// - public async Task Dispatch(TEvent @event) - where TEvent : IEvent - { - var tasks = new List(); - - foreach (var projection in projections) - { - var projectionType = typeof(IProjectOn<>).MakeGenericType(@event.GetType()); - if (!projectionType.IsAssignableFrom(projection.GetType())) - continue; - - var method = typeof(IProjectOn<>) - .MakeGenericType(@event.GetType()) - .GetMethod(nameof(IProjectOn.Apply)); - - var task = (Task)method.Invoke(projection, new object[] { @event }); - - tasks.Add(task); - - logger?.LogInformation("Action=Event_Dispatcher_View, Event={Event}, Apply:{Apply}", - @event.Name, projection.GetType().Name); - } - - if (!tasks.Any()) - return; - - await Task.WhenAll(tasks); - } - } -} \ No newline at end of file diff --git a/src/SourceFlow/Impl/ViewModelStoreAdapter.cs b/src/SourceFlow/Impl/ViewModelStoreAdapter.cs new file mode 100644 index 0000000..7935b43 --- /dev/null +++ b/src/SourceFlow/Impl/ViewModelStoreAdapter.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using SourceFlow.Observability; +using SourceFlow.Projections; + +namespace SourceFlow.Impl +{ + internal class ViewModelStoreAdapter : IViewModelStoreAdapter + { + private readonly IViewModelStore store; + private readonly IDomainTelemetryService telemetry; + + public ViewModelStoreAdapter(IViewModelStore store, IDomainTelemetryService telemetry = null) + { + this.store = store; + this.telemetry = telemetry; + } + + public Task Find(int id) where TViewModel : class, IViewModel + { + if (telemetry != null) + { + return telemetry.TraceAsync( + "sourceflow.viewmodelstore.find", + () => store.Get(id), + activity => + { + activity?.SetTag("sourceflow.viewmodel_type", typeof(TViewModel).Name); + activity?.SetTag("sourceflow.viewmodel_id", id); + }); + } + + return store.Get(id); + } + + public Task Persist(TViewModel model) where TViewModel : class, IViewModel + { + if (telemetry != null) + { + return telemetry.TraceAsync( + "sourceflow.viewmodelstore.persist", + () => store.Persist(model), + activity => + { + activity?.SetTag("sourceflow.viewmodel_type", typeof(TViewModel).Name); + activity?.SetTag("sourceflow.viewmodel_id", model.Id); + }); + } + + return store.Persist(model); + } + + public Task Delete(TViewModel entity) where TViewModel : class, IViewModel + { + if (telemetry != null) + { + return telemetry.TraceAsync( + "sourceflow.viewmodelstore.delete", + () => store.Delete(entity), + activity => + { + activity?.SetTag("sourceflow.viewmodel_type", typeof(TViewModel).Name); + activity?.SetTag("sourceflow.viewmodel_id", entity.Id); + }); + } + + return store.Delete(entity); + } + } +} diff --git a/src/SourceFlow/IocExtensions.cs b/src/SourceFlow/IocExtensions.cs index 8a2b0db..3ae1547 100644 --- a/src/SourceFlow/IocExtensions.cs +++ b/src/SourceFlow/IocExtensions.cs @@ -4,526 +4,191 @@ using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; using SourceFlow.Aggregate; using SourceFlow.Impl; using SourceFlow.Messaging.Bus; +using SourceFlow.Messaging.Bus.Impl; +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Commands.Impl; +using SourceFlow.Messaging.Events; +using SourceFlow.Messaging.Events.Impl; +using SourceFlow.Observability; using SourceFlow.Projections; using SourceFlow.Saga; -using SourceFlow.Services; namespace SourceFlow { /// - /// Extension methods for setting up SourceFlow using ioc container. + /// Extension methods for setting up SourceFlow using IoC container. /// public static class IocExtensions { /// /// Configures the SourceFlow with aggregates, sagas and services with IoC Container. - /// Only supports when aggregates, sagas and services can be initialized with default constructor. /// - /// - public static void UseSourceFlow(this IServiceCollection services) + /// The service collection to register services with. + /// Optional parameter to specify assemblies to scan for implementations. + /// If not provided, uses the current SourceFlow assembly and calling assembly. + /// The service lifetime to use for registered services (default: Singleton). + public static void UseSourceFlow(this IServiceCollection services, params Assembly[] assemblies) { - UseSourceFlow(services, config => - { - config.WithAggregates(); - config.WithSagas(); - config.WithServices(); - }); + UseSourceFlow(services, ServiceLifetime.Singleton, assemblies); } /// /// Configures the SourceFlow with aggregates, sagas and services with IoC Container. - /// Supports custom configuration for aggregates, sagas and services. /// - /// - /// - public static void UseSourceFlow(this IServiceCollection services, Action configuration) + /// The service collection to register services with. + /// The service lifetime to use for registered services. + /// Optional parameter to specify assemblies to scan for implementations. + /// If not provided, uses the current SourceFlow assembly and calling assembly. + public static void UseSourceFlow(this IServiceCollection services, ServiceLifetime lifetime, params Assembly[] assemblies) { - services.AddAsImplementationsOfInterface(lifetime: ServiceLifetime.Singleton); - services.AddAsImplementationsOfInterface(lifetime: ServiceLifetime.Singleton); - services.AddAsImplementationsOfInterface(lifetime: ServiceLifetime.Singleton); - services.AddAsImplementationsOfInterface(lifetime: ServiceLifetime.Singleton); - - services.AddSingleton(c => new SagaDispatcher( - c.GetService>())); - - services.AddSingleton(c => new SagaDispatcher( - c.GetService>())); - - services.AddSingleton(c => + // If no assemblies are specified, scan assemblies that contain SourceFlow types and calling assembly + if (assemblies.Length == 0) { - var commandBus = new CommandBus( - c.GetService(), - c.GetService>()); - - var dispatcher = c.GetService(); - commandBus.Dispatchers += dispatcher.Dispatch; - - return commandBus; - }); - - services.AddSingleton(c => new AggregateDispatcher( - c.GetServices(), - c.GetService>()) - ); - - services.AddSingleton(c => new AggregateDispatcher( - c.GetServices(), - c.GetService>()) - ); - - services.AddSingleton(c => new ProjectionDispatcher( - c.GetServices(), - c.GetService>()) - ); - - services.AddSingleton(c => new ProjectionDispatcher( - c.GetServices(), - c.GetService>()) - ); - services.AddSingleton(c => - { - var queue = new EventQueue( - c.GetService>()); - // need to register event handlers for the projection before aggregates - var projectionDispatcher = c.GetService(); - queue.Dispatchers += projectionDispatcher.Dispatch; - // need to register event handlers for the aggregates after projections - var aggregateDispatcher = c.GetService(); - queue.Dispatchers += aggregateDispatcher.Dispatch; - - return queue; - }); - - services.AddSingleton(); - services.AddSingleton(c => new CommandPublisher(c.GetService())); - services.AddSingleton(c => new CommandReplayer(c.GetService())); - - configuration(new SourceFlowConfig { Services = services }); + assemblies = new[] { + Assembly.GetExecutingAssembly(), + Assembly.GetCallingAssembly() + }; + } - //var serviceProvider = services.BuildServiceProvider(); - //var accountService = serviceProvider.GetRequiredService(); - //var saga = serviceProvider.GetRequiredService(); - //var logger = serviceProvider.GetRequiredService>(); - //var dataView = serviceProvider.GetRequiredService(); + // Register single implementation of stores. + services.AddFirstImplementationAsInterface(assemblies, lifetime); + services.AddFirstImplementationAsInterface(assemblies, lifetime); + services.AddFirstImplementationAsInterface(assemblies, lifetime); + + // Register factories + services.Add(ServiceDescriptor.Describe(typeof(IAggregateFactory), typeof(AggregateFactory), lifetime)); + + // Register observability options (disabled by default to avoid breaking changes) + services.TryAddSingleton(new DomainObservabilityOptions { Enabled = false }); + + // Register domain telemetry service as Singleton (it's stateless and thread-safe) + services.TryAddSingleton(); + + // Only register adapters if they haven't been registered yet + // This allows tests and consumers to provide their own adapter implementations + // Store adapters must be Scoped to match the lifetime of the underlying stores + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + // Register infrastructure services that will be used by Sagas/Aggregates + // Command pipeline services must be Scoped to work with scoped store adapters + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Register Lazy to break circular dependency + // Sagas and Aggregates will receive this instead of direct ICommandPublisher + services.AddScoped>(provider => + new Lazy(() => provider.GetRequiredService())); + + services.AddImplementationAsInterfaces(assemblies, lifetime); + services.AddImplementationAsInterfaces(assemblies, lifetime); + services.AddImplementationAsInterfaces(assemblies, lifetime); + + // Register event subscribers as singleton services + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); } /// - /// Registers a service with the SourceFlow configuration. - /// When factory is not provided, uses default constructor to create service instance. + /// Add Implementations of an interfaces by all their other interfaces. /// - /// Service Type that implements IService. - /// - /// Factory to return service instance using service provider. - /// - public static ISourceFlowConfig WithService(this ISourceFlowConfig config, Func factory = null) - where TService : class, IService, new() + /// + /// + /// + /// + private static void AddImplementationAsInterfaces(this IServiceCollection services, Assembly[] assemblies, ServiceLifetime lifetime) { - ((SourceFlowConfig)config).Services.AddSingleton(c => + var aggregateTypes = GetImplementedTypes(assemblies); + foreach (var aggregateType in aggregateTypes) { - var serviceInstance = factory != null ? factory(c) : new TService(); + // Register as interface IAggregate + services.Add(ServiceDescriptor.Describe(typeof(T), aggregateType, lifetime)); - typeof(TService) - .GetField("aggregateFactory", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(serviceInstance, c.GetRequiredService()); + // Register as concrete type for direct access + services.Add(ServiceDescriptor.Describe(aggregateType, aggregateType, lifetime)); - typeof(TService) - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(serviceInstance, c.GetService>()); - - return serviceInstance; - }); - - var interfaces = typeof(TService).GetInterfaces(); - - foreach (var intrface in interfaces) - { - ((SourceFlowConfig)config).Services.AddSingleton(intrface, c => + // Register as all other interfaces the aggregate implements + var interfaces = aggregateType.GetInterfaces() + .Where(i => i != typeof(T) && i.IsPublic); + foreach (var iface in interfaces) { - var serviceInstance = factory != null ? factory(c) : new TService(); - - typeof(TService) - .GetField("aggregateFactory", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(serviceInstance, c.GetRequiredService()); - - typeof(TService) - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(serviceInstance, c.GetService>()); - - return serviceInstance; - }); + services.Add(ServiceDescriptor.Describe(iface, aggregateType, lifetime)); + } } - - return config; - } - - /// - /// Registers an aggregate with the SourceFlow configuration. - /// When no factory is provided, uses default constructor to create aggregate instance. - /// - /// Aggregate implementation of IAggregate. - /// - /// Factory to return aggrgate instance using service provider. - /// - public static ISourceFlowConfig WithAggregate(this ISourceFlowConfig config, Func factory = null) - where TAggregate : class, IAggregate, new() - { - ((SourceFlowConfig)config).Services.AddSingleton(c => - { - var aggrgateInstance = factory != null ? factory(c) : new TAggregate(); - - typeof(TAggregate) - .GetField("commandPublisher", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetRequiredService()); - - typeof(TAggregate) - .GetField("commandReplayer", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetRequiredService()); - - typeof(TAggregate) - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetService>()); - - return aggrgateInstance; - }); - - ((SourceFlowConfig)config).Services.AddSingleton(c => - { - var aggrgateInstance = factory != null ? factory(c) : new TAggregate(); - - typeof(TAggregate) - .GetField("commandPublisher", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetRequiredService()); - - typeof(TAggregate) - .GetField("commandReplayer", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetRequiredService()); - - typeof(TAggregate) - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetService>()); - - return aggrgateInstance; - }); - - return config; - } - - /// - /// Registers a saga with the SourceFlow configuration. - /// When no factory is provided, uses default constructor to create saga instance. - /// - /// Aggregate implementation supported by TSaga. Implementation of IAggregate. - /// Saga that implementation for a given Aggregate. Implementation of ISaga. - /// - /// Factory to return aggrgate instance using service provider. - /// - /// - public static ISourceFlowConfig WithSaga(this ISourceFlowConfig config, Func> factory = null) - where TAggregate : IEntity - where TSaga : class, ISaga, new() - { - ((SourceFlowConfig)config).Services.AddSingleton(c => - { - var saga = factory != null ? factory(c) : new TSaga(); - - if (saga == null) - throw new InvalidOperationException($"Saga registration for {typeof(TAggregate).Name} returned null."); - - typeof(TSaga) - .GetField("commandPublisher", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(saga, c.GetRequiredService()); - - typeof(TSaga) - .GetField("eventQueue", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(saga, c.GetRequiredService()); - - typeof(TSaga) - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(saga, c.GetService>()); - - typeof(TSaga) - .GetField("repository", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(saga, c.GetRequiredService()); - - var dispatcher = c.GetRequiredService(); - dispatcher.Register(saga); - - return (TSaga)saga; - }); - - return config; } /// /// Registers all implementations of a given interface in the IoC container. /// - /// - /// - /// - /// - private static IServiceCollection AddAsImplementationsOfInterface(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Scoped) + /// The interface to register implementations for. + /// The service collection to register services with. + /// The assemblies to scan for implementations. + /// The service lifetime for registered implementations. + /// The service collection for chaining. + private static IServiceCollection AddFirstImplementationAsInterface(this IServiceCollection services, Assembly[] assemblies, ServiceLifetime lifetime = ServiceLifetime.Singleton) { var interfaceType = typeof(TInterface); + var implementationTypes = GetImplementedTypes(interfaceType, assemblies); - var assemblies = AppDomain.CurrentDomain - .GetAssemblies() - .Where(a => !a.IsDynamic && !string.IsNullOrWhiteSpace(a.Location)); + if (!implementationTypes.Any()) + return services; - var implementationTypes = assemblies - .SelectMany(a => - { - try - { return a.GetTypes(); } - catch { return Array.Empty(); } // Prevent ReflectionTypeLoadException - }) - .Where(t => - interfaceType.IsAssignableFrom(t) && - t.IsClass && !t.IsAbstract && !t.IsGenericType); - - foreach (var implType in implementationTypes) - { - services.TryAddEnumerable(ServiceDescriptor.Describe(interfaceType, implType, lifetime)); - - var interfaces = implType.GetInterfaces().Where(t => !t.AssemblyQualifiedName.Equals(interfaceType.AssemblyQualifiedName)); - - foreach (var intrface in interfaces) - { - services.TryAddEnumerable(ServiceDescriptor.Describe(intrface, implType, lifetime)); - } - } + var implType = implementationTypes.First(); + services.TryAdd(ServiceDescriptor.Describe(interfaceType, implType, lifetime)); return services; } - /// - /// Gets all types that implement a given interface from all loaded assemblies. - /// - /// - /// - private static IEnumerable GetTypesFromAssemblies(Type interfaceType) + private static IEnumerable GetImplementedTypes(Assembly[] assemblies) { - var assemblies = AppDomain.CurrentDomain - .GetAssemblies() - .Where(a => !a.IsDynamic && !string.IsNullOrWhiteSpace(a.Location)); + return GetImplementedTypes(typeof(TInterface), assemblies); + } + private static IEnumerable GetImplementedTypes(Type interfaceType, Assembly[] assemblies) + { var implementationTypes = assemblies + .Where(a => !a.IsDynamic && !string.IsNullOrWhiteSpace(a.Location)) .SelectMany(a => { try - { return a.GetTypes(); } - catch { return Array.Empty(); } // Prevent ReflectionTypeLoadException - }) - .Where(t => - interfaceType.IsAssignableFrom(t) && - t.IsClass && !t.IsAbstract && !t.IsGenericType); - - return implementationTypes; - } - - /// - /// Registers all services that implement the IService interface in the IoC container. - /// When factory is not provided, uses default constructor to create service instances. - /// - /// - /// Factory to return service instances by given type. - /// - /// - public static ISourceFlowConfig WithServices(this ISourceFlowConfig config, Func serviceFactory = null) - { - var interfaceType = typeof(IService); - var implementationTypes = GetTypesFromAssemblies(interfaceType); - - foreach (var implType in implementationTypes) - { - var serviceInstance = serviceFactory != null - ? serviceFactory(implType) - : (IService)Activator.CreateInstance(implType); - - if (serviceInstance == null) - throw new InvalidOperationException($"Service registration for {implType.Name} returned null."); - - var loggerType = typeof(ILogger<>).MakeGenericType(implType); - - ((SourceFlowConfig)config).Services.AddSingleton(c => - { - implType - .GetField("aggregateFactory", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(serviceInstance, c.GetRequiredService()); - - implType - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(serviceInstance, (ILogger)c.GetService(loggerType)); - - return serviceInstance; - }); - - var interfaces = implType.GetInterfaces(); - - foreach (var intrface in interfaces) - ((SourceFlowConfig)config).Services.AddSingleton(intrface, c => { - implType - .GetField("aggregateFactory", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(serviceInstance, c.GetRequiredService()); - - implType - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(serviceInstance, (ILogger)c.GetService(loggerType)); - - return serviceInstance; - }); - } - - return config; - } - - /// - /// Registers all aggregates that implement the IAggregate interface in the IoC container. - /// When factory is not provided, uses default constructor to create aggrgate instances. - /// - /// - /// Factory to return aggregate instances by given type. - /// - /// - public static ISourceFlowConfig WithAggregates(this ISourceFlowConfig config, Func aggregateFactory = null) - { - var interfaceType = typeof(IAggregate); - var implementationTypes = GetTypesFromAssemblies(interfaceType); - - foreach (var implType in implementationTypes) - { - var aggrgateInstance = aggregateFactory != null - ? aggregateFactory(implType) - : (IAggregate)Activator.CreateInstance(implType); - - if (aggrgateInstance == null) - throw new InvalidOperationException($"Aggregate registration for {implType.Name} returned null."); - - var loggerType = typeof(ILogger<>).MakeGenericType(implType); - - ((SourceFlowConfig)config).Services.AddSingleton(implType, c => - { - implType - .GetField("commandPublisher", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetRequiredService()); - - implType - .GetField("commandReplayer", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetRequiredService()); - - implType - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, (ILogger)c.GetService(loggerType)); - - return aggrgateInstance; - }); - - var interfaces = implType.GetInterfaces(); - - foreach (var intrface in interfaces) - ((SourceFlowConfig)config).Services.AddSingleton(intrface, c => + return a.GetTypes() + .Where(t => interfaceType.IsAssignableFrom(t) && + t.IsClass && + !t.IsAbstract && + t.IsPublic && + !t.IsGenericType && + !t.ContainsGenericParameters); + } + catch (ReflectionTypeLoadException) { - implType - .GetField("commandPublisher", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetRequiredService()); - - implType - .GetField("commandReplayer", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, c.GetRequiredService()); - - implType - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(aggrgateInstance, (ILogger)c.GetService(loggerType)); - - return aggrgateInstance; - }); - } - - return config; - } - - /// - /// Registers all sagas that implement the ISaga interface in the IoC container. - /// When factory is not provided, uses default constructor to create saga instances. - /// - /// - /// Factory to return saga instances by given type. - /// - /// - public static ISourceFlowConfig WithSagas(this ISourceFlowConfig config, Func sagaFactory = null) - { - var interfaceType = typeof(ISaga); - var implementationTypes = GetTypesFromAssemblies(interfaceType); - - foreach (var implType in implementationTypes) - { - var sagaInstance = sagaFactory != null - ? sagaFactory(implType) - : (ISaga)Activator.CreateInstance(implType); - - if (sagaInstance == null) - throw new InvalidOperationException($"Saga registration for {implType.Name} returned null."); - - var loggerType = typeof(ILogger<>).MakeGenericType(implType); - - var interfaces = implType.GetInterfaces(); - - var index = 1; - - foreach (var intrface in interfaces) - ((SourceFlowConfig)config).Services.AddSingleton(intrface, c => + // On cases where some types can't be loaded + return a.GetTypes() + .Where(t => t != null && + interfaceType.IsAssignableFrom(t) && + t.IsClass && + !t.IsAbstract && + t.IsPublic && + !t.IsGenericType && + !t.ContainsGenericParameters); + } + catch { - implType - .GetField("commandPublisher", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(sagaInstance, c.GetRequiredService()); - - implType - .GetField("eventQueue", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(sagaInstance, c.GetRequiredService()); - - implType - .GetField("logger", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(sagaInstance, (ILogger)c.GetService(loggerType)); - - implType - .GetField("repository", BindingFlags.Instance | BindingFlags.NonPublic) - ?.SetValue(sagaInstance, c.GetRequiredService()); - - if (index == 1) - { - var dispatcher = c.GetRequiredService(); - dispatcher.Register(sagaInstance); - } - - index++; - - return sagaInstance; - }); - } - - return config; - } - - /// - /// Interface for SourceFlow configuration. - /// - public interface ISourceFlowConfig - { - } + return Array.Empty(); + } + }) + .Distinct(); - /// - /// Configuration class for SourceFlow. - /// - public class SourceFlowConfig : ISourceFlowConfig - { - /// - /// Service collection for SourceFlow configuration. - /// - public IServiceCollection Services { get; set; } + return implementationTypes; } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/Bus/ICommandBus.cs b/src/SourceFlow/Messaging/Bus/ICommandBus.cs index 5738c2c..2f31eb4 100644 --- a/src/SourceFlow/Messaging/Bus/ICommandBus.cs +++ b/src/SourceFlow/Messaging/Bus/ICommandBus.cs @@ -1,5 +1,5 @@ -using System; using System.Threading.Tasks; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Messaging.Bus { @@ -15,7 +15,7 @@ public interface ICommandBus /// /// Task Publish(TCommand command) - where TCommand : ICommand; + where TCommand : ICommand; /// /// Replays all commands for a given aggregate. @@ -23,10 +23,5 @@ Task Publish(TCommand command) /// Unique aggregate entity id. /// Task Replay(int aggregateId); - - /// - /// Represents command dispathers that can handle the publishing of commands. - /// - event EventHandler Dispatchers; } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/Bus/ICommandDispatcher.cs b/src/SourceFlow/Messaging/Bus/ICommandDispatcher.cs deleted file mode 100644 index 190aa90..0000000 --- a/src/SourceFlow/Messaging/Bus/ICommandDispatcher.cs +++ /dev/null @@ -1,20 +0,0 @@ -using SourceFlow.Saga; - -namespace SourceFlow.Messaging.Bus -{ - public interface ICommandDispatcher - { - /// - /// Registers a saga with the command bus. - /// - /// - void Register(ISaga saga); - - /// - /// Dispatches a command to the registered sagas. - /// - /// - /// - void Dispatch(object sender, ICommand command); - } -} \ No newline at end of file diff --git a/src/SourceFlow/Messaging/Bus/ICommandReplayer.cs b/src/SourceFlow/Messaging/Bus/ICommandReplayer.cs deleted file mode 100644 index 7e6898d..0000000 --- a/src/SourceFlow/Messaging/Bus/ICommandReplayer.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Threading.Tasks; - -namespace SourceFlow.Messaging.Bus -{ - /// - /// Interface for replaying commands in the event-driven architecture. - /// - public interface ICommandReplayer - { - /// - /// Replays all commands for a given aggregate. - /// - /// Unique aggregate entity id. - /// - Task Replay(int aggregateId); - } -} \ No newline at end of file diff --git a/src/SourceFlow/Messaging/Bus/IEventDispatcher.cs b/src/SourceFlow/Messaging/Bus/IEventDispatcher.cs deleted file mode 100644 index 2d07cee..0000000 --- a/src/SourceFlow/Messaging/Bus/IEventDispatcher.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace SourceFlow.Messaging.Bus -{ - public interface IEventDispatcher - { - void Dispatch(object sender, IEvent @event); - } -} \ No newline at end of file diff --git a/src/SourceFlow/Messaging/Bus/Impl/CommandBus.cs b/src/SourceFlow/Messaging/Bus/Impl/CommandBus.cs new file mode 100644 index 0000000..3759166 --- /dev/null +++ b/src/SourceFlow/Messaging/Bus/Impl/CommandBus.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SourceFlow.Messaging.Commands; +using SourceFlow.Observability; + +namespace SourceFlow.Messaging.Bus.Impl +{ + /// + /// Command bus implementation that handles commands and events in an event-driven architecture. + /// + internal class CommandBus : ICommandBus + { + /// + /// The command store used to persist commands. + /// + private readonly ICommandStoreAdapter commandStore; + + /// + /// Logger for the command bus to log events and errors. + /// + private readonly ILogger logger; + + /// + /// Represents command dispathers that can handle the publishing of commands. + /// + public IEnumerable commandDispatchers; + + /// + /// Telemetry service for observability. + /// + private readonly IDomainTelemetryService telemetry; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + public CommandBus(IEnumerable commandDispatchers, ICommandStoreAdapter commandStore, ILogger logger, IDomainTelemetryService telemetry) + { + this.commandStore = commandStore ?? throw new ArgumentNullException(nameof(commandStore)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.commandDispatchers = commandDispatchers ?? throw new ArgumentNullException(nameof(commandDispatchers)); + this.telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + } + + /// + /// Publishes a command to all subscribed sagas. + /// + /// + /// + /// + /// + Task ICommandBus.Publish(TCommand command) + { + if (command == null) + throw new ArgumentNullException(nameof(command)); + + return Dispatch(command); + } + + private async Task Dispatch(TCommand command) where TCommand : ICommand + { + await telemetry.TraceAsync( + "sourceflow.commandbus.dispatch", + async () => + { + // 1. Set event sequence no. + if (!((IMetadata)command).Metadata.IsReplay) + ((IMetadata)command).Metadata.SequenceNo = await commandStore.GetNextSequenceNo(command.Entity.Id); + + var tasks = new List(); + + // 2. Dispatch command to handlers. + foreach (var dispatcher in commandDispatchers) + tasks.Add(DispatchCommand(command, dispatcher)); + + if (tasks.Any()) + await Task.WhenAll(tasks); + + // 3. When event is not replayed + if (!((IMetadata)command).Metadata.IsReplay) + // 3.1. Append event to event store. + await commandStore.Append(command); + }, + activity => + { + activity?.SetTag("command.type", command.GetType().Name); + activity?.SetTag("command.entity_id", command.Entity.Id); + activity?.SetTag("command.sequence_no", ((IMetadata)command).Metadata.SequenceNo); + activity?.SetTag("command.is_replay", ((IMetadata)command).Metadata.IsReplay); + }); + + // Record metric + telemetry.RecordCommandExecuted(command.GetType().Name, command.Entity.Id); + } + + /// + /// Dispatches a command to a specific dispatcher. + /// + /// + /// + /// + /// + private Task DispatchCommand(TCommand command, ICommandDispatcher dispatcher) where TCommand : ICommand + { + // 2.2 Log event. + logger?.LogInformation("Action=Command_Dispatched, Dispatcher={Dispatcher}, Command={Command}, Payload={Payload}, SequenceNo={No}", + dispatcher.GetType().Name, command.GetType().Name, command.Payload.GetType().Name, ((IMetadata)command).Metadata.SequenceNo); + + // 2.1 Dispatch to each dispatcher + return dispatcher.Dispatch(command); + } + + /// + /// Replays commands for a given aggregate. + /// + /// Unique aggregate entity id. + /// + async Task ICommandBus.Replay(int entityId) + { + await telemetry.TraceAsync( + "sourceflow.commandbus.replay", + async () => + { + var commands = await commandStore.Load(entityId); + + if (commands == null || !commands.Any()) + return; + + foreach (var command in commands.ToList()) + { + command.Metadata.IsReplay = true; + + // Call Dispatch with the concrete command type to preserve generics + var commandType = command.GetType(); + var dispatchMethod = this.GetType().GetMethod(nameof(Dispatch), + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var genericDispatchMethod = dispatchMethod.MakeGenericMethod(commandType); + await (Task)genericDispatchMethod.Invoke(this, new object[] { command }); + } + }, + activity => + { + activity?.SetTag("entity_id", entityId); + }); + } + } +} diff --git a/src/SourceFlow/Messaging/Command.cs b/src/SourceFlow/Messaging/Command.cs deleted file mode 100644 index 2d93b8f..0000000 --- a/src/SourceFlow/Messaging/Command.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace SourceFlow.Messaging -{ - /// - /// Base class for command in the command-driven architecture. - /// - public abstract class Command : ICommand - where TPayload : class, IPayload, new() - { - /// - /// Initializes a new instance of the class with a new payload. - /// - /// - public Command(TPayload payload) - { - Metadata = new Metadata(); - Name = GetType().Name; - Payload = payload; - } - - /// - /// Metadata associated with the command, which includes information such as event ID, occurrence time, and sequence number. - /// - public Metadata Metadata { get; set; } = new Metadata(); - - /// - /// Name of the command, typically the class name. - /// - public string Name { get; set; } - - /// - /// Payload of the command, containing the data associated with the command. - /// - public TPayload Payload { get; set; } - - /// - /// Payload of the command, containing the data associated with the command. - /// - IPayload ICommand.Payload - { - get { return Payload; } - set - { - Payload = (TPayload)value; - } - } - } -} \ No newline at end of file diff --git a/src/SourceFlow/Messaging/Commands/Command.cs b/src/SourceFlow/Messaging/Commands/Command.cs new file mode 100644 index 0000000..07f0ac7 --- /dev/null +++ b/src/SourceFlow/Messaging/Commands/Command.cs @@ -0,0 +1,91 @@ +namespace SourceFlow.Messaging.Commands +{ + /// + /// Base class for command in the command-driven architecture. + /// + public abstract class Command : ICommand + where TPayload : class, IPayload, new() + { + /// + /// Parameterless constructor for deserialization. + /// + protected Command() + { + Metadata = new Metadata(); + Name = GetType().Name; + Payload = new TPayload(); + Entity = new EntityRef(); + } + + /// + /// Initializes a new instance of the class with a new payload. + /// + /// + public Command(int entityId, TPayload payload) + { + Metadata = new Metadata(); + Name = GetType().Name; + Payload = payload; + Entity = new EntityRef { Id = entityId }; + } + + /// + /// Initializes a new instance of the Command class with the specified entity state and payload. + /// + /// true to indicate that the associated entity is new or to be created; otherwise, false. + /// The payload data to associate with the command. + public Command(bool newEntity, TPayload payload) + { + Metadata = new Metadata(); + Name = GetType().Name; + Payload = payload; + Entity = new EntityRef { Id = 0, IsNew = newEntity }; + } + + /// + /// Initializes a new instance of the Command class with the specified entity ID, new entity state, and payload. + /// + /// The ID of the entity associated with the command. + /// true to indicate that the associated entity is new or to be created; otherwise, false. + /// The payload data to associate with the command. + public Command(int entityId, bool newEntity, TPayload payload) + { + Metadata = new Metadata(); + Name = GetType().Name; + Payload = payload; + Entity = new EntityRef { Id = entityId, IsNew = newEntity }; + } + + /// + /// Metadata associated with the command, which includes information such as event ID, occurrence time, and sequence number. + /// + public Metadata Metadata { get; set; } = new Metadata(); + + /// + /// Name of the command, typically the class name. + /// + public string Name { get; set; } + + /// + /// Payload of the command, containing the data associated with the command. + /// + public TPayload Payload { get; set; } + + /// + /// Entity reference associated with the command. + /// + public EntityRef Entity { get; set; } + + /// + /// Payload of the command, containing the data associated with the command. + /// + IPayload ICommand.Payload + { + get { return Payload; } + set + { + Payload = (TPayload)value; + } + } + } +} diff --git a/src/SourceFlow/Messaging/Commands/CommandData.cs b/src/SourceFlow/Messaging/Commands/CommandData.cs new file mode 100644 index 0000000..d0c1303 --- /dev/null +++ b/src/SourceFlow/Messaging/Commands/CommandData.cs @@ -0,0 +1,19 @@ +using System; + +namespace SourceFlow.Messaging.Commands +{ + /// + /// Data transfer object representing a serialized command for storage. + /// + public class CommandData + { + public int EntityId { get; set; } + public int SequenceNo { get; set; } + public string CommandName { get; set; } = string.Empty; + public string CommandType { get; set; } = string.Empty; + public string PayloadType { get; set; } = string.Empty; + public string PayloadData { get; set; } = string.Empty; + public string Metadata { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + } +} diff --git a/src/SourceFlow/Messaging/Commands/ICommand.cs b/src/SourceFlow/Messaging/Commands/ICommand.cs new file mode 100644 index 0000000..9d375e9 --- /dev/null +++ b/src/SourceFlow/Messaging/Commands/ICommand.cs @@ -0,0 +1,27 @@ +namespace SourceFlow.Messaging.Commands +{ + /// + /// Interface for commands in the event-driven architecture. + /// + public interface ICommand : IName, IMetadata + { + /// + /// Payload of the command, which is an entity that contains the data associated with the command. + /// + IPayload Payload { get; set; } + + /// + /// Reference to the entity associated with the command. + /// + EntityRef Entity { get; set; } + } + + /// + /// Reference to an entity in the event-driven architecture. + /// + public class EntityRef : IEntity + { + public int Id { get; set; } + public bool IsNew { get; set; } + } +} diff --git a/src/SourceFlow/Messaging/Commands/ICommandDispatcher.cs b/src/SourceFlow/Messaging/Commands/ICommandDispatcher.cs new file mode 100644 index 0000000..650bed6 --- /dev/null +++ b/src/SourceFlow/Messaging/Commands/ICommandDispatcher.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace SourceFlow.Messaging.Commands +{ + public interface ICommandDispatcher + { + /// + /// Dispatches a command to the registered sagas. + /// + /// + Task Dispatch(TCommand command) where TCommand : ICommand; + } +} diff --git a/src/SourceFlow/Messaging/Bus/ICommandPublisher.cs b/src/SourceFlow/Messaging/Commands/ICommandPublisher.cs similarity index 60% rename from src/SourceFlow/Messaging/Bus/ICommandPublisher.cs rename to src/SourceFlow/Messaging/Commands/ICommandPublisher.cs index b288acd..8152ea5 100644 --- a/src/SourceFlow/Messaging/Bus/ICommandPublisher.cs +++ b/src/SourceFlow/Messaging/Commands/ICommandPublisher.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace SourceFlow.Messaging.Bus +namespace SourceFlow.Messaging.Commands { /// /// Interface for publishing commands to bus in the event-driven architecture. @@ -14,6 +14,13 @@ public interface ICommandPublisher /// /// Task Publish(TCommand command) - where TCommand : ICommand; + where TCommand : ICommand; + + /// + /// Replays commands for the specified entity Id. + /// + /// + /// + Task ReplayCommands(int entityId); } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/Commands/ICommandSubscriber.cs b/src/SourceFlow/Messaging/Commands/ICommandSubscriber.cs new file mode 100644 index 0000000..62640fc --- /dev/null +++ b/src/SourceFlow/Messaging/Commands/ICommandSubscriber.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; + +namespace SourceFlow.Messaging.Commands +{ + /// + /// Interface for subscribing commands in the event-driven architecture. + /// + public interface ICommandSubscriber + { + ///// + ///// Registers a saga with the command bus. + ///// + ///// + //void Register(ISaga saga); + + /// + /// Subscribes a command + /// + /// + /// + /// + Task Subscribe(TCommand command) + where TCommand : ICommand; + } +} diff --git a/src/SourceFlow/Messaging/Commands/Impl/CommandDispatcher.cs b/src/SourceFlow/Messaging/Commands/Impl/CommandDispatcher.cs new file mode 100644 index 0000000..0e585b6 --- /dev/null +++ b/src/SourceFlow/Messaging/Commands/Impl/CommandDispatcher.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SourceFlow.Observability; +using SourceFlow.Performance; + +namespace SourceFlow.Messaging.Commands.Impl +{ + /// + /// This dispatcher is responsible for dispatching commands to registered sagas in an event-driven architecture. + /// + internal class CommandDispatcher : ICommandDispatcher + { + /// + /// Collection of sagas registered with the dispatcher. + /// + private readonly IEnumerable commandSubscribers; + + /// + /// Logger for the command dispatcher to log events and errors. + /// + private readonly ILogger logger; + + /// + /// Telemetry service for observability. + /// + private readonly IDomainTelemetryService telemetry; + + /// + /// Initializes a new instance of the class with the specified logger. + /// + /// + /// + /// + public CommandDispatcher(IEnumerable commandSubscribers, ILogger logger, IDomainTelemetryService telemetry) + { + this.logger = logger; + this.commandSubscribers = commandSubscribers; + this.telemetry = telemetry; + } + + /// + /// Dispatches a command to all sagas that are registered with the command dispatcher. + /// + /// + /// + public Task Dispatch(TCommand command) where TCommand : ICommand + { + return Send(command); + } + + /// + /// Publishes a command to all sagas that are registered with the command dispatcher. + /// + /// + /// + /// + private Task Send(TCommand command) where TCommand : ICommand + { + return telemetry.TraceAsync( + "sourceflow.commanddispatcher.send", + async () => + { + if (!commandSubscribers.Any()) + { + logger?.LogInformation("Action=Command_Dispatcher, Command={Command}, Payload={Payload}, SequenceNo={No}, Message=No subscribers Found", + command.GetType().Name, command.Payload.GetType().Name, ((IMetadata)command).Metadata.SequenceNo); + + return; + } + + // Use ArrayPool-based optimization for task collection + await TaskBufferPool.ExecuteAsync( + commandSubscribers, + subscriber => subscriber.Subscribe(command)); + }, + activity => + { + activity?.SetTag("command.type", command.GetType().Name); + activity?.SetTag("command.entity_id", command.Entity.Id); + activity?.SetTag("subscribers.count", commandSubscribers.Count()); + }); + } + } +} diff --git a/src/SourceFlow/Impl/CommandPublisher.cs b/src/SourceFlow/Messaging/Commands/Impl/CommandPublisher.cs similarity index 63% rename from src/SourceFlow/Impl/CommandPublisher.cs rename to src/SourceFlow/Messaging/Commands/Impl/CommandPublisher.cs index 08e2daf..e358ff2 100644 --- a/src/SourceFlow/Impl/CommandPublisher.cs +++ b/src/SourceFlow/Messaging/Commands/Impl/CommandPublisher.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using SourceFlow.Messaging.Bus; -namespace SourceFlow.Impl +namespace SourceFlow.Messaging.Commands.Impl { /// /// Implementation of the ICommandPublisher interface for publishing commands to bus. @@ -23,6 +23,16 @@ public CommandPublisher(ICommandBus commandBus) this.commandBus = commandBus; } + /// + /// Replays commands for a specific entity by its Id. + /// + /// + /// + public Task ReplayCommands(int entityId) + { + return commandBus.Replay(entityId); + } + /// /// Publishes a command to the command bus. /// @@ -31,15 +41,18 @@ public CommandPublisher(ICommandBus commandBus) /// /// /// - async Task ICommandPublisher.Publish(TCommand command) + Task ICommandPublisher.Publish(TCommand command) { if (command == null) throw new ArgumentNullException(nameof(command)); - if (command.Payload?.Id == null) - throw new InvalidOperationException(nameof(command) + "requires payload entity with id"); + if (command.Entity == null) + throw new InvalidOperationException(nameof(command) + " requires entity reference."); + + if (!command.Entity.IsNew && command.Entity?.Id == null) + throw new InvalidOperationException(nameof(command) + " requires entity id when not new entity."); - await commandBus.Publish(command); + return commandBus.Publish(command); } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/Event.cs b/src/SourceFlow/Messaging/Events/Event.cs similarity index 85% rename from src/SourceFlow/Messaging/Event.cs rename to src/SourceFlow/Messaging/Events/Event.cs index bfadcb8..d076da3 100644 --- a/src/SourceFlow/Messaging/Event.cs +++ b/src/SourceFlow/Messaging/Events/Event.cs @@ -1,6 +1,4 @@ -using SourceFlow.Aggregate; - -namespace SourceFlow.Messaging +namespace SourceFlow.Messaging.Events { /// /// Base class for implementing events in an event-driven architecture. @@ -10,14 +8,14 @@ public abstract class Event : IEvent where TEntity : IEntity { /// - /// Initializes a new instance of the class with a specified payload. + /// Initializes a new instance of the class with a specified entity. /// - /// - public Event(TEntity payload) + /// + public Event(TEntity entity) { Metadata = new Metadata(); Name = GetType().Name; - Payload = payload; + Payload = entity; } /// @@ -47,4 +45,4 @@ IEntity IEvent.Payload } } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/IEvent.cs b/src/SourceFlow/Messaging/Events/IEvent.cs similarity index 81% rename from src/SourceFlow/Messaging/IEvent.cs rename to src/SourceFlow/Messaging/Events/IEvent.cs index a4243a1..0f62822 100644 --- a/src/SourceFlow/Messaging/IEvent.cs +++ b/src/SourceFlow/Messaging/Events/IEvent.cs @@ -1,6 +1,4 @@ -using SourceFlow.Aggregate; - -namespace SourceFlow.Messaging +namespace SourceFlow.Messaging.Events { public interface IEvent : IName, IMetadata @@ -10,4 +8,4 @@ public interface IEvent : IName, IMetadata /// IEntity Payload { get; set; } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/Events/IEventDispatcher.cs b/src/SourceFlow/Messaging/Events/IEventDispatcher.cs new file mode 100644 index 0000000..50bef03 --- /dev/null +++ b/src/SourceFlow/Messaging/Events/IEventDispatcher.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace SourceFlow.Messaging.Events +{ + public interface IEventDispatcher + { + Task Dispatch(TEvent @event) where TEvent : IEvent; + } +} diff --git a/src/SourceFlow/Messaging/Bus/IEventQueue.cs b/src/SourceFlow/Messaging/Events/IEventQueue.cs similarity index 61% rename from src/SourceFlow/Messaging/Bus/IEventQueue.cs rename to src/SourceFlow/Messaging/Events/IEventQueue.cs index c1dd570..01135d9 100644 --- a/src/SourceFlow/Messaging/Bus/IEventQueue.cs +++ b/src/SourceFlow/Messaging/Events/IEventQueue.cs @@ -1,15 +1,9 @@ -using System; using System.Threading.Tasks; -namespace SourceFlow.Messaging.Bus +namespace SourceFlow.Messaging.Events { public interface IEventQueue { - /// - /// Dispatchers that are invoked to publish an event that is dequeued from the event queue. - /// - event EventHandler Dispatchers; - /// /// Enqueues an event in order to publish to subcribers. /// @@ -19,4 +13,4 @@ public interface IEventQueue Task Enqueue(TEvent @event) where TEvent : IEvent; } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/Events/IEventSubscriber.cs b/src/SourceFlow/Messaging/Events/IEventSubscriber.cs new file mode 100644 index 0000000..9d5623b --- /dev/null +++ b/src/SourceFlow/Messaging/Events/IEventSubscriber.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace SourceFlow.Messaging.Events +{ + /// + /// Defines a contract for subscribing to events within the event-driven architecture. + /// + public interface IEventSubscriber + { + /// + /// Subscribes to the specified event. + /// + /// + /// + /// + Task Subscribe(TEvent @event) where TEvent : IEvent; + } +} diff --git a/src/SourceFlow/Messaging/Events/Impl/EventDispatcher.cs b/src/SourceFlow/Messaging/Events/Impl/EventDispatcher.cs new file mode 100644 index 0000000..8fe4a0e --- /dev/null +++ b/src/SourceFlow/Messaging/Events/Impl/EventDispatcher.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SourceFlow.Observability; +using SourceFlow.Performance; + +namespace SourceFlow.Messaging.Events.Impl +{ + internal class EventDispatcher : IEventDispatcher + { + /// + /// Represents a collection of subscribers interested in the event. + /// + /// This collection contains instances of objects implementing the interface. Each subscribers in the collection subscribes to events of interest. + private IEnumerable subscribers; + + /// + /// Logger for the event queue to log events and errors. + /// + private readonly ILogger logger; + + /// + /// Telemetry service for observability. + /// + private readonly IDomainTelemetryService telemetry; + + /// + /// Initializes a new instance of the class with the specified subscribers and logger. + /// + /// + /// + /// + /// + public EventDispatcher(IEnumerable subscribers, ILogger logger, IDomainTelemetryService telemetry) + { + this.subscribers = subscribers ?? throw new ArgumentNullException(nameof(subscribers)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + } + + /// + /// Dispatch the event to all subscribers that can handle it. + /// + /// + /// + /// + public Task Dispatch(TEvent @event) where TEvent : IEvent + { + return telemetry.TraceAsync( + "sourceflow.eventdispatcher.dispatch", + async () => + { + // Use ArrayPool-based optimization for task collection + await TaskBufferPool.ExecuteAsync( + subscribers, + subscriber => + { + logger?.LogInformation("Action=Event_Dispatcher, Event={Event}, Subscriber:{subscriber}", + @event.Name, subscribers.GetType().Name); + + return subscriber.Subscribe(@event); + }); + }, + activity => + { + activity?.SetTag("event.type", @event.GetType().Name); + activity?.SetTag("event.name", @event.Name); + activity?.SetTag("subscribers.count", subscribers.Count()); + }); + } + } +} diff --git a/src/SourceFlow/Messaging/Events/Impl/EventQueue.cs b/src/SourceFlow/Messaging/Events/Impl/EventQueue.cs new file mode 100644 index 0000000..e32124a --- /dev/null +++ b/src/SourceFlow/Messaging/Events/Impl/EventQueue.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SourceFlow.Observability; + +namespace SourceFlow.Messaging.Events.Impl +{ + /// + /// EventQueue is responsible for managing the flow of events in an event-driven architecture. + /// + internal class EventQueue : IEventQueue + { + /// + /// Logger for the event queue to log events and errors. + /// + private readonly ILogger logger; + + /// + /// Represents event dispathers that can handle the dequeuing of events. + /// + public IEnumerable eventDispatchers; + + /// + /// Telemetry service for observability. + /// + private readonly IDomainTelemetryService telemetry; + + /// + /// Initializes a new instance of the class with the specified logger. + /// + /// + /// + /// + /// + public EventQueue(IEnumerable eventDispatchers, ILogger logger, IDomainTelemetryService telemetry) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.eventDispatchers = eventDispatchers ?? throw new ArgumentNullException(nameof(eventDispatchers)); + this.telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + } + + /// + /// Enqueues an event in order to publish to subcribers. + /// + /// + /// + /// + public Task Enqueue(TEvent @event) + where TEvent : IEvent + { + if (@event == null) + throw new ArgumentNullException(nameof(@event)); + + return telemetry.TraceAsync( + "sourceflow.eventqueue.enqueue", + async () => + { + var tasks = new List(); + foreach (var eventDispatcher in eventDispatchers) + tasks.Add(DispatchEvent(@event, eventDispatcher)); + + if (tasks.Any()) + await Task.WhenAll(tasks); + }, + activity => + { + activity?.SetTag("event.type", @event.GetType().Name); + activity?.SetTag("event.name", @event.Name); + }); + } + + private Task DispatchEvent(TEvent @event, IEventDispatcher eventDispatcher) where TEvent : IEvent + { + logger?.LogInformation("Action=Event_Enqueue, Dispatcher={Dispatcher}, Event={Event}, Payload={Payload}", eventDispatcher.GetType().Name, @event.GetType().Name, @event.Payload.GetType().Name); + return eventDispatcher.Dispatch(@event); + } + } +} diff --git a/src/SourceFlow/Messaging/ICommand.cs b/src/SourceFlow/Messaging/ICommand.cs deleted file mode 100644 index 167a58d..0000000 --- a/src/SourceFlow/Messaging/ICommand.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace SourceFlow.Messaging -{ - /// - /// Interface for commands in the event-driven architecture. - /// - public interface ICommand : IName, IMetadata - { - /// - /// Payload of the command, which is an entity that contains the data associated with the command. - /// - IPayload Payload { get; set; } - } -} \ No newline at end of file diff --git a/src/SourceFlow/Messaging/IMetadata.cs b/src/SourceFlow/Messaging/IMetadata.cs index 5310632..a829b56 100644 --- a/src/SourceFlow/Messaging/IMetadata.cs +++ b/src/SourceFlow/Messaging/IMetadata.cs @@ -52,4 +52,4 @@ public Metadata() /// public Dictionary Properties { get; set; } = new Dictionary(); } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/IName.cs b/src/SourceFlow/Messaging/IName.cs index 6440a2f..c4f95bd 100644 --- a/src/SourceFlow/Messaging/IName.cs +++ b/src/SourceFlow/Messaging/IName.cs @@ -10,4 +10,4 @@ public interface IName /// string Name { get; set; } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Messaging/IPayload.cs b/src/SourceFlow/Messaging/IPayload.cs index 1336e86..1e72950 100644 --- a/src/SourceFlow/Messaging/IPayload.cs +++ b/src/SourceFlow/Messaging/IPayload.cs @@ -5,9 +5,5 @@ namespace SourceFlow.Messaging /// public interface IPayload { - /// - /// Unique identifier for the Aggregate. - /// - int Id { get; set; } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Observability/DomainTelemetryService.cs b/src/SourceFlow/Observability/DomainTelemetryService.cs new file mode 100644 index 0000000..32156ad --- /dev/null +++ b/src/SourceFlow/Observability/DomainTelemetryService.cs @@ -0,0 +1,293 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Threading.Tasks; + +namespace SourceFlow.Observability +{ + /// + /// Provides OpenTelemetry tracing and metrics for domain-level operations. + /// Tracks aggregate commands, saga execution, and serialization operations. + /// + public class DomainTelemetryService : IDomainTelemetryService + { + private readonly DomainObservabilityOptions _options; + private readonly ActivitySource? _activitySource; + private readonly Meter? _meter; + + // Counters + private readonly Counter? _commandsExecuted; + + private readonly Counter? _sagasExecuted; + private readonly Counter? _entitiesCreated; + private readonly Counter? _serializationOperations; + + // Histograms + private readonly Histogram? _operationDuration; + + private readonly Histogram? _serializationDuration; + + private static void SetActivityException(Activity? activity, Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.SetTag("exception.type", ex.GetType().FullName); + activity?.SetTag("exception.message", ex.Message); + } + + public DomainTelemetryService(DomainObservabilityOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + + if (_options.Enabled) + { + _activitySource = new ActivitySource( + _options.ServiceName, + _options.ServiceVersion); + + _meter = new Meter( + _options.ServiceName, + _options.ServiceVersion); + + _commandsExecuted = _meter.CreateCounter( + "sourceflow.domain.commands.executed", + description: "Number of aggregate commands executed"); + + _sagasExecuted = _meter.CreateCounter( + "sourceflow.domain.sagas.executed", + description: "Number of sagas executed"); + + _entitiesCreated = _meter.CreateCounter( + "sourceflow.domain.entities.created", + description: "Number of entities created"); + + _serializationOperations = _meter.CreateCounter( + "sourceflow.domain.serialization.operations", + description: "Number of serialization/deserialization operations"); + + _operationDuration = _meter.CreateHistogram( + "sourceflow.domain.operation.duration", + unit: "ms", + description: "Duration of domain operations in milliseconds"); + + _serializationDuration = _meter.CreateHistogram( + "sourceflow.domain.serialization.duration", + unit: "ms", + description: "Duration of serialization operations in milliseconds"); + } + } + + /// + /// Executes an async operation with telemetry tracking. + /// + public async Task TraceAsync( + string operationName, + Func> operation, + Action? enrichActivity = null) + { + if (!_options.Enabled || _activitySource == null) + { + return await operation(); + } + + using var activity = _activitySource.StartActivity(operationName, ActivityKind.Internal); + + var stopwatch = Stopwatch.StartNew(); + try + { + enrichActivity?.Invoke(activity!); + + var result = await operation(); + + activity?.SetStatus(ActivityStatusCode.Ok); + + return result; + } + catch (Exception ex) + { + SetActivityException(activity, ex); + throw; + } + finally + { + stopwatch.Stop(); + _operationDuration?.Record(stopwatch.Elapsed.TotalMilliseconds, + new KeyValuePair("operation", operationName)); + } + } + + /// + /// Executes an async operation with telemetry tracking. + /// + public async Task TraceAsync( + string operationName, + Func operation, + Action? enrichActivity = null) + { + if (!_options.Enabled || _activitySource == null) + { + await operation(); + return; + } + + using var activity = _activitySource.StartActivity(operationName, ActivityKind.Internal); + + var stopwatch = Stopwatch.StartNew(); + try + { + enrichActivity?.Invoke(activity!); + + await operation(); + + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + SetActivityException(activity, ex); + throw; + } + finally + { + stopwatch.Stop(); + _operationDuration?.Record(stopwatch.Elapsed.TotalMilliseconds, + new KeyValuePair("operation", operationName)); + } + } + + /// + /// Traces a serialization operation with duration tracking. + /// + public T TraceSerialization( + string operationType, + Func operation, + Action? enrichActivity = null) + { + if (!_options.Enabled || _activitySource == null) + { + return operation(); + } + + using var activity = _activitySource.StartActivity($"sourceflow.domain.{operationType}", ActivityKind.Internal); + + var stopwatch = Stopwatch.StartNew(); + try + { + enrichActivity?.Invoke(activity!); + + var result = operation(); + + activity?.SetStatus(ActivityStatusCode.Ok); + _serializationOperations?.Add(1, new KeyValuePair("operation", operationType)); + + return result; + } + catch (Exception ex) + { + SetActivityException(activity, ex); + throw; + } + finally + { + stopwatch.Stop(); + _serializationDuration?.Record(stopwatch.Elapsed.TotalMilliseconds, + new KeyValuePair("operation", operationType)); + } + } + + /// + /// Records a command execution metric. + /// + public void RecordCommandExecuted(string commandType, int entityId) + { + if (_options.Enabled) + { + _commandsExecuted?.Add(1, + new KeyValuePair("command_type", commandType), + new KeyValuePair("entity_id", entityId)); + } + } + + /// + /// Records a saga execution metric. + /// + public void RecordSagaExecuted(string sagaType) + { + if (_options.Enabled) + { + _sagasExecuted?.Add(1, + new KeyValuePair("saga_type", sagaType)); + } + } + + /// + /// Records an entity creation metric. + /// + public void RecordEntityCreated(string entityType) + { + if (_options.Enabled) + { + _entitiesCreated?.Add(1, + new KeyValuePair("entity_type", entityType)); + } + } + } + + /// + /// Interface for domain telemetry service. + /// + public interface IDomainTelemetryService + { + /// + /// Executes an async operation with telemetry tracking. + /// + Task TraceAsync(string operationName, Func> operation, Action? enrichActivity = null); + + /// + /// Executes an async operation with telemetry tracking. + /// + Task TraceAsync(string operationName, Func operation, Action? enrichActivity = null); + + /// + /// Traces a serialization operation with duration tracking. + /// + T TraceSerialization(string operationType, Func operation, Action? enrichActivity = null); + + /// + /// Records a command execution metric. + /// + void RecordCommandExecuted(string commandType, int entityId); + + /// + /// Records a saga execution metric. + /// + void RecordSagaExecuted(string sagaType); + + /// + /// Records an entity creation metric. + /// + void RecordEntityCreated(string entityType); + } + + /// + /// Configuration options for domain-level observability. + /// + public class DomainObservabilityOptions + { + /// + /// Gets or sets whether observability is enabled. + /// + public bool Enabled { get; set; } = false; // Disabled by default to avoid breaking changes + + /// + /// Gets or sets the service name for telemetry. + /// + public string ServiceName { get; set; } = "SourceFlow.Domain"; + + /// + /// Gets or sets the service version for telemetry. + /// + public string ServiceVersion { get; set; } = "1.0.0"; + } +} diff --git a/src/SourceFlow/Observability/OpenTelemetryExtensions.cs b/src/SourceFlow/Observability/OpenTelemetryExtensions.cs new file mode 100644 index 0000000..13a54f5 --- /dev/null +++ b/src/SourceFlow/Observability/OpenTelemetryExtensions.cs @@ -0,0 +1,175 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace SourceFlow.Observability +{ + /// + /// Extension methods for configuring OpenTelemetry for SourceFlow. + /// Provides easy setup for tracing and metrics with various exporters. + /// + public static class OpenTelemetryExtensions + { + /// + /// Adds OpenTelemetry tracing and metrics for SourceFlow with default configuration. + /// + /// The service collection. + /// The name of the service for telemetry. + /// The version of the service. + /// The service collection for chaining. + public static IServiceCollection AddSourceFlowTelemetry( + this IServiceCollection services, + string serviceName = "SourceFlow.Domain", + string serviceVersion = "1.0.0") + { + return AddSourceFlowTelemetry(services, options => + { + options.Enabled = true; + options.ServiceName = serviceName; + options.ServiceVersion = serviceVersion; + }); + } + + /// + /// Adds OpenTelemetry tracing and metrics for SourceFlow with custom configuration. + /// + /// The service collection. + /// Action to configure observability options. + /// The service collection for chaining. + public static IServiceCollection AddSourceFlowTelemetry( + this IServiceCollection services, + Action configureOptions) + { + if (configureOptions == null) + throw new ArgumentNullException(nameof(configureOptions)); + + var options = new DomainObservabilityOptions(); + configureOptions(options); + + // Register the configured options + services.AddSingleton(options); + + if (!options.Enabled) + return services; + + // Configure OpenTelemetry Resource + var resourceBuilder = ResourceBuilder.CreateDefault() + .AddService(serviceName: options.ServiceName, serviceVersion: options.ServiceVersion); + + // Add OpenTelemetry Tracing + services.AddOpenTelemetry() + .WithTracing(builder => + { + builder + .SetResourceBuilder(resourceBuilder) + .AddSource(options.ServiceName); + }) + .WithMetrics(builder => + { + builder + .SetResourceBuilder(resourceBuilder) + .AddMeter(options.ServiceName); + }); + + return services; + } + + /// + /// Adds Console exporter for OpenTelemetry traces and metrics. + /// Useful for development and debugging. + /// + /// The OpenTelemetry builder. + /// The OpenTelemetry builder for chaining. + public static OpenTelemetryBuilder AddSourceFlowConsoleExporter(this OpenTelemetryBuilder builder) + { + return builder + .WithTracing(tracing => tracing.AddConsoleExporter()) + .WithMetrics(metrics => metrics.AddConsoleExporter()); + } + + /// + /// Adds OTLP (OpenTelemetry Protocol) exporter for traces and metrics. + /// This is the standard protocol for production observability platforms. + /// + /// The OpenTelemetry builder. + /// The OTLP endpoint URL (e.g., "http://localhost:4317"). + /// The OpenTelemetry builder for chaining. + public static OpenTelemetryBuilder AddSourceFlowOtlpExporter( + this OpenTelemetryBuilder builder, + string endpoint = null) + { + return builder + .WithTracing(tracing => + { + if (endpoint != null) + tracing.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)); + else + tracing.AddOtlpExporter(); + }) + .WithMetrics(metrics => + { + if (endpoint != null) + metrics.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)); + else + metrics.AddOtlpExporter(); + }); + } + + /// + /// Adds enrichment tags to all traces and metrics. + /// Useful for adding environment, region, or other context information. + /// + /// The OpenTelemetry builder. + /// Key-value pairs to add as resource attributes. + /// The OpenTelemetry builder for chaining. + public static OpenTelemetryBuilder AddSourceFlowResourceAttributes( + this OpenTelemetryBuilder builder, + params (string Key, object Value)[] attributes) + { + return builder.ConfigureResource(resource => + { + foreach (var (key, value) in attributes) + { + resource.AddAttributes(new[] { new System.Collections.Generic.KeyValuePair(key, value) }); + } + }); + } + + /// + /// Adds HTTP instrumentation to trace HTTP calls. + /// Note: Requires OpenTelemetry.Instrumentation.Http package to be installed separately. + /// + /// The OpenTelemetry builder. + /// The OpenTelemetry builder for chaining. + public static OpenTelemetryBuilder AddSourceFlowHttpInstrumentation(this OpenTelemetryBuilder builder) + { + // Note: This method requires OpenTelemetry.Instrumentation.Http package + // Users should install it separately if they need HTTP instrumentation + // return builder.WithTracing(tracing => tracing.AddHttpClientInstrumentation()); + return builder; + } + + /// + /// Configures batch processing for exporters to optimize throughput. + /// + /// The OpenTelemetry builder. + /// Maximum queue size for batching (default: 2048). + /// Maximum batch size for export (default: 512). + /// Delay between exports in milliseconds (default: 5000). + /// The OpenTelemetry builder for chaining. + public static OpenTelemetryBuilder ConfigureSourceFlowBatchProcessing( + this OpenTelemetryBuilder builder, + int maxQueueSize = 2048, + int maxExportBatchSize = 512, + int scheduledDelayMilliseconds = 5000) + { + return builder.WithTracing(tracing => + { + tracing.SetSampler(new AlwaysOnSampler()); + }); + } + } +} diff --git a/src/SourceFlow/Performance/ByteArrayPool.cs b/src/SourceFlow/Performance/ByteArrayPool.cs new file mode 100644 index 0000000..5053459 --- /dev/null +++ b/src/SourceFlow/Performance/ByteArrayPool.cs @@ -0,0 +1,203 @@ +using System; +using System.Buffers; +using System.Text.Json; + +namespace SourceFlow.Performance +{ + /// + /// Provides ArrayPool-based optimization for JSON serialization operations. + /// Reduces allocations in high-throughput scenarios by reusing byte buffers. + /// + internal static class ByteArrayPool + { + private static readonly ArrayPool Pool = ArrayPool.Shared; + + /// + /// Serializes an object to JSON using a pooled buffer. + /// + /// The type of object to serialize. + /// The value to serialize. + /// Optional JsonSerializerOptions. + /// The JSON string representation. + public static string Serialize(T value, JsonSerializerOptions options = null) + { + if (value == null) + return string.Empty; + + return SerializeCore(writer => + { + if (options != null) + JsonSerializer.Serialize(writer, value, options); + else + JsonSerializer.Serialize(writer, value); + }); + } + + /// + /// Serializes an object to JSON using a pooled buffer with runtime type information. + /// + /// The value to serialize. + /// The runtime type of the value. + /// Optional JsonSerializerOptions. + /// The JSON string representation. + public static string Serialize(object value, Type inputType, JsonSerializerOptions options = null) + { + if (value == null) + return string.Empty; + + return SerializeCore(writer => + { + if (options != null) + JsonSerializer.Serialize(writer, value, inputType, options); + else + JsonSerializer.Serialize(writer, value, inputType); + }); + } + + private static string SerializeCore(Action writeAction) + { + using (var bufferWriter = new PooledBufferWriter(Pool)) + { + using (var writer = new Utf8JsonWriter(bufferWriter)) + { + writeAction(writer); + } + + var writtenSpan = bufferWriter.WrittenSpan; + return System.Text.Encoding.UTF8.GetString(writtenSpan.ToArray()); + } + } + + /// + /// Deserializes JSON to an object using a pooled buffer. + /// + /// The type to deserialize to. + /// The JSON string to deserialize. + /// Optional JsonSerializerOptions. + /// The deserialized object. + public static T Deserialize(string json, JsonSerializerOptions options = null) + { + if (string.IsNullOrEmpty(json)) + return default(T); + + var byteCount = System.Text.Encoding.UTF8.GetByteCount(json); + var buffer = Pool.Rent(byteCount); + + try + { + var bytesWritten = System.Text.Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0); + var span = new ReadOnlySpan(buffer, 0, bytesWritten); + + return options != null + ? JsonSerializer.Deserialize(span, options) + : JsonSerializer.Deserialize(span); + } + finally + { + Pool.Return(buffer); + } + } + + /// + /// Deserializes JSON to an object using a pooled buffer with runtime type information. + /// + /// The JSON string to deserialize. + /// The runtime type to deserialize to. + /// Optional JsonSerializerOptions. + /// The deserialized object. + public static object Deserialize(string json, Type returnType, JsonSerializerOptions options = null) + { + if (string.IsNullOrEmpty(json)) + return null; + + var byteCount = System.Text.Encoding.UTF8.GetByteCount(json); + var buffer = Pool.Rent(byteCount); + + try + { + var bytesWritten = System.Text.Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0); + var span = new ReadOnlySpan(buffer, 0, bytesWritten); + + return options != null + ? JsonSerializer.Deserialize(span, returnType, options) + : JsonSerializer.Deserialize(span, returnType); + } + finally + { + Pool.Return(buffer); + } + } + + /// + /// A pooled buffer writer that uses ArrayPool for efficient memory allocation. + /// + private sealed class PooledBufferWriter : IBufferWriter, IDisposable + { + private readonly ArrayPool _pool; + private byte[] _buffer; + private int _index; + + public PooledBufferWriter(ArrayPool pool, int initialCapacity = 4096) + { + _pool = pool; + _buffer = _pool.Rent(initialCapacity); + _index = 0; + } + + public ReadOnlySpan WrittenSpan => new ReadOnlySpan(_buffer, 0, _index); + + public void Advance(int count) + { + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + if (_index + count > _buffer.Length) + throw new InvalidOperationException("Cannot advance past the end of the buffer."); + + _index += count; + } + + public Memory GetMemory(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + return _buffer.AsMemory(_index); + } + + public Span GetSpan(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + return _buffer.AsSpan(_index); + } + + private void CheckAndResizeBuffer(int sizeHint) + { + if (sizeHint < 0) + throw new ArgumentOutOfRangeException(nameof(sizeHint)); + + if (sizeHint == 0) + sizeHint = 1; + + var availableSpace = _buffer.Length - _index; + + if (sizeHint > availableSpace) + { + var newSize = Math.Max(_buffer.Length * 2, _buffer.Length + sizeHint); + var newBuffer = _pool.Rent(newSize); + + _buffer.AsSpan(0, _index).CopyTo(newBuffer); + + _pool.Return(_buffer); + _buffer = newBuffer; + } + } + + public void Dispose() + { + if (_buffer != null) + { + _pool.Return(_buffer); + _buffer = null; + } + } + } + } +} diff --git a/src/SourceFlow/Performance/TaskBufferPool.cs b/src/SourceFlow/Performance/TaskBufferPool.cs new file mode 100644 index 0000000..ad4d692 --- /dev/null +++ b/src/SourceFlow/Performance/TaskBufferPool.cs @@ -0,0 +1,130 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceFlow.Performance +{ + /// + /// Provides ArrayPool-based optimization for collecting and executing tasks. + /// Reduces allocations in high-throughput scenarios. + /// + internal static class TaskBufferPool + { + private static readonly ArrayPool Pool = ArrayPool.Shared; + + /// + /// Executes a collection of tasks using pooled array buffers to reduce allocations. + /// + /// The type of items to process. + /// The collection of items to process. + /// Function that creates a task for each item. + /// A task that completes when all tasks are complete. + public static async Task ExecuteAsync(IEnumerable items, Func taskFactory) + { + if (items == null) + throw new ArgumentNullException(nameof(items)); + if (taskFactory == null) + throw new ArgumentNullException(nameof(taskFactory)); + + // For ICollection, we can optimize by knowing the count upfront + if (items is ICollection collection) + { + var count = collection.Count; + if (count == 0) + return; + + // For very small counts, just use direct execution without pooling + if (count == 1) + { + using (var enumerator = collection.GetEnumerator()) + { + if (enumerator.MoveNext()) + await taskFactory(enumerator.Current); + } + return; + } + + // Rent array from pool + var taskBuffer = Pool.Rent(count); + try + { + var index = 0; + foreach (var item in collection) + { + taskBuffer[index++] = taskFactory(item); + } + + // Create array segment to avoid allocating the full buffer + var tasks = new ArraySegment(taskBuffer, 0, count); + // Cast to IEnumerable to avoid ambiguity with ReadOnlySpan overload in .NET 9+ + await Task.WhenAll((IEnumerable)tasks); + } + finally + { + // Clear references to prevent memory leaks + Array.Clear(taskBuffer, 0, count); + Pool.Return(taskBuffer); + } + } + else + { + // For non-collection enumerables, use temporary list + // This is still better than allocating in the calling code + var taskList = new List(); + foreach (var item in items) + { + taskList.Add(taskFactory(item)); + } + + if (taskList.Count > 0) + await Task.WhenAll(taskList); + } + } + + /// + /// Collects tasks from items and returns them as a pooled array. + /// Caller is responsible for returning the buffer via ReturnBuffer. + /// + /// The type of items to process. + /// The collection of items to process. + /// Function that creates a task for each item. + /// The actual number of tasks in the buffer. + /// A rented array buffer containing the tasks. + public static Task[] RentAndCollect(ICollection items, Func taskFactory, out int actualCount) + { + if (items == null) + throw new ArgumentNullException(nameof(items)); + if (taskFactory == null) + throw new ArgumentNullException(nameof(taskFactory)); + + actualCount = items.Count; + if (actualCount == 0) + return Array.Empty(); + + var buffer = Pool.Rent(actualCount); + var index = 0; + foreach (var item in items) + { + buffer[index++] = taskFactory(item); + } + + return buffer; + } + + /// + /// Returns a rented buffer back to the pool. + /// + /// The buffer to return. + /// The number of items that were used in the buffer. + public static void ReturnBuffer(Task[] buffer, int count) + { + if (buffer == null || buffer.Length == 0) + return; + + // Clear references to prevent memory leaks + Array.Clear(buffer, 0, count); + Pool.Return(buffer); + } + } +} diff --git a/src/SourceFlow/Projections/EventSubscriber.cs b/src/SourceFlow/Projections/EventSubscriber.cs new file mode 100644 index 0000000..0cef987 --- /dev/null +++ b/src/SourceFlow/Projections/EventSubscriber.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SourceFlow.Messaging; +using SourceFlow.Messaging.Events; + +namespace SourceFlow.Projections +{ + /// + /// This subscriber is responsible for subsribing events to apply view views. + /// + internal class EventSubscriber : IEventSubscriber + { + /// + /// Represents a collection of transforms used to modify or manipulate views. + /// + /// This collection contains instances of objects implementing the interface. Each view in the collection can be applied to alter the appearance + /// or behavior of a view. + private IEnumerable views; + + /// + /// Logger for the event queue to log events and errors. + /// + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class with the specified views and logger. + /// + /// + /// + /// + public EventSubscriber(IEnumerable views, ILogger logger) + { + this.views = views ?? throw new ArgumentNullException(nameof(views)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Dispatch the event to all view views that can handle it. + /// + /// + /// + /// + public Task Subscribe(TEvent @event) + where TEvent : IEvent + { + if (!views.Any()) + { + logger?.LogInformation("Action=Command_Dispatcher, Command={Command}, Payload={Payload}, SequenceNo={No}, Message=No Sagas Found", + @event.GetType().Name, @event.Payload.GetType().Name, ((IMetadata)@event).Metadata.SequenceNo); + + return Task.CompletedTask; + } + + var tasks = new List(); + foreach (var view in views) + { + if (view == null || !View.CanHandle(view, @event.GetType())) + continue; + + logger?.LogInformation("Action=Projection_Apply, Event={Event}, Projection={Projection}, SequenceNo={No}", + @event.GetType().Name, view.GetType().Name, ((IMetadata)@event).Metadata.SequenceNo); + + tasks.Add(view.Apply(@event)); + } + + return Task.WhenAll(tasks); + } + } +} diff --git a/src/SourceFlow/Projections/IProjection.cs b/src/SourceFlow/Projections/IProjectOn.cs similarity index 61% rename from src/SourceFlow/Projections/IProjection.cs rename to src/SourceFlow/Projections/IProjectOn.cs index 6dbd344..57c973f 100644 --- a/src/SourceFlow/Projections/IProjection.cs +++ b/src/SourceFlow/Projections/IProjectOn.cs @@ -1,20 +1,13 @@ using System.Threading.Tasks; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Events; namespace SourceFlow.Projections { - /// - /// Interface for applying an event to view model for data projection. - /// - public interface IProjection - { - } - /// /// Interface for applying an event to view model for data projection. /// /// - public interface IProjectOn : IProjection + public interface IProjectOn where TEvent : IEvent { /// @@ -22,6 +15,6 @@ public interface IProjectOn : IProjection /// /// /// - Task Apply(TEvent @event); + Task On(TEvent @event); } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Projections/IView.cs b/src/SourceFlow/Projections/IView.cs new file mode 100644 index 0000000..a20da97 --- /dev/null +++ b/src/SourceFlow/Projections/IView.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using SourceFlow.Messaging.Events; + +namespace SourceFlow.Projections +{ + /// + /// Interface for applying an event to view model for data projection. + /// + public interface IView + { + Task Apply(TEvent @event) + where TEvent : IEvent; + } +} diff --git a/src/SourceFlow/Projections/IViewModel.cs b/src/SourceFlow/Projections/IViewModel.cs index 9acc5e3..f60d2b5 100644 --- a/src/SourceFlow/Projections/IViewModel.cs +++ b/src/SourceFlow/Projections/IViewModel.cs @@ -7,4 +7,4 @@ public interface IViewModel { int Id { get; set; } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Projections/View.cs b/src/SourceFlow/Projections/View.cs new file mode 100644 index 0000000..5a78307 --- /dev/null +++ b/src/SourceFlow/Projections/View.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SourceFlow.Messaging; + +namespace SourceFlow.Projections +{ + public abstract class View : IView where TViewModel : class, IViewModel + { + protected IViewModelStoreAdapter viewModelStore; + protected ILogger logger; + + protected View(IViewModelStoreAdapter viewModelStore, ILogger logger) + { + this.viewModelStore = viewModelStore ?? throw new ArgumentNullException(nameof(viewModelStore)); ; + this.logger = logger; + } + + /// + /// Determines whether the specified view instance can handle the given event type. + /// + /// The view instance to evaluate. Must not be . + /// The type of the event to check. Must not be . + /// if the saga instance can handle the specified event type; otherwise, . + internal static bool CanHandle(IView instance, Type eventType) + { + if (instance == null || eventType == null) + return false; + + var handlerType = typeof(IProjectOn<>).MakeGenericType(eventType); + return handlerType.IsAssignableFrom(instance.GetType()); + } + + async Task IView.Apply(TEvent @event) + { + var viewType = GetType(); + var eventName = typeof(TEvent).Name; + + if (!(this is IProjectOn handles)) + { + logger?.LogWarning("Action=View_CannotHandle, View={View}, Event={Event}, Reason=NotImplementingIProjectOn", viewType, eventName); + return; + } + + logger?.LogInformation("Action=View_Starting, View={View}, Event={Event}", viewType, eventName); + + var viewModel = (TViewModel)await handles.On(@event); + + logger?.LogInformation("Action=View_Handled, View={View}, Event={Event}, Payload={Payload}, SequenceNo={No}", + viewType, eventName, @event.Payload.GetType().Name, ((IMetadata)@event).Metadata?.SequenceNo); + + if (viewModel != null) + await viewModelStore.Persist(viewModel); + } + + /// + /// Finds the view model by identifier. + /// + /// + /// + /// + protected Task Find(int id) where TViewModel : class, IViewModel + { + return viewModelStore.Find(id); + } + + /// + /// Projects the specified view model. + /// + /// + /// + /// The persisted view model + protected Task Persist(TViewModel viewModel) where TViewModel : class, IViewModel + { + return viewModelStore.Persist(viewModel); + } + } +} diff --git a/src/SourceFlow/Impl/SagaDispatcher.cs b/src/SourceFlow/Saga/CommandSubscriber.cs similarity index 62% rename from src/SourceFlow/Impl/SagaDispatcher.cs rename to src/SourceFlow/Saga/CommandSubscriber.cs index a64fa6c..714ca06 100644 --- a/src/SourceFlow/Impl/SagaDispatcher.cs +++ b/src/SourceFlow/Saga/CommandSubscriber.cs @@ -2,46 +2,34 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using SourceFlow.Aggregate; using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; -using SourceFlow.Saga; +using SourceFlow.Messaging.Commands; -namespace SourceFlow.Impl +namespace SourceFlow.Saga { /// /// This dispatcher is responsible for dispatching commands to registered sagas in an event-driven architecture. /// - internal class SagaDispatcher : ICommandDispatcher + internal class CommandSubscriber : ICommandSubscriber { /// /// Collection of sagas registered with the dispatcher. /// - private readonly ICollection sagas; + private readonly IEnumerable sagas; /// /// Logger for the command dispatcher to log events and errors. /// - private readonly ILogger logger; + private readonly ILogger logger; /// - /// Initializes a new instance of the class with the specified logger. + /// Initializes a new instance of the class with the specified logger. /// /// - public SagaDispatcher(ILogger logger) + public CommandSubscriber(IEnumerable sagas, ILogger logger) { this.logger = logger; - sagas = new List(); - } - - /// - /// Dispatches a command to all sagas that are registered with the command dispatcher. - /// - /// - /// - public void Dispatch(object sender, ICommand command) - { - Send(command).GetAwaiter().GetResult(); + this.sagas = sagas; } /// @@ -50,14 +38,14 @@ public void Dispatch(object sender, ICommand command) /// /// /// - private async Task Send(TCommand command) where TCommand : ICommand + public Task Subscribe(TCommand command) where TCommand : ICommand { if (!sagas.Any()) { logger?.LogInformation("Action=Command_Dispatcher, Command={Command}, Payload={Payload}, SequenceNo={No}, Message=No Sagas Found", command.GetType().Name, command.Payload.GetType().Name, ((IMetadata)command).Metadata.SequenceNo); - return; + return Task.CompletedTask; } var tasks = new List(); @@ -69,7 +57,7 @@ private async Task Send(TCommand command) where TCommand : ICommand tasks.Add(Send(saga, command)); } - await Task.WhenAll(tasks); + return Task.WhenAll(tasks); } /// @@ -79,23 +67,14 @@ private async Task Send(TCommand command) where TCommand : ICommand /// /// /// - private async Task Send(ISaga saga, TCommand command) where TCommand : ICommand + private Task Send(ISaga saga, TCommand command) where TCommand : ICommand { // 4. Log event. logger?.LogInformation("Action=Command_Dispatcher_Send, Command={Command}, Payload={Payload}, SequenceNo={No}, Saga={Saga}", command.GetType().Name, command.Payload.GetType().Name, ((IMetadata)command).Metadata.SequenceNo, saga.GetType().Name); // 2. handle event by Saga? - await saga.Handle(command); - } - - /// - /// Registers a saga with the dispatcher. - /// - /// - public void Register(ISaga saga) - { - sagas.Add(saga); + return saga.Handle(command); } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Saga/IHandles.cs b/src/SourceFlow/Saga/IHandles.cs index 65c8d72..3f7b460 100644 --- a/src/SourceFlow/Saga/IHandles.cs +++ b/src/SourceFlow/Saga/IHandles.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Saga { @@ -11,10 +11,11 @@ public interface IHandles where TCommand : ICommand { /// - /// Handles the specified command. + /// Handles the specified command against entity. /// + /// /// - /// - Task Handle(TCommand command); + /// updated entity. + Task Handle(IEntity entity, TCommand command); } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Saga/IHandlesWithEvent.cs b/src/SourceFlow/Saga/IHandlesWithEvent.cs new file mode 100644 index 0000000..aedc5ff --- /dev/null +++ b/src/SourceFlow/Saga/IHandlesWithEvent.cs @@ -0,0 +1,16 @@ +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Events; + +namespace SourceFlow.Saga +{ + /// + /// Interface for handling command and producing event in the event-driven saga. + /// + /// On the Command of type TCommand. + /// Raises event of type TEvent upon success. + public interface IHandlesWithEvent : IHandles + where TCommand : ICommand + where TEvent : IEvent + { + } +} diff --git a/src/SourceFlow/Saga/ISaga.cs b/src/SourceFlow/Saga/ISaga.cs index bdbb108..396531e 100644 --- a/src/SourceFlow/Saga/ISaga.cs +++ b/src/SourceFlow/Saga/ISaga.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; -using SourceFlow.Aggregate; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Saga { @@ -27,4 +26,4 @@ public interface ISaga Task Handle(TCommand command) where TCommand : ICommand; } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Saga/Saga.cs b/src/SourceFlow/Saga/Saga.cs index a5d284f..949dacb 100644 --- a/src/SourceFlow/Saga/Saga.cs +++ b/src/SourceFlow/Saga/Saga.cs @@ -1,9 +1,11 @@ using System; +using System.Linq; +using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using SourceFlow.Aggregate; using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Events; namespace SourceFlow.Saga { @@ -19,7 +21,7 @@ public abstract class Saga : ISaga /// /// The command publisher is typically used to dispatch commands to their respective /// handlers. Derived classes can use this member to publish commands as part of their functionality. - protected ICommandPublisher commandPublisher; + protected Lazy commandPublisher; /// /// Represents the queue used to manage and process events. @@ -28,37 +30,41 @@ public abstract class Saga : ISaga protected IEventQueue eventQueue; /// - /// Represents the repository used for accessing and managing domain entities. + /// Represents the entityStore used for accessing and managing domain entities. /// - /// This field is intended for internal use to interact with the domain repository. It + /// This field is intended for internal use to interact with the domain entityStore. It /// provides access to the underlying data storage and retrieval mechanisms. - protected IRepository repository; + protected IEntityStoreAdapter entityStore; /// /// Logger for the saga to log events and errors. /// - protected ILogger logger; + protected ILogger logger; /// /// Initializes a new instance of the class. /// - protected Saga() + public Saga(Lazy commandPublisher, IEventQueue eventQueue, IEntityStoreAdapter entityStore, ILogger logger) { + this.commandPublisher = commandPublisher ?? throw new ArgumentNullException(nameof(commandPublisher)); + this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); + this.entityStore = entityStore ?? throw new ArgumentNullException(nameof(entityStore)); + this.logger = logger; } /// /// Determines whether the specified saga instance can handle the given event type. /// /// The saga instance to evaluate. Must not be . - /// The type of the event to check. Must not be . + /// The type of the command to check. Must not be . /// if the saga instance can handle the specified event type; otherwise, . - internal static bool CanHandle(ISaga instance, Type eventType) + internal static bool CanHandle(ISaga instance, Type commandType) { - if (instance == null || eventType == null) + if (instance == null || commandType == null) return false; - var handlerType = typeof(IHandles<>).MakeGenericType(eventType); + var handlerType = typeof(IHandles<>).MakeGenericType(commandType); return handlerType.IsAssignableFrom(instance.GetType()); } @@ -66,33 +72,119 @@ internal static bool CanHandle(ISaga instance, Type eventType) /// Handles the specified command as part of the saga's workflow. /// /// This method dynamically resolves the appropriate command handler for the given - /// command type and invokes its Handle method. If the saga cannot handle the specified command, the + /// command type and invokes its On method. If the saga cannot handle the specified command, the /// method returns without performing any action. /// The type of the command to handle. /// The command to be processed by the saga. Must not be . /// async Task ISaga.Handle(TCommand command) { - if (!CanHandle(this, command.GetType())) + if (!(this is IHandles handles)) + { + logger?.LogWarning("Action=Saga_CannotHandle, Saga={Saga}, Command={Command}, Reason=NotImplementingIHandles", + GetType().Name, typeof(TCommand).Name); return; + } - var method = typeof(IHandles<>) - .MakeGenericType(command.GetType()) - .GetMethod(nameof(IHandles.Handle)); + logger?.LogInformation("Action=Saga_Starting, Saga={Saga}, Command={Command}", + GetType().Name, typeof(TCommand).Name); - var task = (Task)method.Invoke(this, new object[] { command }); + TAggregate entity; + if (command.Entity.IsNew) + entity = InitialiseEntity(command.Entity.Id); + else + entity = await entityStore.Get(command.Entity.Id); - logger?.LogInformation("Action=Saga_Handled, Command={Command}, Payload={Payload}, SequenceNo={No}, Saga={Saga}, Handler:{Handler}", - command.GetType().Name, command.Payload.GetType().Name, ((IMetadata)command).Metadata.SequenceNo, GetType().Name, method.Name); + entity = (TAggregate)await handles.Handle(entity, command); - await Task.Run(() => task); + logger?.LogInformation("Action=Saga_Handled, Command={Command}, Payload={Payload}, SequenceNo={No}, Saga={Saga}", + command.GetType().Name, command.Payload.GetType().Name, ((IMetadata)command).Metadata.SequenceNo, GetType().Name); + + if (entity != null) + entity = await entityStore.Persist(entity); + + await RaiseEvent(command, entity); + } + + private Task RaiseEvent(TCommand command, TAggregate entity) where TCommand : ICommand + { + try + { + var handlesWithEventInterface = this.GetType() + .GetInterfaces() + .FirstOrDefault(i => + i.IsGenericType && + i.GetGenericTypeDefinition() == typeof(IHandlesWithEvent<,>) && + i.GetGenericArguments()[0].IsAssignableFrom(typeof(TCommand)) + ); + + if (handlesWithEventInterface != null) + { + var eventType = handlesWithEventInterface.GetGenericArguments()[1]; + + object eventInstance = null; + + // Try parameterless constructor first + try + { + eventInstance = Activator.CreateInstance(eventType); + } + catch + { + // Try constructor that accepts the aggregate/entity payload + var ctor = eventType.GetConstructors() + .FirstOrDefault(c => + { + var ps = c.GetParameters(); + return ps.Length == 1 && ps[0].ParameterType.IsAssignableFrom(entity.GetType()); + }); + + if (ctor != null) + { + eventInstance = ctor.Invoke(new object[] { entity }); + } + } + + if (eventInstance is IEvent ev) + { + // Ensure payload set + if (ev.Payload == null && entity != null) + ev.Payload = entity; + + // Call Raise with the concrete event type to preserve generics + var raiseMethod = this.GetType().GetMethod(nameof(Raise), BindingFlags.NonPublic | BindingFlags.Instance); + var genericRaiseMethod = raiseMethod.MakeGenericMethod(eventType); + return (Task)genericRaiseMethod.Invoke(this, new object[] { ev }); + } + } + } + catch (Exception ex) + { + // Don't break saga processing if raising event fails; log the error. + logger?.LogError(ex, "Action=Saga_RaiseEventFailed, Saga={Saga}, Command={Command}", GetType().Name, command.GetType().Name); + } + + return Task.CompletedTask; + } + + /// + /// Initialises a new instance of the aggregate entity with the specified ID. + /// + /// + /// + /// + private TAggregate InitialiseEntity(int id) + { + var entity = Activator.CreateInstance(typeof(TAggregate), true); + ((IEntity)entity).Id = id; + return (TAggregate)entity; } /// /// Publishes the specified command to the command bus. /// /// If the does not have an entity type specified, it will be - /// automatically set to the type of TAggregate. + /// automatically set to the type of TEntity. /// The type of the command to publish. Must implement . /// The command to be published. Cannot be . /// A task that represents the asynchronous operation of publishing the command. @@ -105,10 +197,10 @@ protected Task Publish(TCommand command) if (command == null) throw new ArgumentNullException(nameof(command)); - if (command.Payload?.Id == null) - throw new InvalidOperationException(nameof(command) + "requires source entity id"); + if (command.Entity?.Id == null) + throw new InvalidOperationException(nameof(command) + "requires entity reference with id"); - return commandPublisher.Publish(command); + return commandPublisher.Value.Publish(command); } /// @@ -132,4 +224,4 @@ protected Task Raise(TEvent @event) return eventQueue.Enqueue(@event); } } -} \ No newline at end of file +} diff --git a/src/SourceFlow/Services/IService.cs b/src/SourceFlow/Services/IService.cs deleted file mode 100644 index 0c4fe13..0000000 --- a/src/SourceFlow/Services/IService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Threading.Tasks; -using SourceFlow.Aggregate; - -namespace SourceFlow.Services -{ - /// - /// Interface for the service layer in the event-driven architecture. - /// - public interface IService - { - /// - /// Creates an initialised aggregate root instance. - /// - /// - /// - Task CreateAggregate() where TAggregateRoot : class, IAggregate; - } -} \ No newline at end of file diff --git a/src/SourceFlow/Services/Service.cs b/src/SourceFlow/Services/Service.cs deleted file mode 100644 index befcdc1..0000000 --- a/src/SourceFlow/Services/Service.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using SourceFlow.Aggregate; - -namespace SourceFlow.Services -{ - /// - /// Base class for services in the event-driven architecture. - /// - public abstract class Service : IService - { - /// - /// Factory for creating aggregate roots. - /// - protected IAggregateFactory aggregateFactory; - - /// - /// Logger for the service to log events and errors. - /// - protected ILogger logger; - - /// - /// Creates an initialised aggregate root. - /// - /// - /// Implementation of IAggregate - public async Task CreateAggregate() where TAggregate : class, IAggregate - { - var aggregate = await aggregateFactory.Create(); - return aggregate; - } - } -} \ No newline at end of file diff --git a/src/SourceFlow/SourceFlow.csproj b/src/SourceFlow/SourceFlow.csproj index f5de4e9..4ff0e86 100644 --- a/src/SourceFlow/SourceFlow.csproj +++ b/src/SourceFlow/SourceFlow.csproj @@ -1,26 +1,27 @@ - + - net462;netstandard2.0;netstandard2.1;net9.0 - 1.0.0-alpha.1 + net462;netstandard2.0;netstandard2.1;net9.0;net10.0 + 9.0 + 1.0.0 https://github.com/CodeShayk/SourceFlow.Net git https://github.com/CodeShayk/SourceFlow.Net/wiki - Code Shayk - Code Shayk + CodeShayk + CodeShayk SourceFlow.Net SourceFlow.Net SourceFlow.Net True - A modern, lightweight, and extensible framework for building event-sourced applications using Domain-Driven Design (DDD) principles and Command Query Responsibility Segregation (CQRS) patterns. - Copyright (c) 2025 Code Shayk - README.md + SourceFlow.Net is a modern, lightweight, and extensible framework for building event-sourced applications using Domain-Driven Design (DDD) principles and Command Query Responsibility Segregation (CQRS) patterns. Build scalable, maintainable applications with complete event sourcing, aggregate pattern implementation, saga orchestration for long-running transactions, and view model projections. Supports .NET Framework 4.6.2, .NET Standard 2.0/2.1, .NET 9.0, and .NET 10.0 with built-in OpenTelemetry observability. + Copyright (c) 2025 CodeShayk + docs\SourceFlow.Net-README.md ninja-icon-16.png 1.0.0 1.0.0 LICENSE True - Includes Core Event sourcing & CQRS functionality. + v1.0.0 - Initial stable release! Complete event sourcing and CQRS implementation with Aggregate pattern for managing root entities, Saga orchestration for long-running transactions, event-driven communication, view model projection system, multi-framework support (.NET 4.6.2, .NET Standard 2.0/2.1, .NET 9.0, .NET 10.0), OpenTelemetry integration for observability, and dependency injection support. Production-ready with comprehensive test coverage. Events;Commands;DDD;CQRS;Event-Sourcing;ViewModel;Aggregates;EventStore;Domain driven design; Event Sourcing; Command Query Responsibility Segregation; Command Pattern; Publisher Subscriber; PuB-Sub False @@ -44,8 +45,14 @@ True - - + + + + + + + + <_Parameter1>SourceFlow.Core.Tests @@ -60,9 +67,9 @@ True \ - + True - \ + \docs diff --git a/tests/SourceFlow.Core.Tests/Aggregates/AggregateTests.cs b/tests/SourceFlow.Core.Tests/Aggregates/AggregateTests.cs index b925e26..aae6151 100644 --- a/tests/SourceFlow.Core.Tests/Aggregates/AggregateTests.cs +++ b/tests/SourceFlow.Core.Tests/Aggregates/AggregateTests.cs @@ -1,81 +1,109 @@ using Microsoft.Extensions.Logging; using Moq; using SourceFlow.Aggregate; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; +using SourceFlow.Core.Tests.Impl; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.Aggregates { [TestFixture] public class AggregateTests { - public class DummyEntity : IEntity - { public int Id { get; set; } } + private Mock commandPublisherMock; + private Mock> loggerMock; + private Lazy lazyCommandPublisher; + private TestAggregate aggregate; - public class TestAggregate : Aggregate + [SetUp] + public void Setup() { - public TestAggregate() : this(new Mock().Object, new Mock().Object, new Mock().Object) - { - } + commandPublisherMock = new Mock(); + loggerMock = new Mock>(); + lazyCommandPublisher = new Lazy(() => commandPublisherMock.Object); + aggregate = new TestAggregate(lazyCommandPublisher, loggerMock.Object); + } - public TestAggregate(ICommandPublisher publisher, ICommandReplayer replayer, ILogger logger) - { - commandPublisher = publisher; - commandReplayer = replayer; - this.logger = logger; - } + [Test] + public void Constructor_SetsCommandPublisher() + { + // Assert + Assert.That(aggregate.GetCommandPublisher().Value, Is.EqualTo(commandPublisherMock.Object)); + } - public Task TestSend(ICommand command) => Send(command); + [Test] + public void Constructor_SetsLogger() + { + // Assert + Assert.That(aggregate.GetLogger(), Is.EqualTo(loggerMock.Object)); } [Test] - public async Task Replay_DelegatesToCommandReplayer() + public async Task ReplayCommands_DelegatesToCommandPublisher() { - var publisher = new Mock().Object; - var replayerMock = new Mock(); - replayerMock.Setup(r => r.Replay(It.IsAny())).Returns(Task.CompletedTask); - var logger = new Mock().Object; - var aggregate = new TestAggregate(publisher, replayerMock.Object, logger); - await aggregate.Replay(42); - replayerMock.Verify(r => r.Replay(42), Times.Once); + // Arrange + var entityId = 42; + + // Act + await aggregate.ReplayCommands(entityId); + + // Assert + commandPublisherMock.Verify(cp => cp.ReplayCommands(entityId), Times.Once); } [Test] - public void Send_NullCommand_ThrowsArgumentNullException() + public async Task Send_ValidCommand_DelegatesToCommandPublisher() { - var publisher = new Mock().Object; - var replayer = new Mock().Object; - var logger = new Mock().Object; - var aggregate = new TestAggregate(publisher, replayer, logger); - Assert.ThrowsAsync(async () => await aggregate.TestSend(null)); + // Arrange + var command = new DummyCommand(); + + // Act + await aggregate.SendCommand(command); + + // Assert + commandPublisherMock.Verify(cp => cp.Publish(It.IsAny()), Times.Once); } [Test] - public void Send_NullPayload_ThrowsInvalidOperationException() + public async Task Send_NullCommand_ThrowsArgumentNullException() { - var publisher = new Mock().Object; - var replayer = new Mock().Object; - var logger = new Mock().Object; - var aggregate = new TestAggregate(publisher, replayer, logger); - var commandMock = new Mock(); - commandMock.Setup(c => c.Payload).Returns((IPayload)null); - Assert.ThrowsAsync(async () => await aggregate.TestSend(commandMock.Object)); + // Assert + Assert.ThrowsAsync(async () => + await aggregate.SendCommand(null!)); } [Test] - public async Task Send_ValidCommand_DelegatesToPublisher() + public async Task Send_NullPayload_PublishesCommand() + { + // Arrange + var command = new DummyCommand { Payload = null! }; + + // This should delegate to publisher (no validation in Send method) + await aggregate.SendCommand(command); + + // Assert + commandPublisherMock.Verify(cp => cp.Publish(It.IsAny()), Times.Once); + } + + // Test aggregate concrete implementation + private class TestAggregate : Aggregate + { + public TestAggregate(Lazy commandPublisher, ILogger logger) + : base(commandPublisher, logger) + { + } + + // Expose protected members for testing + public Lazy GetCommandPublisher() => commandPublisher; + + public ILogger GetLogger() => logger; + + // Expose Send method for testing + public Task SendCommand(ICommand command) => Send(command); + } + + private class TestEntity : IEntity { - var publisherMock = new Mock(); - publisherMock.Setup(p => p.Publish(It.IsAny())).Returns(Task.CompletedTask); - var replayer = new Mock().Object; - var logger = new Mock().Object; - var aggregate = new TestAggregate(publisherMock.Object, replayer, logger); - var payloadMock = new Mock(); - payloadMock.Setup(p => p.Id).Returns(1); - var commandMock = new Mock(); - commandMock.Setup(c => c.Payload).Returns(payloadMock.Object); - await aggregate.TestSend(commandMock.Object); - publisherMock.Verify(p => p.Publish(commandMock.Object), Times.Once); + public int Id { get; set; } = 1; } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Aggregates/EventSubscriberTests.cs b/tests/SourceFlow.Core.Tests/Aggregates/EventSubscriberTests.cs new file mode 100644 index 0000000..33d3b58 --- /dev/null +++ b/tests/SourceFlow.Core.Tests/Aggregates/EventSubscriberTests.cs @@ -0,0 +1,155 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SourceFlow.Aggregate; +using SourceFlow.Messaging.Events; + +namespace SourceFlow.Core.Tests.Aggregates +{ + public class DummyAggregateEntity : IEntity + { + public int Id { get; set; } + } + + public class DummyAggregateEvent : Event + { + public DummyAggregateEvent(DummyAggregateEntity payload) : base(payload) + { + } + } + + public class TestAggregate : IAggregate, ISubscribes + { + public bool Handled { get; private set; } = false; + + public Task On(DummyAggregateEvent @event) + { + Handled = true; + return Task.CompletedTask; + } + } + + public class NonMatchingAggregate : IAggregate + { + // This aggregate does not implement ISubscribes so won't handle DummyAggregateEvent + } + + [TestFixture] + public class AggregateEventSubscriberTests + { + private Mock> _mockLogger; + private DummyAggregateEvent _testEvent; + + [SetUp] + public void SetUp() + { + _mockLogger = new Mock>(); + _testEvent = new DummyAggregateEvent(new DummyAggregateEntity { Id = 1 }); + } + + [Test] + public void Constructor_WithNullAggregates_ThrowsArgumentNullException() + { + // Arrange + IEnumerable nullAggregates = null!; + + // Act & Assert + Assert.Throws(() => + new EventSubscriber(nullAggregates, _mockLogger.Object)); + } + + [Test] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Arrange + var aggregates = new List { new TestAggregate() }; + + // Act & Assert + Assert.Throws(() => + new EventSubscriber(aggregates, null)); + } + + [Test] + public void Constructor_WithValidParameters_Succeeds() + { + // Arrange + var aggregates = new List { new TestAggregate() }; + + // Act + var subscriber = new EventSubscriber(aggregates, _mockLogger.Object); + + // Assert + Assert.IsNotNull(subscriber); + } + + [Test] + public async Task Subscribe_WithMatchingAggregate_HandlesEvent() + { + // Arrange + var testAggregate = new TestAggregate(); + var aggregates = new List { testAggregate }; + var subscriber = new EventSubscriber(aggregates, _mockLogger.Object); + + // Act + await subscriber.Subscribe(_testEvent); + + // Assert + Assert.IsTrue(testAggregate.Handled); + } + + [Test] + public async Task Subscribe_WithNonMatchingAggregate_DoesNotHandleEvent() + { + // Arrange + var nonMatchingAggregate = new NonMatchingAggregate(); + var aggregates = new List { nonMatchingAggregate }; + var subscriber = new EventSubscriber(aggregates, _mockLogger.Object); + + // Act + await subscriber.Subscribe(_testEvent); + + // This test is more about ensuring no exception is thrown and that non-matching aggregates + // are simply skipped, which is the expected behavior + } + + [Test] + public async Task Subscribe_WithMultipleAggregates_HandlesEventInMatchingAggregatesOnly() + { + // Arrange + var matchingAggregate1 = new TestAggregate(); + var matchingAggregate2 = new TestAggregate(); + var nonMatchingAggregate = new NonMatchingAggregate(); + var aggregates = new List { matchingAggregate1, nonMatchingAggregate, matchingAggregate2 }; + var subscriber = new EventSubscriber(aggregates, _mockLogger.Object); + + // Act + await subscriber.Subscribe(_testEvent); + + // Assert + Assert.IsTrue(matchingAggregate1.Handled); + Assert.IsTrue(matchingAggregate2.Handled); + } + + [Test] + public async Task Subscribe_WithNoMatchingAggregates_DoesNotThrow() + { + // Arrange + var nonMatchingAggregate = new NonMatchingAggregate(); + var aggregates = new List { nonMatchingAggregate }; + var subscriber = new EventSubscriber(aggregates, _mockLogger.Object); + + // Act & Assert + Assert.DoesNotThrowAsync(async () => await subscriber.Subscribe(_testEvent)); + } + + [Test] + public async Task Subscribe_WithEmptyAggregatesCollection_DoesNotThrow() + { + // Arrange + var aggregates = new List(); + var subscriber = new EventSubscriber(aggregates, _mockLogger.Object); + + // Act & Assert + Assert.DoesNotThrowAsync(async () => await subscriber.Subscribe(_testEvent)); + } + } +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Aggregates/AccountAggregate.cs b/tests/SourceFlow.Core.Tests/E2E/Aggregates/AccountAggregate.cs index 01dc2cd..546dce0 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Aggregates/AccountAggregate.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Aggregates/AccountAggregate.cs @@ -1,59 +1,68 @@ +using Microsoft.Extensions.Logging; using SourceFlow.Aggregate; using SourceFlow.Core.Tests.E2E.Commands; using SourceFlow.Core.Tests.E2E.Events; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.E2E.Aggregates { public class AccountAggregate : Aggregate, - ISubscribes - + ISubscribes, IAccountAggregate { - public void CreateAccount(int accountId, string holder, decimal amount) + public AccountAggregate(Lazy commandPublisher, ILogger logger) : + base(commandPublisher, logger) + { + } + + public Task CreateAccount(int accountId, string holder, decimal amount) { - Send(new CreateAccount(new Payload + var command = new CreateAccount(new Payload { - Id = accountId, AccountName = holder, InitialAmount = amount - })); + }); + + command.Entity.Id = accountId; + + return Send(command); } - public void Deposit(int accountId, decimal amount) + public Task Deposit(int accountId, decimal amount) { - Send(new DepositMoney(new TransactPayload + return Send(new DepositMoney(accountId, new TransactPayload { - Id = accountId, Amount = amount, Type = TransactionType.Deposit })); } - public void Withdraw(int accountId, decimal amount) + public Task Withdraw(int accountId, decimal amount) { - Send(new WithdrawMoney(new TransactPayload + return Send(new WithdrawMoney(accountId, new TransactPayload { - Id = accountId, Amount = amount, Type = TransactionType.Withdrawal })); } - public void Close(int accountId, string reason) + public Task CloseAccount(int accountId, string reason) { - Send(new CloseAccount(new ClosurePayload + return Send(new CloseAccount(accountId, new ClosurePayload { - Id = accountId, ClosureReason = reason })); } - public Task Handle(AccountCreated @event) + public Task On(AccountCreated @event) { - return Send(new ActivateAccount(new ActivationPayload - { - Id = @event.Payload.Id, - ActiveOn = DateTime.UtcNow, - })); + logger.LogInformation("Action=Aggregate_Subscribes, Aggregate={Aggregate}, Event={Event}, ", this.GetType().Name, @event.GetType().Name); + + return Task.CompletedTask; + } + + public Task RepayHistory(int accountId) + { + return ReplayCommands(accountId); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Aggregates/BankAccount.cs b/tests/SourceFlow.Core.Tests/E2E/Aggregates/BankAccount.cs index 591190d..2734986 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Aggregates/BankAccount.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Aggregates/BankAccount.cs @@ -1,5 +1,3 @@ -using SourceFlow.Aggregate; - namespace SourceFlow.Core.Tests.E2E.Aggregates { public class BankAccount : IEntity @@ -9,7 +7,7 @@ public class BankAccount : IEntity public string AccountName { get; set; } = string.Empty; public decimal Balance { get; set; } public bool IsClosed { get; set; } - public string ClosureReason { get; internal set; } + public string ClosureReason { get; internal set; } = string.Empty; public DateTime ActiveOn { get; internal set; } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Aggregates/IAccountAggregate.cs b/tests/SourceFlow.Core.Tests/E2E/Aggregates/IAccountAggregate.cs new file mode 100644 index 0000000..ff83071 --- /dev/null +++ b/tests/SourceFlow.Core.Tests/E2E/Aggregates/IAccountAggregate.cs @@ -0,0 +1,19 @@ +using SourceFlow.Core.Tests.E2E.Events; + +namespace SourceFlow.Core.Tests.E2E.Aggregates +{ + public interface IAccountAggregate + { + Task CloseAccount(int accountId, string reason); + + Task CreateAccount(int accountId, string holder, decimal amount); + + Task Deposit(int accountId, decimal amount); + + Task On(AccountCreated @event); + + Task Withdraw(int accountId, decimal amount); + + Task RepayHistory(int accountId); + } +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Aggregates/TransactionType.cs b/tests/SourceFlow.Core.Tests/E2E/Aggregates/TransactionType.cs index ac26653..002df2b 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Aggregates/TransactionType.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Aggregates/TransactionType.cs @@ -5,4 +5,4 @@ public enum TransactionType Deposit, Withdrawal } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Commands/ActivateAccount.cs b/tests/SourceFlow.Core.Tests/E2E/Commands/ActivateAccount.cs index 4d895ce..f6117d3 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Commands/ActivateAccount.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Commands/ActivateAccount.cs @@ -1,11 +1,11 @@ -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.E2E.Commands { public class ActivateAccount : Command { - public ActivateAccount(ActivationPayload payload) : base(payload) + public ActivateAccount(int entityId, ActivationPayload payload) : base(entityId, payload) { } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Commands/CloseAccount.cs b/tests/SourceFlow.Core.Tests/E2E/Commands/CloseAccount.cs index 0d0adc1..0e21f4d 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Commands/CloseAccount.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Commands/CloseAccount.cs @@ -1,11 +1,11 @@ -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.E2E.Commands { public class CloseAccount : Command { - public CloseAccount(ClosurePayload payload) : base(payload) + public CloseAccount(int entityId, ClosurePayload payload) : base(entityId, payload) { } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Commands/CreateAccount.cs b/tests/SourceFlow.Core.Tests/E2E/Commands/CreateAccount.cs index 186f5b3..cbae1cb 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Commands/CreateAccount.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Commands/CreateAccount.cs @@ -1,11 +1,11 @@ -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.E2E.Commands { public class CreateAccount : Command { - public CreateAccount(Payload payload) : base(payload) + public CreateAccount(Payload payload) : base(true, payload) { } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Commands/DepositMoney.cs b/tests/SourceFlow.Core.Tests/E2E/Commands/DepositMoney.cs index d6b83c5..68f15e6 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Commands/DepositMoney.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Commands/DepositMoney.cs @@ -1,11 +1,11 @@ -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.E2E.Commands { public class DepositMoney : Command { - public DepositMoney(TransactPayload payload) : base(payload) + public DepositMoney(int entityId, TransactPayload payload) : base(entityId, payload) { } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Commands/Payload.cs b/tests/SourceFlow.Core.Tests/E2E/Commands/Payload.cs index d8be043..15182ca 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Commands/Payload.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Commands/Payload.cs @@ -13,7 +13,7 @@ public class Payload : IPayload { public int Id { get; set; } public decimal InitialAmount { get; set; } - public string AccountName { get; set; } + public string AccountName { get; set; } = string.Empty; } public class TransactPayload : IPayload @@ -30,4 +30,4 @@ public class ClosurePayload : IPayload public bool IsClosed { get; set; } public string ClosureReason { get; set; } = string.Empty; } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Commands/WithdrawMoney.cs b/tests/SourceFlow.Core.Tests/E2E/Commands/WithdrawMoney.cs index 1dc64f4..cd7e2b3 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Commands/WithdrawMoney.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Commands/WithdrawMoney.cs @@ -1,11 +1,11 @@ -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.E2E.Commands { public class WithdrawMoney : Command { - public WithdrawMoney(TransactPayload payload) : base(payload) + public WithdrawMoney(int entityId, TransactPayload payload) : base(entityId, payload) { } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/E2E.Tests.cs b/tests/SourceFlow.Core.Tests/E2E/E2E.Tests.cs index 47cf85c..8ebfcbf 100644 --- a/tests/SourceFlow.Core.Tests/E2E/E2E.Tests.cs +++ b/tests/SourceFlow.Core.Tests/E2E/E2E.Tests.cs @@ -1,9 +1,8 @@ +using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SourceFlow.Core.Tests.E2E.Aggregates; using SourceFlow.Core.Tests.E2E.Projections; -using SourceFlow.Core.Tests.E2E.Sagas; -using SourceFlow.Core.Tests.E2E.Services; using SourceFlow.Saga; namespace SourceFlow.Core.Tests.E2E @@ -12,10 +11,11 @@ namespace SourceFlow.Core.Tests.E2E public class ProgramIntegrationTests { private ServiceProvider _serviceProvider; - private IAccountService _accountService; + private IAccountAggregate _accountAggregate; private ISaga _saga; private ILogger _logger; - private IViewProvider _viewRepository; + private IViewModelStoreAdapter _viewRepository; + private int _accountId = 999; [SetUp] public void SetUp() @@ -30,21 +30,16 @@ public void SetUp() }); // Register SourceFlow and all required services - services.UseSourceFlow( - configuration => - { - configuration - .WithAggregate() - .WithSaga() - .WithService(); - }); + // Pass the test assembly so it can discover E2E aggregates, sagas, and projections + services.UseSourceFlow(Assembly.GetExecutingAssembly()); _serviceProvider = services.BuildServiceProvider(); - _accountService = _serviceProvider.GetRequiredService(); _saga = _serviceProvider.GetRequiredService(); + _accountAggregate = _serviceProvider.GetRequiredService(); + _logger = _serviceProvider.GetRequiredService>(); - _viewRepository = _serviceProvider.GetRequiredService(); + _viewRepository = _serviceProvider.GetRequiredService(); } [TearDown] @@ -58,47 +53,47 @@ public void TearDown() public async Task EndToEnd_AccountLifecycle_WorksAsExpected() { // Create account - var accountId = await _accountService.CreateAccountAsync("John Doe", 1000m); - _logger.LogInformation("Action=Test_Create_Account, Account: {accountId}", accountId); + await _accountAggregate.CreateAccount(_accountId, "John Doe", 1000m); + _logger.LogInformation("Action=Test_Create_Account, Account: {accountId}", _accountId); // Perform deposit var amount = 500m; _logger.LogInformation("Action=Test_Deposit, Amount={Amount}", amount); - await _accountService.DepositAsync(accountId, amount); + await _accountAggregate.Deposit(_accountId, amount); // Perform withdraw amount = 200m; _logger.LogInformation("Action=Test_Withdraw, Amount={Amount}", amount); - await _accountService.WithdrawAsync(accountId, amount); + await _accountAggregate.Withdraw(_accountId, amount); // Perform another deposit amount = 100m; _logger.LogInformation("Action=Test_Deposit, Amount={Amount}", amount); - await _accountService.DepositAsync(accountId, amount); + await _accountAggregate.Deposit(_accountId, amount); - // Find current state and assertions - var account = await _viewRepository.Find(accountId); + // Get current state and assertions + var account = await _viewRepository.Find(_accountId); Assert.That(account, Is.Not.Null); - Assert.That(accountId, Is.EqualTo(account.Id)); - Assert.That("John Doe", Is.EqualTo(account.AccountName)); - Assert.That(1000m + 500m - 200m + 100m, Is.EqualTo(account.CurrentBalance)); + Assert.That(_accountId, Is.EqualTo(account.Id)); + Assert.That(account.AccountName, Is.EqualTo("John Doe")); + Assert.That(account.CurrentBalance, Is.EqualTo(1000m + 500m - 200m + 100m)); Assert.That(account.TransactionCount, Is.GreaterThanOrEqualTo(3)); Assert.That(account.IsClosed, Is.False); // Replay account history (should not throw) - Assert.DoesNotThrowAsync(async () => await _accountService.ReplayHistoryAsync(accountId)); + Assert.DoesNotThrowAsync(async () => await _accountAggregate.RepayHistory(_accountId)); // Fetch state again, should be the same - var replayedAccount = await _viewRepository.Find(accountId); + var replayedAccount = await _viewRepository.Find(_accountId); Assert.That(account.CurrentBalance, Is.EqualTo(replayedAccount.CurrentBalance)); Assert.That(account.TransactionCount, Is.EqualTo(replayedAccount.TransactionCount)); - // Close account - Assert.DoesNotThrowAsync(async () => await _accountService.CloseAccountAsync(accountId, "Customer account close request")); + // CloseAccount account + Assert.DoesNotThrowAsync(async () => await _accountAggregate.CloseAccount(_accountId, "Customer account close request")); // Final state - var closedAccount = await _viewRepository.Find(accountId); + var closedAccount = await _viewRepository.Find(_accountId); Assert.That(closedAccount.IsClosed, Is.True); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Events/AccountCreated.cs b/tests/SourceFlow.Core.Tests/E2E/Events/AccountCreated.cs index e6eacaf..b1ae37f 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Events/AccountCreated.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Events/AccountCreated.cs @@ -1,5 +1,5 @@ using SourceFlow.Core.Tests.E2E.Aggregates; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Events; namespace SourceFlow.Core.Tests.E2E.Events { @@ -9,4 +9,4 @@ public AccountCreated(BankAccount payload) : base(payload) { } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Events/AccountUpdated.cs b/tests/SourceFlow.Core.Tests/E2E/Events/AccountUpdated.cs index d5ac968..4fdf79b 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Events/AccountUpdated.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Events/AccountUpdated.cs @@ -1,5 +1,5 @@ using SourceFlow.Core.Tests.E2E.Aggregates; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Events; namespace SourceFlow.Core.Tests.E2E.Events { @@ -9,4 +9,4 @@ public AccountUpdated(BankAccount payload) : base(payload) { } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryRepository.cs b/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryEntityStore.cs similarity index 70% rename from tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryRepository.cs rename to tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryEntityStore.cs index 2f75fc0..d95737b 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryRepository.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryEntityStore.cs @@ -1,13 +1,12 @@ using System.Collections.Concurrent; -using SourceFlow.Aggregate; namespace SourceFlow.Core.Tests.E2E.Impl { - public class InMemoryRepository : IRepository + public class InMemoryEntityStore : IEntityStore { private readonly ConcurrentDictionary _cache = new(); - public Task Delete(TEntity entity) where TEntity : IEntity + public Task Delete(TEntity entity) where TEntity : class, IEntity { if (entity?.Id == null) throw new ArgumentNullException(nameof(entity)); @@ -24,10 +23,13 @@ public Task Get(int id) where TEntity : class, IEntity var success = _cache.TryGetValue(id, out var entity); - return Task.FromResult(success ? (TEntity)entity : null); + if (!success || entity == null) + throw new InvalidOperationException($"Entity not found for ID: {id}"); + + return Task.FromResult((TEntity)entity); } - public Task Persist(TEntity entity) where TEntity : IEntity + public Task Persist(TEntity entity) where TEntity : class, IEntity { if (entity?.Id == null) throw new ArgumentNullException(nameof(entity)); @@ -37,7 +39,7 @@ public Task Persist(TEntity entity) where TEntity : IEntity _cache[entity.Id] = entity; - return Task.CompletedTask; + return Task.FromResult(entity); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryEventStore.cs b/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryEventStore.cs index 5aff60f..6a5d76c 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryEventStore.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryEventStore.cs @@ -1,35 +1,27 @@ using System.Collections.Concurrent; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.E2E.Impl { public class InMemoryEventStore : ICommandStore { - private readonly ConcurrentDictionary> _store = new(); + private readonly ConcurrentDictionary> _store = new(); - public Task Append(ICommand @event) + public Task Append(CommandData command) { - if (!_store.ContainsKey(@event.Payload.Id)) - _store[@event.Payload.Id] = new List(); + if (!_store.ContainsKey(command.EntityId)) + _store[command.EntityId] = new List(); - _store[@event.Payload.Id].Add(@event); + _store[command.EntityId].Add(command); return Task.CompletedTask; } - public async Task> Load(int aggregateId) + public async Task> Load(int entityId) { - return await Task.FromResult(_store.TryGetValue(aggregateId, out var events) + return await Task.FromResult(_store.TryGetValue(entityId, out var events) ? events - : Enumerable.Empty()); - } - - public Task GetNextSequenceNo(int aggregateId) - { - if (_store.TryGetValue(aggregateId, out var events)) - return Task.FromResult(events.Max(c => ((IMetadata)c).Metadata.SequenceNo) + 1); - - return Task.FromResult(1); + : Enumerable.Empty()); } } -} \ No newline at end of file +} diff --git a/src/SourceFlow.ConsoleApp/Impl/InMemoryViewProvider.cs b/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryViewModelStore.cs similarity index 52% rename from src/SourceFlow.ConsoleApp/Impl/InMemoryViewProvider.cs rename to tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryViewModelStore.cs index ec32f93..2f7ba72 100644 --- a/src/SourceFlow.ConsoleApp/Impl/InMemoryViewProvider.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryViewModelStore.cs @@ -1,33 +1,26 @@ using System.Collections.Concurrent; using SourceFlow.Projections; -namespace SourceFlow.ConsoleApp.Impl +namespace SourceFlow.Core.Tests.E2E.Impl { - public class InMemoryViewProvider : IViewProvider + public class InMemoryViewModelStore : IViewModelStore { private readonly ConcurrentDictionary _cache = new(); - public Task Delete(TViewModel model) where TViewModel : IViewModel - { - if (model?.Id == null) - throw new ArgumentNullException(nameof(model)); - - _cache.TryRemove(model.Id, out _); - - return Task.CompletedTask; - } - - public Task Find(int id) where TViewModel : class, IViewModel + public Task Get(int id) where TViewModel : class, IViewModel { if (id == 0) throw new ArgumentNullException(nameof(id)); var success = _cache.TryGetValue(id, out var model); - return Task.FromResult(success ? (TViewModel)model : null); + if (!success || model == null) + throw new InvalidOperationException($"ViewModel not found for ID: {id}"); + + return Task.FromResult((TViewModel)model); } - public Task Push(TViewModel model) where TViewModel : IViewModel + public Task Persist(TViewModel model) where TViewModel : class, IViewModel { if (model?.Id == null) throw new ArgumentNullException(nameof(model)); @@ -37,7 +30,20 @@ public Task Push(TViewModel model) where TViewModel : IViewModel _cache[model.Id] = model; + return Task.FromResult(model); + } + + public Task Delete(TViewModel model) where TViewModel : class, IViewModel + { + if (model?.Id == null) + throw new ArgumentNullException(nameof(model)); + + var success = _cache.Remove(model.Id, out var rmodel); + + if (!success || rmodel == null) + throw new InvalidOperationException($"ViewModel not found for ID: {model.Id}"); + return Task.CompletedTask; } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryViewProvider.cs b/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryViewProvider.cs deleted file mode 100644 index dfb6278..0000000 --- a/tests/SourceFlow.Core.Tests/E2E/Impl/InMemoryViewProvider.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Concurrent; -using SourceFlow.Projections; - -namespace SourceFlow.Core.Tests.E2E.Impl -{ - public class InMemoryViewProvider : IViewProvider - { - private readonly ConcurrentDictionary _cache = new(); - - public Task Find(int id) where TViewModel : class, IViewModel - { - if (id == 0) - throw new ArgumentNullException(nameof(id)); - - var success = _cache.TryGetValue(id, out var model); - - return Task.FromResult(success ? (TViewModel)model : null); - } - - public Task Push(TViewModel model) where TViewModel : IViewModel - { - if (model?.Id == null) - throw new ArgumentNullException(nameof(model)); - - if (model.Id == 0) - model.Id = new Random().Next(); - - _cache[model.Id] = model; - - return Task.CompletedTask; - } - } -} \ No newline at end of file diff --git a/tests/SourceFlow.Core.Tests/E2E/Projections/AccountView.cs b/tests/SourceFlow.Core.Tests/E2E/Projections/AccountView.cs index d467d9d..2c61d2b 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Projections/AccountView.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Projections/AccountView.cs @@ -1,21 +1,20 @@ +using Microsoft.Extensions.Logging; using SourceFlow.Core.Tests.E2E.Events; using SourceFlow.Projections; namespace SourceFlow.Core.Tests.E2E.Projections { - public class AccountView : IProjectOn, + public class AccountView : View, + IProjectOn, IProjectOn { - private readonly IViewProvider provider; - - public AccountView(IViewProvider provider) + public AccountView(IViewModelStoreAdapter viewModelStore, ILogger logger) : base(viewModelStore, logger) { - this.provider = provider ?? throw new ArgumentNullException(nameof(provider)); } - public async Task Apply(AccountCreated @event) + public async Task On(AccountCreated @event) { - var view = new AccountViewModel + var viewModel = new AccountViewModel { Id = @event.Payload.Id, AccountName = @event.Payload.AccountName, @@ -24,30 +23,27 @@ public async Task Apply(AccountCreated @event) CreatedDate = @event.Payload.CreatedOn, LastUpdated = DateTime.UtcNow, TransactionCount = 0, - ClosureReason = null, + ClosureReason = null!, Version = 1 }; - await provider.Push(view); + return viewModel; } - public async Task Apply(AccountUpdated @event) + public async Task On(AccountUpdated @event) { - var view = await provider.Find(@event.Payload.Id); - - if (view == null) - throw new InvalidOperationException($"Account view not found for ID: {@event.Payload.Id}"); - - view.CurrentBalance = @event.Payload.Balance; - view.LastUpdated = DateTime.UtcNow; - view.AccountName = @event.Payload.AccountName; - view.IsClosed = @event.Payload.IsClosed; - view.ClosureReason = @event.Payload.ClosureReason; - view.ActiveOn = @event.Payload.ActiveOn; - view.Version++; - view.TransactionCount++; - - await provider.Push(view); + var viewModel = await Find(@event.Payload.Id); + + viewModel.CurrentBalance = @event.Payload.Balance; + viewModel.LastUpdated = DateTime.UtcNow; + viewModel.AccountName = @event.Payload.AccountName; + viewModel.IsClosed = @event.Payload.IsClosed; + viewModel.ClosureReason = @event.Payload.ClosureReason; + viewModel.ActiveOn = @event.Payload.ActiveOn; + viewModel.Version++; + viewModel.TransactionCount++; + + return viewModel; } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Projections/AccountViewModel.cs b/tests/SourceFlow.Core.Tests/E2E/Projections/AccountViewModel.cs index 2977863..ab2b07a 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Projections/AccountViewModel.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Projections/AccountViewModel.cs @@ -11,8 +11,8 @@ public class AccountViewModel : IViewModel public DateTime LastUpdated { get; set; } public int TransactionCount { get; set; } public bool IsClosed { get; set; } - public string ClosureReason { get; set; } + public string ClosureReason { get; set; } = string.Empty; public int Version { get; set; } public DateTime ActiveOn { get; set; } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Sagas/AccountSaga.cs b/tests/SourceFlow.Core.Tests/E2E/Sagas/AccountSaga.cs index b259636..730aaf0 100644 --- a/tests/SourceFlow.Core.Tests/E2E/Sagas/AccountSaga.cs +++ b/tests/SourceFlow.Core.Tests/E2E/Sagas/AccountSaga.cs @@ -2,21 +2,28 @@ using SourceFlow.Core.Tests.E2E.Aggregates; using SourceFlow.Core.Tests.E2E.Commands; using SourceFlow.Core.Tests.E2E.Events; +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Events; using SourceFlow.Saga; namespace SourceFlow.Core.Tests.E2E.Sagas { public class AccountSaga : Saga, - IHandles, - IHandles, - IHandles, - IHandles, - IHandles + IHandlesWithEvent, + IHandlesWithEvent, + IHandlesWithEvent, + IHandlesWithEvent, + IHandles { - public async Task Handle(CreateAccount command) + public AccountSaga(Lazy commandPublisher, IEventQueue eventQueue, IEntityStoreAdapter repository, ILogger logger) : + base(commandPublisher, eventQueue, repository, logger) + { + } + + public Task Handle(IEntity entity, CreateAccount command) { logger.LogInformation("Action=Account_Created, Account={AccountId}, Holder={AccountName}, Initial_Balance={InitialBalance}", - command.Payload.Id, command.Payload.AccountName, command.Payload.InitialAmount); + command.Entity.Id, command.Payload.AccountName, command.Payload.InitialAmount); if (string.IsNullOrEmpty(command.Payload.AccountName)) throw new ArgumentException("Account create requires account holder name.", nameof(command.Payload.AccountName)); @@ -24,85 +31,75 @@ public async Task Handle(CreateAccount command) if (command.Payload.InitialAmount <= 0) throw new ArgumentException("Account create requires initial amount.", nameof(command.Payload.InitialAmount)); - var account = new BankAccount - { - Id = command.Payload.Id, - AccountName = command.Payload.AccountName, - Balance = command.Payload.InitialAmount - }; + var account = (BankAccount)entity; - await repository.Persist(account); + account.AccountName = command.Payload.AccountName; + account.Balance = command.Payload.InitialAmount; - await Raise(new AccountCreated(account)); + return Task.FromResult(account); } - public async Task Handle(ActivateAccount command) + public Task Handle(IEntity entity, ActivateAccount command) { - logger.LogInformation("Action=Account_Activate, ActivatedOn={ActiveOn}, Account={AccountId}", command.Payload.ActiveOn, command.Payload.Id); + logger.LogInformation("Action=Account_Activate, ActivatedOn={ActiveOn}, Account={AccountId}", command.Payload.ActiveOn, command.Entity.Id); + + if (command.Payload.ActiveOn == DateTime.MinValue) + throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.ActiveOn)); - var account = await repository.Get(command.Payload.Id); + var account = (BankAccount)entity; if (account.IsClosed) throw new InvalidOperationException("Cannot deposit to a closed account"); - if (command.Payload.ActiveOn == DateTime.MinValue) - throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.ActiveOn)); - account.ActiveOn = command.Payload.ActiveOn; - await repository.Persist(account); - - await Raise(new AccountUpdated(account)); + return Task.FromResult(account); } - public async Task Handle(DepositMoney command) + public Task Handle(IEntity entity, DepositMoney command) { - logger.LogInformation("Action=Money_Deposited, Amount={Amount}, Account={AccountId}", command.Payload.Amount, command.Payload.Id); + logger.LogInformation("Action=Money_Deposited, Amount={Amount}, Account={AccountId}", command.Payload.Amount, command.Entity.Id); + + if (command.Payload.Amount <= 0) + throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.Amount)); - var account = await repository.Get(command.Payload.Id); + var account = (BankAccount)entity; if (account.IsClosed) throw new InvalidOperationException("Cannot deposit to a closed account"); - if (command.Payload.Amount <= 0) - throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.Amount)); - command.Payload.CurrentBalance = account.Balance + command.Payload.Amount; account.Balance = command.Payload.CurrentBalance; - await repository.Persist(account); - - await Raise(new AccountUpdated(account)); + return Task.FromResult(account); } - public async Task Handle(WithdrawMoney command) + public Task Handle(IEntity entity, WithdrawMoney command) { - logger.LogInformation("Action=Money_Withdrawn, Amount={Amount}, Account={AccountId}", command.Payload.Amount, command.Payload.Id); + logger.LogInformation("Action=Money_Withdrawn, Amount={Amount}, Account={AccountId}", command.Payload.Amount, command.Entity.Id); + + if (command.Payload.Amount <= 0) + throw new ArgumentException("Withdrawal amount must be positive", nameof(command.Payload.Amount)); - var account = await repository.Get(command.Payload.Id); + var account = (BankAccount)entity; if (account.IsClosed) throw new InvalidOperationException("Cannot deposit to a closed account"); - if (command.Payload.Amount <= 0) - throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.Amount)); - command.Payload.CurrentBalance = account.Balance - command.Payload.Amount; account.Balance = command.Payload.CurrentBalance; - await repository.Persist(account); - - await Raise(new AccountUpdated(account)); + return Task.FromResult(account); } - public async Task Handle(CloseAccount command) + public Task Handle(IEntity entity, CloseAccount command) { - logger.LogInformation("Action=Account_Closed, Account={AccountId}, Reason={Reason}", command.Payload.Id, command.Payload.ClosureReason); + logger.LogInformation("Action=Account_Closed, Account={AccountId}, Reason={Reason}", command.Entity.Id, command.Payload.ClosureReason); if (string.IsNullOrWhiteSpace(command.Payload.ClosureReason)) throw new ArgumentException("Reason for closing cannot be empty", nameof(command.Payload.ClosureReason)); - var account = await repository.Get(command.Payload.Id); + var account = (BankAccount)entity; if (account.IsClosed) throw new InvalidOperationException("Cannot close account on a closed account"); @@ -110,9 +107,7 @@ public async Task Handle(CloseAccount command) account.ClosureReason = command.Payload.ClosureReason; account.IsClosed = command.Payload.IsClosed = true; - await repository.Persist(account); - - await Raise(new AccountUpdated(account)); + return Task.FromResult(account); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/E2E/Services/AccountService.cs b/tests/SourceFlow.Core.Tests/E2E/Services/AccountService.cs deleted file mode 100644 index 3b95d04..0000000 --- a/tests/SourceFlow.Core.Tests/E2E/Services/AccountService.cs +++ /dev/null @@ -1,78 +0,0 @@ -using SourceFlow.Core.Tests.E2E.Aggregates; -using SourceFlow.Services; - -namespace SourceFlow.Core.Tests.E2E.Services -{ - public class AccountService : Service, IAccountService - { - public async Task CreateAccountAsync(string accountHolderName, decimal initialBalance) - { - if (string.IsNullOrEmpty(accountHolderName)) - throw new ArgumentException("Account create requires account holder name.", nameof(accountHolderName)); - - if (initialBalance <= 0) - throw new ArgumentException("Account create requires initial amount.", nameof(initialBalance)); - - var account = await CreateAggregate(); - if (account == null) - throw new InvalidOperationException("Failed to create account aggregate"); - - var accountId = new Random().Next(); // Simulating a unique account ID generation - - account.CreateAccount(accountId, accountHolderName, initialBalance); - - return accountId; - } - - public async Task DepositAsync(int accountId, decimal amount) - { - if (accountId <= 0) - throw new ArgumentException("Deposit amount must need account id", nameof(amount)); - - if (amount <= 0) - throw new ArgumentException("Deposit amount must be positive", nameof(amount)); - - var account = await CreateAggregate(); - - account.Deposit(accountId, amount); - } - - public async Task WithdrawAsync(int accountId, decimal amount) - { - if (accountId <= 0) - throw new ArgumentException("Withdraw amount must need account id", nameof(amount)); - - if (amount <= 0) - throw new ArgumentException("Withdraw amount must be positive", nameof(amount)); - - var account = await CreateAggregate(); - if (account == null) - throw new InvalidOperationException("Failed to create account aggregate"); - - account.Withdraw(accountId, amount); - } - - public async Task CloseAccountAsync(int accountId, string reason) - { - if (accountId <= 0) - throw new ArgumentException("Close account requires valid account id", nameof(accountId)); - - if (string.IsNullOrEmpty(reason)) - throw new ArgumentException("Close account requires reason", nameof(reason)); - - var account = await CreateAggregate(); - - account.Close(accountId, reason); - } - - public async Task ReplayHistoryAsync(int accountId) - { - if (accountId <= 0) - throw new ArgumentException("Account history requires valid account id", nameof(accountId)); - - var account = await CreateAggregate(); - - await account.Replay(accountId); - } - } -} \ No newline at end of file diff --git a/tests/SourceFlow.Core.Tests/E2E/Services/IAccountService.cs b/tests/SourceFlow.Core.Tests/E2E/Services/IAccountService.cs deleted file mode 100644 index d71273e..0000000 --- a/tests/SourceFlow.Core.Tests/E2E/Services/IAccountService.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace SourceFlow.Core.Tests.E2E.Services -{ - public interface IAccountService - { - Task CloseAccountAsync(int accountId, string reason); - - Task CreateAccountAsync(string accountHolderName, decimal initialBalance); - - Task DepositAsync(int accountId, decimal amount); - - Task WithdrawAsync(int accountId, decimal amount); - - Task ReplayHistoryAsync(int accountId); - } -} \ No newline at end of file diff --git a/tests/SourceFlow.Core.Tests/Impl/AggregateFactoryTests.cs b/tests/SourceFlow.Core.Tests/Impl/AggregateFactoryTests.cs index 9bff715..4b2295b 100644 --- a/tests/SourceFlow.Core.Tests/Impl/AggregateFactoryTests.cs +++ b/tests/SourceFlow.Core.Tests/Impl/AggregateFactoryTests.cs @@ -24,7 +24,7 @@ public async Task Create_ReturnsAggregateInstance() var factory = new AggregateFactory(spMock.Object); var result = await factory.Create(); Assert.IsNotNull(result); - Assert.AreSame(aggregateMock.Object, result); + Assert.That(result, Is.SameAs(aggregateMock.Object)); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Impl/AggregateDispatcherTests.cs b/tests/SourceFlow.Core.Tests/Impl/AggregateSubscriberTests.cs similarity index 52% rename from tests/SourceFlow.Core.Tests/Impl/AggregateDispatcherTests.cs rename to tests/SourceFlow.Core.Tests/Impl/AggregateSubscriberTests.cs index c12ce1b..b825d73 100644 --- a/tests/SourceFlow.Core.Tests/Impl/AggregateDispatcherTests.cs +++ b/tests/SourceFlow.Core.Tests/Impl/AggregateSubscriberTests.cs @@ -1,47 +1,47 @@ -using System; -using System.Collections.Generic; using Microsoft.Extensions.Logging; using Moq; -using NUnit.Framework; using SourceFlow.Aggregate; -using SourceFlow.Impl; -using SourceFlow.Messaging.Bus; +using SourceFlow.Messaging.Events; namespace SourceFlow.Core.Tests.Impl { [TestFixture] - public class AggregateDispatcherTests + public class AggregateSubscriberTests { [Test] public void Constructor_NullAggregates_ThrowsArgumentNullException() { - var loggerMock = new Mock>(); - Assert.Throws(() => new AggregateDispatcher(null, loggerMock.Object)); + var loggerMock = new Mock>(); + Assert.Throws(() => new Aggregate.EventSubscriber(null, loggerMock.Object)); } [Test] public void Constructor_NullLogger_ThrowsArgumentNullException() { var aggregates = new List(); - Assert.Throws(() => new AggregateDispatcher(aggregates, null)); + Assert.Throws(() => new Aggregate.EventSubscriber(aggregates, null)); } [Test] - public void Dispatch_ValidEvent_LogsInformation() + public async Task Dispatch_ValidEvent_LogsInformation() { - var loggerMock = new Mock>(); + var loggerMock = new Mock>(); var aggregateMock = new Mock(); + // Make the aggregate implement ISubscribes so it gets called + aggregateMock.As>() + .Setup(a => a.On(It.IsAny())) + .Returns(Task.CompletedTask); var aggregates = new List { aggregateMock.Object }; - var dispatcher = new AggregateDispatcher(aggregates, loggerMock.Object); + var dispatcher = new Aggregate.EventSubscriber(aggregates, loggerMock.Object); var eventMock = new DummyEvent(); - dispatcher.Dispatch(this, eventMock); + await dispatcher.Subscribe(eventMock); loggerMock.Verify(l => l.Log( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - (Func)It.IsAny()), + It.IsAny(), + (Func)It.IsAny()), Times.AtLeastOnce); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Impl/CommandBusTests.cs b/tests/SourceFlow.Core.Tests/Impl/CommandBusTests.cs index 07df49f..731c9e1 100644 --- a/tests/SourceFlow.Core.Tests/Impl/CommandBusTests.cs +++ b/tests/SourceFlow.Core.Tests/Impl/CommandBusTests.cs @@ -1,61 +1,251 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; -using NUnit.Framework; using SourceFlow.Messaging; using SourceFlow.Messaging.Bus; -using SourceFlow.Impl; +using SourceFlow.Messaging.Bus.Impl; +using SourceFlow.Messaging.Commands; +using SourceFlow.Observability; namespace SourceFlow.Core.Tests.Impl { [TestFixture] public class CommandBusTests { + private Mock commandStoreMock; + private Mock> loggerMock; + private Mock commandDispatcherMock; + private Mock telemetryMock; + private CommandBus commandBus; + + [SetUp] + public void Setup() + { + commandStoreMock = new Mock(); + loggerMock = new Mock>(); + commandDispatcherMock = new Mock(); + telemetryMock = new Mock(); + + // Setup telemetry mock to execute operations directly + telemetryMock.Setup(t => t.TraceAsync(It.IsAny(), It.IsAny>(), It.IsAny>())) + .Returns((string name, Func operation, Action enrich) => operation()); + + commandBus = new CommandBus( + new[] { commandDispatcherMock.Object }, + commandStoreMock.Object, + loggerMock.Object, + telemetryMock.Object); + } + + [Test] + public void Constructor_NullCommandStore_ThrowsArgumentNullException() + { + Assert.Throws(() => + new CommandBus(new[] { commandDispatcherMock.Object }, null, loggerMock.Object, telemetryMock.Object)); + } + + [Test] + public void Constructor_NullLogger_ThrowsArgumentNullException() + { + Assert.Throws(() => + new CommandBus(new[] { commandDispatcherMock.Object }, commandStoreMock.Object, null, telemetryMock.Object)); + } + + [Test] + public void Constructor_NullCommandDispatcher_ThrowsArgumentNullException() + { + Assert.Throws(() => + new CommandBus(null, commandStoreMock.Object, loggerMock.Object, telemetryMock.Object)); + } + [Test] public void Constructor_SetsDependencies() { - var store = new Mock().Object; - var logger = new Mock>().Object; - var bus = new CommandBus(store, logger); - Assert.IsNotNull(bus); + Assert.That(commandBus.commandDispatchers.ElementAt(0), Is.EqualTo(commandDispatcherMock.Object)); + } + + [Test] + public async Task Publish_NullCommand_ThrowsArgumentNullException() + { + ICommandBus bus = commandBus; + Assert.ThrowsAsync(async () => + await bus.Publish(null!)); + } + + [Test] + public async Task Publish_ValidCommand_SetsSequenceNumber() + { + // Arrange + var command = new DummyCommand(); + commandStoreMock.Setup(cs => cs.GetNextSequenceNo(It.IsAny())) + .ReturnsAsync(42); + + // Act + ICommandBus bus = commandBus; + await bus.Publish(command); + + // Assert + Assert.That(command.Metadata.SequenceNo, Is.EqualTo(42)); + } + + [Test] + public async Task Publish_ValidCommand_DispatchesToCommandDispatcher() + { + // Arrange + var command = new DummyCommand(); + commandStoreMock.Setup(cs => cs.GetNextSequenceNo(It.IsAny())) + .ReturnsAsync(1); + + // Act + ICommandBus bus = commandBus; + await bus.Publish(command); + + // Assert + commandDispatcherMock.Verify(cd => cd.Dispatch(command), Times.Once); + } + + [Test] + public async Task Publish_ValidCommand_LogsInformation() + { + // Arrange + var command = new DummyCommand(); + commandStoreMock.Setup(cs => cs.GetNextSequenceNo(It.IsAny())) + .ReturnsAsync(1); + + // Act + ICommandBus bus = commandBus; + await bus.Publish(command); + + // Assert + loggerMock.Verify(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.AtLeastOnce); + } + + [Test] + public async Task Publish_ValidCommand_AppendsToStore() + { + // Arrange + var command = new DummyCommand(); + commandStoreMock.Setup(cs => cs.GetNextSequenceNo(It.IsAny())) + .ReturnsAsync(1); + + // Act + ICommandBus bus = commandBus; + await bus.Publish(command); + + // Assert + commandStoreMock.Verify(cs => cs.Append(command), Times.Once); } [Test] - public void Publish_NullCommand_ThrowsArgumentNullException() + public async Task Publish_ReplayCommand_DoesNotSetSequenceNumber() { - var store = new Mock().Object; - var logger = new Mock>().Object; - var bus = (ICommandBus)new CommandBus(store, logger); - Assert.ThrowsAsync(async () => await bus.Publish(null)); + // Arrange + var command = new DummyCommand(); + command.Metadata.IsReplay = true; + command.Metadata.SequenceNo = 99; + + // Act + ICommandBus bus = commandBus; + await bus.Publish(command); + + // Assert + Assert.That(command.Metadata.SequenceNo, Is.EqualTo(99)); + commandStoreMock.Verify(cs => cs.GetNextSequenceNo(It.IsAny()), Times.Never); } [Test] - public async Task Publish_ValidCommand_InvokesDispatchers() + public async Task Publish_ReplayCommand_DoesNotAppendToStore() { - var storeMock = new Mock(); - storeMock.Setup(s => s.GetNextSequenceNo(It.IsAny())).ReturnsAsync(1); - storeMock.Setup(s => s.Append(It.IsAny())).Returns(Task.CompletedTask); - var logger = new Mock>().Object; - var bus = new CommandBus(storeMock.Object, logger); - var commandMock = new DummyCommand(); - bool dispatcherCalled = false; - bus.Dispatchers += (s, c) => dispatcherCalled = true; - await ((ICommandBus)bus).Publish(commandMock); - Assert.IsTrue(dispatcherCalled); + // Arrange + var command = new DummyCommand(); + command.Metadata.IsReplay = true; + + // Act + ICommandBus bus = commandBus; + await bus.Publish(command); + + // Assert + commandStoreMock.Verify(cs => cs.Append(It.IsAny()), Times.Never); } [Test] - public async Task Replay_NoCommands_DoesNothing() + public async Task Replay_NoCommands_DoesNotDispatch() { - var storeMock = new Mock(); - storeMock.Setup(s => s.Load(It.IsAny())).ReturnsAsync((IEnumerable)null); - var logger = new Mock>().Object; - var bus = (ICommandBus)new CommandBus(storeMock.Object, logger); - await bus.Replay(42); - Assert.Pass(); + // Arrange + commandStoreMock.Setup(cs => cs.Load(It.IsAny())) + .ReturnsAsync((IEnumerable)null!); + + // Act + ICommandBus bus = commandBus; + await bus.Replay(1); + + // Assert + commandDispatcherMock.Verify(cd => cd.Dispatch(It.IsAny()), Times.Never); + } + + [Test] + public async Task Replay_WithCommands_DispatchesAllCommands() + { + // Arrange + var commands = new List + { + new DummyCommand { Metadata = new Metadata() }, + new DummyCommand { Metadata = new Metadata() }, + new DummyCommand { Metadata = new Metadata() } + }; + commandStoreMock.Setup(cs => cs.Load(It.IsAny())) + .ReturnsAsync(commands); + + // Act + ICommandBus bus = commandBus; + await bus.Replay(1); + + // Assert + commandDispatcherMock.Verify(cd => cd.Dispatch(It.IsAny()), Times.Exactly(3)); + } + + [Test] + public async Task Replay_WithCommands_MarksCommandsAsReplay() + { + // Arrange + var commands = new List + { + new DummyCommand { Metadata = new Metadata() }, + new DummyCommand { Metadata = new Metadata() } + }; + commandStoreMock.Setup(cs => cs.Load(It.IsAny())) + .ReturnsAsync(commands); + + // Act + ICommandBus bus = commandBus; + await bus.Replay(1); + + // Assert + Assert.That(commands.All(c => c.Metadata.IsReplay), Is.True); + } + + [Test] + public async Task Replay_WithCommands_DoesNotAppendToStore() + { + // Arrange + var commands = new List + { + new DummyCommand { Metadata = new Metadata() } + }; + commandStoreMock.Setup(cs => cs.Load(It.IsAny())) + .ReturnsAsync(commands); + + // Act + ICommandBus bus = commandBus; + await bus.Replay(1); + + // Assert + commandStoreMock.Verify(cs => cs.Append(It.IsAny()), Times.Never); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Impl/CommandPublisherTests.cs b/tests/SourceFlow.Core.Tests/Impl/CommandPublisherTests.cs index fac1b18..e1fcb91 100644 --- a/tests/SourceFlow.Core.Tests/Impl/CommandPublisherTests.cs +++ b/tests/SourceFlow.Core.Tests/Impl/CommandPublisherTests.cs @@ -1,7 +1,8 @@ using Moq; -using SourceFlow.Impl; using SourceFlow.Messaging; using SourceFlow.Messaging.Bus; +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Commands.Impl; namespace SourceFlow.Core.Tests.Impl { @@ -21,7 +22,7 @@ public void Publish_NullCommand_ThrowsArgumentNullException() { var bus = new Mock().Object; var publisher = (ICommandPublisher)new CommandPublisher(bus); - Assert.ThrowsAsync(async () => await publisher.Publish(null)); + Assert.ThrowsAsync(async () => await publisher.Publish(null!)); } [Test] @@ -30,7 +31,7 @@ public void Publish_NullPayloadId_ThrowsInvalidOperationException() var bus = new Mock().Object; var publisher = (ICommandPublisher)new CommandPublisher(bus); var commandMock = new Mock(); - commandMock.Setup(c => c.Payload).Returns((IPayload)null); + commandMock.Setup(c => c.Payload).Returns((IPayload?)null!); Assert.ThrowsAsync(async () => await publisher.Publish(commandMock.Object)); } @@ -41,11 +42,11 @@ public async Task Publish_ValidCommand_DelegatesToCommandBus() busMock.Setup(b => b.Publish(It.IsAny())).Returns(Task.CompletedTask); var publisher = (ICommandPublisher)new CommandPublisher(busMock.Object); var payloadMock = new Mock(); - payloadMock.Setup(p => p.Id).Returns(1); var commandMock = new Mock(); commandMock.Setup(c => c.Payload).Returns(payloadMock.Object); + commandMock.Setup(p => p.Entity).Returns(new EntityRef { Id = 1 }); await publisher.Publish(commandMock.Object); busMock.Verify(b => b.Publish(commandMock.Object), Times.Once); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Impl/CommandReplayerTests.cs b/tests/SourceFlow.Core.Tests/Impl/CommandReplayerTests.cs deleted file mode 100644 index 45d3817..0000000 --- a/tests/SourceFlow.Core.Tests/Impl/CommandReplayerTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Moq; -using SourceFlow.Impl; -using SourceFlow.Messaging.Bus; - -namespace SourceFlow.Core.Tests.Impl -{ - [TestFixture] - public class CommandReplayerTests - { - [Test] - public void Constructor_SetsCommandBus() - { - var bus = new Mock().Object; - var replayer = new CommandReplayer(bus); - Assert.IsNotNull(replayer); - } - - [Test] - public async Task Replay_DelegatesToCommandBus() - { - var busMock = new Mock(); - busMock.Setup(b => b.Replay(It.IsAny())).Returns(Task.CompletedTask); - var replayer = (ICommandReplayer)new CommandReplayer(busMock.Object); - await replayer.Replay(42); - busMock.Verify(b => b.Replay(42), Times.Once); - } - } -} \ No newline at end of file diff --git a/tests/SourceFlow.Core.Tests/Impl/DummyCommand.cs b/tests/SourceFlow.Core.Tests/Impl/DummyCommand.cs index fe198d3..6b5bab1 100644 --- a/tests/SourceFlow.Core.Tests/Impl/DummyCommand.cs +++ b/tests/SourceFlow.Core.Tests/Impl/DummyCommand.cs @@ -1,4 +1,5 @@ using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.Impl { @@ -9,15 +10,17 @@ public DummyCommand() Payload = new DummyPayload(); Name = "DummyCommand"; Metadata = new Metadata(); + Entity = new EntityRef { Id = 0 }; } public IPayload Payload { get; set; } public string Name { get; set; } public Metadata Metadata { get; set; } + public EntityRef Entity { get; set; } } internal class DummyPayload : IPayload { - public int Id { get; set; } + public int EntityId { get; set; } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Impl/DummyEvent.cs b/tests/SourceFlow.Core.Tests/Impl/DummyEvent.cs index 2e171b9..e54b5b7 100644 --- a/tests/SourceFlow.Core.Tests/Impl/DummyEvent.cs +++ b/tests/SourceFlow.Core.Tests/Impl/DummyEvent.cs @@ -1,5 +1,5 @@ -using SourceFlow.Aggregate; using SourceFlow.Messaging; +using SourceFlow.Messaging.Events; namespace SourceFlow.Core.Tests.Impl { @@ -20,4 +20,4 @@ public class DummyEntity : IEntity { public int Id { get; set; } = 1; } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Impl/EventQueueTests.cs b/tests/SourceFlow.Core.Tests/Impl/EventQueueTests.cs index d81fe3e..541c7c7 100644 --- a/tests/SourceFlow.Core.Tests/Impl/EventQueueTests.cs +++ b/tests/SourceFlow.Core.Tests/Impl/EventQueueTests.cs @@ -1,43 +1,134 @@ -using System; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; -using NUnit.Framework; -using SourceFlow.Aggregate; -using SourceFlow.Impl; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; +using SourceFlow.Messaging.Events; +using SourceFlow.Messaging.Events.Impl; +using SourceFlow.Observability; namespace SourceFlow.Core.Tests.Impl { [TestFixture] public class EventQueueTests { + private Mock> loggerMock; + private Mock eventDispatcherMock; + private Mock telemetryMock; + private EventQueue eventQueue; + + [SetUp] + public void Setup() + { + loggerMock = new Mock>(); + eventDispatcherMock = new Mock(); + telemetryMock = new Mock(); + + // Setup telemetry mock to execute operations directly + telemetryMock.Setup(t => t.TraceAsync(It.IsAny(), It.IsAny>(), It.IsAny>())) + .Returns((string name, Func operation, Action enrich) => operation()); + + eventQueue = new EventQueue(new[] { eventDispatcherMock.Object }, loggerMock.Object, telemetryMock.Object); + } + [Test] public void Constructor_NullLogger_ThrowsArgumentNullException() { - Assert.Throws(() => new EventQueue(null)); + Assert.Throws(() => + new EventQueue(new[] { eventDispatcherMock.Object }, null, telemetryMock.Object)); + } + + [Test] + public void Constructor_NullEventDispatcher_ThrowsArgumentNullException() + { + Assert.Throws(() => + new EventQueue(null, loggerMock.Object, telemetryMock.Object)); + } + + [Test] + public void Constructor_SetsDependencies() + { + Assert.That(eventQueue.eventDispatchers.ElementAt(0), Is.EqualTo(eventDispatcherMock.Object)); } [Test] public async Task Enqueue_NullEvent_ThrowsArgumentNullException() { - var logger = new Mock>().Object; - var queue = new EventQueue(logger); - await Task.Yield(); - Assert.ThrowsAsync(async () => await queue.Enqueue(null)); + Assert.ThrowsAsync(async () => + await eventQueue.Enqueue(null!)); + } + + [Test] + public async Task Enqueue_ValidEvent_DispatchesToEventDispatcher() + { + // Arrange + var @event = new DummyEvent(); + + // Act + await eventQueue.Enqueue(@event); + + // Assert + eventDispatcherMock.Verify(ed => ed.Dispatch(@event), Times.Once); + } + + [Test] + public async Task Enqueue_ValidEvent_LogsInformation() + { + // Arrange + var @event = new DummyEvent(); + + // Act + await eventQueue.Enqueue(@event); + + // Assert + loggerMock.Verify(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.AtLeastOnce); + } + + [Test] + public async Task Enqueue_ValidEvent_DispatchesAfterLogging() + { + // Arrange + var @event = new DummyEvent(); + var callSequence = new System.Collections.Generic.List(); + + eventDispatcherMock.Setup(ed => ed.Dispatch(It.IsAny())) + .Callback(() => callSequence.Add("Dispatch")) + .Returns(Task.CompletedTask); + + loggerMock.Setup(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback(() => callSequence.Add("Log")); + + // Act + await eventQueue.Enqueue(@event); + + // Assert + Assert.That(callSequence[0], Is.EqualTo("Log")); + Assert.That(callSequence[1], Is.EqualTo("Dispatch")); } [Test] - public async Task Enqueue_ValidEvent_InvokesDispatchers() + public async Task Enqueue_MultipleEvents_DispatchesAll() { - var logger = new Mock>().Object; - var queue = new EventQueue(logger); - var eventMock = new DummyEvent(); - bool dispatcherCalled = false; - queue.Dispatchers += (s, e) => dispatcherCalled = true; - await queue.Enqueue(eventMock); - Assert.IsTrue(dispatcherCalled); + // Arrange + var event1 = new DummyEvent(); + var event2 = new DummyEvent(); + var event3 = new DummyEvent(); + + // Act + await eventQueue.Enqueue(event1); + await eventQueue.Enqueue(event2); + await eventQueue.Enqueue(event3); + + // Assert + eventDispatcherMock.Verify(ed => ed.Dispatch(It.IsAny()), Times.Exactly(3)); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Impl/ProjectionDispatcherTests.cs b/tests/SourceFlow.Core.Tests/Impl/ProjectionDispatcherTests.cs deleted file mode 100644 index 94c2d4e..0000000 --- a/tests/SourceFlow.Core.Tests/Impl/ProjectionDispatcherTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.Extensions.Logging; -using Moq; -using SourceFlow.Impl; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; -using SourceFlow.Projections; - -namespace SourceFlow.Core.Tests.Impl -{ - [TestFixture] - public class ProjectionDispatcherTests - { - [Test] - public void Constructor_NullProjections_ThrowsArgumentNullException() - { - var logger = new Mock>().Object; - Assert.Throws(() => new ProjectionDispatcher(null, logger)); - } - - [Test] - public void Constructor_NullLogger_ThrowsArgumentNullException() - { - var projections = new List(); - Assert.Throws(() => new ProjectionDispatcher(projections, null)); - } - - [Test] - public void Dispatch_ValidEvent_LogsInformation() - { - var loggerMock = new Mock>(); - var projectionMock = new Mock(); - var projections = new List { projectionMock.Object }; - var dispatcher = new ProjectionDispatcher(projections, loggerMock.Object); - var eventMock = new Mock(); - eventMock.Setup(e => e.Name).Returns("TestEvent"); - dispatcher.Dispatch(this, eventMock.Object); - loggerMock.Verify(l => l.Log( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - (Func)It.IsAny()), - Times.AtLeastOnce); - } - } -} \ No newline at end of file diff --git a/tests/SourceFlow.Core.Tests/Impl/ProjectionSubscriberTests.cs b/tests/SourceFlow.Core.Tests/Impl/ProjectionSubscriberTests.cs new file mode 100644 index 0000000..a06931e --- /dev/null +++ b/tests/SourceFlow.Core.Tests/Impl/ProjectionSubscriberTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SourceFlow.Messaging; +using SourceFlow.Messaging.Events; +using SourceFlow.Projections; + +namespace SourceFlow.Core.Tests.Impl +{ + [TestFixture] + public class ProjectionSubscriberTests + { + [Test] + public void Constructor_NullProjections_ThrowsArgumentNullException() + { + var logger = new Mock>().Object; + Assert.Throws(() => new SourceFlow.Projections.EventSubscriber(null, logger)); + } + + [Test] + public void Constructor_NullLogger_ThrowsArgumentNullException() + { + var projections = new List(); + Assert.Throws(() => new SourceFlow.Projections.EventSubscriber(projections, null)); + } + + [Test] + public async Task Dispatch_ValidEvent_LogsInformation() + { + var loggerMock = new Mock>(); + + // Create a concrete test event implementation instead of a mock + var metadata = new Metadata { SequenceNo = 1 }; + var payload = new TestEntity { Id = 1 }; + var testEvent = new TestEvent + { + Name = "TestEvent", + Metadata = metadata, + Payload = payload + }; + + // Create a concrete test projection instead of a mock + var testProjection = new TestProjection(); + var projections = new List { testProjection }; + + var dispatcher = new SourceFlow.Projections.EventSubscriber(projections, loggerMock.Object); + await dispatcher.Subscribe(testEvent); + + loggerMock.Verify(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny()), + Times.AtLeastOnce); + } + + // Test entity implementation + private class TestEntity : IEntity + { + public int Id { get; set; } + } + + // Test event implementation + private class TestEvent : IEvent + { + public string Name { get; set; } = string.Empty; + public IEntity Payload { get; set; } = null!; + public Metadata Metadata { get; set; } = null!; + } + + // Test projection implementation + private class TestProjection : View, IProjectOn + { + public TestProjection() : base(new Mock().Object, new Mock>().Object) + { + } + + public Task On(TestEvent @event) + { + return Task.FromResult(new TestProjectionViewModel { Id = 1 }); + } + } + + private class TestProjectionViewModel : IViewModel + { + public int Id { get; set; } + } + } +} diff --git a/tests/SourceFlow.Core.Tests/Impl/SagaDispatcherTests.cs b/tests/SourceFlow.Core.Tests/Impl/SagaDispatcherTests.cs index f5f8125..08953e0 100644 --- a/tests/SourceFlow.Core.Tests/Impl/SagaDispatcherTests.cs +++ b/tests/SourceFlow.Core.Tests/Impl/SagaDispatcherTests.cs @@ -1,10 +1,6 @@ -using System; using Microsoft.Extensions.Logging; using Moq; -using NUnit.Framework; -using SourceFlow.Impl; -using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; +using SourceFlow.Messaging.Commands; using SourceFlow.Saga; namespace SourceFlow.Core.Tests.Impl @@ -15,37 +11,30 @@ public class SagaDispatcherTests [Test] public void Constructor_SetsLogger() { - var logger = new Mock>().Object; - var dispatcher = new SagaDispatcher(logger); + var logger = new Mock>().Object; + var sagas = new Mock>().Object; + var dispatcher = new CommandSubscriber(sagas, logger); Assert.IsNotNull(dispatcher); } [Test] - public void Register_AddsSaga() + public async Task Dispatch_WithNoSagas_LogsInformation() { - var logger = new Mock>().Object; - var dispatcher = new SagaDispatcher(logger); - var sagaMock = new Mock(); - dispatcher.Register(sagaMock.Object); - Assert.Pass(); // No exception means success - } + var loggerMock = new Mock>(); + // Use an empty list instead of a mock to avoid null reference issues + var sagas = new List(); - [Test] - public void Dispatch_WithNoSagas_LogsInformation() - { - var loggerMock = new Mock>(); - var dispatcher = new SagaDispatcher(loggerMock.Object); + var dispatcher = new CommandSubscriber(sagas, loggerMock.Object); var commandMock = new DummyCommand(); - var metadataMock = new Mock(); - dispatcher.Dispatch(this, commandMock); + await dispatcher.Subscribe(commandMock); loggerMock.Verify(l => l.Log( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), - (Func)It.IsAny()), + It.IsAny(), + (Func)It.IsAny()), Times.AtLeastOnce); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Interfaces/IEventReplayerTests.cs b/tests/SourceFlow.Core.Tests/Interfaces/IEventReplayerTests.cs deleted file mode 100644 index a74e6da..0000000 --- a/tests/SourceFlow.Core.Tests/Interfaces/IEventReplayerTests.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Moq; -using SourceFlow.Messaging.Bus; - -namespace SourceFlow.Core.Tests.Interfaces -{ - public class IEventReplayerTests - { - [Test] - public async Task ReplayEventsAsync_DoesNotThrow() - { - var mock = new Mock(); - mock.Setup(r => r.Replay(It.IsAny())).Returns(Task.CompletedTask); - Assert.DoesNotThrowAsync(async () => await mock.Object.Replay(42)); - } - } -} \ No newline at end of file diff --git a/tests/SourceFlow.Core.Tests/Interfaces/IViewModelRepositoryTests.cs b/tests/SourceFlow.Core.Tests/Interfaces/IViewModelRepositoryTests.cs index 53b5ddd..3137260 100644 --- a/tests/SourceFlow.Core.Tests/Interfaces/IViewModelRepositoryTests.cs +++ b/tests/SourceFlow.Core.Tests/Interfaces/IViewModelRepositoryTests.cs @@ -11,19 +11,19 @@ public class DummyViewModel : IViewModel [Test] public async Task GetByIdAsync_ReturnsModel() { - var mock = new Mock(); + var mock = new Mock(); mock.Setup(r => r.Find(1)).ReturnsAsync(new DummyViewModel { Id = 1 }); var result = await mock.Object.Find(1); Assert.That(result, Is.Not.Null); - Assert.That(1, Is.EqualTo(result.Id)); + Assert.That(result.Id, Is.EqualTo(1)); } [Test] public async Task PersistAsync_DoesNotThrow() { - var mock = new Mock(); - mock.Setup(r => r.Push(It.IsAny())).Returns(Task.CompletedTask); - Assert.DoesNotThrowAsync(async () => await mock.Object.Push(new DummyViewModel())); + var mock = new Mock(); + mock.Setup(r => r.Persist(It.IsAny())).Returns(vm => Task.FromResult(vm)); + Assert.DoesNotThrowAsync(async () => await mock.Object.Persist(new DummyViewModel())); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Ioc/DummyCommandStore.cs b/tests/SourceFlow.Core.Tests/Ioc/DummyCommandStore.cs index a1c67d1..4f30577 100644 --- a/tests/SourceFlow.Core.Tests/Ioc/DummyCommandStore.cs +++ b/tests/SourceFlow.Core.Tests/Ioc/DummyCommandStore.cs @@ -1,8 +1,8 @@ -using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.Ioc { - public class DummyCommandStore : ICommandStore + public class DummyCommandStore : ICommandStoreAdapter { public Task Append(ICommand command) { @@ -13,7 +13,7 @@ public Task Append(ICommand command) public Task> Load(int aggregateId) { // Simulate loading commands - return Task.FromResult>(null); + return Task.FromResult>(null!); } public Task GetNextSequenceNo(int aggregateId) @@ -22,4 +22,4 @@ public Task GetNextSequenceNo(int aggregateId) return Task.FromResult(1); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Ioc/IocExtensionsTests.cs b/tests/SourceFlow.Core.Tests/Ioc/IocExtensionsTests.cs index 903dfef..331991f 100644 --- a/tests/SourceFlow.Core.Tests/Ioc/IocExtensionsTests.cs +++ b/tests/SourceFlow.Core.Tests/Ioc/IocExtensionsTests.cs @@ -1,204 +1,281 @@ -using System; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Moq; -using NUnit.Framework; -using SourceFlow; using SourceFlow.Aggregate; -using SourceFlow.Impl; -using SourceFlow.Messaging; using SourceFlow.Messaging.Bus; -using SourceFlow.Saga; -using SourceFlow.Services; +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Events; +using SourceFlow.Projections; -namespace SourceFlow.Core.Tests.Ioc +namespace SourceFlow.Tests.Ioc { - public class DummyService : IService + // Test implementations for required interfaces + public class TestRepository : IEntityStoreAdapter { - public DummyService() - { } + public Task Get(int id) where TEntity : class, IEntity + { + return Task.FromResult(null!); + } + + public Task Persist(TEntity entity) where TEntity : class, IEntity + { + return Task.FromResult(entity); + } - public Task CreateAggregate() where TAggregateRoot : class, IAggregate + public Task Delete(TEntity entity) where TEntity : class, IEntity { - var mock = new Mock(); - return Task.FromResult(mock.Object); + return Task.CompletedTask; } } - public class DummyAggregate : Aggregate + public class TestCommandStore : ICommandStoreAdapter { - public DummyAggregate() - { } + public Task Append(ICommand command) + { + return Task.CompletedTask; + } + + public Task> Load(int aggregateId) + { + return Task.FromResult>(new List()); + } - public DummyAggregate(ICommandPublisher publisher, ICommandReplayer replayer, ILogger logger) + public Task GetNextSequenceNo(int aggregateId) { - commandPublisher = publisher; - commandReplayer = replayer; - this.logger = logger; + return Task.FromResult(0); } } - public class DummySaga : ISaga + public class TestViewProvider : IViewModelStoreAdapter { - public Task Handle(TCommand command) where TCommand : ICommand => Task.CompletedTask; - } + public Task Find(int id) where TViewModel : class, IViewModel + { + return Task.FromResult(null!); + } - public class DummyEntity : IEntity - { public int Id { get; set; } } + public Task Persist(TViewModel model) where TViewModel : class, IViewModel + { + return Task.FromResult(model); + } + + public Task Delete(TViewModel model) where TViewModel : class, IViewModel + { + return Task.CompletedTask; + } + } [TestFixture] public class IocExtensionsTests { - [Test] - public void UseSourceFlow_AddsExpectedServices() + private ServiceCollection _services = null!; + private ServiceProvider _serviceProvider = null!; + + [SetUp] + public void SetUp() { - var services = new ServiceCollection(); - services.UseSourceFlow(); - services.AddLogging(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); - }); - // Should register core types - Assert.IsTrue(services.Count > 0); + _services = new ServiceCollection(); + _services.AddLogging(); // Add logging services + + // Register test implementations for required interfaces + _services.AddSingleton(); + _services.AddSingleton(); + _services.AddSingleton(); + } + + [TearDown] + public void TearDown() + { + _serviceProvider?.Dispose(); } [Test] - public void UseSourceFlow_WithCustomConfig_AddsExpectedServices() + public void UseSourceFlow_RegistersMultipleEventSubscribers() { - var services = new ServiceCollection(); - services.UseSourceFlow(cfg => { }); - Assert.IsTrue(services.Count > 0); + // Arrange + // Test implementations already registered in SetUp + + // Act + _services.UseSourceFlow(); + + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var eventSubscribers = _serviceProvider.GetServices(); + + // Should have at least 2 event subscribers (Aggregate and Projections) + Assert.That(eventSubscribers, Is.Not.Null); + Assert.That(eventSubscribers.Count(), Is.GreaterThanOrEqualTo(2), + "Should have at least 2 event subscribers (Aggregate and Projections)"); } [Test] - public void UseSourceFlow_ResolvesCoreServices() + public void UseSourceFlow_RegistersCommandSubscriber() { - var services = new ServiceCollection(); - services.AddLogging(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); - }); + // Arrange + // Test implementations already registered in SetUp + + // Act + _services.UseSourceFlow(); - services.UseSourceFlow(); + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var commandSubscriber = _serviceProvider.GetService(); - var provider = services.BuildServiceProvider(); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); + Assert.That(commandSubscriber, Is.Not.Null, "ICommandSubscriber should be registered"); } [Test] - public void WithService_RegistersService() + public void UseSourceFlow_RegistersCommandDispatcher() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - config.WithService(); - Assert.IsTrue(config.Services.Count > 0); + // Arrange + // Test implementations already registered in SetUp + + // Act + _services.UseSourceFlow(); + + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var commandDispatcher = _serviceProvider.GetService(); + + Assert.That(commandDispatcher, Is.Not.Null, "ICommandDispatcher should be registered"); } [Test] - public void WithService_ResolvesService() + public void UseSourceFlow_RegistersCommandBus() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - config.Services.AddLogging(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); - }); + // Arrange + // Test implementations already registered in SetUp + + // Act + _services.UseSourceFlow(); - config.WithService(); - var provider = config.Services.BuildServiceProvider(); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var commandBus = _serviceProvider.GetService(); + + Assert.That(commandBus, Is.Not.Null, "ICommandBus should be registered"); } [Test] - public void WithAggregate_RegistersAggregate() + public void UseSourceFlow_RegistersCommandPublisher() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - config.Services.AddLogging(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); - }); - config.Services.AddSingleton(); - config.Services.AddSingleton(); + // Arrange + // Test implementations already registered in SetUp + + // Act + _services.UseSourceFlow(); - config.WithAggregate(c => new DummyAggregate(c.GetService(), c.GetService(), c.GetService())); - Assert.IsTrue(config.Services.Count > 0); + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var commandPublisher = _serviceProvider.GetService(); + + Assert.That(commandPublisher, Is.Not.Null, "ICommandPublisher should be registered"); } [Test] - public void WithAggregate_ResolvesAggregate() + public void UseSourceFlow_RegistersEventDispatcher() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - config.Services.AddLogging(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); - }); + // Arrange + // Test implementations already registered in SetUp - config.Services.AddSingleton(c => new CommandBus(new Mock().Object, c.GetService>())); - config.Services.AddSingleton(); - config.Services.AddSingleton(); + // Act + _services.UseSourceFlow(); - config.WithAggregate(); + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var eventDispatcher = _serviceProvider.GetService(); - var provider = config.Services.BuildServiceProvider(); - Assert.IsNotNull(provider.GetService()); - Assert.IsNotNull(provider.GetService()); + Assert.That(eventDispatcher, Is.Not.Null, "IEventDispatcher should be registered"); } [Test] - public void WithSaga_RegistersSaga() + public void UseSourceFlow_RegistersEventQueue() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - config.WithSaga(); - Assert.IsTrue(config.Services.Count > 0); + // Arrange + // Test implementations already registered in SetUp + + // Act + _services.UseSourceFlow(); + + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var eventQueue = _serviceProvider.GetService(); + + Assert.That(eventQueue, Is.Not.Null, "IEventQueue should be registered"); } [Test] - public void WithSaga_ResolvesSaga() + public void UseSourceFlow_RegistersRequiredInfrastructureServices() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - config.Services.AddLogging(builder => - { - builder.AddConsole(); - builder.SetMinimumLevel(LogLevel.Information); - }); + // Arrange + // Test implementations already registered in SetUp - config.WithSaga(); - config.Services.AddSingleton(); + // Act + _services.UseSourceFlow(); - var provider = config.Services.BuildServiceProvider(); - Assert.IsNotNull(provider.GetService()); + // Assert + _serviceProvider = _services.BuildServiceProvider(); + + // Check that all infrastructure services are registered + Assert.That(_serviceProvider.GetService(), Is.Not.Null, "IEntityStore should be registered"); + Assert.That(_serviceProvider.GetService(), Is.Not.Null, "ICommandStore should be registered"); + Assert.That(_serviceProvider.GetService(), Is.Not.Null, "IViewModelStore should be registered"); } [Test] - public void WithServices_ThrowsIfFactoryReturnsNull() + public void UseSourceFlow_RegistersAggregateFactory() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - Assert.Throws(() => config.WithServices(_ => null)); + // Arrange + // Test implementations already registered in SetUp + + // Act + _services.UseSourceFlow(); + + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var aggregateFactory = _serviceProvider.GetService(); + + Assert.That(aggregateFactory, Is.Not.Null, "IAggregateFactory should be registered"); } [Test] - public void WithAggregates_ThrowsIfFactoryReturnsNull() + public void UseSourceFlow_RegistersAllServices_WithoutThrowing() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - Assert.Throws(() => config.WithAggregates(_ => null)); + // Arrange + // Test implementations already registered in SetUp + + // Act & Assert - should not throw + Assert.DoesNotThrow(() => + { + _services.UseSourceFlow(); + _serviceProvider = _services.BuildServiceProvider(); + + // Try to resolve all major services to ensure they can be created + _ = _serviceProvider.GetService(); + _ = _serviceProvider.GetService(); + _ = _serviceProvider.GetService(); + _ = _serviceProvider.GetService(); + _ = _serviceProvider.GetService(); + _ = _serviceProvider.GetService(); + _ = _serviceProvider.GetService(); + _ = _serviceProvider.GetService(); + _ = _serviceProvider.GetService(); + }); } [Test] - public void WithSagas_ThrowsIfFactoryReturnsNull() + public void UseSourceFlow_RegistersEventSubscribersAsEnumerable() { - var config = new IocExtensions.SourceFlowConfig { Services = new ServiceCollection() }; - Assert.Throws(() => config.WithSagas(_ => null)); + // Arrange + // Test implementations already registered in SetUp + + // Act + _services.UseSourceFlow(); + + // Assert + _serviceProvider = _services.BuildServiceProvider(); + var eventSubscribers = _serviceProvider.GetServices(); + + Assert.That(eventSubscribers, Is.Not.Null, "IEventSubscriber enumerable should not be null"); + Assert.That(eventSubscribers.Count(), Is.GreaterThanOrEqualTo(2), + "Should have at least 2 IEventSubscriber implementations"); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Ioc/SourceFlowConfigTests.cs b/tests/SourceFlow.Core.Tests/Ioc/SourceFlowConfigTests.cs deleted file mode 100644 index 466982a..0000000 --- a/tests/SourceFlow.Core.Tests/Ioc/SourceFlowConfigTests.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace SourceFlow.Core.Tests.Ioc -{ - [TestFixture] - public class SourceFlowConfigTests - { - [Test] - public void Constructor_InitializesServicesProperty() - { - var config = new IocExtensions.SourceFlowConfig(); - Assert.IsNull(config.Services); // Default is null - config.Services = new ServiceCollection(); - Assert.IsNotNull(config.Services); - } - - [Test] - public void ImplementsInterface() - { - var config = new IocExtensions.SourceFlowConfig(); - Assert.IsInstanceOf(config); - } - } -} \ No newline at end of file diff --git a/tests/SourceFlow.Core.Tests/Ioc/TestImplementations.cs b/tests/SourceFlow.Core.Tests/Ioc/TestImplementations.cs new file mode 100644 index 0000000..e8daf48 --- /dev/null +++ b/tests/SourceFlow.Core.Tests/Ioc/TestImplementations.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SourceFlow.Aggregate; +using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Events; +using SourceFlow.Projections; +using SourceFlow.Saga; + +namespace SourceFlow.Tests.Ioc +{ + public class TestEntity : IEntity + { + public int Id { get; set; } + } + + public class TestPayload : IPayload + { + // Empty implementation for test + } + + public class TestCommand : ICommand, IName, IMetadata + { + public string Name { get; set; } = "TestCommand"; + public EntityRef Entity { get; set; } = new EntityRef { Id = 1, IsNew = false }; + public IPayload Payload { get; set; } = new TestPayload(); + public Metadata Metadata { get; set; } = new Metadata(); + } + + public class TestEvent : IEvent, IName, IMetadata + { + public string Name { get; set; } = "TestEvent"; + public IEntity Payload { get; set; } = new TestEntity(); + public Metadata Metadata { get; set; } = new Metadata(); + } + + internal class TestAggregate : Aggregate, ITestAggregate, IHandles + { + public TestAggregate(Lazy commandPublisher, ILogger logger) + : base(commandPublisher, logger) { } + + public Task Handle(IEntity entity, TestCommand command) + { + // Implementation not needed for test + return Task.FromResult(entity); + } + } + + internal class TestSaga : Saga, ITestSaga, IHandles + { + public TestSaga(Lazy commandPublisher, IEventQueue eventQueue, + IEntityStoreAdapter repository, ILogger logger) + : base(commandPublisher, eventQueue, repository, logger) { } + + public Task Handle(IEntity entity, TestCommand command) + { + // Implementation not needed for test + return Task.FromResult(entity); + } + } + + public interface ITestAggregate + { } + + public interface ITestSaga + { } + + public class TestProjection : View, IProjectOn + { + public TestProjection() : base(new Mock().Object, new Mock>().Object) + { + } + + public Task On(TestEvent @event) + { + // Implementation not needed for test + return Task.FromResult(new TestViewModel { Id = 1 }); + } + } + + public class TestViewModel : IViewModel + { + public int Id { get; set; } + } +} diff --git a/tests/SourceFlow.Core.Tests/Messaging/CommandTests.cs b/tests/SourceFlow.Core.Tests/Messaging/CommandTests.cs index 90868d5..286e226 100644 --- a/tests/SourceFlow.Core.Tests/Messaging/CommandTests.cs +++ b/tests/SourceFlow.Core.Tests/Messaging/CommandTests.cs @@ -1,15 +1,16 @@ using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; namespace SourceFlow.Core.Tests.Messaging { public class DummyPayload : IPayload { - public int Id { get; set; } + public int EntityId { get; set; } } public class DummyCommand : Command { - public DummyCommand(DummyPayload payload) : base(payload) + public DummyCommand(int entityId, DummyPayload payload) : base(entityId, payload) { } } @@ -20,21 +21,21 @@ public class CommandTests [Test] public void Constructor_InitializesProperties() { - var payload = new DummyPayload { Id = 42 }; - var command = new DummyCommand(payload); + var payload = new DummyPayload { EntityId = 42 }; + var command = new DummyCommand(42, payload); Assert.IsNotNull(command.Metadata); - Assert.AreEqual("DummyCommand", command.Name); - Assert.AreSame(payload, command.Payload); + Assert.That(command.Name, Is.EqualTo("DummyCommand")); + Assert.That(command.Payload, Is.SameAs(payload)); } [Test] public void ICommandPayload_GetSet_WorksCorrectly() { - var payload = new DummyPayload { Id = 7 }; - var command = new DummyCommand(new DummyPayload()); + var payload = new DummyPayload { EntityId = 7 }; + var command = new DummyCommand(7, new DummyPayload()); ((ICommand)command).Payload = payload; - Assert.AreSame(payload, command.Payload); - Assert.AreSame(payload, ((ICommand)command).Payload); + Assert.That(command.Payload, Is.SameAs(payload)); + Assert.That(((ICommand)command).Payload, Is.SameAs(payload)); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Messaging/EventTests.cs b/tests/SourceFlow.Core.Tests/Messaging/EventTests.cs index 41c5c09..9a6d90c 100644 --- a/tests/SourceFlow.Core.Tests/Messaging/EventTests.cs +++ b/tests/SourceFlow.Core.Tests/Messaging/EventTests.cs @@ -1,5 +1,4 @@ -using SourceFlow.Aggregate; -using SourceFlow.Messaging; +using SourceFlow.Messaging.Events; namespace SourceFlow.Core.Tests.Messaging { @@ -24,8 +23,8 @@ public void Constructor_InitializesProperties() var payload = new DummyEntity { Id = 99 }; var ev = new DummyEvent(payload); Assert.IsNotNull(ev.Metadata); - Assert.AreEqual("DummyEvent", ev.Name); - Assert.AreSame(payload, ev.Payload); + Assert.That(ev.Name, Is.EqualTo("DummyEvent")); + Assert.That(ev.Payload, Is.SameAs(payload)); } [Test] @@ -34,8 +33,8 @@ public void IEventPayload_GetSet_WorksCorrectly() var payload = new DummyEntity { Id = 123 }; var ev = new DummyEvent(new DummyEntity()); ((IEvent)ev).Payload = payload; - Assert.AreSame(payload, ev.Payload); - Assert.AreSame(payload, ((IEvent)ev).Payload); + Assert.That(ev.Payload, Is.SameAs(payload)); + Assert.That(((IEvent)ev).Payload, Is.SameAs(payload)); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Messaging/MetadataTests.cs b/tests/SourceFlow.Core.Tests/Messaging/MetadataTests.cs index a4ffad1..7206afa 100644 --- a/tests/SourceFlow.Core.Tests/Messaging/MetadataTests.cs +++ b/tests/SourceFlow.Core.Tests/Messaging/MetadataTests.cs @@ -9,7 +9,7 @@ public class MetadataTests public void Constructor_InitializesProperties() { var metadata = new Metadata(); - Assert.AreNotEqual(Guid.Empty, metadata.EventId); + Assert.That(metadata.EventId, Is.Not.EqualTo(Guid.Empty)); Assert.That(metadata.OccurredOn, Is.Not.EqualTo(default(DateTime))); Assert.IsFalse(metadata.IsReplay); Assert.IsNotNull(metadata.Properties); @@ -27,11 +27,11 @@ public void Properties_CanBeSetAndGet() metadata.OccurredOn = now; metadata.SequenceNo = 42; metadata.Properties["foo"] = 123; - Assert.AreEqual(guid, metadata.EventId); + Assert.That(metadata.EventId, Is.EqualTo(guid)); Assert.IsTrue(metadata.IsReplay); - Assert.AreEqual(now, metadata.OccurredOn); - Assert.AreEqual(42, metadata.SequenceNo); - Assert.AreEqual(123, metadata.Properties["foo"]); + Assert.That(metadata.OccurredOn, Is.EqualTo(now)); + Assert.That(metadata.SequenceNo, Is.EqualTo(42)); + Assert.That(metadata.Properties["foo"], Is.EqualTo(123)); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Projections/EventSubscriberTests.cs b/tests/SourceFlow.Core.Tests/Projections/EventSubscriberTests.cs new file mode 100644 index 0000000..74eb34a --- /dev/null +++ b/tests/SourceFlow.Core.Tests/Projections/EventSubscriberTests.cs @@ -0,0 +1,169 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SourceFlow.Messaging.Events; +using SourceFlow.Projections; + +namespace SourceFlow.Core.Tests.Projections +{ + public class DummyProjectionEntity : IEntity + { + public int Id { get; set; } + } + + public class DummyProjectionEvent : Event + { + public DummyProjectionEvent(DummyProjectionEntity payload) : base(payload) + { + } + } + + public class TestProjection : View, IProjectOn + { + public TestProjection() : base(new Mock().Object, new Mock>().Object) + { + } + + public bool Applied { get; private set; } = false; + + public Task On(DummyProjectionEvent @event) + { + Applied = true; + return Task.FromResult(new TestProjectionViewModel { Id = 1 }); + } + } + + public class TestProjectionViewModel : IViewModel + { + public int Id { get; set; } + } + + public class NonMatchingProjection : View + { + public NonMatchingProjection() : base(new Mock().Object, new Mock>().Object) + { + } + + // This projection does not implement IProjectOn so won't handle DummyProjectionEvent + } + + [TestFixture] + public class EventSubscriberTests + { + private Mock> _mockLogger; + private DummyProjectionEvent _testEvent; + + [SetUp] + public void SetUp() + { + _mockLogger = new Mock>(); + _testEvent = new DummyProjectionEvent(new DummyProjectionEntity { Id = 1 }); + } + + [Test] + public void Constructor_WithNullProjections_ThrowsArgumentNullException() + { + // Arrange + IEnumerable nullProjections = null!; + + // Act & Assert + Assert.Throws(() => + new EventSubscriber(nullProjections, _mockLogger.Object)); + } + + [Test] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Arrange + var projections = new List { new TestProjection() }; + + // Act & Assert + Assert.Throws(() => + new EventSubscriber(projections, null)); + } + + [Test] + public void Constructor_WithValidParameters_Succeeds() + { + // Arrange + var projections = new List { new TestProjection() }; + + // Act + var subscriber = new EventSubscriber(projections, _mockLogger.Object); + + // Assert + Assert.IsNotNull(subscriber); + } + + [Test] + public async Task Subscribe_WithMatchingProjection_AppliesProjection() + { + // Arrange + var testProjection = new TestProjection(); + var projections = new List { testProjection }; + var subscriber = new EventSubscriber(projections, _mockLogger.Object); + + // Act + await subscriber.Subscribe(_testEvent); + + // Assert + Assert.IsTrue(testProjection.Applied); + } + + [Test] + public async Task Subscribe_WithNonMatchingProjection_DoesNotApplyProjection() + { + // Arrange + var nonMatchingProjection = new NonMatchingProjection(); + var projections = new List { nonMatchingProjection }; + var subscriber = new EventSubscriber(projections, _mockLogger.Object); + + // Act + await subscriber.Subscribe(_testEvent); + + // Assert + // We can't directly test this, but we know that non-matching projections won't be applied + // since they don't implement IProjectOn + } + + [Test] + public async Task Subscribe_WithMultipleProjections_AppliesMatchingProjectionsOnly() + { + // Arrange + var matchingProjection1 = new TestProjection(); + var matchingProjection2 = new TestProjection(); + var nonMatchingProjection = new NonMatchingProjection(); + var projections = new List { matchingProjection1, nonMatchingProjection, matchingProjection2 }; + var subscriber = new EventSubscriber(projections, _mockLogger.Object); + + // Act + await subscriber.Subscribe(_testEvent); + + // Assert + Assert.IsTrue(matchingProjection1.Applied); + Assert.IsTrue(matchingProjection2.Applied); + } + + [Test] + public async Task Subscribe_WithNoMatchingProjections_DoesNotThrow() + { + // Arrange + var nonMatchingProjection = new NonMatchingProjection(); + var projections = new List { nonMatchingProjection }; + var subscriber = new EventSubscriber(projections, _mockLogger.Object); + + // Act & Assert + Assert.DoesNotThrowAsync(async () => await subscriber.Subscribe(_testEvent)); + } + + [Test] + public async Task Subscribe_WithEmptyProjectionsCollection_DoesNotThrow() + { + // Arrange + var projections = new List(); + var subscriber = new EventSubscriber(projections, _mockLogger.Object); + + // Act & Assert + Assert.DoesNotThrowAsync(async () => await subscriber.Subscribe(_testEvent)); + } + } +} diff --git a/tests/SourceFlow.Core.Tests/Sagas/CommandSubscriberTests.cs b/tests/SourceFlow.Core.Tests/Sagas/CommandSubscriberTests.cs new file mode 100644 index 0000000..6dc4efb --- /dev/null +++ b/tests/SourceFlow.Core.Tests/Sagas/CommandSubscriberTests.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.Logging; +using Moq; +using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; +using SourceFlow.Saga; + +namespace SourceFlow.Core.Tests.Sagas +{ + public class DummyCommandPayload : IPayload + { + public int Id { get; set; } + public string Data { get; set; } = string.Empty; + } + + public class DummyCommand : Command + { + public DummyCommand(DummyCommandPayload payload) : base(true, payload) + { + } + } + + public class TestSaga : ISaga, IHandles + { + public bool Handled { get; private set; } = false; + public Type LastHandledCommandType { get; private set; } = null!; + public DummyCommand LastHandledCommand { get; private set; } = null!; + + public Task Handle(TCommand command) where TCommand : ICommand + { + if (this is IHandles handles) + { + Handled = true; + LastHandledCommandType = typeof(TCommand); + if (command is DummyCommand dummyCommand) + { + LastHandledCommand = dummyCommand; + } + } + return Task.CompletedTask; + } + + public Task Handle(IEntity entity, DummyCommand command) + { + Handled = true; + LastHandledCommandType = typeof(DummyCommand); + LastHandledCommand = command; + return Task.FromResult(entity); + } + } + + public class NonHandlingSaga : ISaga + { + public bool Handled { get; private set; } = false; + + public Task Handle(TCommand command) where TCommand : ICommand + { + // This saga doesn't implement IHandles, so it won't handle the command + // But we still want to track if this method was called + Handled = true; // This will be true if the ISaga.On method is called + return Task.CompletedTask; + } + } + + [TestFixture] + public class CommandSubscriberTests + { + private Mock> _mockLogger; + private DummyCommand _testCommand; + + [SetUp] + public void SetUp() + { + _mockLogger = new Mock>(); + _testCommand = new DummyCommand(new DummyCommandPayload { Id = 1, Data = "Test" }); + } + + [Test] + public void Constructor_WithValidParameters_Succeeds() + { + // Arrange + var sagas = new List { new TestSaga() }; + + // Act + var subscriber = new CommandSubscriber(sagas, _mockLogger.Object); + + // Assert + Assert.IsNotNull(subscriber); + } + + [Test] + public async Task Subscribe_WithMatchingSaga_HandlesCommand() + { + // Arrange + var testSaga = new TestSaga(); + var sagas = new List { testSaga }; + var subscriber = new CommandSubscriber(sagas, _mockLogger.Object); + + // Act + await subscriber.Subscribe(_testCommand); + + // Assert + Assert.IsTrue(testSaga.Handled); + Assert.That(testSaga.LastHandledCommandType, Is.EqualTo(typeof(DummyCommand))); + } + + [Test] + public async Task Subscribe_WithEmptySagasCollection_DoesNotThrow() + { + // Arrange + var sagas = new List(); + + // Act + var subscriber = new CommandSubscriber(sagas, _mockLogger.Object); + + // Assert + Assert.IsNotNull(subscriber); + + // Act & Assert - should not throw and should just return early + Assert.DoesNotThrowAsync(async () => await subscriber.Subscribe(_testCommand)); + } + + [Test] + public async Task Subscribe_WithMultipleSagas_HandlesCommandInAllMatchingSagas() + { + // Arrange + var testSaga1 = new TestSaga(); + var testSaga2 = new TestSaga(); + var nonHandlingSaga = new NonHandlingSaga(); + var sagas = new List { testSaga1, nonHandlingSaga, testSaga2 }; + var subscriber = new CommandSubscriber(sagas, _mockLogger.Object); + + // Act + await subscriber.Subscribe(_testCommand); + + // Assert + Assert.IsTrue(testSaga1.Handled); + Assert.IsTrue(testSaga2.Handled); + Assert.IsFalse(nonHandlingSaga.Handled); // This saga doesn't implement IHandles + Assert.That(testSaga1.LastHandledCommandType, Is.EqualTo(typeof(DummyCommand))); + Assert.That(testSaga2.LastHandledCommandType, Is.EqualTo(typeof(DummyCommand))); + } + + [Test] + public async Task Subscribe_NullSagas_StillCreatesSubscriber() + { + // Arrange & Act + var subscriber = new CommandSubscriber(null, _mockLogger.Object); + + // Assert + Assert.IsNotNull(subscriber); + + // Note: The CommandSubscriber constructor doesn't validate null sagas, + // so we just test that it doesn't throw during construction. + // During Subscribe(), it would check sagas.Any() which would handle null. + } + } +} diff --git a/tests/SourceFlow.Core.Tests/Sagas/SagaTests.cs b/tests/SourceFlow.Core.Tests/Sagas/SagaTests.cs index 9840818..b8e85bc 100644 --- a/tests/SourceFlow.Core.Tests/Sagas/SagaTests.cs +++ b/tests/SourceFlow.Core.Tests/Sagas/SagaTests.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Logging; using Moq; -using SourceFlow.Aggregate; using SourceFlow.Messaging; -using SourceFlow.Messaging.Bus; +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Events; using SourceFlow.Saga; namespace SourceFlow.Core.Tests.Sagas @@ -12,19 +12,19 @@ public class SagaTests { public class TestSaga : Saga, IHandles { - public TestSaga() : this(new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object) + public TestSaga() : base(new Lazy(() => new Mock().Object), new Mock().Object, new Mock().Object, new Mock>().Object) { } - public TestSaga(ICommandPublisher publisher, IEventQueue queue, IRepository repo, ILogger logger) + public TestSaga(Lazy publisher, IEventQueue queue, IEntityStoreAdapter repo, ILogger logger) : base(publisher, queue, repo, logger) { commandPublisher = publisher; eventQueue = queue; - repository = repo; + entityStore = repo; this.logger = logger; } - public Task Handle(ICommand command) => Task.CompletedTask; + public Task Handle(IEntity entity, ICommand command) => Task.FromResult(entity); public Task TestPublish(ICommand command) => Publish(command); @@ -34,7 +34,7 @@ public TestSaga(ICommandPublisher publisher, IEventQueue queue, IRepository repo [Test] public void CanHandle_ReturnsTrueForMatchingType() { - var saga = new TestSaga(null, null, null, null); + var saga = new TestSaga(); Assert.IsTrue(Saga.CanHandle(saga, typeof(ICommand))); } @@ -42,24 +42,23 @@ public void CanHandle_ReturnsTrueForMatchingType() public void CanHandle_ReturnsFalseForNulls() { Assert.IsFalse(Saga.CanHandle(null, typeof(ICommand))); - var saga = new TestSaga(null, null, null, null); + var saga = new TestSaga(); Assert.IsFalse(Saga.CanHandle(saga, null)); } [Test] public void Publish_NullCommand_ThrowsArgumentNullException() { - var saga = new TestSaga(new Mock().Object, null, null, null); - Assert.ThrowsAsync(async () => await saga.TestPublish(null)); + var saga = new TestSaga(); + Assert.ThrowsAsync(async () => await saga.TestPublish(null!)); } [Test] public void Publish_NullPayload_ThrowsInvalidOperationException() { - var publisher = new Mock().Object; - var saga = new TestSaga(publisher, null, null, null); + var saga = new TestSaga(); var commandMock = new Mock(); - commandMock.Setup(c => c.Payload).Returns((IPayload)null); + commandMock.Setup(c => c.Payload).Returns((IPayload?)null!); Assert.ThrowsAsync(async () => await saga.TestPublish(commandMock.Object)); } @@ -68,11 +67,12 @@ public async Task Publish_ValidCommand_DelegatesToPublisher() { var publisherMock = new Mock(); publisherMock.Setup(p => p.Publish(It.IsAny())).Returns(Task.CompletedTask); - var saga = new TestSaga(publisherMock.Object, null, null, null); + var saga = new TestSaga(new Lazy(() => publisherMock.Object), new Mock().Object, new Mock().Object, new Mock>().Object); var payloadMock = new Mock(); - payloadMock.Setup(p => p.Id).Returns(1); + var commandMock = new Mock(); commandMock.Setup(c => c.Payload).Returns(payloadMock.Object); + commandMock.Setup(p => p.Entity).Returns(new EntityRef { Id = 1 }); await saga.TestPublish(commandMock.Object); publisherMock.Verify(p => p.Publish(commandMock.Object), Times.Once); } @@ -80,17 +80,16 @@ public async Task Publish_ValidCommand_DelegatesToPublisher() [Test] public void Raise_NullEvent_ThrowsArgumentNullException() { - var saga = new TestSaga(null, new Mock().Object, null, null); - Assert.ThrowsAsync(async () => await saga.TestRaise(null)); + var saga = new TestSaga(); + Assert.ThrowsAsync(async () => await saga.TestRaise(null!)); } [Test] public void Raise_NullPayload_ThrowsInvalidOperationException() { - var queue = new Mock().Object; - var saga = new TestSaga(null, queue, null, null); + var saga = new TestSaga(); var eventMock = new Mock(); - eventMock.Setup(e => e.Payload).Returns((IEntity)null); + eventMock.Setup(e => e.Payload).Returns((IEntity?)null!); Assert.ThrowsAsync(async () => await saga.TestRaise(eventMock.Object)); } @@ -99,7 +98,7 @@ public async Task Raise_ValidEvent_DelegatesToQueue() { var queueMock = new Mock(); queueMock.Setup(q => q.Enqueue(It.IsAny())).Returns(Task.CompletedTask); - var saga = new TestSaga(null, queueMock.Object, null, null); + var saga = new TestSaga(new Lazy(() => new Mock().Object), queueMock.Object, new Mock().Object, new Mock>().Object); var payloadMock = new Mock(); var eventMock = new Mock(); eventMock.Setup(e => e.Payload).Returns(payloadMock.Object); @@ -107,4 +106,4 @@ public async Task Raise_ValidEvent_DelegatesToQueue() queueMock.Verify(q => q.Enqueue(eventMock.Object), Times.Once); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Core.Tests/Services/ServiceTests.cs b/tests/SourceFlow.Core.Tests/Services/ServiceTests.cs deleted file mode 100644 index bee46a5..0000000 --- a/tests/SourceFlow.Core.Tests/Services/ServiceTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.Extensions.Logging; -using Moq; -using SourceFlow.Aggregate; -using SourceFlow.Services; - -namespace SourceFlow.Core.Tests.Services -{ - [TestFixture] - public class ServiceTests - { - public class TestService : Service - { - public TestService() : this(new Mock().Object, new Mock().Object) - { - } - - public TestService(IAggregateFactory factory, ILogger logger) - { - aggregateFactory = factory; - this.logger = logger; - } - - public new Task CreateAggregate() where TAggregate : class, IAggregate => base.CreateAggregate(); - } - - [Test] - public async Task CreateAggregate_DelegatesToFactory() - { - var aggregateMock = new Mock(); - var factoryMock = new Mock(); - factoryMock.Setup(f => f.Create()).ReturnsAsync(aggregateMock.Object); - var logger = new Mock().Object; - var service = new TestService(factoryMock.Object, logger); - var result = await service.CreateAggregate(); - Assert.IsNotNull(result); - Assert.AreSame(aggregateMock.Object, result); - } - } -} \ No newline at end of file diff --git a/tests/SourceFlow.Core.Tests/SourceFlow.Core.Tests.csproj b/tests/SourceFlow.Core.Tests/SourceFlow.Core.Tests.csproj index 9cc5458..e0b6d1d 100644 --- a/tests/SourceFlow.Core.Tests/SourceFlow.Core.Tests.csproj +++ b/tests/SourceFlow.Core.Tests/SourceFlow.Core.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -10,14 +10,14 @@ - - + + - - + + - - + + @@ -27,4 +27,8 @@ + + + + diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/Configutaion/ConnectionStringConfigurationTests.cs b/tests/SourceFlow.Net.EntityFramework.Tests/Configutaion/ConnectionStringConfigurationTests.cs new file mode 100644 index 0000000..c060b1b --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/Configutaion/ConnectionStringConfigurationTests.cs @@ -0,0 +1,102 @@ +#nullable enable + +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SourceFlow.Stores.EntityFramework.Extensions; + +namespace SourceFlow.Stores.EntityFramework.Tests.Configutaion +{ + [TestFixture] + public class ConnectionStringConfigurationTests + { + [Test] + public void AddSourceFlowEfStores_SingleConnectionString_RegistersCorrectly() + { + // Arrange + var services = new ServiceCollection(); + var connectionString = "DataSource=:memory:"; + + // Act + services.AddSourceFlowEfStores(connectionString); + + // Assert - Services should be registered without throwing exceptions + var serviceProvider = services.BuildServiceProvider(); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + } + + [Test] + public void AddSourceFlowEfStores_SeparateConnectionStrings_RegistersCorrectly() + { + // Arrange + var services = new ServiceCollection(); + var cmdConnectionString = "DataSource=command.db"; + var entityConnectionString = "DataSource=entity.db"; + var viewModelConnectionString = "DataSource=viewModel.db"; + + // Act + services.AddSourceFlowEfStores( + cmdConnectionString, + entityConnectionString, + viewModelConnectionString); + + // Assert - Services should be registered without throwing exceptions + var serviceProvider = services.BuildServiceProvider(); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + } + + [Test] + public void AddSourceFlowEfStores_WithConfiguration_RegistersCorrectly() + { + // Arrange + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + {"ConnectionStrings:SourceFlow.Command", "DataSource=command.db"}, + {"ConnectionStrings:SourceFlow.Entity", "DataSource=entity.db"}, + {"ConnectionStrings:SourceFlow.ViewModel", "DataSource=viewModel.db"} + }) + .Build(); + + var services = new ServiceCollection(); + + // Act + services.AddSourceFlowEfStores(config); + + // Assert - Services should be registered without throwing exceptions + var serviceProvider = services.BuildServiceProvider(); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + } + + [Test] + public void AddSourceFlowEfStores_WithOptionsAction_RegistersCorrectly() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddSourceFlowEfStores(options => + { + options.CommandConnectionString = "DataSource=command.db"; + options.EntityConnectionString = "DataSource=entity.db"; + options.ViewModelConnectionString = "DataSource=viewModel.db"; + }); + + // Assert - Services should be registered without throwing exceptions + var serviceProvider = services.BuildServiceProvider(); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + Assert.DoesNotThrow(() => serviceProvider.GetRequiredService()); + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/AccountAggregate.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/AccountAggregate.cs new file mode 100644 index 0000000..67d1594 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/AccountAggregate.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using SourceFlow.Aggregate; +using SourceFlow.Messaging.Commands; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Commands; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Events; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates +{ + public class AccountAggregate : Aggregate, + ISubscribes, IAccountAggregate + { + public AccountAggregate(Lazy commandPublisher, ILogger logger) : + base(commandPublisher, logger) + { + } + + public Task CreateAccount(int accountId, string holder, decimal amount) + { + var command = new CreateAccount(accountId, new Payload + { + AccountName = holder, + InitialAmount = amount + }); + + return Send(command); + } + + public Task Deposit(int accountId, decimal amount) + { + return Send(new DepositMoney(accountId, new TransactPayload + { + Amount = amount, + Type = TransactionType.Deposit + })); + } + + public Task Withdraw(int accountId, decimal amount) + { + return Send(new WithdrawMoney(accountId, new TransactPayload + { + Amount = amount, + Type = TransactionType.Withdrawal + })); + } + + public Task CloseAccount(int accountId, string reason) + { + return Send(new CloseAccount(accountId, new ClosurePayload + { + ClosureReason = reason + })); + } + + public Task On(AccountCreated @event) + { + // To prevent infinite loops, this method does nothing + // Activation should happen through commands, not through event handling cycles + return Task.CompletedTask; + } + + public Task RepayHistory(int accountId) + { + return ReplayCommands(accountId); + } + } +} diff --git a/src/SourceFlow.ConsoleApp/Aggregates/BankAccount.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/BankAccount.cs similarity index 83% rename from src/SourceFlow.ConsoleApp/Aggregates/BankAccount.cs rename to tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/BankAccount.cs index 39adf33..f4003ef 100644 --- a/src/SourceFlow.ConsoleApp/Aggregates/BankAccount.cs +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/BankAccount.cs @@ -1,6 +1,6 @@ -using SourceFlow.Aggregate; +using System; -namespace SourceFlow.ConsoleApp.Aggregates +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates { public class BankAccount : IEntity { @@ -12,4 +12,4 @@ public class BankAccount : IEntity public string ClosureReason { get; internal set; } public DateTime ActiveOn { get; internal set; } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/IAccountAggregate.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/IAccountAggregate.cs new file mode 100644 index 0000000..95e190c --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/IAccountAggregate.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Events; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates +{ + public interface IAccountAggregate + { + Task CloseAccount(int accountId, string reason); + + Task CreateAccount(int accountId, string holder, decimal amount); + + Task Deposit(int accountId, decimal amount); + + Task On(AccountCreated @event); + + Task Withdraw(int accountId, decimal amount); + + Task RepayHistory(int accountId); + } +} diff --git a/src/SourceFlow.ConsoleApp/Aggregates/TransactionType.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/TransactionType.cs similarity index 55% rename from src/SourceFlow.ConsoleApp/Aggregates/TransactionType.cs rename to tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/TransactionType.cs index 855de66..4b59448 100644 --- a/src/SourceFlow.ConsoleApp/Aggregates/TransactionType.cs +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Aggregates/TransactionType.cs @@ -1,8 +1,8 @@ -namespace SourceFlow.ConsoleApp.Aggregates +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates { public enum TransactionType { Deposit, Withdrawal } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/ActivateAccount.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/ActivateAccount.cs new file mode 100644 index 0000000..46a1b1e --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/ActivateAccount.cs @@ -0,0 +1,16 @@ +using SourceFlow.Messaging.Commands; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Commands +{ + public class ActivateAccount : Command + { + // Parameterless constructor for deserialization + public ActivateAccount() : base() + { + } + + public ActivateAccount(int entityId, ActivationPayload payload) : base(entityId, payload) + { + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/CloseAccount.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/CloseAccount.cs new file mode 100644 index 0000000..3223ee3 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/CloseAccount.cs @@ -0,0 +1,16 @@ +using SourceFlow.Messaging.Commands; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Commands +{ + public class CloseAccount : Command + { + // Parameterless constructor for deserialization + public CloseAccount() : base() + { + } + + public CloseAccount(int entityId, ClosurePayload payload) : base(entityId, payload) + { + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/CreateAccount.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/CreateAccount.cs new file mode 100644 index 0000000..b639959 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/CreateAccount.cs @@ -0,0 +1,16 @@ +using SourceFlow.Messaging.Commands; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Commands +{ + public class CreateAccount : Command + { + // Parameterless constructor for deserialization + public CreateAccount() : base() + { + } + + public CreateAccount(int entityId, Payload payload) : base(entityId, true, payload) + { + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/DepositMoney.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/DepositMoney.cs new file mode 100644 index 0000000..809e4cf --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/DepositMoney.cs @@ -0,0 +1,16 @@ +using SourceFlow.Messaging.Commands; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Commands +{ + public class DepositMoney : Command + { + // Parameterless constructor for deserialization + public DepositMoney() : base() + { + } + + public DepositMoney(int entityId, TransactPayload payload) : base(entityId, payload) + { + } + } +} diff --git a/src/SourceFlow.ConsoleApp/Commands/Payload.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/Payload.cs similarity index 84% rename from src/SourceFlow.ConsoleApp/Commands/Payload.cs rename to tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/Payload.cs index 20165ca..c3a4df7 100644 --- a/src/SourceFlow.ConsoleApp/Commands/Payload.cs +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/Payload.cs @@ -1,7 +1,8 @@ -using SourceFlow.ConsoleApp.Aggregates; +using System; using SourceFlow.Messaging; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates; -namespace SourceFlow.ConsoleApp.Commands +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Commands { public class ActivationPayload : IPayload { @@ -30,4 +31,4 @@ public class ClosurePayload : IPayload public bool IsClosed { get; set; } public string ClosureReason { get; set; } = string.Empty; } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/WithdrawMoney.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/WithdrawMoney.cs new file mode 100644 index 0000000..e87c9bf --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Commands/WithdrawMoney.cs @@ -0,0 +1,16 @@ +using SourceFlow.Messaging.Commands; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Commands +{ + public class WithdrawMoney : Command + { + // Parameterless constructor for deserialization + public WithdrawMoney() : base() + { + } + + public WithdrawMoney(int entityId, TransactPayload payload) : base(entityId, payload) + { + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/E2E.Tests.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/E2E.Tests.cs new file mode 100644 index 0000000..f463ca1 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/E2E.Tests.cs @@ -0,0 +1,133 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using SourceFlow.Saga; +using SourceFlow.Stores.EntityFramework.Extensions; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Projections; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E +{ + [TestFixture] + public class ProgramIntegrationTests + { + private ServiceProvider _serviceProvider; + private IAccountAggregate _accountAggregate; + private ISaga _saga; + private ILogger _logger; + private IViewModelStoreAdapter _viewRepository; + private int _accountId = 999; + + [SetUp] + public void SetUp() + { + // Clear any previous registrations + EntityDbContext.ClearRegistrations(); + ViewModelDbContext.ClearRegistrations(); + + // Register the test assembly for scanning + EntityDbContext.RegisterAssembly(typeof(BankAccount).Assembly); + ViewModelDbContext.RegisterAssembly(typeof(AccountViewModel).Assembly); + + var services = new ServiceCollection(); + + // Register logging with console provider + services.AddLogging(builder => + { + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); + }); + + // Register SourceFlow and all required services + // Pass the test assembly so it can discover E2E aggregates, sagas, and projections + services.UseSourceFlow(Assembly.GetExecutingAssembly()); + // Register EF store implementations with SQLite with sensitive data logging. + services.AddSourceFlowEfStoresWithCustomProvider(options => + options.UseSqlite("DataSource=sourceflow.db") + .EnableSensitiveDataLogging() + .LogTo(Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information)); + + _serviceProvider = services.BuildServiceProvider(); + + // Ensure all database schemas are created fresh for each test + // Delete the database once (all contexts share the same database file) + var commandContext = _serviceProvider.GetRequiredService(); + commandContext.Database.EnsureDeleted(); + + // Create all tables for each context + commandContext.Database.EnsureCreated(); + + var entityContext = _serviceProvider.GetRequiredService(); + entityContext.Database.EnsureCreated(); + entityContext.ApplyMigrations(); // On migrations for registered entity types + + var viewModelContext = _serviceProvider.GetRequiredService(); + viewModelContext.Database.EnsureCreated(); + viewModelContext.ApplyMigrations(); // On migrations for registered view model types + + _saga = _serviceProvider.GetRequiredService(); + _accountAggregate = _serviceProvider.GetRequiredService(); + + _logger = _serviceProvider.GetRequiredService>(); + _viewRepository = _serviceProvider.GetRequiredService(); + } + + [TearDown] + public void TearDown() + { + if (_serviceProvider is not null) + _serviceProvider.Dispose(); + } + + [Test] + public async Task EndToEnd_AccountLifecycle_WorksAsExpected() + { + // Create account + await _accountAggregate.CreateAccount(_accountId, "John Doe", 1000m); + _logger.LogInformation("Action=Test_Create_Account, Account: {accountId}", _accountId); + + // Perform deposit + var amount = 500m; + _logger.LogInformation("Action=Test_Deposit, Amount={Amount}", amount); + await _accountAggregate.Deposit(_accountId, amount); + + // Perform withdraw + amount = 200m; + _logger.LogInformation("Action=Test_Withdraw, Amount={Amount}", amount); + await _accountAggregate.Withdraw(_accountId, amount); + + // Perform another deposit + amount = 100m; + _logger.LogInformation("Action=Test_Deposit, Amount={Amount}", amount); + await _accountAggregate.Deposit(_accountId, amount); + + // Get current state and assertions + var account = await _viewRepository.Find(_accountId); + Assert.That(account, Is.Not.Null); + Assert.That(_accountId, Is.EqualTo(account.Id)); + Assert.That(account.AccountName, Is.EqualTo("John Doe")); + Assert.That(account.CurrentBalance, Is.EqualTo(1000m + 500m - 200m + 100m)); + Assert.That(account.TransactionCount, Is.GreaterThanOrEqualTo(3)); + Assert.That(account.IsClosed, Is.False); + + // Replay account history (should not throw) + Assert.DoesNotThrowAsync(async () => await _accountAggregate.RepayHistory(_accountId)); + + // Fetch state again, should be the same + var replayedAccount = await _viewRepository.Find(_accountId); + Assert.That(account.CurrentBalance, Is.EqualTo(replayedAccount.CurrentBalance)); + Assert.That(account.TransactionCount, Is.EqualTo(replayedAccount.TransactionCount)); + + // CloseAccount account + Assert.DoesNotThrowAsync(async () => await _accountAggregate.CloseAccount(_accountId, "Customer account close request")); + + // Final state + var closedAccount = await _viewRepository.Find(_accountId); + Assert.That(closedAccount.IsClosed, Is.True); + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Events/AccountCreated.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Events/AccountCreated.cs new file mode 100644 index 0000000..dfe0c99 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Events/AccountCreated.cs @@ -0,0 +1,12 @@ +using SourceFlow.Messaging.Events; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Events +{ + public class AccountCreated : Event + { + public AccountCreated(BankAccount payload) : base(payload) + { + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Events/AccountUpdated.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Events/AccountUpdated.cs new file mode 100644 index 0000000..003b2d8 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Events/AccountUpdated.cs @@ -0,0 +1,12 @@ +using SourceFlow.Messaging.Events; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates; + +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Events +{ + public class AccountUpdated : Event + { + public AccountUpdated(BankAccount payload) : base(payload) + { + } + } +} diff --git a/src/SourceFlow.ConsoleApp/Projections/AccountView.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Projections/AccountView.cs similarity index 55% rename from src/SourceFlow.ConsoleApp/Projections/AccountView.cs rename to tests/SourceFlow.Net.EntityFramework.Tests/E2E/Projections/AccountView.cs index 3c4b786..b83b200 100644 --- a/src/SourceFlow.ConsoleApp/Projections/AccountView.cs +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Projections/AccountView.cs @@ -1,19 +1,20 @@ -using SourceFlow.ConsoleApp.Events; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using SourceFlow.Projections; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Events; -namespace SourceFlow.ConsoleApp.Projections +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Projections { - public class AccountView : IProjectOn, + public class AccountView : View, + IProjectOn, IProjectOn { - private readonly IViewProvider provider; - - public AccountView(IViewProvider provider) + public AccountView(IViewModelStoreAdapter viewModelStore, ILogger logger) : base(viewModelStore, logger) { - this.provider = provider ?? throw new ArgumentNullException(nameof(provider)); } - public async Task Apply(AccountCreated @event) + public async Task On(AccountCreated @event) { var view = new AccountViewModel { @@ -24,19 +25,16 @@ public async Task Apply(AccountCreated @event) CreatedDate = @event.Payload.CreatedOn, LastUpdated = DateTime.UtcNow, TransactionCount = 0, - ClosureReason = null, + ClosureReason = null!, Version = 1 }; - await provider.Push(view); + return await viewModelStore.Persist(view); } - public async Task Apply(AccountUpdated @event) + public async Task On(AccountUpdated @event) { - var view = await provider.Find(@event.Payload.Id); - - if (view == null) - throw new InvalidOperationException($"Account view not found for ID: {@event.Payload.Id}"); + var view = await viewModelStore.Find(@event.Payload.Id); view.CurrentBalance = @event.Payload.Balance; view.LastUpdated = DateTime.UtcNow; @@ -47,7 +45,7 @@ public async Task Apply(AccountUpdated @event) view.Version++; view.TransactionCount++; - await provider.Push(view); + return await viewModelStore.Persist(view); } } -} \ No newline at end of file +} diff --git a/src/SourceFlow.ConsoleApp/Projections/AccountViewModel.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Projections/AccountViewModel.cs similarity index 87% rename from src/SourceFlow.ConsoleApp/Projections/AccountViewModel.cs rename to tests/SourceFlow.Net.EntityFramework.Tests/E2E/Projections/AccountViewModel.cs index d1a8dd9..3acddaf 100644 --- a/src/SourceFlow.ConsoleApp/Projections/AccountViewModel.cs +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Projections/AccountViewModel.cs @@ -1,6 +1,7 @@ +using System; using SourceFlow.Projections; -namespace SourceFlow.ConsoleApp.Projections +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Projections { public class AccountViewModel : IViewModel { @@ -15,4 +16,4 @@ public class AccountViewModel : IViewModel public int Version { get; set; } public DateTime ActiveOn { get; set; } } -} \ No newline at end of file +} diff --git a/src/SourceFlow.ConsoleApp/Sagas/AccountSaga.cs b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Sagas/AccountSaga.cs similarity index 54% rename from src/SourceFlow.ConsoleApp/Sagas/AccountSaga.cs rename to tests/SourceFlow.Net.EntityFramework.Tests/E2E/Sagas/AccountSaga.cs index 6e8fb91..23af612 100644 --- a/src/SourceFlow.ConsoleApp/Sagas/AccountSaga.cs +++ b/tests/SourceFlow.Net.EntityFramework.Tests/E2E/Sagas/AccountSaga.cs @@ -1,22 +1,31 @@ +using System; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using SourceFlow.ConsoleApp.Aggregates; -using SourceFlow.ConsoleApp.Commands; -using SourceFlow.ConsoleApp.Events; +using SourceFlow.Messaging.Commands; +using SourceFlow.Messaging.Events; using SourceFlow.Saga; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Aggregates; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Commands; +using SourceFlow.Stores.EntityFramework.Tests.E2E.Events; -namespace SourceFlow.ConsoleApp.Sagas +namespace SourceFlow.Stores.EntityFramework.Tests.E2E.Sagas { public class AccountSaga : Saga, - IHandles, - IHandles, - IHandles, - IHandles, - IHandles + IHandlesWithEvent, + IHandlesWithEvent, + IHandlesWithEvent, + IHandlesWithEvent, + IHandles { - public async Task Handle(CreateAccount command) + public AccountSaga(Lazy commandPublisher, IEventQueue eventQueue, IEntityStoreAdapter repository, ILogger logger) : + base(commandPublisher, eventQueue, repository, logger) + { + } + + public Task Handle(IEntity entity, CreateAccount command) { logger.LogInformation("Action=Account_Created, Account={AccountId}, Holder={AccountName}, Initial_Balance={InitialBalance}", - command.Payload.Id, command.Payload.AccountName, command.Payload.InitialAmount); + command.Entity.Id, command.Payload.AccountName, command.Payload.InitialAmount); if (string.IsNullOrEmpty(command.Payload.AccountName)) throw new ArgumentException("Account create requires account holder name.", nameof(command.Payload.AccountName)); @@ -24,85 +33,75 @@ public async Task Handle(CreateAccount command) if (command.Payload.InitialAmount <= 0) throw new ArgumentException("Account create requires initial amount.", nameof(command.Payload.InitialAmount)); - var account = new BankAccount - { - Id = command.Payload.Id, - AccountName = command.Payload.AccountName, - Balance = command.Payload.InitialAmount - }; + var account = (BankAccount)entity; - await repository.Persist(account); + account.AccountName = command.Payload.AccountName; + account.Balance = command.Payload.InitialAmount; - await Raise(new AccountCreated(account)); + return Task.FromResult(account); } - public async Task Handle(ActivateAccount command) + public Task Handle(IEntity entity, ActivateAccount command) { - logger.LogInformation("Action=Account_Activate, ActivatedOn={ActiveOn}, Account={AccountId}", command.Payload.ActiveOn, command.Payload.Id); + logger.LogInformation("Action=Account_Activate, ActivatedOn={ActiveOn}, Account={AccountId}", command.Payload.ActiveOn, command.Entity.Id); + + if (command.Payload.ActiveOn == DateTime.MinValue) + throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.ActiveOn)); - var account = await repository.Get(command.Payload.Id); + var account = (BankAccount)entity; if (account.IsClosed) throw new InvalidOperationException("Cannot deposit to a closed account"); - if (command.Payload.ActiveOn == DateTime.MinValue) - throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.ActiveOn)); - account.ActiveOn = command.Payload.ActiveOn; - await repository.Persist(account); - - await Raise(new AccountUpdated(account)); + return Task.FromResult(account); } - public async Task Handle(DepositMoney command) + public Task Handle(IEntity entity, DepositMoney command) { - logger.LogInformation("Action=Money_Deposited, Amount={Amount}, Account={AccountId}", command.Payload.Amount, command.Payload.Id); + logger.LogInformation("Action=Money_Deposited, Amount={Amount}, Account={AccountId}", command.Payload.Amount, command.Entity.Id); + + if (command.Payload.Amount <= 0) + throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.Amount)); - var account = await repository.Get(command.Payload.Id); + var account = (BankAccount)entity; if (account.IsClosed) throw new InvalidOperationException("Cannot deposit to a closed account"); - if (command.Payload.Amount <= 0) - throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.Amount)); - command.Payload.CurrentBalance = account.Balance + command.Payload.Amount; account.Balance = command.Payload.CurrentBalance; - await repository.Persist(account); - - await Raise(new AccountUpdated(account)); + return Task.FromResult(account); } - public async Task Handle(WithdrawMoney command) + public Task Handle(IEntity entity, WithdrawMoney command) { - logger.LogInformation("Action=Money_Withdrawn, Amount={Amount}, Account={AccountId}", command.Payload.Amount, command.Payload.Id); + logger.LogInformation("Action=Money_Withdrawn, Amount={Amount}, Account={AccountId}", command.Payload.Amount, command.Entity.Id); + + if (command.Payload.Amount <= 0) + throw new ArgumentException("Withdrawal amount must be positive", nameof(command.Payload.Amount)); - var account = await repository.Get(command.Payload.Id); + var account = (BankAccount)entity; if (account.IsClosed) throw new InvalidOperationException("Cannot deposit to a closed account"); - if (command.Payload.Amount <= 0) - throw new ArgumentException("Deposit amount must be positive", nameof(command.Payload.Amount)); - command.Payload.CurrentBalance = account.Balance - command.Payload.Amount; account.Balance = command.Payload.CurrentBalance; - await repository.Persist(account); - - await Raise(new AccountUpdated(account)); + return Task.FromResult(account); } - public async Task Handle(CloseAccount command) + public Task Handle(IEntity entity, CloseAccount command) { - logger.LogInformation("Action=Account_Closed, Account={AccountId}, Reason={Reason}", command.Payload.Id, command.Payload.ClosureReason); + logger.LogInformation("Action=Account_Closed, Account={AccountId}, Reason={Reason}", command.Entity.Id, command.Payload.ClosureReason); if (string.IsNullOrWhiteSpace(command.Payload.ClosureReason)) throw new ArgumentException("Reason for closing cannot be empty", nameof(command.Payload.ClosureReason)); - var account = await repository.Get(command.Payload.Id); + var account = (BankAccount)entity; if (account.IsClosed) throw new InvalidOperationException("Cannot close account on a closed account"); @@ -110,9 +109,7 @@ public async Task Handle(CloseAccount command) account.ClosureReason = command.Payload.ClosureReason; account.IsClosed = command.Payload.IsClosed = true; - await repository.Persist(account); - - await Raise(new AccountUpdated(account)); + return Task.FromResult(account); } } -} \ No newline at end of file +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/SourceFlow.Stores.EntityFramework.Tests.csproj b/tests/SourceFlow.Net.EntityFramework.Tests/SourceFlow.Stores.EntityFramework.Tests.csproj new file mode 100644 index 0000000..0432689 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/SourceFlow.Stores.EntityFramework.Tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + false + true + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfCommandStoreIntegrationTests.cs b/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfCommandStoreIntegrationTests.cs new file mode 100644 index 0000000..d00f949 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfCommandStoreIntegrationTests.cs @@ -0,0 +1,274 @@ +#nullable enable + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; +using SourceFlow.Stores.EntityFramework.Options; +using SourceFlow.Stores.EntityFramework.Services; +using SourceFlow.Stores.EntityFramework.Stores; +using SourceFlow.Stores.EntityFramework.Tests.TestModels; + +namespace SourceFlow.Stores.EntityFramework.Tests.Stores +{ + [TestFixture] + public class EfCommandStoreIntegrationTests + { + private ServiceProvider? _serviceProvider; + private ICommandStore? _store; + private CommandDbContext? _context; + + [SetUp] + public void Setup() + { + // Clear any previous registrations + EntityDbContext.ClearRegistrations(); + ViewModelDbContext.ClearRegistrations(); + + // Register the test assembly for scanning + EntityDbContext.RegisterAssembly(typeof(TestEntity).Assembly); + ViewModelDbContext.RegisterAssembly(typeof(TestViewModel).Assembly); + + // Create a shared in-memory SQLite connection for all contexts to share the same database + var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var services = new ServiceCollection(); + + // Configure and register the specific DbContext instances with shared connection + // Use EnableServiceProviderCaching(false) to avoid EF Core 9.0 multiple provider conflicts + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + + // Register SourceFlowEfOptions with resilience and observability disabled for tests + var options = new SourceFlowEfOptions + { + Resilience = { Enabled = false }, + Observability = { Enabled = false } + }; + services.AddSingleton(options); + + // Register required services (resilience policy and telemetry service) + services.AddScoped(); + services.AddScoped(); + + // Register the stores that will use these specific DbContext instances + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + _serviceProvider = services.BuildServiceProvider(); + + // Create and open the in-memory database - schema needs to be created for all contexts + var commandContext = _serviceProvider.GetRequiredService(); + commandContext.Database.EnsureCreated(); // This creates the Commands schema + + var entityContext = _serviceProvider.GetRequiredService(); + entityContext.Database.EnsureCreated(); // This creates the Entities schema + entityContext.ApplyMigrations(); // On migrations for registered entity types + + var viewModelContext = _serviceProvider.GetRequiredService(); + viewModelContext.Database.EnsureCreated(); // This creates the ViewModels schema + viewModelContext.ApplyMigrations(); // On migrations for registered view model types + + _context = commandContext; + _store = _serviceProvider.GetRequiredService(); + } + + [TearDown] + public void TearDown() + { + _context?.Database.CloseConnection(); + _context?.Dispose(); + _serviceProvider?.Dispose(); + } + + [Test] + public async Task Append_ValidCommand_StoresCommandInDatabase() + { + // Arrange + var commandData = new CommandData + { + EntityId = 1, + SequenceNo = 1, + CommandName = "TestCommand", + CommandType = typeof(TestCommand).AssemblyQualifiedName ?? string.Empty, + PayloadType = typeof(TestPayload).AssemblyQualifiedName ?? string.Empty, + PayloadData = System.Text.Json.JsonSerializer.Serialize(new TestPayload { Action = "Create", Data = "Test data" }), + Metadata = System.Text.Json.JsonSerializer.Serialize(new Metadata { SequenceNo = 1 }), + Timestamp = DateTime.UtcNow + }; + + // Act + await _store!.Append(commandData); + + // Assert + var commands = await _store.Load(1); + var commandsList = commands.ToList(); + + Assert.That(commandsList, Has.Count.EqualTo(1)); + Assert.That(commandsList[0].EntityId, Is.EqualTo(1)); + Assert.That(commandsList[0].SequenceNo, Is.EqualTo(1)); + } + + [Test] + public async Task Append_MultipleCommands_StoresAllCommandsInOrder() + { + // Arrange + var commandData1 = new CommandData + { + EntityId = 1, + SequenceNo = 1, + CommandName = "TestCommand", + CommandType = typeof(TestCommand).AssemblyQualifiedName ?? string.Empty, + PayloadType = typeof(TestPayload).AssemblyQualifiedName ?? string.Empty, + PayloadData = System.Text.Json.JsonSerializer.Serialize(new TestPayload { Action = "Create", Data = "First" }), + Metadata = System.Text.Json.JsonSerializer.Serialize(new Metadata { SequenceNo = 1 }), + Timestamp = DateTime.UtcNow + }; + + var commandData2 = new CommandData + { + EntityId = 1, + SequenceNo = 2, + CommandName = "TestCommand", + CommandType = typeof(TestCommand).AssemblyQualifiedName ?? string.Empty, + PayloadType = typeof(TestPayload).AssemblyQualifiedName ?? string.Empty, + PayloadData = System.Text.Json.JsonSerializer.Serialize(new TestPayload { Action = "Update", Data = "Second" }), + Metadata = System.Text.Json.JsonSerializer.Serialize(new Metadata { SequenceNo = 2 }), + Timestamp = DateTime.UtcNow + }; + + var commandData3 = new CommandData + { + EntityId = 1, + SequenceNo = 3, + CommandName = "TestCommand", + CommandType = typeof(TestCommand).AssemblyQualifiedName ?? string.Empty, + PayloadType = typeof(TestPayload).AssemblyQualifiedName ?? string.Empty, + PayloadData = System.Text.Json.JsonSerializer.Serialize(new TestPayload { Action = "Delete", Data = "Third" }), + Metadata = System.Text.Json.JsonSerializer.Serialize(new Metadata { SequenceNo = 3 }), + Timestamp = DateTime.UtcNow + }; + + // Act + await _store!.Append(commandData1); + await _store.Append(commandData2); + await _store.Append(commandData3); + + // Assert + var commands = await _store.Load(1); + var commandsList = commands.ToList(); + + Assert.That(commandsList, Has.Count.EqualTo(3)); + Assert.That(commandsList[0].SequenceNo, Is.EqualTo(1)); + Assert.That(commandsList[1].SequenceNo, Is.EqualTo(2)); + Assert.That(commandsList[2].SequenceNo, Is.EqualTo(3)); + } + + [Test] + public async Task Load_NonExistentEntity_ReturnsEmptyList() + { + // Act + var commands = await _store!.Load(999); + + // Assert + Assert.That(commands, Is.Empty); + } + + [Test] + public async Task Append_CommandsForDifferentEntities_StoresSeparately() + { + // Arrange + var commandData1 = new CommandData + { + EntityId = 1, + SequenceNo = 1, + CommandName = "TestCommand", + CommandType = typeof(TestCommand).AssemblyQualifiedName ?? string.Empty, + PayloadType = typeof(TestPayload).AssemblyQualifiedName ?? string.Empty, + PayloadData = System.Text.Json.JsonSerializer.Serialize(new TestPayload { Action = "Create", Data = "Entity 1" }), + Metadata = System.Text.Json.JsonSerializer.Serialize(new Metadata { SequenceNo = 1 }), + Timestamp = DateTime.UtcNow + }; + + var commandData2 = new CommandData + { + EntityId = 2, + SequenceNo = 1, + CommandName = "TestCommand", + CommandType = typeof(TestCommand).AssemblyQualifiedName ?? string.Empty, + PayloadType = typeof(TestPayload).AssemblyQualifiedName ?? string.Empty, + PayloadData = System.Text.Json.JsonSerializer.Serialize(new TestPayload { Action = "Create", Data = "Entity 2" }), + Metadata = System.Text.Json.JsonSerializer.Serialize(new Metadata { SequenceNo = 1 }), + Timestamp = DateTime.UtcNow + }; + + // Act + await _store!.Append(commandData1); + await _store.Append(commandData2); + + // Assert + var commands1 = await _store.Load(1); + var commands2 = await _store.Load(2); + + Assert.That(commands1.Count(), Is.EqualTo(1)); + Assert.That(commands2.Count(), Is.EqualTo(1)); + Assert.That(commands1.First().EntityId, Is.EqualTo(1)); + Assert.That(commands2.First().EntityId, Is.EqualTo(2)); + } + + [Test] + public void Append_NullCommand_ThrowsArgumentNullException() + { + // Act & Assert + Assert.ThrowsAsync(async () => + await _store!.Append(null!)); + } + + [Test] + public async Task Load_AfterMultipleAppends_ReturnsCommandsInCorrectOrder() + { + // Arrange + for (int i = 1; i <= 10; i++) + { + var commandData = new CommandData + { + EntityId = 1, + SequenceNo = i, + CommandName = "TestCommand", + CommandType = typeof(TestCommand).AssemblyQualifiedName ?? string.Empty, + PayloadType = typeof(TestPayload).AssemblyQualifiedName ?? string.Empty, + PayloadData = System.Text.Json.JsonSerializer.Serialize(new TestPayload { Action = $"Action{i}", Data = $"Data{i}" }), + Metadata = System.Text.Json.JsonSerializer.Serialize(new Metadata { SequenceNo = i }), + Timestamp = DateTime.UtcNow + }; + await _store!.Append(commandData); + } + + // Act + var commands = await _store!.Load(1); + var commandsList = commands.ToList(); + + // Assert + Assert.That(commandsList, Has.Count.EqualTo(10)); + for (int i = 0; i < 10; i++) + { + Assert.That(commandsList[i].SequenceNo, Is.EqualTo(i + 1)); + } + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfEntityStoreIntegrationTests.cs b/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfEntityStoreIntegrationTests.cs new file mode 100644 index 0000000..5235d73 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfEntityStoreIntegrationTests.cs @@ -0,0 +1,267 @@ +#nullable enable + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SourceFlow.Stores.EntityFramework.Options; +using SourceFlow.Stores.EntityFramework.Services; +using SourceFlow.Stores.EntityFramework.Stores; +using SourceFlow.Stores.EntityFramework.Tests.TestModels; + +namespace SourceFlow.Stores.EntityFramework.Tests.Stores +{ + [TestFixture] + public class EfEntityStoreIntegrationTests + { + private ServiceProvider? _serviceProvider; + private IEntityStore? _store; + private EntityDbContext? _context; + + [SetUp] + public void Setup() + { + // Clear any previous registrations + EntityDbContext.ClearRegistrations(); + ViewModelDbContext.ClearRegistrations(); + + // Register the test assembly for scanning + EntityDbContext.RegisterAssembly(typeof(TestEntity).Assembly); + ViewModelDbContext.RegisterAssembly(typeof(TestViewModel).Assembly); + + // Create a shared in-memory SQLite connection for all contexts to share the same database + var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var services = new ServiceCollection(); + + // Configure SQLite in-memory database for testing - using shared connection for all contexts + // Use EnableServiceProviderCaching(false) to avoid EF Core 9.0 multiple provider conflicts + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + + // Register all contexts for testing (even though only EntityDbContext is used by the store) + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + + // Register SourceFlowEfOptions with default settings for tests + var efOptions = new SourceFlowEfOptions(); + services.AddSingleton(efOptions); + + // Register common services manually (don't use AddSourceFlowEfStores as it would add SQL Server) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + _serviceProvider = services.BuildServiceProvider(); + + // Create and open the in-memory database - ensure all contexts schemas are created + var commandContext = _serviceProvider.GetRequiredService(); + commandContext.Database.EnsureCreated(); // This creates the Commands schema + + _context = _serviceProvider.GetRequiredService(); + _context.Database.EnsureCreated(); // This creates the Entities schema + _context.ApplyMigrations(); // On migrations for registered entity types + + var viewModelContext = _serviceProvider.GetRequiredService(); + viewModelContext.Database.EnsureCreated(); // This creates the ViewModels schema + viewModelContext.ApplyMigrations(); // On migrations for registered view model types + + _store = _serviceProvider.GetRequiredService(); + } + + [TearDown] + public void TearDown() + { + _context?.Database.CloseConnection(); + _context?.Dispose(); + _serviceProvider?.Dispose(); + } + + [Test] + public async Task Persist_NewEntity_StoresEntityInDatabase() + { + // Arrange + var entity = new TestEntity + { + Id = 1, + Name = "Test Entity", + Description = "Test Description", + Value = 42 + }; + + // Act + await _store!.Persist(entity); + + // Assert + var retrieved = await _store.Get(1); + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Id, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Test Entity")); + Assert.That(retrieved.Description, Is.EqualTo("Test Description")); + Assert.That(retrieved.Value, Is.EqualTo(42)); + } + + [Test] + public async Task Persist_ExistingEntity_UpdatesEntity() + { + // Arrange + var entity = new TestEntity + { + Id = 1, + Name = "Original Name", + Description = "Original Description", + Value = 10 + }; + + await _store!.Persist(entity); + + // Act - Update the entity + entity.Name = "Updated Name"; + entity.Description = "Updated Description"; + entity.Value = 20; + await _store.Persist(entity); + + // Assert + var retrieved = await _store.Get(1); + Assert.That(retrieved.Name, Is.EqualTo("Updated Name")); + Assert.That(retrieved.Description, Is.EqualTo("Updated Description")); + Assert.That(retrieved.Value, Is.EqualTo(20)); + } + + [Test] + public async Task Get_NonExistentEntity_ThrowsInvalidOperationException() + { + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _store!.Get(999)); + + Assert.That(ex!.Message, Does.Contain("not found")); + } + + [Test] + public async Task Delete_ExistingEntity_RemovesEntityFromDatabase() + { + // Arrange + var entity = new TestEntity + { + Id = 1, + Name = "Test Entity", + Description = "Test Description", + Value = 42 + }; + + await _store!.Persist(entity); + + // Act + await _store.Delete(entity); + + // Assert + Assert.ThrowsAsync(async () => + await _store.Get(1)); + } + + [Test] + public void Delete_NonExistentEntity_ThrowsInvalidOperationException() + { + // Arrange + var entity = new TestEntity { Id = 999, Name = "Non-existent" }; + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _store!.Delete(entity)); + + Assert.That(ex!.Message, Does.Contain("not found")); + } + + [Test] + public void Persist_EntityWithInvalidId_ThrowsArgumentException() + { + // Arrange + var entity = new TestEntity { Id = 0, Name = "Invalid" }; + + // Act & Assert + Assert.ThrowsAsync(async () => + await _store!.Persist(entity)); + } + + [Test] + public void Persist_NullEntity_ThrowsArgumentNullException() + { + // Act & Assert + Assert.ThrowsAsync(async () => + await _store!.Persist(null!)); + } + + [Test] + public void Get_InvalidId_ThrowsArgumentException() + { + // Act & Assert + Assert.ThrowsAsync(async () => + await _store!.Get(0)); + } + + [Test] + public async Task Persist_MultipleEntities_StoresAllEntities() + { + // Arrange & Act + for (int i = 1; i <= 5; i++) + { + var entity = new TestEntity + { + Id = i, + Name = $"Entity {i}", + Description = $"Description {i}", + Value = i * 10 + }; + await _store!.Persist(entity); + } + + // Assert + for (int i = 1; i <= 5; i++) + { + var retrieved = await _store!.Get(i); + Assert.That(retrieved.Id, Is.EqualTo(i)); + Assert.That(retrieved.Name, Is.EqualTo($"Entity {i}")); + Assert.That(retrieved.Value, Is.EqualTo(i * 10)); + } + } + + [Test] + public async Task Persist_SameIdDifferentOperations_MaintainsDataIntegrity() + { + // Arrange + var entity = new TestEntity + { + Id = 1, + Name = "First Version", + Description = "Description 1", + Value = 100 + }; + + // Act - Create + await _store!.Persist(entity); + var v1 = await _store.Get(1); + + // Act - Update + entity.Name = "Second Version"; + entity.Value = 200; + await _store.Persist(entity); + var v2 = await _store.Get(1); + + // Assert + Assert.That(v1.Name, Is.EqualTo("First Version")); + Assert.That(v1.Value, Is.EqualTo(100)); + Assert.That(v2.Name, Is.EqualTo("Second Version")); + Assert.That(v2.Value, Is.EqualTo(200)); + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfViewModelStoreIntegrationTests.cs b/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfViewModelStoreIntegrationTests.cs new file mode 100644 index 0000000..d646244 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/Stores/EfViewModelStoreIntegrationTests.cs @@ -0,0 +1,267 @@ +#nullable enable + +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SourceFlow.Stores.EntityFramework.Options; +using SourceFlow.Stores.EntityFramework.Services; +using SourceFlow.Stores.EntityFramework.Stores; +using SourceFlow.Stores.EntityFramework.Tests.TestModels; + +namespace SourceFlow.Stores.EntityFramework.Tests.Stores +{ + [TestFixture] + public class EfViewModelStoreIntegrationTests + { + private ServiceProvider? _serviceProvider; + private IViewModelStore? _store; + private ViewModelDbContext? _context; + + [SetUp] + public void Setup() + { + // Clear any previous registrations + ViewModelDbContext.ClearRegistrations(); + EntityDbContext.ClearRegistrations(); + + // Register the test assembly for scanning + ViewModelDbContext.RegisterAssembly(typeof(TestViewModel).Assembly); + EntityDbContext.RegisterAssembly(typeof(TestEntity).Assembly); + + // Create a shared in-memory SQLite connection for all contexts to share the same database + var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var services = new ServiceCollection(); + + // Configure SQLite in-memory database for testing - using shared connection for all contexts + // Use EnableServiceProviderCaching(false) to avoid EF Core 9.0 multiple provider conflicts + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + + // Register all contexts for testing (even though only ViewModelDbContext is used by the store) + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + services.AddDbContext(options => + options.UseSqlite(connection) + .EnableServiceProviderCaching(false)); + + // Register SourceFlowEfOptions with default settings for tests + var efOptions = new SourceFlowEfOptions(); + services.AddSingleton(efOptions); + + // Register common services manually (don't use AddSourceFlowEfStores as it would add SQL Server) + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + _serviceProvider = services.BuildServiceProvider(); + + // Create and open the in-memory database - ensure all contexts schemas are created + var commandContext = _serviceProvider.GetRequiredService(); + commandContext.Database.EnsureCreated(); // This creates the Commands schema + + var entityContext = _serviceProvider.GetRequiredService(); + entityContext.Database.EnsureCreated(); // This creates the Entities schema + entityContext.ApplyMigrations(); // On migrations for registered entity types + + _context = _serviceProvider.GetRequiredService(); + _context.Database.EnsureCreated(); // This creates the ViewModels schema + _context.ApplyMigrations(); // On migrations for registered view model types + + _store = _serviceProvider.GetRequiredService(); + } + + [TearDown] + public void TearDown() + { + _context?.Database.CloseConnection(); + _context?.Dispose(); + _serviceProvider?.Dispose(); + } + + [Test] + public async Task Persist_NewViewModel_StoresViewModelInDatabase() + { + // Arrange + var viewModel = new TestViewModel + { + Id = 1, + Name = "Test ViewModel", + Data = "Test Data", + Count = 42 + }; + + // Act + await _store!.Persist(viewModel); + + // Assert + var retrieved = await _store.Get(1); + Assert.That(retrieved, Is.Not.Null); + Assert.That(retrieved.Id, Is.EqualTo(1)); + Assert.That(retrieved.Name, Is.EqualTo("Test ViewModel")); + Assert.That(retrieved.Data, Is.EqualTo("Test Data")); + Assert.That(retrieved.Count, Is.EqualTo(42)); + } + + [Test] + public async Task Persist_ExistingViewModel_UpdatesViewModel() + { + // Arrange + var viewModel = new TestViewModel + { + Id = 1, + Name = "Original Name", + Data = "Original Data", + Count = 10 + }; + + await _store!.Persist(viewModel); + + // Act - Update the view model + viewModel.Name = "Updated Name"; + viewModel.Data = "Updated Data"; + viewModel.Count = 20; + await _store.Persist(viewModel); + + // Assert + var retrieved = await _store.Get(1); + Assert.That(retrieved.Name, Is.EqualTo("Updated Name")); + Assert.That(retrieved.Data, Is.EqualTo("Updated Data")); + Assert.That(retrieved.Count, Is.EqualTo(20)); + } + + [Test] + public async Task Get_NonExistentViewModel_ThrowsInvalidOperationException() + { + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _store!.Get(999)); + + Assert.That(ex!.Message, Does.Contain("not found")); + } + + [Test] + public async Task Delete_ExistingViewModel_RemovesViewModelFromDatabase() + { + // Arrange + var viewModel = new TestViewModel + { + Id = 1, + Name = "Test ViewModel", + Data = "Test Data", + Count = 42 + }; + + await _store!.Persist(viewModel); + + // Act + await _store.Delete(viewModel); + + // Assert + Assert.ThrowsAsync(async () => + await _store.Get(1)); + } + + [Test] + public void Delete_NonExistentViewModel_ThrowsInvalidOperationException() + { + // Arrange + var viewModel = new TestViewModel { Id = 999, Name = "Non-existent" }; + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _store!.Delete(viewModel)); + + Assert.That(ex!.Message, Does.Contain("not found")); + } + + [Test] + public void Persist_ViewModelWithInvalidId_ThrowsArgumentException() + { + // Arrange + var viewModel = new TestViewModel { Id = 0, Name = "Invalid" }; + + // Act & Assert + Assert.ThrowsAsync(async () => + await _store!.Persist(viewModel)); + } + + [Test] + public void Persist_NullViewModel_ThrowsArgumentNullException() + { + // Act & Assert + Assert.ThrowsAsync(async () => + await _store!.Persist(null!)); + } + + [Test] + public void Get_InvalidId_ThrowsArgumentException() + { + // Act & Assert + Assert.ThrowsAsync(async () => + await _store!.Get(0)); + } + + [Test] + public async Task Persist_MultipleViewModels_StoresAllViewModels() + { + // Arrange & Act + for (int i = 1; i <= 5; i++) + { + var viewModel = new TestViewModel + { + Id = i, + Name = $"ViewModel {i}", + Data = $"Data {i}", + Count = i * 10 + }; + await _store!.Persist(viewModel); + } + + // Assert + for (int i = 1; i <= 5; i++) + { + var retrieved = await _store!.Get(i); + Assert.That(retrieved.Id, Is.EqualTo(i)); + Assert.That(retrieved.Name, Is.EqualTo($"ViewModel {i}")); + Assert.That(retrieved.Count, Is.EqualTo(i * 10)); + } + } + + [Test] + public async Task Persist_SameIdDifferentOperations_MaintainsDataIntegrity() + { + // Arrange + var viewModel = new TestViewModel + { + Id = 1, + Name = "First Version", + Data = "Data 1", + Count = 100 + }; + + // Act - Create + await _store!.Persist(viewModel); + var v1 = await _store.Get(1); + + // Act - Update + viewModel.Name = "Second Version"; + viewModel.Count = 200; + await _store.Persist(viewModel); + var v2 = await _store.Get(1); + + // Assert + Assert.That(v1.Name, Is.EqualTo("First Version")); + Assert.That(v1.Count, Is.EqualTo(100)); + Assert.That(v2.Name, Is.EqualTo("Second Version")); + Assert.That(v2.Count, Is.EqualTo(200)); + } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/TestModels/TestModels.cs b/tests/SourceFlow.Net.EntityFramework.Tests/TestModels/TestModels.cs new file mode 100644 index 0000000..7a64330 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/TestModels/TestModels.cs @@ -0,0 +1,44 @@ +using SourceFlow.Messaging; +using SourceFlow.Messaging.Commands; +using SourceFlow.Projections; + +namespace SourceFlow.Stores.EntityFramework.Tests.TestModels +{ + public class TestEntity : IEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int Value { get; set; } + } + + public class TestPayload : IPayload + { + public string Action { get; set; } = string.Empty; + public string Data { get; set; } = string.Empty; + } + + public class TestCommand : Command + { + // Parameterless constructor for EF and JSON deserialization + public TestCommand() : base(false, new TestPayload()) + { + } + + public TestCommand(int entityId, TestPayload payload) : base(entityId, payload) + { + } + + public TestCommand(bool newEntity, TestPayload payload) : base(newEntity, payload) + { + } + } + + public class TestViewModel : IViewModel + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Data { get; set; } = string.Empty; + public int Count { get; set; } + } +} diff --git a/tests/SourceFlow.Net.EntityFramework.Tests/Unit/SourceFlowEfOptionsTests.cs b/tests/SourceFlow.Net.EntityFramework.Tests/Unit/SourceFlowEfOptionsTests.cs new file mode 100644 index 0000000..0095aa7 --- /dev/null +++ b/tests/SourceFlow.Net.EntityFramework.Tests/Unit/SourceFlowEfOptionsTests.cs @@ -0,0 +1,100 @@ +using System; +using NUnit.Framework; +using SourceFlow.Stores.EntityFramework.Options; + +namespace SourceFlow.Stores.EntityFramework.Tests.Unit +{ + [TestFixture] + public class SourceFlowEfOptionsTests + { + [Test] + public void GetConnectionString_WithCommandStoreTypeAndCommandConnectionString_ReturnsCommandConnectionString() + { + // Arrange + var options = new SourceFlowEfOptions + { + CommandConnectionString = "Command Connection", + EntityConnectionString = "Entity Connection", + ViewModelConnectionString = "ViewModel Connection" + }; + + // Act + var result = options.GetConnectionString(StoreType.Command); + + // Assert + Assert.That(result, Is.EqualTo("Command Connection")); + } + + [Test] + public void GetConnectionString_WithEntityStoreTypeAndEntityConnectionString_ReturnsEntityConnectionString() + { + // Arrange + var options = new SourceFlowEfOptions + { + CommandConnectionString = "Command Connection", + EntityConnectionString = "Entity Connection", + ViewModelConnectionString = "ViewModel Connection" + }; + + // Act + var result = options.GetConnectionString(StoreType.Entity); + + // Assert + Assert.That(result, Is.EqualTo("Entity Connection")); + } + + [Test] + public void GetConnectionString_WithViewModelStoreTypeAndViewModelConnectionString_ReturnsViewModelConnectionString() + { + // Arrange + var options = new SourceFlowEfOptions + { + CommandConnectionString = "Command Connection", + EntityConnectionString = "Entity Connection", + ViewModelConnectionString = "ViewModel Connection" + }; + + // Act + var result = options.GetConnectionString(StoreType.ViewModel); + + // Assert + Assert.That(result, Is.EqualTo("ViewModel Connection")); + } + + [Test] + public void GetConnectionString_WithCommandStoreTypeAndNoCommandConnectionStringButDefault_ReturnsDefaultConnectionString() + { + // Arrange + var options = new SourceFlowEfOptions + { + DefaultConnectionString = "Default Connection" + }; + + // Act + var result = options.GetConnectionString(StoreType.Command); + + // Assert + Assert.That(result, Is.EqualTo("Default Connection")); + } + + [Test] + public void GetConnectionString_WithUnknownStoreType_ThrowsArgumentException() + { + // Arrange + var options = new SourceFlowEfOptions(); + + // Act & Assert + Assert.Throws(() => options.GetConnectionString((StoreType)999)); + } + + [Test] + public void GetConnectionString_WithNoConnectionStrings_ThrowsInvalidOperationException() + { + // Arrange + var options = new SourceFlowEfOptions(); + + // Act & Assert + Assert.Throws(() => options.GetConnectionString(StoreType.Command)); + } + } +}