From f99697a851474bc41c04648b66c138c8da20d7dd Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Tue, 15 Apr 2025 08:42:35 -0400 Subject: [PATCH 01/41] Add documentation prompts for Reactive Domain library --- doc-prompt.md | 258 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 doc-prompt.md diff --git a/doc-prompt.md b/doc-prompt.md new file mode 100644 index 00000000..d8e1fb33 --- /dev/null +++ b/doc-prompt.md @@ -0,0 +1,258 @@ +# Prompts for creating comprehensive documentation for the Reactive Domain library + +{{ ... }} + +Use Mermaid to generate diagrams and flowcharts. + +Use the following prompts to create comprehensive documentation for the Reactive Domain library: + +## Table of Contents Prompt + +Create a table of contents for the documentation, including: + +1. Overview +2. Core Concepts +3. Component Documentation +4. Interface Documentation +5. Usage Patterns +6. Code Examples +7. Troubleshooting Guide +8. API Reference +9. Architecture Guide +10. Migration Guide +11. Glossary +12. FAQ +13. Deployment Guide +14. Performance Optimization Guide +15. Security Guide +16. Integration Guide +17. Video Tutorial Script +18. Workshop Materials +19. Documentation Structure + + +## Overview Prompt + +Generate comprehensive documentation for the Reactive Domain library, which is an open-source framework for implementing event sourcing in .NET projects using reactive programming principles. The documentation should cover all aspects of the library, including its core concepts, components, interfaces, and usage patterns. The documentation should be accessible to developers of all skill levels, from beginners to experts. + +## Core Concepts Prompt + +Document the core concepts of event sourcing as implemented in Reactive Domain, including: + +1. Event sourcing fundamentals and how they're implemented in Reactive Domain +2. The event store architecture and integration with EventStoreDB +3. The CQRS (Command Query Responsibility Segregation) pattern implementation +4. The reactive programming principles used throughout the library +5. The domain-driven design concepts that underpin the framework +6. How events, commands, and messages flow through the system +7. The correlation and causation tracking mechanisms + +## Component Documentation Prompt + +Create detailed documentation for each of the following components, including their purpose, interfaces, implementation details, and usage examples: + +1. **ReactiveDomain.Core**: Document the fundamental interfaces like `IEventSource`, `IMetadataSource`, and other core abstractions. +2. **ReactiveDomain.Foundation**: Document the domain implementation including `AggregateRoot`, `EventRecorder`, and repository patterns. +3. **ReactiveDomain.Messaging**: Document the messaging framework, including message types, handlers, and routing. +4. **ReactiveDomain.Persistence**: Document the event storage mechanisms, including `EventData`, `EventReadResult`, and stream operations. +5. **ReactiveDomain.Transport**: Document the transport layer for messages. +6. **ReactiveDomain.Testing**: Document the testing utilities and frameworks for event-sourced systems. +7. **ReactiveDomain.Policy**: Document the policy implementation and enforcement mechanisms. +8. **ReactiveDomain.IdentityStorage**: Document the identity storage mechanisms. +9. **ReactiveDomain.Tools**: Document the developer tools and utilities. + +## Interface Documentation Prompt + +Generate detailed documentation for all public interfaces in the library, including: + +1. `IEventSource` - The core interface for event-sourced entities +2. `IRepository` - The repository pattern implementation for event-sourced aggregates +3. `ICorrelatedRepository` - The repository with correlation support +4. `IListener` - The event stream listener interface +5. `IMetadataSource` - The metadata handling interface +6. `ISnapshotSource` - The snapshot mechanism interface +7. `IStreamStoreConnection` - The event store connection interface +8. `IEventSerializer` - The event serialization interface +9. `IMessage`, `ICommand`, `IEvent` - The message type interfaces +10. `ICorrelatedMessage`, `ICorrelatedEventSource` - The correlation tracking interfaces + +For each interface, include: +- Purpose and responsibility +- Method and property descriptions +- Usage patterns and best practices +- Implementation considerations +- Common pitfalls and how to avoid them + +## Usage Patterns Prompt + +Document the common usage patterns and best practices for Reactive Domain, including: + +1. Setting up a new Reactive Domain project +2. Creating and working with aggregates +3. Implementing commands and events +4. Setting up repositories and event stores +5. Implementing projections and read models +6. Handling concurrency and versioning +7. Error handling and recovery strategies +8. Testing event-sourced systems +9. Performance optimization techniques +10. Integration with other systems and frameworks + +## Code Examples Prompt + +Create practical code examples that demonstrate: + +1. Creating a new aggregate root +2. Handling commands and generating events +3. Saving and retrieving aggregates from repositories +4. Setting up event listeners and subscribers +5. Implementing projections for read models +6. Handling correlation and causation +7. Implementing snapshots for performance +8. Testing aggregates and event handlers +9. Integration with ASP.NET Core or other .NET applications +10. Complete sample applications demonstrating end-to-end workflows + +## Troubleshooting Guide Prompt + +Create a troubleshooting guide that addresses common issues and challenges when working with Reactive Domain, including: + +1. Event versioning and schema evolution +2. Handling concurrency conflicts +3. Debugging event-sourced systems +4. Performance issues and optimization +5. Integration challenges with existing systems +6. Testing strategies and common testing issues +7. Deployment considerations and best practices +8. Monitoring and observability + +## API Reference Prompt + +Generate a complete API reference for all public types, methods, properties, and events in the Reactive Domain library, organized by namespace and assembly. The reference should include: + +1. Type signatures and inheritance hierarchies +2. Method signatures, parameters, and return types +3. Property types and accessibility +4. Event patterns and subscription models +5. Extension points and customization options +6. Deprecation notices and migration paths + +## Architecture Guide Prompt + +Create an architecture guide that explains: + +1. The high-level architecture of Reactive Domain +2. The design principles and patterns used +3. The component interactions and dependencies +4. The extension points and customization options +5. The integration patterns with other systems +6. Scaling and performance considerations +7. Security considerations and best practices + +## Migration Guide Prompt + +Create a migration guide for users upgrading from previous versions of Reactive Domain, including: + +1. Breaking changes and deprecations +2. New features and enhancements +3. Migration strategies and patterns +4. Backward compatibility considerations +5. Testing strategies for migrations + +## Glossary Prompt + +Create a comprehensive glossary of terms used in Reactive Domain and event sourcing, including: + +1. Event sourcing terminology +2. CQRS terminology +3. Domain-driven design terminology +4. Reactive programming terminology +5. Reactive Domain-specific terminology + +## FAQ Prompt + +Generate a frequently asked questions section that addresses common questions about Reactive Domain, including: + +1. When to use event sourcing and CQRS +2. Performance considerations and optimizations +3. Scaling event-sourced systems +4. Integration with existing systems +5. Testing strategies and best practices +6. Common pitfalls and how to avoid them +7. Comparison with other event sourcing frameworks + +## Deployment Guide Prompt + +Create a deployment guide that covers: + +1. Development environment setup +2. Testing environment configuration +3. Production deployment considerations +4. Scaling strategies +5. Monitoring and observability +6. Backup and recovery strategies +7. Security considerations + +## Performance Optimization Guide Prompt + +Generate a performance optimization guide that covers: + +1. Event store performance considerations +2. Snapshot strategies for performance +3. Read model optimization techniques +4. Message handling performance +5. Scaling strategies for high-throughput systems +6. Monitoring and profiling techniques +7. Benchmarking and performance testing + +## Security Guide Prompt + +Create a security guide that addresses: + +1. Authentication and authorization in event-sourced systems +2. Data protection and privacy considerations +3. Audit logging and compliance +4. Secure deployment practices +5. Threat modeling for event-sourced systems +6. Security testing strategies + +## Integration Guide Prompt + +Generate an integration guide that covers: + +1. Integration with ASP.NET Core +2. Integration with other .NET frameworks and libraries +3. Integration with non-.NET systems +4. API design for event-sourced systems +5. Message contracts and versioning +6. Integration testing strategies + +## Video Tutorial Script Prompt + +Create scripts for video tutorials that demonstrate: + +1. Getting started with Reactive Domain +2. Building a complete application with Reactive Domain +3. Advanced usage patterns and techniques +4. Performance optimization and scaling +5. Testing strategies and best practices + +## Workshop Materials Prompt + +Generate workshop materials for training developers on Reactive Domain, including: + +1. Presentation slides +2. Hands-on exercises +3. Code samples and starter projects +4. Discussion questions and activities +5. Assessment materials + +## Documentation Structure Prompt + +Organize all the documentation into a coherent structure with: + +1. A logical hierarchy of topics +2. Clear navigation paths for different user journeys +3. Cross-references between related topics +4. Progressive disclosure of complexity +5. Search-friendly organization and metadata From f9b8082ec69fbd0eef2cdc2aa6c6bb84bb99091a Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Tue, 15 Apr 2025 20:51:22 -0400 Subject: [PATCH 02/41] Add comprehensive documentation for Reactive Domain library --- README.md | 16 + docs/README.md | 194 ++++ docs/api-reference/README.md | 92 ++ .../assemblies/reactivedomain-core.md | 378 +++++++ docs/api-reference/types/aggregate-root.md | 235 +++++ .../types/icorrelated-repository.md | 218 ++++ docs/api-reference/types/ievent-source.md | 202 ++++ docs/api-reference/types/irepository.md | 180 ++++ docs/architecture.md | 908 ++++++++++++++++ docs/code-examples/README.md | 50 + docs/components/README.md | 27 + docs/components/core.md | 196 ++++ docs/core-concepts.md | 176 ++++ docs/deployment.md | 829 +++++++++++++++ docs/faq.md | 327 ++++++ docs/glossary.md | 148 +++ docs/integration.md | 801 ++++++++++++++ docs/interfaces/README.md | 36 + docs/interfaces/event-source.md | 272 +++++ docs/migration.md | 640 +++++++++++ docs/overview.md | 71 ++ docs/performance.md | 949 +++++++++++++++++ docs/security.md | 990 ++++++++++++++++++ docs/troubleshooting.md | 770 ++++++++++++++ docs/usage-patterns.md | 789 ++++++++++++++ docs/video-tutorial-script.md | 626 +++++++++++ docs/workshop-materials.md | 825 +++++++++++++++ 27 files changed, 10945 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/api-reference/README.md create mode 100644 docs/api-reference/assemblies/reactivedomain-core.md create mode 100644 docs/api-reference/types/aggregate-root.md create mode 100644 docs/api-reference/types/icorrelated-repository.md create mode 100644 docs/api-reference/types/ievent-source.md create mode 100644 docs/api-reference/types/irepository.md create mode 100644 docs/architecture.md create mode 100644 docs/code-examples/README.md create mode 100644 docs/components/README.md create mode 100644 docs/components/core.md create mode 100644 docs/core-concepts.md create mode 100644 docs/deployment.md create mode 100644 docs/faq.md create mode 100644 docs/glossary.md create mode 100644 docs/integration.md create mode 100644 docs/interfaces/README.md create mode 100644 docs/interfaces/event-source.md create mode 100644 docs/migration.md create mode 100644 docs/overview.md create mode 100644 docs/performance.md create mode 100644 docs/security.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/usage-patterns.md create mode 100644 docs/video-tutorial-script.md create mode 100644 docs/workshop-materials.md diff --git a/README.md b/README.md index a27b97c2..3982ebd4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,22 @@ Reactive Domain is an open source framework for implementing event sourcing in . The framework is highly opinionated. It focuses on using a small number of consistent patterns and design principles in its public interfaces to enable developers to get up to speed quickly. Ease of use and "design for code review" have been the driving forces behind the framework's evolution. Where trade-offs have been necessary, these principles have been emphasized over performance. +## Documentation + +Comprehensive documentation for Reactive Domain is available in the [docs](./docs) directory. The documentation includes: + +- [Introduction and Overview](./docs/README.md) - Get started with Reactive Domain +- [Core Concepts](./docs/core-concepts.md) - Learn about event sourcing fundamentals +- [Usage Patterns](./docs/usage-patterns.md) - Discover common patterns and best practices +- [API Reference](./docs/api-reference/README.md) - Explore the API documentation +- [Troubleshooting Guide](./docs/troubleshooting.md) - Solve common issues +- [Architecture Guide](./docs/architecture.md) - Understand the system architecture +- [Migration Guide](./docs/migration.md) - Migrate between versions +- [Security Guide](./docs/security.md) - Implement security best practices +- [Performance Optimization](./docs/performance.md) - Optimize your application + +For developers new to Reactive Domain, we recommend starting with the [Introduction](./docs/README.md) followed by the [Core Concepts](./docs/core-concepts.md) guide. + ## Contributing Pull requests are welcome! Take a look at the open issues, join our [discussion on Slack](https://reactivedomain.slack.com), or contribute in an area where you see a need. Contributors and participants on our Slack channels are expected to abide by the project's [code of conduct](CODE_OF_CONDUCT.md). Read the full guidelines on [contributing](CONTRIBUTING.md). \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..b321abc5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,194 @@ +# Reactive Domain Documentation + +Welcome to the comprehensive documentation for the Reactive Domain library, an open-source framework for implementing event sourcing in .NET projects using reactive programming principles. + +> **Version Information**: This documentation corresponds to the [reactive-documentation](https://github.com/linedata/reactive-domain/tree/reactive-documentation) branch, which was created from trunk commit [05e5268](https://github.com/linedata/reactive-domain/commit/05e5268f0ceef1034885905402590486fcb6fcad) ("Removes .NET 6 support", 2025-02-18). If you notice differences between this documentation and your code, please check which version of Reactive Domain you're using. + +## Introduction + +Reactive Domain is built on several key design principles: + +1. **Simplicity and Consistency**: The framework emphasizes a small number of consistent patterns and design principles in its public interfaces, making it easier for developers to learn and use. + +2. **Developer Experience**: Ease of use and "design for code review" have been the driving forces behind the framework's evolution. The API is designed to be intuitive and self-documenting. + +3. **Opinionated Design**: The framework makes opinionated choices about implementation details to provide a clear path for developers to follow. + +4. **Pragmatic Trade-offs**: Where trade-offs have been necessary, the framework prioritizes developer experience and code clarity over performance optimizations. + +## Key Features + +- **Event Sourcing**: A complete implementation of event sourcing patterns, including aggregates, events, and repositories. +- **CQRS Support**: Tools for implementing Command Query Responsibility Segregation, including command handlers and projections. +- **EventStoreDB Integration**: Built-in support for storing and retrieving events using EventStoreDB. +- **Messaging Framework**: A comprehensive messaging system for handling commands, events, and queries. +- **Testing Utilities**: Tools for testing event-sourced applications, including in-memory event stores and test fixtures. +- **Correlation and Causation Tracking**: Built-in support for tracking correlation and causation IDs across message flows. +- **Snapshotting**: Support for creating and restoring from snapshots to improve performance. + +## Table of Contents + +1. [Core Concepts](core-concepts.md) + - Event Sourcing Fundamentals + - Event Store Architecture + - CQRS Implementation + - Reactive Programming Principles + - Domain-Driven Design Concepts + - Event, Command, and Message Flow + - Correlation and Causation Tracking + +2. [Component Documentation](components/README.md) + - [ReactiveDomain.Core](components/core.md) + - ReactiveDomain.Foundation + - ReactiveDomain.Messaging + - ReactiveDomain.Persistence + - ReactiveDomain.Transport + - ReactiveDomain.Testing + - ReactiveDomain.Policy + - ReactiveDomain.IdentityStorage + - ReactiveDomain.Tools + +3. [Interface Documentation](interfaces/README.md) + - [IEventSource](interfaces/event-source.md) + - IRepository + - ICorrelatedRepository + - IListener + - IMetadataSource + - ISnapshotSource + - IStreamStoreConnection + - IEventSerializer + - IMessage, ICommand, IEvent + - ICorrelatedMessage, ICorrelatedEventSource + +4. [Usage Patterns](usage-patterns.md) + - Setting Up a New Reactive Domain Project + - Creating and Working with Aggregates + - Implementing Commands and Events + - Setting Up Repositories and Event Stores + - Implementing Projections and Read Models + - Handling Concurrency and Versioning + - Error Handling and Recovery Strategies + - Testing Event-Sourced Systems + - Performance Optimization Techniques + - Integration with Other Systems and Frameworks + +5. [Code Examples](code-examples/README.md) + - Creating a New Aggregate Root + - Handling Commands and Generating Events + - Saving and Retrieving Aggregates + - Setting Up Event Listeners + - Implementing Projections + - Handling Correlation and Causation + - Implementing Snapshots + - Testing Aggregates and Event Handlers + - Integration with ASP.NET Core + - Complete Sample Applications + +6. [Troubleshooting Guide](troubleshooting.md) + - Event Versioning and Schema Evolution + - Handling Concurrency Conflicts + - Debugging Event-Sourced Systems + - Performance Issues and Optimization + - Integration Challenges + - Testing Strategies and Common Issues + - Deployment Considerations + +7. [API Reference](api-reference/README.md) + - Type Signatures and Inheritance Hierarchies + - Method Signatures and Parameters + - Property Types and Accessibility + - Event Patterns and Subscription Models + - Extension Points and Customization Options + - Deprecation Notices and Migration Paths + +8. [Architecture Guide](architecture.md) + - High-Level Architecture + - Design Principles and Patterns + - Component Interactions + - Extension Points + - Integration Patterns + - Scaling and Performance Considerations + - Security Considerations + +9. [Migration Guide](migration.md) + - Breaking Changes and Deprecations + - New Features and Enhancements + - Migration Strategies + - Backward Compatibility Considerations + - Testing Strategies for Migrations + +10. [Glossary](glossary.md) + - Event Sourcing Terminology + - CQRS Terminology + - Domain-Driven Design Terminology + - Reactive Programming Terminology + - Reactive Domain-Specific Terminology + +11. [FAQ](faq.md) + - When to Use Event Sourcing and CQRS + - Performance Considerations and Optimizations + - Scaling Event-Sourced Systems + - Integration with Existing Systems + - Testing Strategies and Best Practices + - Common Pitfalls and How to Avoid Them + - Comparison with Other Event Sourcing Frameworks + +12. [Deployment Guide](deployment.md) + - Development Environment Setup + - Testing Environment Configuration + - Production Deployment Considerations + - Scaling Strategies + - Monitoring and Observability + - Backup and Recovery Strategies + - Security Considerations + +13. [Performance Optimization Guide](performance.md) + - Event Store Performance Considerations + - Snapshot Strategies + - Read Model Optimization Techniques + - Message Handling Performance + - Scaling Strategies for High-Throughput Systems + - Monitoring and Profiling Techniques + - Benchmarking and Performance Testing + +14. [Security Guide](security.md) + - Authentication and Authorization + - Data Protection and Privacy + - Audit Logging and Compliance + - Secure Deployment Practices + - Threat Modeling + - Security Testing Strategies + +15. [Integration Guide](integration.md) + - Integration with ASP.NET Core + - Integration with Other .NET Frameworks + - Integration with Non-.NET Systems + - API Design for Event-Sourced Systems + - Message Contracts and Versioning + - Integration Testing Strategies + +## Getting Started + +If you're new to Reactive Domain, we recommend starting with the [Core Concepts](core-concepts.md) section to understand the fundamental principles behind event sourcing and how they're implemented in Reactive Domain. + +For practical guidance, check out the [Usage Patterns](usage-patterns.md) and [Code Examples](code-examples/README.md) sections. + +## Architecture Overview + +Reactive Domain is organized into several key components: + +```mermaid +graph TD + A[Client Application] --> B[ReactiveDomain.Messaging] + B --> C[ReactiveDomain.Foundation] + C --> D[ReactiveDomain.Core] + C --> E[ReactiveDomain.Persistence] + E --> F[EventStoreDB] + B --> G[ReactiveDomain.Transport] + H[ReactiveDomain.Testing] --> C + I[ReactiveDomain.Tools] --> C +``` + +## Contributing + +Contributions to this documentation are welcome! Please see the [CONTRIBUTING.md](../CONTRIBUTING.md) file for guidelines. diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md new file mode 100644 index 00000000..ae0bef2c --- /dev/null +++ b/docs/api-reference/README.md @@ -0,0 +1,92 @@ +# Reactive Domain API Reference + +[← Back to Table of Contents](../README.md) + +This section provides a comprehensive reference for all public types, methods, properties, and events in the Reactive Domain library, organized by namespace and key types. + +## Table of Contents + +- [Organization](#organization) +- [Key Types](#key-types) +- [Namespaces](#namespaces) +- [Assemblies](#assemblies) + +## Organization + +To make the API reference more manageable, we've organized it into the following sections: + +1. **Key Types**: Documentation for the most important and commonly used types +2. **Namespaces**: Documentation organized by namespace +3. **Assemblies**: Documentation organized by assembly + +Each type is documented with: +- Type signature and inheritance hierarchy +- Method signatures, parameters, and return types +- Property types and accessibility +- Usage examples and common patterns +- Related types and interfaces + +## Key Types + +### Core Interfaces + +- [IEventSource](types/ievent-source.md) - The core interface for event-sourced entities +- [IRepository](types/irepository.md) - Interface for repositories +- [ICorrelatedRepository](types/icorrelated-repository.md) - Repository with correlation support +- [IMetadataSource](types/imetadata-source.md) - Interface for metadata handling +- [ISnapshotSource](types/isnapshot-source.md) - Interface for snapshot support + +### Base Classes + +- [AggregateRoot](types/aggregate-root.md) - Base class for domain aggregates +- [EventRecorder](types/event-recorder.md) - Utility for recording events + +### Message Types + +- [IMessage](types/imessage.md) - Base interface for messages +- [ICommand](types/icommand.md) - Interface for commands +- [IEvent](types/ievent.md) - Interface for events +- [ICorrelatedMessage](types/icorrelated-message.md) - Interface for correlated messages + +### Repositories + +- [StreamStoreRepository](types/stream-store-repository.md) - Implementation of IRepository +- [CorrelatedStreamStoreRepository](types/correlated-stream-store-repository.md) - Implementation of ICorrelatedRepository + +### Event Store + +- [IStreamStoreConnection](types/istream-store-connection.md) - Interface for event store connections +- [StreamStoreConnection](types/stream-store-connection.md) - Implementation of IStreamStoreConnection + +## Namespaces + +The Reactive Domain library is organized into the following namespaces: + +- [ReactiveDomain](namespaces/reactivedomain.md) - Core interfaces and types +- [ReactiveDomain.Foundation](namespaces/reactivedomain-foundation.md) - Domain implementation +- [ReactiveDomain.Foundation.Domain](namespaces/reactivedomain-foundation-domain.md) - Domain-specific types +- [ReactiveDomain.Foundation.StreamStore](namespaces/reactivedomain-foundation-streamstore.md) - Stream store implementation +- [ReactiveDomain.Messaging](namespaces/reactivedomain-messaging.md) - Messaging framework +- [ReactiveDomain.Messaging.Messages](namespaces/reactivedomain-messaging-messages.md) - Message types +- [ReactiveDomain.Persistence](namespaces/reactivedomain-persistence.md) - Event storage +- [ReactiveDomain.Transport](namespaces/reactivedomain-transport.md) - Transport layer +- [ReactiveDomain.Testing](namespaces/reactivedomain-testing.md) - Testing utilities +- [ReactiveDomain.Policy](namespaces/reactivedomain-policy.md) - Policy implementation +- [ReactiveDomain.IdentityStorage](namespaces/reactivedomain-identitystorage.md) - Identity storage +- [ReactiveDomain.Tools](namespaces/reactivedomain-tools.md) - Developer tools + +## Assemblies + +The Reactive Domain library consists of the following assemblies: + +- [ReactiveDomain.Core](assemblies/reactivedomain-core.md) - Core interfaces and abstractions +- [ReactiveDomain.Foundation](assemblies/reactivedomain-foundation.md) - Domain implementation +- [ReactiveDomain.Messaging](assemblies/reactivedomain-messaging.md) - Messaging framework +- [ReactiveDomain.Persistence](assemblies/reactivedomain-persistence.md) - Event storage +- [ReactiveDomain.Transport](assemblies/reactivedomain-transport.md) - Transport layer +- [ReactiveDomain.Testing](assemblies/reactivedomain-testing.md) - Testing utilities +- [ReactiveDomain.Policy](assemblies/reactivedomain-policy.md) - Policy implementation +- [ReactiveDomain.IdentityStorage](assemblies/reactivedomain-identitystorage.md) - Identity storage +- [ReactiveDomain.Tools](assemblies/reactivedomain-tools.md) - Developer tools + +[↑ Back to Top](#reactive-domain-api-reference) | [← Back to Table of Contents](../README.md) diff --git a/docs/api-reference/assemblies/reactivedomain-core.md b/docs/api-reference/assemblies/reactivedomain-core.md new file mode 100644 index 00000000..3b550626 --- /dev/null +++ b/docs/api-reference/assemblies/reactivedomain-core.md @@ -0,0 +1,378 @@ +# ReactiveDomain.Core Assembly + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +The ReactiveDomain.Core assembly contains the fundamental interfaces and abstractions that form the foundation of the Reactive Domain library. These core interfaces define the contract for event sourcing and are used throughout the library. + +## Table of Contents + +- [Interfaces](#interfaces) + - [IEventSource](#ieventsource) + - [IMetadataSource](#imetadatasource) +- [Classes](#classes) + - [Metadata](#metadata) +- [Exceptions](#exceptions) + - [AggregateNotFoundException](#aggregatenotfoundexception) + - [AggregateDeletedException](#aggregatedeletedexception) + - [AggregateVersionException](#aggregateversionexception) + +## Interfaces + +### IEventSource + +**Namespace**: `ReactiveDomain` + +**Purpose**: Represents a source of events from the perspective of restoring from and taking events. + +**Declaration**: +```csharp +public interface IEventSource +{ + Guid Id { get; } + long ExpectedVersion { get; set; } + void RestoreFromEvents(IEnumerable events); + void UpdateWithEvents(IEnumerable events, long expectedVersion); + object[] TakeEvents(); +} +``` + +#### Properties + +##### Id + +**Type**: `Guid` +**Accessibility**: `get` +**Description**: Gets the unique identifier for this EventSource. This must be provided by the implementing class. + +##### ExpectedVersion + +**Type**: `long` +**Accessibility**: `get`, `set` +**Description**: Gets or sets the expected version this instance is at. + +#### Methods + +##### RestoreFromEvents + +**Signature**: `void RestoreFromEvents(IEnumerable events)` +**Parameters**: +- `events` (`IEnumerable`): The events to restore from. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `events` is `null`. + +**Description**: Restores this instance from the history of events. + +##### UpdateWithEvents + +**Signature**: `void UpdateWithEvents(IEnumerable events, long expectedVersion)` +**Parameters**: +- `events` (`IEnumerable`): The events to update with. +- `expectedVersion` (`long`): The expected version to start from. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `events` is `null`. +- `System.InvalidOperationException`: Thrown when this instance does not have historical events or expected version mismatch. + +**Description**: Updates this instance with the provided events, starting from the expected version. + +##### TakeEvents + +**Signature**: `object[] TakeEvents()` +**Returns**: The recorded events. + +**Description**: Takes the recorded history of events from this instance (CQS violation, beware). + +### IMetadataSource + +**Namespace**: `ReactiveDomain` + +**Purpose**: Defines the contract for entities that have associated metadata. + +**Declaration**: +```csharp +public interface IMetadataSource +{ + Metadata ReadMetadata(); + Metadata Initialize(); + void Initialize(Metadata md); +} +``` + +#### Methods + +##### ReadMetadata + +**Signature**: `Metadata ReadMetadata()` +**Returns**: The object's metadata. + +**Description**: Gets the object's metadata. + +##### Initialize + +**Signature**: `Metadata Initialize()` +**Returns**: The initialized metadata. + +**Description**: Initializes an object's metadata using default values. + +##### Initialize + +**Signature**: `void Initialize(Metadata md)` +**Parameters**: +- `md` (`Metadata`): The metadata to use for initialization. + +**Description**: Initializes an object using the provided metadata. + +## Classes + +### Metadata + +**Namespace**: `ReactiveDomain` + +**Purpose**: Represents metadata associated with an object. + +**Declaration**: +```csharp +public class Metadata : Dictionary +{ + public Metadata(); + public Metadata(IDictionary dictionary); + public Metadata(Metadata metadata); + public T GetValue(string key); + public bool TryGetValue(string key, out T value); +} +``` + +#### Constructors + +##### Metadata() + +**Signature**: `public Metadata()` +**Description**: Initializes a new instance of the Metadata class. + +##### Metadata(IDictionary) + +**Signature**: `public Metadata(IDictionary dictionary)` +**Parameters**: +- `dictionary` (`IDictionary`): The dictionary to initialize from. + +**Description**: Initializes a new instance of the Metadata class with the specified dictionary. + +##### Metadata(Metadata) + +**Signature**: `public Metadata(Metadata metadata)` +**Parameters**: +- `metadata` (`Metadata`): The metadata to copy from. + +**Description**: Initializes a new instance of the Metadata class with the specified metadata. + +#### Methods + +##### GetValue + +**Signature**: `public T GetValue(string key)` +**Type Parameters**: +- `T`: The type of the value to get. + +**Parameters**: +- `key` (`string`): The key of the value to get. + +**Returns**: The value associated with the specified key. + +**Exceptions**: +- `KeyNotFoundException`: Thrown when the key is not found. +- `InvalidCastException`: Thrown when the value cannot be cast to the specified type. + +**Description**: Gets the value associated with the specified key and casts it to the specified type. + +##### TryGetValue + +**Signature**: `public bool TryGetValue(string key, out T value)` +**Type Parameters**: +- `T`: The type of the value to get. + +**Parameters**: +- `key` (`string`): The key of the value to get. +- `value` (`T`): When this method returns, contains the value associated with the specified key, if the key is found; otherwise, the default value for the type of the value parameter. + +**Returns**: `true` if the key was found; otherwise, `false`. + +**Description**: Tries to get the value associated with the specified key and cast it to the specified type. + +## Exceptions + +### AggregateNotFoundException + +**Namespace**: `ReactiveDomain` + +**Purpose**: Thrown when an aggregate is not found. + +**Declaration**: +```csharp +public class AggregateNotFoundException : Exception +{ + public AggregateNotFoundException(Guid id, Type type); + public AggregateNotFoundException(Guid id, Type type, Exception innerException); + public Guid Id { get; } + public Type Type { get; } +} +``` + +#### Constructors + +##### AggregateNotFoundException(Guid, Type) + +**Signature**: `public AggregateNotFoundException(Guid id, Type type)` +**Parameters**: +- `id` (`Guid`): The ID of the aggregate that was not found. +- `type` (`Type`): The type of the aggregate that was not found. + +**Description**: Initializes a new instance of the AggregateNotFoundException class. + +##### AggregateNotFoundException(Guid, Type, Exception) + +**Signature**: `public AggregateNotFoundException(Guid id, Type type, Exception innerException)` +**Parameters**: +- `id` (`Guid`): The ID of the aggregate that was not found. +- `type` (`Type`): The type of the aggregate that was not found. +- `innerException` (`Exception`): The exception that is the cause of the current exception. + +**Description**: Initializes a new instance of the AggregateNotFoundException class with a specified error message and a reference to the inner exception that is the cause of this exception. + +#### Properties + +##### Id + +**Type**: `Guid` +**Accessibility**: `get` +**Description**: Gets the ID of the aggregate that was not found. + +##### Type + +**Type**: `Type` +**Accessibility**: `get` +**Description**: Gets the type of the aggregate that was not found. + +### AggregateDeletedException + +**Namespace**: `ReactiveDomain` + +**Purpose**: Thrown when an attempt is made to access a deleted aggregate. + +**Declaration**: +```csharp +public class AggregateDeletedException : Exception +{ + public AggregateDeletedException(Guid id, Type type); + public AggregateDeletedException(Guid id, Type type, Exception innerException); + public Guid Id { get; } + public Type Type { get; } +} +``` + +#### Constructors + +##### AggregateDeletedException(Guid, Type) + +**Signature**: `public AggregateDeletedException(Guid id, Type type)` +**Parameters**: +- `id` (`Guid`): The ID of the deleted aggregate. +- `type` (`Type`): The type of the deleted aggregate. + +**Description**: Initializes a new instance of the AggregateDeletedException class. + +##### AggregateDeletedException(Guid, Type, Exception) + +**Signature**: `public AggregateDeletedException(Guid id, Type type, Exception innerException)` +**Parameters**: +- `id` (`Guid`): The ID of the deleted aggregate. +- `type` (`Type`): The type of the deleted aggregate. +- `innerException` (`Exception`): The exception that is the cause of the current exception. + +**Description**: Initializes a new instance of the AggregateDeletedException class with a specified error message and a reference to the inner exception that is the cause of this exception. + +#### Properties + +##### Id + +**Type**: `Guid` +**Accessibility**: `get` +**Description**: Gets the ID of the deleted aggregate. + +##### Type + +**Type**: `Type` +**Accessibility**: `get` +**Description**: Gets the type of the deleted aggregate. + +### AggregateVersionException + +**Namespace**: `ReactiveDomain` + +**Purpose**: Thrown when there is a version mismatch for an aggregate. + +**Declaration**: +```csharp +public class AggregateVersionException : Exception +{ + public AggregateVersionException(Guid id, Type type, long expectedVersion, long actualVersion); + public AggregateVersionException(Guid id, Type type, long expectedVersion, long actualVersion, Exception innerException); + public Guid Id { get; } + public Type Type { get; } + public long ExpectedVersion { get; } + public long ActualVersion { get; } +} +``` + +#### Constructors + +##### AggregateVersionException(Guid, Type, long, long) + +**Signature**: `public AggregateVersionException(Guid id, Type type, long expectedVersion, long actualVersion)` +**Parameters**: +- `id` (`Guid`): The ID of the aggregate with the version mismatch. +- `type` (`Type`): The type of the aggregate with the version mismatch. +- `expectedVersion` (`long`): The expected version. +- `actualVersion` (`long`): The actual version. + +**Description**: Initializes a new instance of the AggregateVersionException class. + +##### AggregateVersionException(Guid, Type, long, long, Exception) + +**Signature**: `public AggregateVersionException(Guid id, Type type, long expectedVersion, long actualVersion, Exception innerException)` +**Parameters**: +- `id` (`Guid`): The ID of the aggregate with the version mismatch. +- `type` (`Type`): The type of the aggregate with the version mismatch. +- `expectedVersion` (`long`): The expected version. +- `actualVersion` (`long`): The actual version. +- `innerException` (`Exception`): The exception that is the cause of the current exception. + +**Description**: Initializes a new instance of the AggregateVersionException class with a specified error message and a reference to the inner exception that is the cause of this exception. + +#### Properties + +##### Id + +**Type**: `Guid` +**Accessibility**: `get` +**Description**: Gets the ID of the aggregate with the version mismatch. + +##### Type + +**Type**: `Type` +**Accessibility**: `get` +**Description**: Gets the type of the aggregate with the version mismatch. + +##### ExpectedVersion + +**Type**: `long` +**Accessibility**: `get` +**Description**: Gets the expected version. + +##### ActualVersion + +**Type**: `long` +**Accessibility**: `get` +**Description**: Gets the actual version. + +[↑ Back to Top](#reactivedomaincore-assembly) | [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) diff --git a/docs/api-reference/types/aggregate-root.md b/docs/api-reference/types/aggregate-root.md new file mode 100644 index 00000000..b62b7323 --- /dev/null +++ b/docs/api-reference/types/aggregate-root.md @@ -0,0 +1,235 @@ +# AggregateRoot Class + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `AggregateRoot` class is a base class for domain aggregates in Reactive Domain. It implements the `IEventSource` interface and provides common functionality for event sourcing. + +**Namespace**: `ReactiveDomain.Foundation` +**Assembly**: `ReactiveDomain.Foundation.dll` + +```csharp +public abstract class AggregateRoot : IEventSource +{ + protected AggregateRoot(Guid id); + protected AggregateRoot(Guid id, ICorrelatedMessage source); + protected AggregateRoot(Guid id, IEnumerable events); + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + protected void RaiseEvent(object @event); + + public void RestoreFromEvents(IEnumerable events); + public void UpdateWithEvents(IEnumerable events, long expectedVersion); + public object[] TakeEvents(); +} +``` + +## Constructors + +### AggregateRoot(Guid) + +Initializes a new instance of the `AggregateRoot` class with the specified ID. + +```csharp +protected AggregateRoot(Guid id); +``` + +**Parameters**: +- `id` (`System.Guid`): The unique identifier for the aggregate. + +### AggregateRoot(Guid, ICorrelatedMessage) + +Initializes a new instance of the `AggregateRoot` class with the specified ID and correlation source. + +```csharp +protected AggregateRoot(Guid id, ICorrelatedMessage source); +``` + +**Parameters**: +- `id` (`System.Guid`): The unique identifier for the aggregate. +- `source` (`ReactiveDomain.ICorrelatedMessage`): The source message for correlation. + +### AggregateRoot(Guid, IEnumerable) + +Initializes a new instance of the `AggregateRoot` class with the specified ID and restores it from the provided events. + +```csharp +protected AggregateRoot(Guid id, IEnumerable events); +``` + +**Parameters**: +- `id` (`System.Guid`): The unique identifier for the aggregate. +- `events` (`System.Collections.Generic.IEnumerable`): The events to restore from. + +## Properties + +### Id + +Gets the unique identifier for this aggregate. + +```csharp +public Guid Id { get; } +``` + +**Property Type**: `System.Guid` +**Accessibility**: `get` + +### ExpectedVersion + +Gets or sets the expected version this aggregate is at. This is used for optimistic concurrency control. + +```csharp +public long ExpectedVersion { get; set; } +``` + +**Property Type**: `System.Int64` +**Accessibility**: `get`, `set` + +## Methods + +### RaiseEvent + +Raises an event, which will be recorded and applied to the aggregate. + +```csharp +protected void RaiseEvent(object @event); +``` + +**Parameters**: +- `event` (`System.Object`): The event to raise. + +**Remarks**: This method records the event and applies it to the aggregate by calling the appropriate `Apply` method. + +### RestoreFromEvents + +Restores this aggregate from the history of events. + +```csharp +public void RestoreFromEvents(IEnumerable events); +``` + +**Parameters**: +- `events` (`System.Collections.Generic.IEnumerable`): The events to restore from. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `events` is `null`. + +**Remarks**: This method applies each event in sequence to rebuild the aggregate's state. + +### UpdateWithEvents + +Updates this aggregate with the provided events, starting from the expected version. + +```csharp +public void UpdateWithEvents(IEnumerable events, long expectedVersion); +``` + +**Parameters**: +- `events` (`System.Collections.Generic.IEnumerable`): The events to update with. +- `expectedVersion` (`System.Int64`): The expected version to start from. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `events` is `null`. +- `System.InvalidOperationException`: Thrown when this aggregate does not have historical events or expected version mismatch. + +**Remarks**: This method checks that the expected version matches the aggregate's current version, and then applies each event in sequence. + +### TakeEvents + +Takes the recorded history of events from this aggregate. + +```csharp +public object[] TakeEvents(); +``` + +**Returns**: `System.Object[]` - The recorded events. + +**Remarks**: This method returns the recorded events and clears the aggregate's record of those events. + +## Usage + +The `AggregateRoot` class is designed to be subclassed by domain aggregates. Subclasses should: + +1. Define private `Apply` methods for each event type +2. Use the `RaiseEvent` method to record and apply events +3. Define public methods that represent domain operations + +## Example Implementation + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + + // Constructor for creating a new account + public Account(Guid id) : base(id) + { + } + + // Constructor for creating a new account with correlation + public Account(Guid id, ICorrelatedMessage source) : base(id, source) + { + } + + // Constructor for restoring an account from events + protected Account(Guid id, IEnumerable events) : base(id, events) + { + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + RaiseEvent(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new AmountWithdrawn(Id, amount)); + } + + public decimal GetBalance() + { + return _balance; + } + + private void Apply(AmountDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(AmountWithdrawn @event) + { + _balance -= @event.Amount; + } +} +``` + +## Inheritance Hierarchy + +- `System.Object` + - `ReactiveDomain.Foundation.AggregateRoot` + +## Implemented Interfaces + +- `ReactiveDomain.IEventSource` + +## Related Types + +- [IEventSource](ievent-source.md): The interface implemented by `AggregateRoot` +- [ICorrelatedEventSource](icorrelated-event-source.md): Interface for correlation tracking +- [ISnapshotSource](isnapshot-source.md): Interface for snapshot support +- [IRepository](irepository.md): Interface for repositories that work with aggregates +- [EventRecorder](event-recorder.md): Utility used internally by `AggregateRoot` to record events + +[↑ Back to Top](#aggregateroot-class) | [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) diff --git a/docs/api-reference/types/icorrelated-repository.md b/docs/api-reference/types/icorrelated-repository.md new file mode 100644 index 00000000..b26f346f --- /dev/null +++ b/docs/api-reference/types/icorrelated-repository.md @@ -0,0 +1,218 @@ +# ICorrelatedRepository Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `ICorrelatedRepository` interface extends the repository pattern with correlation support. It allows tracking correlation and causation IDs across message flows when working with event-sourced aggregates. + +**Namespace**: `ReactiveDomain.Foundation` +**Assembly**: `ReactiveDomain.Foundation.dll` + +```csharp +public interface ICorrelatedRepository +{ + bool TryGetById(Guid id, out TAggregate aggregate, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; + bool TryGetById(Guid id, int version, out TAggregate aggregate, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; + TAggregate GetById(Guid id, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; + TAggregate GetById(Guid id, int version, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; + void Save(IEventSource aggregate); + void Delete(IEventSource aggregate); + void HardDelete(IEventSource aggregate); +} +``` + +## Methods + +### TryGetById(Guid, out TAggregate, ICorrelatedMessage) + +Attempts to retrieve an aggregate by its ID with correlation information. + +```csharp +bool TryGetById(Guid id, out TAggregate aggregate, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; +``` + +**Type Parameters**: +- `TAggregate`: The type of the aggregate to retrieve. + +**Parameters**: +- `id` (`System.Guid`): The ID of the aggregate to retrieve. +- `aggregate` (`TAggregate`): When this method returns, contains the aggregate with the specified ID, if found; otherwise, the default value for the type of the `aggregate` parameter. +- `source` (`ReactiveDomain.ICorrelatedMessage`): The source message for correlation. + +**Returns**: `System.Boolean` - `true` if the aggregate was found; otherwise, `false`. + +**Remarks**: This method attempts to retrieve an aggregate by its ID and sets up correlation information. If the aggregate is not found, it returns `false` and sets `aggregate` to `null`. + +### TryGetById(Guid, int, out TAggregate, ICorrelatedMessage) + +Attempts to retrieve an aggregate by its ID and version with correlation information. + +```csharp +bool TryGetById(Guid id, int version, out TAggregate aggregate, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; +``` + +**Type Parameters**: +- `TAggregate`: The type of the aggregate to retrieve. + +**Parameters**: +- `id` (`System.Guid`): The ID of the aggregate to retrieve. +- `version` (`System.Int32`): The version of the aggregate to retrieve. +- `aggregate` (`TAggregate`): When this method returns, contains the aggregate with the specified ID and version, if found; otherwise, the default value for the type of the `aggregate` parameter. +- `source` (`ReactiveDomain.ICorrelatedMessage`): The source message for correlation. + +**Returns**: `System.Boolean` - `true` if the aggregate was found; otherwise, `false`. + +**Remarks**: This method attempts to retrieve an aggregate by its ID and version and sets up correlation information. If the aggregate is not found, it returns `false` and sets `aggregate` to `null`. + +### GetById(Guid, ICorrelatedMessage) + +Retrieves an aggregate by its ID with correlation information. + +```csharp +TAggregate GetById(Guid id, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; +``` + +**Type Parameters**: +- `TAggregate`: The type of the aggregate to retrieve. + +**Parameters**: +- `id` (`System.Guid`): The ID of the aggregate to retrieve. +- `source` (`ReactiveDomain.ICorrelatedMessage`): The source message for correlation. + +**Returns**: `TAggregate` - The aggregate with the specified ID. + +**Exceptions**: +- `ReactiveDomain.AggregateNotFoundException`: Thrown when the aggregate with the specified ID is not found. +- `ReactiveDomain.AggregateDeletedException`: Thrown when the aggregate with the specified ID has been deleted. + +**Remarks**: This method retrieves an aggregate by its ID and sets up correlation information. If the aggregate is not found, it throws an exception. + +### GetById(Guid, int, ICorrelatedMessage) + +Retrieves an aggregate by its ID and version with correlation information. + +```csharp +TAggregate GetById(Guid id, int version, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; +``` + +**Type Parameters**: +- `TAggregate`: The type of the aggregate to retrieve. + +**Parameters**: +- `id` (`System.Guid`): The ID of the aggregate to retrieve. +- `version` (`System.Int32`): The version of the aggregate to retrieve. +- `source` (`ReactiveDomain.ICorrelatedMessage`): The source message for correlation. + +**Returns**: `TAggregate` - The aggregate with the specified ID and version. + +**Exceptions**: +- `ReactiveDomain.AggregateNotFoundException`: Thrown when the aggregate with the specified ID is not found. +- `ReactiveDomain.AggregateDeletedException`: Thrown when the aggregate with the specified ID has been deleted. +- `ReactiveDomain.AggregateVersionException`: Thrown when the specified version does not match the expected version. + +**Remarks**: This method retrieves an aggregate by its ID and version and sets up correlation information. If the aggregate is not found, it throws an exception. + +### Save + +Saves an aggregate to the repository. + +```csharp +void Save(IEventSource aggregate); +``` + +**Parameters**: +- `aggregate` (`ReactiveDomain.IEventSource`): The aggregate to save. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `aggregate` is `null`. +- `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. + +**Remarks**: This method saves an aggregate to the repository. It takes the events from the aggregate and appends them to the event stream in the repository. + +### Delete + +Marks an aggregate as deleted in the repository. + +```csharp +void Delete(IEventSource aggregate); +``` + +**Parameters**: +- `aggregate` (`ReactiveDomain.IEventSource`): The aggregate to delete. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `aggregate` is `null`. +- `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. + +**Remarks**: This method marks an aggregate as deleted in the repository. It appends a deletion event to the event stream. The aggregate can still be retrieved, but will be marked as deleted. + +### HardDelete + +Permanently deletes an aggregate from the repository. + +```csharp +void HardDelete(IEventSource aggregate); +``` + +**Parameters**: +- `aggregate` (`ReactiveDomain.IEventSource`): The aggregate to delete. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `aggregate` is `null`. +- `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. + +**Remarks**: This method permanently deletes an aggregate from the repository. It removes the event stream from the repository. The aggregate cannot be retrieved after this operation. + +## Usage + +The `ICorrelatedRepository` interface is used to store and retrieve event-sourced aggregates with correlation information. It is typically implemented by the `CorrelatedStreamStoreRepository` class. + +```csharp +// Create a repository +var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder(); +var eventStoreConnection = new StreamStoreConnection("MyApp", connectionSettings, "localhost", 1113); +var serializer = new JsonMessageSerializer(); +var repository = new StreamStoreRepository(streamNameBuilder, eventStoreConnection, serializer); +var correlatedRepository = new CorrelatedStreamStoreRepository(repository); + +// Create a command with correlation information +ICorrelatedMessage command = MessageBuilder.New(() => new CreateAccount(Guid.NewGuid())); + +// Create a new aggregate with correlation information +var account = new Account(Guid.NewGuid(), command); +account.Deposit(100); + +// Save the aggregate +correlatedRepository.Save(account); + +// Retrieve the aggregate with correlation information +var retrievedAccount = correlatedRepository.GetById(account.Id, command); + +// Update the aggregate +retrievedAccount.Withdraw(50); +correlatedRepository.Save(retrievedAccount); + +// Delete the aggregate +correlatedRepository.Delete(retrievedAccount); +``` + +## Correlation and Causation + +The `ICorrelatedRepository` interface helps track correlation and causation IDs across message flows: + +- **Correlation ID**: Identifies a business transaction that spans multiple messages +- **Causation ID**: Identifies the message that caused the current message + +When an aggregate is loaded with a source message, the source message's correlation and causation IDs are propagated to any events raised by the aggregate. This allows tracking the flow of messages through the system. + +## Related Types + +- [IRepository](irepository.md): The base repository interface +- [IEventSource](ievent-source.md): The interface for event-sourced entities +- [AggregateRoot](aggregate-root.md): Base class for domain aggregates +- [ICorrelatedMessage](icorrelated-message.md): Interface for correlated messages +- [ICorrelatedEventSource](icorrelated-event-source.md): Interface for correlation tracking in event sources +- [CorrelatedStreamStoreRepository](correlated-stream-store-repository.md): Implementation of `ICorrelatedRepository` + +[↑ Back to Top](#icorrelatedrepository-interface) | [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) diff --git a/docs/api-reference/types/ievent-source.md b/docs/api-reference/types/ievent-source.md new file mode 100644 index 00000000..1c80f461 --- /dev/null +++ b/docs/api-reference/types/ievent-source.md @@ -0,0 +1,202 @@ +# IEventSource Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `IEventSource` interface is the cornerstone of event sourcing in Reactive Domain. It represents a source of events from the perspective of restoring from and taking events, and is primarily used by infrastructure code. + +**Namespace**: `ReactiveDomain` +**Assembly**: `ReactiveDomain.Core.dll` + +```csharp +public interface IEventSource +{ + Guid Id { get; } + long ExpectedVersion { get; set; } + void RestoreFromEvents(IEnumerable events); + void UpdateWithEvents(IEnumerable events, long expectedVersion); + object[] TakeEvents(); +} +``` + +## Properties + +### Id + +Gets the unique identifier for this EventSource. This must be provided by the implementing class. + +```csharp +Guid Id { get; } +``` + +**Property Type**: `System.Guid` +**Accessibility**: `get` + +### ExpectedVersion + +Gets or sets the expected version this instance is at. This is used for optimistic concurrency control. + +```csharp +long ExpectedVersion { get; set; } +``` + +**Property Type**: `System.Int64` +**Accessibility**: `get`, `set` + +## Methods + +### RestoreFromEvents + +Restores this instance from the history of events. + +```csharp +void RestoreFromEvents(IEnumerable events); +``` + +**Parameters**: +- `events` (`System.Collections.Generic.IEnumerable`): The events to restore from. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `events` is `null`. + +### UpdateWithEvents + +Updates this instance with the provided events, starting from the expected version. + +```csharp +void UpdateWithEvents(IEnumerable events, long expectedVersion); +``` + +**Parameters**: +- `events` (`System.Collections.Generic.IEnumerable`): The events to update with. +- `expectedVersion` (`System.Int64`): The expected version to start from. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `events` is `null`. +- `System.InvalidOperationException`: Thrown when this instance does not have historical events or expected version mismatch. + +### TakeEvents + +Takes the recorded history of events from this instance (CQS violation, beware). + +```csharp +object[] TakeEvents(); +``` + +**Returns**: `System.Object[]` - The recorded events. + +## Remarks + +The `IEventSource` interface is fundamental to the event sourcing pattern, where the state of an entity is determined by the sequence of events that have occurred, rather than by its current state. + +Implementations of this interface typically: +1. Use an `EventRecorder` to record events +2. Implement private `Apply` methods for each event type +3. Check version consistency in `UpdateWithEvents` +4. Keep events immutable +5. Follow Command Query Separation (CQS) for methods other than `TakeEvents` + +## Example Implementation + +```csharp +public class Account : IEventSource +{ + private readonly EventRecorder _recorder = new EventRecorder(); + private decimal _balance; + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + public Account(Guid id) + { + Id = id; + ExpectedVersion = -1; + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + _recorder.Record(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + _recorder.Record(new AmountWithdrawn(Id, amount)); + } + + public decimal GetBalance() + { + return _balance; + } + + public void RestoreFromEvents(IEnumerable events) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public void UpdateWithEvents(IEnumerable events, long expectedVersion) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + + if (ExpectedVersion != expectedVersion) + throw new InvalidOperationException($"Expected version {expectedVersion} but was {ExpectedVersion}"); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public object[] TakeEvents() + { + var events = _recorder.RecordedEvents.ToArray(); + _recorder.Reset(); + return events; + } + + private void Apply(object @event) + { + switch (@event) + { + case AmountDeposited e: + _balance += e.Amount; + break; + + case AmountWithdrawn e: + _balance -= e.Amount; + break; + + default: + throw new InvalidOperationException($"Unknown event type: {@event.GetType().Name}"); + } + } +} +``` + +## Related Types + +- [AggregateRoot](aggregate-root.md): A base class that implements `IEventSource` +- [ICorrelatedEventSource](icorrelated-event-source.md): Extends `IEventSource` with correlation tracking +- [ISnapshotSource](isnapshot-source.md): Interface for snapshot support +- [IRepository](irepository.md): Interface for repositories that work with `IEventSource` +- [EventRecorder](event-recorder.md): Utility for recording events + +[↑ Back to Top](#ieventsource-interface) | [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) diff --git a/docs/api-reference/types/irepository.md b/docs/api-reference/types/irepository.md new file mode 100644 index 00000000..103b683e --- /dev/null +++ b/docs/api-reference/types/irepository.md @@ -0,0 +1,180 @@ +# IRepository Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `IRepository` interface defines the contract for repositories that store and retrieve event-sourced aggregates in Reactive Domain. + +**Namespace**: `ReactiveDomain.Foundation` +**Assembly**: `ReactiveDomain.Foundation.dll` + +```csharp +public interface IRepository +{ + bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) where TAggregate : class, IEventSource; + TAggregate GetById(Guid id, int version = int.MaxValue) where TAggregate : class, IEventSource; + void Update(ref TAggregate aggregate, int version = int.MaxValue) where TAggregate : class, IEventSource; + void Save(IEventSource aggregate); + void Delete(IEventSource aggregate); + void HardDelete(IEventSource aggregate); +} +``` + +## Methods + +### TryGetById + +Attempts to retrieve an aggregate by its ID. + +```csharp +bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) where TAggregate : class, IEventSource; +``` + +**Type Parameters**: +- `TAggregate`: The type of the aggregate to retrieve. + +**Parameters**: +- `id` (`System.Guid`): The ID of the aggregate to retrieve. +- `aggregate` (`TAggregate`): When this method returns, contains the aggregate with the specified ID, if found; otherwise, the default value for the type of the `aggregate` parameter. +- `version` (`System.Int32`, optional): The version of the aggregate to retrieve. Defaults to `int.MaxValue`, which retrieves the latest version. + +**Returns**: `System.Boolean` - `true` if the aggregate was found; otherwise, `false`. + +**Remarks**: This method attempts to retrieve an aggregate by its ID. If the aggregate is not found, it returns `false` and sets `aggregate` to `null`. + +### GetById + +Retrieves an aggregate by its ID. + +```csharp +TAggregate GetById(Guid id, int version = int.MaxValue) where TAggregate : class, IEventSource; +``` + +**Type Parameters**: +- `TAggregate`: The type of the aggregate to retrieve. + +**Parameters**: +- `id` (`System.Guid`): The ID of the aggregate to retrieve. +- `version` (`System.Int32`, optional): The version of the aggregate to retrieve. Defaults to `int.MaxValue`, which retrieves the latest version. + +**Returns**: `TAggregate` - The aggregate with the specified ID. + +**Exceptions**: +- `ReactiveDomain.AggregateNotFoundException`: Thrown when the aggregate with the specified ID is not found. +- `ReactiveDomain.AggregateDeletedException`: Thrown when the aggregate with the specified ID has been deleted. + +**Remarks**: This method retrieves an aggregate by its ID. If the aggregate is not found, it throws an exception. + +### Update + +Updates an aggregate with events from the repository. + +```csharp +void Update(ref TAggregate aggregate, int version = int.MaxValue) where TAggregate : class, IEventSource; +``` + +**Type Parameters**: +- `TAggregate`: The type of the aggregate to update. + +**Parameters**: +- `aggregate` (`TAggregate`): The aggregate to update. +- `version` (`System.Int32`, optional): The version to update the aggregate to. Defaults to `int.MaxValue`, which updates to the latest version. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `aggregate` is `null`. +- `System.InvalidOperationException`: Thrown when the version is less than or equal to 0. +- `ReactiveDomain.AggregateNotFoundException`: Thrown when the aggregate with the specified ID is not found. +- `ReactiveDomain.AggregateDeletedException`: Thrown when the aggregate with the specified ID has been deleted. +- `ReactiveDomain.AggregateVersionException`: Thrown when the specified version does not match the expected version. + +**Remarks**: This method updates an aggregate with events from the repository. It loads events from the repository and applies them to the aggregate. + +### Save + +Saves an aggregate to the repository. + +```csharp +void Save(IEventSource aggregate); +``` + +**Parameters**: +- `aggregate` (`ReactiveDomain.IEventSource`): The aggregate to save. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `aggregate` is `null`. +- `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. + +**Remarks**: This method saves an aggregate to the repository. It takes the events from the aggregate and appends them to the event stream in the repository. + +### Delete + +Marks an aggregate as deleted in the repository. + +```csharp +void Delete(IEventSource aggregate); +``` + +**Parameters**: +- `aggregate` (`ReactiveDomain.IEventSource`): The aggregate to delete. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `aggregate` is `null`. +- `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. + +**Remarks**: This method marks an aggregate as deleted in the repository. It appends a deletion event to the event stream. The aggregate can still be retrieved, but will be marked as deleted. + +### HardDelete + +Permanently deletes an aggregate from the repository. + +```csharp +void HardDelete(IEventSource aggregate); +``` + +**Parameters**: +- `aggregate` (`ReactiveDomain.IEventSource`): The aggregate to delete. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `aggregate` is `null`. +- `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. + +**Remarks**: This method permanently deletes an aggregate from the repository. It removes the event stream from the repository. The aggregate cannot be retrieved after this operation. + +## Usage + +The `IRepository` interface is used to store and retrieve event-sourced aggregates. It is typically implemented by the `StreamStoreRepository` class, which stores events in an event store. + +```csharp +// Create a repository +var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder(); +var eventStoreConnection = new StreamStoreConnection("MyApp", connectionSettings, "localhost", 1113); +var serializer = new JsonMessageSerializer(); +var repository = new StreamStoreRepository(streamNameBuilder, eventStoreConnection, serializer); + +// Create a new aggregate +var account = new Account(Guid.NewGuid()); +account.Deposit(100); + +// Save the aggregate +repository.Save(account); + +// Retrieve the aggregate +var retrievedAccount = repository.GetById(account.Id); + +// Update the aggregate +retrievedAccount.Withdraw(50); +repository.Save(retrievedAccount); + +// Delete the aggregate +repository.Delete(retrievedAccount); +``` + +## Related Types + +- [IEventSource](ievent-source.md): The interface for event-sourced entities +- [AggregateRoot](aggregate-root.md): Base class for domain aggregates +- [StreamStoreRepository](stream-store-repository.md): Implementation of `IRepository` +- [ICorrelatedRepository](icorrelated-repository.md): Repository with correlation support + +[↑ Back to Top](#irepository-interface) | [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..8f5d6e7e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,908 @@ +# Architecture Guide + +[← Back to Table of Contents](README.md) + +This guide provides a detailed overview of the Reactive Domain architecture, explaining how the various components work together to support event sourcing and CQRS in .NET applications. + +## Table of Contents + +- [High-Level Architecture](#high-level-architecture) +- [Design Principles and Patterns](#design-principles-and-patterns) +- [Component Interactions](#component-interactions) +- [Extension Points](#extension-points) +- [Integration Patterns](#integration-patterns) +- [Scaling and Performance Considerations](#scaling-and-performance-considerations) +- [Security Considerations](#security-considerations) + +## High-Level Architecture + +Reactive Domain is organized into several key components that work together to provide a complete event sourcing and CQRS solution. The following diagram illustrates the high-level architecture: + +```mermaid +graph TD + A[Client Application] --> B[ReactiveDomain.Messaging] + B --> C[ReactiveDomain.Foundation] + C --> D[ReactiveDomain.Core] + C --> E[ReactiveDomain.Persistence] + E --> F[EventStoreDB] + B --> G[ReactiveDomain.Transport] + H[ReactiveDomain.Testing] --> C + I[ReactiveDomain.Tools] --> C +``` + +### Core Components + +1. **ReactiveDomain.Core** + - Contains the fundamental interfaces and base classes for event sourcing + - Defines the `IEventSource` interface, which is the foundation of event-sourced entities + - Provides base implementations for aggregates, events, and commands + - Implements the core event sourcing patterns + +2. **ReactiveDomain.Foundation** + - Builds on Core to provide higher-level abstractions + - Implements the `AggregateRoot` class, which is the base class for domain aggregates + - Provides repository implementations for storing and retrieving aggregates + - Implements correlation and causation tracking + +3. **ReactiveDomain.Messaging** + - Implements the messaging infrastructure for commands, events, and queries + - Provides message routing and handling mechanisms + - Implements the bus pattern for message distribution + - Supports both synchronous and asynchronous message handling + +4. **ReactiveDomain.Persistence** + - Provides storage mechanisms for events and snapshots + - Implements the EventStoreDB integration + - Handles serialization and deserialization of events + - Manages stream naming and event metadata + +5. **ReactiveDomain.Transport** + - Implements transport mechanisms for distributed messaging + - Supports both in-process and cross-process communication + - Provides reliable message delivery guarantees + - Implements message routing across process boundaries + +6. **ReactiveDomain.Testing** + - Provides testing utilities for event-sourced applications + - Implements in-memory event stores for testing + - Provides test fixtures for aggregates and event handlers + - Supports both unit and integration testing + +7. **ReactiveDomain.Tools** + - Provides utility tools for working with event-sourced systems + - Implements event store exploration and management tools + - Provides diagnostic utilities for troubleshooting + - Supports development and operational workflows + +### Data Flow + +The following diagram illustrates the data flow in a typical Reactive Domain application: + +```mermaid +sequenceDiagram + participant Client + participant CommandHandler + participant Aggregate + participant Repository + participant EventStore + participant Projection + + Client->>CommandHandler: Send Command + CommandHandler->>Aggregate: Load via Repository + Aggregate->>Repository: Get Events + Repository->>EventStore: Read Events + EventStore-->>Repository: Return Events + Repository-->>Aggregate: Apply Events + Aggregate->>Aggregate: Process Command + Aggregate->>Aggregate: Generate Events + Aggregate->>Repository: Save Events + Repository->>EventStore: Append Events + EventStore-->>Repository: Confirm Save + Repository-->>CommandHandler: Return Result + CommandHandler-->>Client: Command Result + EventStore->>Projection: Publish Events + Projection->>Projection: Update Read Model +``` + +## Design Principles and Patterns + +Reactive Domain is built on several key design principles and patterns: + +### 1. Event Sourcing + +Event sourcing is the core pattern in Reactive Domain, where: + +- The state of an entity is determined by a sequence of events +- Events are immutable and represent facts that have occurred +- The current state is derived by replaying events +- Events are stored in an append-only event store + +Implementation in Reactive Domain: +- `IEventSource` interface defines the contract for event-sourced entities +- `AggregateRoot` provides a base implementation for domain aggregates +- Events are stored in EventStoreDB +- Repositories handle loading and saving aggregates + +### 2. Command Query Responsibility Segregation (CQRS) + +CQRS separates the command (write) and query (read) sides of an application: + +- Commands modify state and generate events +- Queries read from optimized read models +- Read models are built by processing events + +Implementation in Reactive Domain: +- Command handlers process commands and update aggregates +- Event handlers update read models +- Read models are optimized for specific query patterns +- Separate repositories for command and query sides + +### 3. Domain-Driven Design (DDD) + +Reactive Domain supports DDD principles: + +- Aggregates encapsulate business rules and enforce invariants +- Entities have identity and lifecycle +- Value objects are immutable and have no identity +- Domain events represent significant state changes +- Repositories provide access to aggregates + +Implementation in Reactive Domain: +- `AggregateRoot` supports DDD aggregates +- Events represent domain events +- Repositories provide aggregate persistence +- Value objects can be used as event properties + +### 4. Messaging Patterns + +Reactive Domain uses messaging patterns for communication: + +- Commands represent intentions to change state +- Events represent state changes that have occurred +- Queries request information from read models +- Message handlers process messages and perform actions + +Implementation in Reactive Domain: +- `IMessage`, `ICommand`, `IEvent` interfaces define message contracts +- Message buses route messages to handlers +- Message handlers process messages and perform actions +- Correlation and causation tracking links related messages + +### 5. Reactive Programming + +Reactive Domain embraces reactive programming principles: + +- Asynchronous message processing +- Event-driven architecture +- Non-blocking operations +- Resilience and fault tolerance + +Implementation in Reactive Domain: +- Asynchronous message handling +- Event-driven workflows +- Reactive streams for event processing +- Error handling and recovery mechanisms + +## Component Interactions + +### Command Processing Flow + +1. **Command Creation** + - A command is created, representing an intention to change state + - Commands include a unique ID, correlation ID, and causation ID + - Commands are validated before processing + +2. **Command Routing** + - Commands are routed to the appropriate command handler + - Routing is based on command type + - Command handlers are registered with the command bus + +3. **Aggregate Loading** + - The command handler loads the target aggregate from the repository + - The repository reads events from the event store + - Events are applied to the aggregate to reconstruct its state + +4. **Command Handling** + - The command is passed to the aggregate for processing + - The aggregate validates the command against its current state + - If valid, the aggregate generates one or more events + - Events are applied to the aggregate to update its state + +5. **Event Persistence** + - The aggregate's events are saved to the event store + - The repository handles optimistic concurrency control + - Events are persisted atomically with the expected version + +6. **Event Publication** + - Events are published to event handlers + - Event handlers update read models and trigger side effects + - Event handlers can be synchronous or asynchronous + +### Query Processing Flow + +1. **Query Creation** + - A query is created, representing a request for information + - Queries include parameters to filter and shape the results + +2. **Query Routing** + - Queries are routed to the appropriate query handler + - Routing is based on query type + - Query handlers are registered with the query bus + +3. **Read Model Access** + - The query handler accesses the appropriate read model + - Read models are optimized for specific query patterns + - Read models can be in-memory, database tables, or other storage + +4. **Result Generation** + - The query handler processes the query against the read model + - Results are filtered and shaped according to the query parameters + - Results are returned to the caller + +### Event Processing Flow + +1. **Event Generation** + - Events are generated by aggregates in response to commands + - Events represent state changes that have occurred + - Events include metadata such as correlation and causation IDs + +2. **Event Persistence** + - Events are persisted to the event store + - Events are stored in streams, typically one stream per aggregate + - Events include metadata for tracing and debugging + +3. **Event Publication** + - Events are published to event handlers + - Event handlers can be synchronous or asynchronous + - Event handlers can update read models, trigger side effects, or start processes + +4. **Read Model Updates** + - Event handlers update read models based on event data + - Read models are optimized for specific query patterns + - Read models can be in-memory, database tables, or other storage + +## Extension Points + +Reactive Domain provides several extension points for customization: + +### 1. Custom Aggregates + +Create custom aggregates by extending `AggregateRoot`: + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + + public Account(Guid id) : base(id) + { + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + RaiseEvent(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new AmountWithdrawn(Id, amount)); + } + + private void Apply(AmountDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(AmountWithdrawn @event) + { + _balance -= @event.Amount; + } +} +``` + +### 2. Custom Event Serialization + +Implement custom event serialization by implementing `IEventSerializer`: + +```csharp +public class CustomEventSerializer : IEventSerializer +{ + public object Deserialize(RecordedEvent recordedEvent) + { + // Custom deserialization logic + var eventType = Type.GetType(recordedEvent.EventType); + var eventData = Encoding.UTF8.GetString(recordedEvent.Data); + return JsonConvert.DeserializeObject(eventData, eventType); + } + + public IEventData Serialize(object @event, Guid eventId) + { + // Custom serialization logic + var eventType = @event.GetType().AssemblyQualifiedName; + var eventData = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(@event)); + var metadata = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new { EventType = eventType })); + + return new EventData(eventId, eventType, true, eventData, metadata); + } +} +``` + +### 3. Custom Stream Naming + +Implement custom stream naming by implementing `IStreamNameBuilder`: + +```csharp +public class CustomStreamNameBuilder : IStreamNameBuilder +{ + public string GenerateForAggregate(Type aggregateType, Guid aggregateId) + { + // Custom stream naming logic + return $"custom-{aggregateType.Name.ToLower()}-{aggregateId}"; + } +} +``` + +### 4. Custom Message Handling + +Implement custom message handling by implementing `ICommandHandler`, `IEventHandler`, or `IQueryHandler`: + +```csharp +public class CustomCommandHandler : ICommandHandler +{ + private readonly IRepository _repository; + + public CustomCommandHandler(IRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccount command) + { + // Custom command handling logic + var account = new Account(command.AccountId); + account.Initialize(command.InitialBalance); + _repository.Save(account); + } +} +``` + +### 5. Custom Event Store Integration + +Implement custom event store integration by implementing `IStreamStoreConnection`: + +```csharp +public class CustomStreamStoreConnection : IStreamStoreConnection +{ + public void Connect() + { + // Custom connection logic + } + + public void Disconnect() + { + // Custom disconnection logic + } + + public IStreamSlice ReadStreamForward(string streamName, long start, int count) + { + // Custom stream reading logic + return new StreamSlice(/* ... */); + } + + public void AppendToStream(string streamName, long expectedVersion, IEnumerable events) + { + // Custom stream appending logic + } + + // Implement other methods +} +``` + +### 6. Custom Snapshots + +Implement custom snapshots by implementing `ISnapshotSource`: + +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + private decimal _balance; + + // ... existing code ... + + public void RestoreFromSnapshot(object snapshot) + { + var accountSnapshot = (AccountSnapshot)snapshot; + _balance = accountSnapshot.Balance; + ExpectedVersion = accountSnapshot.Version; + } + + public object TakeSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + Version = ExpectedVersion + }; + } +} + +public class AccountSnapshot +{ + public decimal Balance { get; set; } + public long Version { get; set; } +} +``` + +## Integration Patterns + +Reactive Domain supports several integration patterns for working with external systems: + +### 1. Event-Driven Integration + +Use events to integrate with external systems: + +```csharp +public class ExternalSystemIntegration : IEventHandler, IEventHandler +{ + private readonly IExternalSystem _externalSystem; + + public ExternalSystemIntegration(IExternalSystem externalSystem) + { + _externalSystem = externalSystem; + } + + public void Handle(AccountCreated @event) + { + _externalSystem.CreateAccount(@event.AccountId, @event.Owner); + } + + public void Handle(AmountDeposited @event) + { + _externalSystem.RecordDeposit(@event.AccountId, @event.Amount); + } +} +``` + +### 2. Command-Driven Integration + +Use commands to integrate with external systems: + +```csharp +public class ExternalSystemCommandHandler : ICommandHandler +{ + private readonly IRepository _repository; + private readonly IExternalSystem _externalSystem; + + public ExternalSystemCommandHandler(IRepository repository, IExternalSystem externalSystem) + { + _repository = repository; + _externalSystem = externalSystem; + } + + public void Handle(CreateExternalAccount command) + { + // Create account in local system + var account = new Account(command.AccountId); + account.Initialize(command.InitialBalance); + _repository.Save(account); + + // Create account in external system + _externalSystem.CreateAccount(command.AccountId, command.Owner); + } +} +``` + +### 3. Saga Pattern + +Use sagas to coordinate complex workflows involving multiple aggregates and external systems: + +```csharp +public class AccountTransferSaga : + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IRepository _repository; + private readonly ICommandBus _commandBus; + + public AccountTransferSaga(IRepository repository, ICommandBus commandBus) + { + _repository = repository; + _commandBus = commandBus; + } + + public void Handle(TransferInitiated @event) + { + _commandBus.Send(new DebitSourceAccount( + @event.TransferId, + @event.SourceAccountId, + @event.Amount)); + } + + public void Handle(SourceAccountDebited @event) + { + _commandBus.Send(new CreditDestinationAccount( + @event.TransferId, + @event.DestinationAccountId, + @event.Amount)); + } + + public void Handle(DestinationAccountCredited @event) + { + _commandBus.Send(new CompleteTransfer(@event.TransferId)); + } + + public void Handle(TransferFailed @event) + { + if (@event.Stage == TransferStage.Debit) + { + // No compensation needed, transfer failed before any changes + _commandBus.Send(new CancelTransfer(@event.TransferId, @event.Reason)); + } + else if (@event.Stage == TransferStage.Credit) + { + // Compensate by crediting the source account + _commandBus.Send(new RefundSourceAccount( + @event.TransferId, + @event.SourceAccountId, + @event.Amount)); + } + } +} +``` + +### 4. Outbox Pattern + +Use the outbox pattern to ensure reliable integration with external systems: + +```csharp +public class OutboxRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly IOutboxStore _outboxStore; + + public OutboxRepository(IRepository innerRepository, IOutboxStore outboxStore) + { + _innerRepository = innerRepository; + _outboxStore = outboxStore; + } + + public void Save(IEventSource aggregate) + { + using (var transaction = new TransactionScope()) + { + _innerRepository.Save(aggregate); + + // Store integration events in the outbox + foreach (var @event in aggregate.TakeEvents()) + { + _outboxStore.Add(new OutboxMessage + { + Id = Guid.NewGuid(), + AggregateId = aggregate.Id, + AggregateType = aggregate.GetType().Name, + EventType = @event.GetType().Name, + EventData = JsonConvert.SerializeObject(@event), + CreatedAt = DateTime.UtcNow + }); + } + + transaction.Complete(); + } + } + + // Implement other methods +} + +public class OutboxProcessor +{ + private readonly IOutboxStore _outboxStore; + private readonly IExternalSystem _externalSystem; + + public OutboxProcessor(IOutboxStore outboxStore, IExternalSystem externalSystem) + { + _outboxStore = outboxStore; + _externalSystem = externalSystem; + } + + public void ProcessOutbox() + { + var messages = _outboxStore.GetPendingMessages(); + + foreach (var message in messages) + { + try + { + var @event = JsonConvert.DeserializeObject(message.EventData, Type.GetType(message.EventType)); + + // Process the event for integration + if (@event is AccountCreated accountCreated) + { + _externalSystem.CreateAccount(accountCreated.AccountId, accountCreated.Owner); + } + else if (@event is AmountDeposited amountDeposited) + { + _externalSystem.RecordDeposit(amountDeposited.AccountId, amountDeposited.Amount); + } + + // Mark as processed + _outboxStore.MarkAsProcessed(message.Id); + } + catch (Exception ex) + { + // Log error and retry later + _outboxStore.MarkAsFailed(message.Id, ex.Message); + } + } + } +} +``` + +## Scaling and Performance Considerations + +### 1. Event Store Scaling + +EventStoreDB can be scaled in several ways: + +- **Single Node**: Suitable for development and small production workloads +- **Cluster**: Multiple nodes for high availability and throughput +- **Projections**: Offload read model building to EventStoreDB projections +- **Subscriptions**: Use persistent subscriptions for reliable event processing + +Recommendations: +- Use a cluster configuration in production +- Configure appropriate hardware for the event store +- Monitor event store performance and adjust resources as needed +- Use snapshots for large aggregates to improve loading performance + +### 2. Read Model Scaling + +Read models can be scaled independently: + +- **Database Scaling**: Scale the database hosting read models +- **Caching**: Use caching to reduce database load +- **Sharding**: Shard read models for high-volume data +- **Eventual Consistency**: Accept eventual consistency for better scalability + +Recommendations: +- Choose the right database for each read model +- Scale read models independently based on query patterns +- Use caching for frequently accessed data +- Consider eventual consistency trade-offs + +### 3. Command Processing Scaling + +Command processing can be scaled in several ways: + +- **Horizontal Scaling**: Deploy multiple command processors +- **Load Balancing**: Distribute commands across processors +- **Command Queuing**: Queue commands for asynchronous processing +- **Command Batching**: Batch related commands for efficiency + +Recommendations: +- Scale command processors based on throughput requirements +- Use load balancing for high-volume command processing +- Consider command queuing for peak load handling +- Batch related commands where appropriate + +### 4. Event Processing Scaling + +Event processing can be scaled in several ways: + +- **Parallel Processing**: Process events in parallel +- **Competing Consumers**: Use competing consumers for event processing +- **Event Partitioning**: Partition events for parallel processing +- **Backpressure**: Implement backpressure for overload protection + +Recommendations: +- Scale event processors based on event volume +- Use competing consumers for high-volume event processing +- Partition events by aggregate type or other criteria +- Implement backpressure mechanisms for overload protection + +### 5. Performance Optimization Techniques + +Several techniques can improve performance: + +- **Snapshots**: Use snapshots to reduce event loading time +- **Caching**: Cache aggregates and read models +- **Batching**: Batch operations for efficiency +- **Asynchronous Processing**: Use asynchronous processing for non-critical operations +- **Read Model Optimization**: Optimize read models for specific query patterns + +Recommendations: +- Use snapshots for aggregates with many events +- Implement caching for frequently accessed data +- Batch operations where appropriate +- Use asynchronous processing for non-critical operations +- Optimize read models for specific query patterns + +## Security Considerations + +### 1. Authentication and Authorization + +- **Command Authorization**: Authorize commands before processing +- **Query Authorization**: Authorize queries before processing +- **Event Authorization**: Control access to events +- **Role-Based Access Control**: Implement RBAC for command and query authorization + +Implementation: +```csharp +public class AuthorizedCommandBus : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly IAuthorizationService _authorizationService; + private readonly IUserContext _userContext; + + public AuthorizedCommandBus( + ICommandBus innerBus, + IAuthorizationService authorizationService, + IUserContext userContext) + { + _innerBus = innerBus; + _authorizationService = authorizationService; + _userContext = userContext; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + // Authorize the command + if (!_authorizationService.IsAuthorized(_userContext.CurrentUser, command)) + { + throw new UnauthorizedAccessException($"User is not authorized to execute {typeof(TCommand).Name}"); + } + + // Forward to inner bus + _innerBus.Send(command); + } +} +``` + +### 2. Data Protection + +- **Event Data Encryption**: Encrypt sensitive event data +- **Metadata Protection**: Protect sensitive metadata +- **Secure Storage**: Secure event store and read model storage +- **Data Masking**: Mask sensitive data in logs and diagnostics + +Implementation: +```csharp +public class EncryptingEventSerializer : IEventSerializer +{ + private readonly IEventSerializer _innerSerializer; + private readonly IEncryptionService _encryptionService; + + public EncryptingEventSerializer( + IEventSerializer innerSerializer, + IEncryptionService encryptionService) + { + _innerSerializer = innerSerializer; + _encryptionService = encryptionService; + } + + public object Deserialize(RecordedEvent recordedEvent) + { + // Decrypt event data if necessary + if (ShouldEncrypt(recordedEvent.EventType)) + { + var decryptedData = _encryptionService.Decrypt(recordedEvent.Data); + var decryptedEvent = new RecordedEvent( + recordedEvent.EventStreamId, + recordedEvent.EventNumber, + recordedEvent.EventId, + recordedEvent.EventType, + decryptedData, + recordedEvent.Metadata, + recordedEvent.IsJson, + recordedEvent.Created); + + return _innerSerializer.Deserialize(decryptedEvent); + } + + return _innerSerializer.Deserialize(recordedEvent); + } + + public IEventData Serialize(object @event, Guid eventId) + { + var eventData = _innerSerializer.Serialize(@event, eventId); + + // Encrypt event data if necessary + if (ShouldEncrypt(@event.GetType().Name)) + { + var encryptedData = _encryptionService.Encrypt(eventData.Data); + return new EventData( + eventData.EventId, + eventData.Type, + eventData.IsJson, + encryptedData, + eventData.Metadata); + } + + return eventData; + } + + private bool ShouldEncrypt(string eventType) + { + // Determine if the event type should be encrypted + return eventType.Contains("Sensitive") || eventType.Contains("Personal"); + } +} +``` + +### 3. Audit Logging + +- **Command Logging**: Log all commands with user context +- **Event Logging**: Log all events with correlation and causation +- **Access Logging**: Log all access to the system +- **Compliance Logging**: Log compliance-related activities + +Implementation: +```csharp +public class AuditingCommandBus : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly IAuditLogger _auditLogger; + private readonly IUserContext _userContext; + + public AuditingCommandBus( + ICommandBus innerBus, + IAuditLogger auditLogger, + IUserContext userContext) + { + _innerBus = innerBus; + _auditLogger = auditLogger; + _userContext = userContext; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + // Log the command for audit purposes + _auditLogger.LogCommand( + command.GetType().Name, + JsonConvert.SerializeObject(command), + _userContext.CurrentUser.Id, + DateTime.UtcNow); + + // Forward to inner bus + _innerBus.Send(command); + } +} +``` + +### 4. Secure Deployment + +- **Secure Configuration**: Protect configuration settings +- **Secret Management**: Use secure secret management +- **Network Security**: Secure network communication +- **Infrastructure Security**: Secure the infrastructure + +Recommendations: +- Use secure configuration management +- Store secrets in a secure vault +- Encrypt network communication +- Implement infrastructure security best practices + +### 5. Threat Modeling + +- **Identify Assets**: Identify valuable assets in the system +- **Identify Threats**: Identify potential threats to those assets +- **Assess Risks**: Assess the risks of each threat +- **Mitigate Risks**: Implement controls to mitigate risks + +Recommendations: +- Conduct regular threat modeling exercises +- Update threat models as the system evolves +- Implement controls based on risk assessment +- Test controls for effectiveness + +[↑ Back to Top](#architecture-guide) | [← Back to Table of Contents](README.md) diff --git a/docs/code-examples/README.md b/docs/code-examples/README.md new file mode 100644 index 00000000..34195a33 --- /dev/null +++ b/docs/code-examples/README.md @@ -0,0 +1,50 @@ +# Reactive Domain Code Examples + +[← Back to Table of Contents](../README.md) + +This section provides practical code examples that demonstrate how to use Reactive Domain in real-world scenarios. + +## Table of Contents + +1. [Creating a New Aggregate Root](creating-aggregate-root.md) +2. [Handling Commands and Generating Events](handling-commands-events.md) +3. [Saving and Retrieving Aggregates](saving-retrieving-aggregates.md) +4. [Setting Up Event Listeners](event-listeners.md) +5. [Implementing Projections](implementing-projections.md) +6. [Handling Correlation and Causation](correlation-causation.md) +7. [Implementing Snapshots](implementing-snapshots.md) +8. [Testing Aggregates and Event Handlers](testing.md) +9. [Integration with ASP.NET Core](aspnet-integration.md) +10. [Complete Sample Applications](sample-applications.md) + +Each example includes: + +- Complete code snippets that you can copy and use in your own projects +- Explanations of key concepts and patterns +- Best practices and common pitfalls to avoid +- Variations for different use cases + +## How to Use These Examples + +The examples in this section are designed to be practical and reusable. You can: + +1. Copy and paste the code into your own projects +2. Use them as templates for your own implementations +3. Adapt them to your specific requirements +4. Learn from them to understand how Reactive Domain works in practice + +## Prerequisites + +To run these examples, you'll need: + +- .NET Core 3.1 or later +- EventStoreDB (for examples that use event storage) +- Basic understanding of event sourcing and CQRS concepts + +## Getting Started + +If you're new to Reactive Domain, we recommend starting with the [Creating a New Aggregate Root](creating-aggregate-root.md) example, followed by [Handling Commands and Generating Events](handling-commands-events.md) and [Saving and Retrieving Aggregates](saving-retrieving-aggregates.md). + +For more advanced scenarios, check out the [Complete Sample Applications](sample-applications.md) section. + +[↑ Back to Top](#reactive-domain-code-examples) | [← Back to Table of Contents](../README.md) diff --git a/docs/components/README.md b/docs/components/README.md new file mode 100644 index 00000000..b6583c5c --- /dev/null +++ b/docs/components/README.md @@ -0,0 +1,27 @@ +# Reactive Domain Components + +[← Back to Table of Contents](../README.md) + +This section provides detailed documentation for each of the major components of the Reactive Domain library. + +## Table of Contents + +1. [ReactiveDomain.Core](core.md) - Fundamental interfaces and abstractions +2. [ReactiveDomain.Foundation](foundation.md) - Domain implementation including aggregates and repositories +3. [ReactiveDomain.Messaging](messaging.md) - Messaging framework for commands, events, and queries +4. [ReactiveDomain.Persistence](persistence.md) - Event storage mechanisms +5. [ReactiveDomain.Transport](transport.md) - Transport layer for messages +6. [ReactiveDomain.Testing](testing.md) - Testing utilities for event-sourced systems +7. [ReactiveDomain.Policy](policy.md) - Policy implementation and enforcement +8. [ReactiveDomain.IdentityStorage](identity-storage.md) - Identity storage mechanisms +9. [ReactiveDomain.Tools](tools.md) - Developer tools and utilities + +Each component documentation includes: + +- Purpose and responsibility of the component +- Key interfaces and classes +- Implementation details +- Usage examples +- Integration with other components + +[↑ Back to Top](#reactive-domain-components) | [← Back to Table of Contents](../README.md) diff --git a/docs/components/core.md b/docs/components/core.md new file mode 100644 index 00000000..46ca08f7 --- /dev/null +++ b/docs/components/core.md @@ -0,0 +1,196 @@ +# ReactiveDomain.Core + +[← Back to Components](README.md) | [← Back to Table of Contents](../README.md) + +The `ReactiveDomain.Core` component provides the fundamental interfaces and abstractions that form the foundation of the Reactive Domain library. These core interfaces define the contract for event sourcing and are used throughout the library. + +## Table of Contents + +- [Purpose and Responsibility](#purpose-and-responsibility) +- [Key Interfaces](#key-interfaces) + - [IEventSource](#ieventsource) + - [IMetadataSource](#imetadatasource) +- [Implementation Details](#implementation-details) +- [Usage Examples](#usage-examples) + - [Implementing IEventSource](#implementing-ieventsource) + - [Using IMetadataSource](#using-imetadatasource) +- [Integration with Other Components](#integration-with-other-components) +- [Best Practices](#best-practices) +- [Common Pitfalls](#common-pitfalls) + +## Purpose and Responsibility + +The primary purpose of the `ReactiveDomain.Core` component is to define the core abstractions for event sourcing, including: + +- Event sources (entities that produce events) +- Metadata sources (entities that have associated metadata) +- Basic event handling and processing + +These abstractions are deliberately minimal and focused, providing just enough structure to support the event sourcing pattern without imposing unnecessary constraints. + +## Key Interfaces + +### IEventSource + +The `IEventSource` interface is the cornerstone of event sourcing in Reactive Domain. It represents a source of events from the perspective of restoring from and taking events. + +```csharp +public interface IEventSource +{ + Guid Id { get; } + long ExpectedVersion { get; set; } + void RestoreFromEvents(IEnumerable events); + void UpdateWithEvents(IEnumerable events, long expectedVersion); + object[] TakeEvents(); +} +``` + +**Key Responsibilities:** + +- **Id**: Provides a unique identifier for the event source +- **ExpectedVersion**: Tracks the version of the event source for optimistic concurrency +- **RestoreFromEvents**: Rebuilds the state of the event source from a sequence of events +- **UpdateWithEvents**: Updates the state of the event source with new events +- **TakeEvents**: Retrieves the events that have been recorded by the event source + +### IMetadataSource + +The `IMetadataSource` interface defines the contract for entities that have associated metadata. + +```csharp +public interface IMetadataSource +{ + Metadata ReadMetadata(); + Metadata Initialize(); + void Initialize(Metadata md); +} +``` + +**Key Responsibilities:** + +- **ReadMetadata**: Retrieves the metadata associated with the entity +- **Initialize**: Initializes the metadata with default values or with provided metadata + +## Implementation Details + +The `ReactiveDomain.Core` component is intentionally lightweight, focusing on interfaces rather than implementations. The actual implementations of these interfaces are provided by other components, particularly `ReactiveDomain.Foundation`. + +The core interfaces are designed to be: + +- **Minimal**: Providing only the essential methods and properties +- **Focused**: Each interface has a single responsibility +- **Composable**: Interfaces can be combined to create more complex behaviors + +## Usage Examples + +### Implementing IEventSource + +```csharp +public class MyAggregate : IEventSource +{ + private readonly EventRecorder _recorder = new EventRecorder(); + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + public MyAggregate(Guid id) + { + Id = id; + ExpectedVersion = -1; + } + + public void DoSomething() + { + // Business logic + _recorder.Record(new SomethingDone(Id)); + } + + public void RestoreFromEvents(IEnumerable events) + { + foreach (var @event in events) + { + Apply(@event); + } + } + + public void UpdateWithEvents(IEnumerable events, long expectedVersion) + { + if (ExpectedVersion != expectedVersion) + throw new InvalidOperationException("Version mismatch"); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public object[] TakeEvents() + { + var events = _recorder.RecordedEvents.ToArray(); + _recorder.Reset(); + return events; + } + + private void Apply(object @event) + { + // Apply event to update state + // This is typically implemented using pattern matching or a dictionary of handlers + } +} +``` + +### Using IMetadataSource + +```csharp +public class MyMetadataEntity : IMetadataSource +{ + private Metadata _metadata; + + public Metadata ReadMetadata() + { + return _metadata ?? Initialize(); + } + + public Metadata Initialize() + { + _metadata = new Metadata(); + _metadata.Add("CreatedAt", DateTime.UtcNow); + return _metadata; + } + + public void Initialize(Metadata md) + { + _metadata = md; + } +} +``` + +## Integration with Other Components + +The `ReactiveDomain.Core` component is used by virtually all other components in the Reactive Domain library: + +- **ReactiveDomain.Foundation** implements the core interfaces to provide concrete aggregates and repositories +- **ReactiveDomain.Messaging** uses the core interfaces to define message types and handlers +- **ReactiveDomain.Persistence** uses the core interfaces to define storage mechanisms for events +- **ReactiveDomain.Testing** uses the core interfaces to provide testing utilities + +## Best Practices + +When working with the `ReactiveDomain.Core` component: + +1. **Keep implementations simple**: The core interfaces are designed to be minimal, and implementations should follow this principle +2. **Separate concerns**: Use the interfaces to create clear boundaries between different parts of your application +3. **Focus on events**: Remember that events are the central concept in event sourcing, and the core interfaces are designed to support this +4. **Use composition**: Combine interfaces to create more complex behaviors rather than creating complex inheritance hierarchies + +## Common Pitfalls + +Some common issues to avoid when working with the `ReactiveDomain.Core` component: + +1. **Mutating events**: Events should be immutable, but the core interfaces don't enforce this +2. **Ignoring version checks**: Always check the `ExpectedVersion` to prevent concurrency issues +3. **Complex event application**: Keep the logic for applying events simple and focused +4. **Leaking implementation details**: The core interfaces should hide implementation details from clients + +[↑ Back to Top](#reactivedomaincore) | [← Back to Components](README.md) | [← Back to Table of Contents](../README.md) diff --git a/docs/core-concepts.md b/docs/core-concepts.md new file mode 100644 index 00000000..ec34b3b9 --- /dev/null +++ b/docs/core-concepts.md @@ -0,0 +1,176 @@ +# Core Concepts of Reactive Domain + +[← Back to Table of Contents](README.md) + +This document explains the fundamental concepts of event sourcing as implemented in the Reactive Domain library. + +## Table of Contents + +- [Event Sourcing Fundamentals](#event-sourcing-fundamentals) +- [Event Store Architecture](#event-store-architecture) +- [CQRS Implementation](#cqrs-implementation) +- [Reactive Programming Principles](#reactive-programming-principles) +- [Domain-Driven Design Concepts](#domain-driven-design-concepts) +- [Event, Command, and Message Flow](#event-command-and-message-flow) +- [Correlation and Causation Tracking](#correlation-and-causation-tracking) +- [Snapshots](#snapshots) +- [Conclusion](#conclusion) + +## Event Sourcing Fundamentals + +### What is Event Sourcing? + +Event sourcing is a design pattern where changes to the application state are captured as a sequence of immutable events. Instead of storing the current state of an entity, event sourcing stores the history of events that led to that state. The current state can be reconstructed by replaying these events. + +In Reactive Domain, event sourcing is implemented through the following key components: + +- **Events**: Immutable records of something that happened in the system +- **Aggregates**: Domain entities that encapsulate state and behavior +- **Event Store**: A specialized database for storing and retrieving events +- **Projections**: Components that transform events into queryable state + +### Benefits of Event Sourcing + +- **Complete Audit Trail**: Every change to the system is recorded as an event +- **Temporal Queries**: The ability to determine the state of the system at any point in time +- **Event Replay**: The ability to replay events to reconstruct state or to create new projections +- **Separation of Concerns**: Clear separation between write and read operations + +## Event Store Architecture + +Reactive Domain integrates with [EventStoreDB](https://eventstore.com), a purpose-built database for event sourcing. The integration is handled through the `ReactiveDomain.Persistence` namespace, which provides: + +- **StreamStoreRepository**: A repository implementation that stores and retrieves events from EventStoreDB +- **StreamNameBuilder**: Utilities for generating consistent stream names +- **EventSerializer**: Components for serializing and deserializing events + +```mermaid +graph TD + A[Domain Aggregate] --> B[Event Source] + B --> C[Repository] + C --> D[Stream Store Connection] + D --> E[EventStoreDB] + F[Event Serializer] --> C +``` + +## CQRS Implementation + +Command Query Responsibility Segregation (CQRS) is a pattern that separates read and write operations. In Reactive Domain, CQRS is implemented through: + +- **Commands**: Requests for the system to perform an action +- **Command Handlers**: Components that process commands and generate events +- **Events**: Records of changes that have occurred +- **Projections**: Components that transform events into queryable state +- **Queries**: Requests for information from the system +- **Query Handlers**: Components that process queries and return results + +```mermaid +sequenceDiagram + participant Client + participant CommandHandler + participant Aggregate + participant Repository + participant EventStore + participant Projection + participant ReadModel + + Client->>CommandHandler: Send Command + CommandHandler->>Aggregate: Apply Command + Aggregate->>Aggregate: Generate Events + Aggregate->>Repository: Save Events + Repository->>EventStore: Store Events + EventStore-->>Projection: Event Notification + Projection->>ReadModel: Update Read Model + Client->>ReadModel: Query + ReadModel-->>Client: Result +``` + +## Reactive Programming Principles + +Reactive Domain incorporates reactive programming principles to handle event flows and asynchronous operations: + +- **Event-Driven**: The system is driven by events that flow through components +- **Asynchronous**: Operations are performed asynchronously to improve scalability +- **Non-Blocking**: Components are designed to be non-blocking to maximize throughput +- **Message-Based**: Communication between components is done through messages + +## Domain-Driven Design Concepts + +Reactive Domain is built on Domain-Driven Design (DDD) principles: + +- **Aggregates**: Clusters of domain objects treated as a single unit +- **Entities**: Objects with a distinct identity that persists over time +- **Value Objects**: Objects without identity that represent descriptive aspects of the domain +- **Repositories**: Components that provide access to aggregates +- **Domain Events**: Events that represent something significant that happened in the domain +- **Bounded Contexts**: Explicit boundaries within which a particular domain model applies + +## Event, Command, and Message Flow + +In Reactive Domain, messages flow through the system in a structured way: + +```mermaid +graph LR + A[Client] --> B[Command] + B --> C[Command Handler] + C --> D[Aggregate] + D --> E[Events] + E --> F[Event Store] + E --> G[Event Handlers] + G --> H[Read Models] + G --> I[Integration Events] + I --> J[External Systems] +``` + +- **Commands** are sent to command handlers +- **Command Handlers** load and update aggregates +- **Aggregates** generate events +- **Events** are stored in the event store and published to event handlers +- **Event Handlers** update read models and generate integration events +- **Integration Events** are sent to external systems + +## Correlation and Causation Tracking + +Reactive Domain provides built-in support for tracking correlation and causation IDs across message flows: + +- **Correlation ID**: Identifies a business transaction that spans multiple messages +- **Causation ID**: Identifies the message that caused the current message + +This is implemented through the `ICorrelatedMessage` interface and related components: + +```csharp +public interface ICorrelatedMessage +{ + Guid MsgId { get; } + Guid CorrelationId { get; } + Guid CausationId { get; } +} +``` + +The `CorrelatedStreamStoreRepository` ensures that correlation and causation IDs are properly propagated when loading and saving aggregates. + +## Snapshots + +For performance optimization, Reactive Domain supports snapshots: + +- **Snapshots**: Point-in-time captures of aggregate state +- **Snapshot Storage**: Mechanisms for storing and retrieving snapshots +- **Snapshot Strategies**: Policies for when to create snapshots + +This is implemented through the `ISnapshotSource` interface: + +```csharp +public interface ISnapshotSource +{ + void RestoreFromSnapshot(object snapshot); + object TakeSnapshot(); +} +``` + +## Conclusion + +These core concepts form the foundation of the Reactive Domain library. Understanding these concepts is essential for effectively using the library to build event-sourced applications. + +For more detailed information on specific components, see the [Component Documentation](components/README.md) section. For practical guidance on using these concepts, see the [Usage Patterns](usage-patterns.md) section. + +[↑ Back to Top](#core-concepts-of-reactive-domain) | [← Back to Table of Contents](README.md) diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 00000000..7a34d522 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,829 @@ +# Deployment Guide + +[← Back to Table of Contents](README.md) + +This guide provides best practices and considerations for deploying Reactive Domain applications to various environments. + +## Table of Contents + +- [Development Environment Setup](#development-environment-setup) +- [Testing Environment Configuration](#testing-environment-configuration) +- [Production Deployment Considerations](#production-deployment-considerations) +- [Scaling Strategies](#scaling-strategies) +- [Monitoring and Observability](#monitoring-and-observability) +- [Backup and Recovery Strategies](#backup-and-recovery-strategies) +- [Security Considerations](#security-considerations) + +## Development Environment Setup + +### Prerequisites + +To set up a development environment for Reactive Domain applications, you'll need: + +- **.NET SDK**: .NET 7.0 or later +- **EventStoreDB**: Version 20.10 or later +- **IDE**: Visual Studio 2022, JetBrains Rider, or Visual Studio Code +- **Git**: For source control +- **Docker**: For containerized development (optional) + +### Local EventStoreDB Setup + +#### Using Docker + +```bash +docker run --name eventstore -it -p 2113:2113 -p 1113:1113 \ + -e EVENTSTORE_CLUSTER_SIZE=1 \ + -e EVENTSTORE_RUN_PROJECTIONS=All \ + -e EVENTSTORE_START_STANDARD_PROJECTIONS=true \ + -e EVENTSTORE_EXT_TCP_PORT=1113 \ + -e EVENTSTORE_HTTP_PORT=2113 \ + -e EVENTSTORE_INSECURE=true \ + -e EVENTSTORE_ENABLE_EXTERNAL_TCP=true \ + -e EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true \ + eventstore/eventstore:latest +``` + +#### Using Installer + +1. Download the installer from [EventStoreDB Downloads](https://eventstore.com/downloads/) +2. Follow the installation instructions for your platform +3. Configure EventStoreDB to run as a service + +### Project Setup + +1. **Create a new solution**: + ```bash + dotnet new sln -n MyReactiveDomainApp + ``` + +2. **Add projects**: + ```bash + dotnet new classlib -n MyReactiveDomainApp.Domain + dotnet new classlib -n MyReactiveDomainApp.Infrastructure + dotnet new classlib -n MyReactiveDomainApp.Application + dotnet new webapi -n MyReactiveDomainApp.Api + dotnet new xunit -n MyReactiveDomainApp.Tests + + dotnet sln add MyReactiveDomainApp.Domain + dotnet sln add MyReactiveDomainApp.Infrastructure + dotnet sln add MyReactiveDomainApp.Application + dotnet sln add MyReactiveDomainApp.Api + dotnet sln add MyReactiveDomainApp.Tests + ``` + +3. **Add Reactive Domain packages**: + ```bash + dotnet add MyReactiveDomainApp.Domain package ReactiveDomain.Core + dotnet add MyReactiveDomainApp.Domain package ReactiveDomain.Foundation + dotnet add MyReactiveDomainApp.Infrastructure package ReactiveDomain.Persistence + dotnet add MyReactiveDomainApp.Infrastructure package ReactiveDomain.Messaging + dotnet add MyReactiveDomainApp.Application package ReactiveDomain.Messaging + dotnet add MyReactiveDomainApp.Tests package ReactiveDomain.Testing + ``` + +4. **Configure connection strings**: + ```json + { + "EventStore": { + "ConnectionString": "tcp://admin:changeit@localhost:1113" + } + } + ``` + +### Development Workflow + +1. **Define domain model**: + - Create aggregates, events, and commands in the Domain project + - Implement business logic in aggregates + +2. **Implement infrastructure**: + - Configure event store connection + - Implement repositories + - Set up message bus + +3. **Implement application services**: + - Create command handlers + - Create event handlers + - Implement read models + +4. **Expose API**: + - Create API controllers + - Configure dependency injection + - Set up authentication and authorization + +5. **Write tests**: + - Unit tests for aggregates + - Integration tests for repositories + - End-to-end tests for API + +## Testing Environment Configuration + +### Environment Setup + +1. **Isolated EventStoreDB**: + - Set up a dedicated EventStoreDB instance for testing + - Use Docker for easy setup and teardown + +2. **Continuous Integration**: + - Configure CI pipeline to run tests + - Set up automated deployment to testing environment + +3. **Test Data Management**: + - Create scripts to initialize test data + - Implement test data cleanup + +### Testing Strategies + +1. **Unit Testing**: + ```csharp + [Fact] + public void CanCreateAccount() + { + // Arrange + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + + // Act + account.Initialize("John Doe", 100); + + // Assert + var events = ((IEventSource)account).TakeEvents(); + Assert.Single(events); + var @event = Assert.IsType(events[0]); + Assert.Equal(accountId, @event.AccountId); + Assert.Equal("John Doe", @event.Owner); + Assert.Equal(100, @event.InitialBalance); + } + ``` + +2. **Integration Testing**: + ```csharp + [Fact] + public async Task CanSaveAndLoadAggregate() + { + // Arrange + var accountId = Guid.NewGuid(); + var connectionString = "tcp://admin:changeit@localhost:1113"; + var connection = new EventStoreConnection(connectionString); + connection.Connect(); + var repository = new StreamStoreRepository(connection); + + // Act - Save + var account = new Account(accountId); + account.Initialize("John Doe", 100); + repository.Save(account); + + // Act - Load + repository.TryGetById(accountId, out var loadedAccount); + + // Assert + Assert.Equal(100, loadedAccount.GetBalance()); + } + ``` + +3. **End-to-End Testing**: + ```csharp + [Fact] + public async Task CanCreateAndRetrieveAccount() + { + // Arrange + var client = _factory.CreateClient(); + var accountId = Guid.NewGuid(); + + // Act - Create + var createResponse = await client.PostAsJsonAsync("/api/accounts", new + { + Id = accountId, + Owner = "John Doe", + InitialBalance = 100 + }); + + // Assert - Create + createResponse.EnsureSuccessStatusCode(); + + // Act - Retrieve + var getResponse = await client.GetAsync($"/api/accounts/{accountId}"); + + // Assert - Retrieve + getResponse.EnsureSuccessStatusCode(); + var account = await getResponse.Content.ReadFromJsonAsync(); + Assert.Equal(accountId, account.Id); + Assert.Equal("John Doe", account.Owner); + Assert.Equal(100, account.Balance); + } + ``` + +### Test Environment Configuration + +```json +{ + "EventStore": { + "ConnectionString": "tcp://admin:changeit@test-eventstore:1113" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} +``` + +## Production Deployment Considerations + +### Infrastructure Requirements + +1. **EventStoreDB Cluster**: + - Minimum 3-node cluster for high availability + - Sufficient storage for event data + - Backup infrastructure + +2. **Application Servers**: + - Sufficient capacity for command processing + - Sufficient capacity for query processing + - Load balancing for high availability + +3. **Read Model Databases**: + - Appropriate database for read models + - Sufficient capacity for query load + - Backup infrastructure + +### Deployment Process + +1. **Database Migrations**: + - Deploy read model database schema changes + - No migrations needed for event store (append-only) + +2. **Application Deployment**: + - Deploy command-side services + - Deploy query-side services + - Deploy API services + +3. **Verification**: + - Verify connectivity to event store + - Verify connectivity to read model databases + - Run smoke tests + +### Containerization + +#### Dockerfile + +```dockerfile +FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +WORKDIR /src +COPY ["MyReactiveDomainApp.Api/MyReactiveDomainApp.Api.csproj", "MyReactiveDomainApp.Api/"] +COPY ["MyReactiveDomainApp.Application/MyReactiveDomainApp.Application.csproj", "MyReactiveDomainApp.Application/"] +COPY ["MyReactiveDomainApp.Domain/MyReactiveDomainApp.Domain.csproj", "MyReactiveDomainApp.Domain/"] +COPY ["MyReactiveDomainApp.Infrastructure/MyReactiveDomainApp.Infrastructure.csproj", "MyReactiveDomainApp.Infrastructure/"] +RUN dotnet restore "MyReactiveDomainApp.Api/MyReactiveDomainApp.Api.csproj" +COPY . . +WORKDIR "/src/MyReactiveDomainApp.Api" +RUN dotnet build "MyReactiveDomainApp.Api.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "MyReactiveDomainApp.Api.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "MyReactiveDomainApp.Api.dll"] +``` + +#### Docker Compose + +```yaml +version: '3.8' + +services: + eventstore: + image: eventstore/eventstore:latest + environment: + - EVENTSTORE_CLUSTER_SIZE=1 + - EVENTSTORE_RUN_PROJECTIONS=All + - EVENTSTORE_START_STANDARD_PROJECTIONS=true + - EVENTSTORE_EXT_TCP_PORT=1113 + - EVENTSTORE_HTTP_PORT=2113 + - EVENTSTORE_INSECURE=true + - EVENTSTORE_ENABLE_EXTERNAL_TCP=true + - EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true + ports: + - "1113:1113" + - "2113:2113" + volumes: + - eventstore-data:/var/lib/eventstore + networks: + - reactive-domain-network + + api: + build: + context: . + dockerfile: MyReactiveDomainApp.Api/Dockerfile + ports: + - "8080:80" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - EventStore__ConnectionString=tcp://admin:changeit@eventstore:1113 + depends_on: + - eventstore + networks: + - reactive-domain-network + +networks: + reactive-domain-network: + driver: bridge + +volumes: + eventstore-data: +``` + +### Kubernetes Deployment + +#### EventStoreDB StatefulSet + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: eventstore +spec: + serviceName: eventstore + replicas: 3 + selector: + matchLabels: + app: eventstore + template: + metadata: + labels: + app: eventstore + spec: + containers: + - name: eventstore + image: eventstore/eventstore:latest + ports: + - containerPort: 1113 + name: tcp + - containerPort: 2113 + name: http + env: + - name: EVENTSTORE_CLUSTER_SIZE + value: "3" + - name: EVENTSTORE_INT_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: EVENTSTORE_CLUSTER_DNS + value: "eventstore-0.eventstore.default.svc.cluster.local,eventstore-1.eventstore.default.svc.cluster.local,eventstore-2.eventstore.default.svc.cluster.local" + - name: EVENTSTORE_CLUSTER_GOSSIP_PORT + value: "2113" + - name: EVENTSTORE_RUN_PROJECTIONS + value: "All" + - name: EVENTSTORE_START_STANDARD_PROJECTIONS + value: "true" + - name: EVENTSTORE_INSECURE + value: "true" + - name: EVENTSTORE_ENABLE_EXTERNAL_TCP + value: "true" + - name: EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP + value: "true" + volumeMounts: + - name: eventstore-data + mountPath: /var/lib/eventstore + volumeClaimTemplates: + - metadata: + name: eventstore-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 10Gi +``` + +#### API Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: reactive-domain-api +spec: + replicas: 3 + selector: + matchLabels: + app: reactive-domain-api + template: + metadata: + labels: + app: reactive-domain-api + spec: + containers: + - name: api + image: myreactivedomainapp/api:latest + ports: + - containerPort: 80 + env: + - name: ASPNETCORE_ENVIRONMENT + value: Production + - name: EventStore__ConnectionString + value: tcp://admin:changeit@eventstore:1113 + readinessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 15 + periodSeconds: 20 +``` + +## Scaling Strategies + +### Command-Side Scaling + +1. **Horizontal Scaling**: + - Deploy multiple instances of command-side services + - Use load balancing to distribute commands + - Ensure idempotent command handling + +2. **Partitioning**: + - Partition aggregates by type or ID + - Route commands to specific partitions + - Reduce contention on aggregates + +3. **Command Queuing**: + - Queue commands for asynchronous processing + - Process commands in batches + - Implement backpressure mechanisms + +### Query-Side Scaling + +1. **Read Model Optimization**: + - Optimize read models for specific query patterns + - Denormalize data for efficient queries + - Use appropriate database technology + +2. **Caching**: + - Cache frequently accessed read models + - Implement cache invalidation based on events + - Use distributed caching for multiple instances + +3. **Read Replicas**: + - Deploy read replicas for high-volume queries + - Distribute queries across replicas + - Accept eventual consistency for better performance + +### EventStoreDB Scaling + +1. **Cluster Configuration**: + - Deploy a multi-node cluster for high availability + - Configure appropriate hardware for each node + - Monitor cluster health and performance + +2. **Stream Partitioning**: + - Partition streams by aggregate type + - Use category projections for efficient querying + - Implement custom stream naming strategies + +3. **Subscription Optimization**: + - Use persistent subscriptions for reliable event processing + - Configure appropriate subscription settings + - Monitor subscription performance + +## Monitoring and Observability + +### Key Metrics + +1. **EventStoreDB Metrics**: + - Queue length + - Write throughput + - Read throughput + - Disk usage + - Memory usage + +2. **Application Metrics**: + - Command throughput + - Command latency + - Event processing throughput + - Event processing latency + - Read model update latency + +3. **Infrastructure Metrics**: + - CPU usage + - Memory usage + - Network throughput + - Disk I/O + - Error rates + +### Logging + +1. **Structured Logging**: + ```csharp + public class LoggingCommandBus : ICommandBus + { + private readonly ICommandBus _innerBus; + private readonly ILogger _logger; + + public LoggingCommandBus(ICommandBus innerBus, ILogger logger) + { + _innerBus = innerBus; + _logger = logger; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + _logger.LogInformation( + "Sending command {CommandType} with ID {CommandId}", + typeof(TCommand).Name, + command is ICorrelatedMessage msg ? msg.CorrelationId : Guid.Empty); + + _innerBus.Send(command); + } + } + ``` + +2. **Event Logging**: + ```csharp + public class LoggingEventHandler : IEventHandler + { + private readonly IEventHandler _innerHandler; + private readonly ILogger> _logger; + + public LoggingEventHandler(IEventHandler innerHandler, ILogger> logger) + { + _innerHandler = innerHandler; + _logger = logger; + } + + public void Handle(TEvent @event) + { + _logger.LogInformation( + "Handling event {EventType}", + typeof(TEvent).Name); + + _innerHandler.Handle(@event); + } + } + ``` + +### Distributed Tracing + +1. **Correlation and Causation Tracking**: + ```csharp + public class TracingCommandBus : ICommandBus + { + private readonly ICommandBus _innerBus; + private readonly ITracer _tracer; + + public TracingCommandBus(ICommandBus innerBus, ITracer tracer) + { + _innerBus = innerBus; + _tracer = tracer; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + using (var scope = _tracer.StartActiveSpan($"command.{typeof(TCommand).Name}")) + { + if (command is ICorrelatedMessage correlatedMessage) + { + scope.SetTag("correlation_id", correlatedMessage.CorrelationId.ToString()); + scope.SetTag("causation_id", correlatedMessage.CausationId.ToString()); + } + + _innerBus.Send(command); + } + } + } + ``` + +### Health Checks + +```csharp +public class EventStoreHealthCheck : IHealthCheck +{ + private readonly IStreamStoreConnection _connection; + + public EventStoreHealthCheck(IStreamStoreConnection connection) + { + _connection = connection; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + // Try to read a stream + var slice = _connection.ReadStreamForward("$stats-0.0.0.0:2113", 0, 1); + + return HealthCheckResult.Healthy("EventStore connection is healthy"); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("EventStore connection is unhealthy", ex); + } + } +} +``` + +## Backup and Recovery Strategies + +### Event Store Backup + +1. **File System Backup**: + - Back up the EventStoreDB data directory + - Use file system snapshots for consistent backups + - Store backups off-site + +2. **Stream Backup**: + - Use the EventStoreDB API to read all streams + - Store events in a backup format + - Implement incremental backup strategies + +3. **Backup Automation**: + - Schedule regular backups + - Verify backup integrity + - Test recovery procedures + +### Recovery Procedures + +1. **EventStoreDB Recovery**: + - Restore from file system backup + - Rebuild indexes if necessary + - Verify stream integrity + +2. **Read Model Recovery**: + - Rebuild read models from events + - Implement catch-up subscriptions + - Verify read model integrity + +3. **Point-in-Time Recovery**: + - Restore events up to a specific point in time + - Rebuild read models to the same point + - Verify system consistency + +### Disaster Recovery + +1. **Multi-Region Deployment**: + - Deploy EventStoreDB clusters in multiple regions + - Implement cross-region replication + - Configure failover procedures + +2. **Recovery Testing**: + - Regularly test recovery procedures + - Simulate disaster scenarios + - Measure recovery time and data loss + +3. **Documentation**: + - Document recovery procedures + - Train operations staff + - Update procedures based on testing results + +## Security Considerations + +### Authentication and Authorization + +1. **API Security**: + - Implement OAuth 2.0 or OpenID Connect + - Use JWT for authentication + - Implement role-based access control + +2. **EventStoreDB Security**: + - Configure EventStoreDB authentication + - Use TLS for secure communication + - Implement network security controls + +3. **Command Authorization**: + ```csharp + public class AuthorizedCommandBus : ICommandBus + { + private readonly ICommandBus _innerBus; + private readonly IAuthorizationService _authorizationService; + private readonly IUserContext _userContext; + + public AuthorizedCommandBus( + ICommandBus innerBus, + IAuthorizationService authorizationService, + IUserContext userContext) + { + _innerBus = innerBus; + _authorizationService = authorizationService; + _userContext = userContext; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + // Authorize the command + if (!_authorizationService.IsAuthorized(_userContext.CurrentUser, command)) + { + throw new UnauthorizedAccessException($"User is not authorized to execute {typeof(TCommand).Name}"); + } + + // Forward to inner bus + _innerBus.Send(command); + } + } + ``` + +### Data Protection + +1. **Sensitive Data Handling**: + - Encrypt sensitive data in events + - Implement data masking for logs + - Use secure storage for secrets + +2. **Event Data Encryption**: + ```csharp + public class EncryptingEventSerializer : IEventSerializer + { + private readonly IEventSerializer _innerSerializer; + private readonly IEncryptionService _encryptionService; + + public EncryptingEventSerializer( + IEventSerializer innerSerializer, + IEncryptionService encryptionService) + { + _innerSerializer = innerSerializer; + _encryptionService = encryptionService; + } + + public object Deserialize(RecordedEvent recordedEvent) + { + // Decrypt event data if necessary + if (ShouldEncrypt(recordedEvent.EventType)) + { + var decryptedData = _encryptionService.Decrypt(recordedEvent.Data); + var decryptedEvent = new RecordedEvent( + recordedEvent.EventStreamId, + recordedEvent.EventNumber, + recordedEvent.EventId, + recordedEvent.EventType, + decryptedData, + recordedEvent.Metadata, + recordedEvent.IsJson, + recordedEvent.Created); + + return _innerSerializer.Deserialize(decryptedEvent); + } + + return _innerSerializer.Deserialize(recordedEvent); + } + + public IEventData Serialize(object @event, Guid eventId) + { + var eventData = _innerSerializer.Serialize(@event, eventId); + + // Encrypt event data if necessary + if (ShouldEncrypt(@event.GetType().Name)) + { + var encryptedData = _encryptionService.Encrypt(eventData.Data); + return new EventData( + eventData.EventId, + eventData.Type, + eventData.IsJson, + encryptedData, + eventData.Metadata); + } + + return eventData; + } + + private bool ShouldEncrypt(string eventType) + { + // Determine if the event type should be encrypted + return eventType.Contains("Sensitive") || eventType.Contains("Personal"); + } + } + ``` + +3. **Transport Security**: + - Use HTTPS for all API communication + - Use TLS for EventStoreDB communication + - Implement proper certificate management + +### Compliance + +1. **Audit Logging**: + - Log all security-relevant events + - Implement tamper-evident logging + - Store logs securely + +2. **Data Retention**: + - Implement data retention policies + - Provide mechanisms for data deletion + - Document compliance measures + +3. **Privacy Controls**: + - Implement privacy by design + - Provide mechanisms for data subject requests + - Document privacy impact assessments + +[↑ Back to Top](#deployment-guide) | [← Back to Table of Contents](README.md) diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 00000000..5c204960 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,327 @@ +# Frequently Asked Questions + +[← Back to Table of Contents](README.md) + +This section addresses common questions about Reactive Domain and event sourcing. + +## Table of Contents + +- [When to Use Event Sourcing and CQRS](#when-to-use-event-sourcing-and-cqrs) +- [Performance Considerations and Optimizations](#performance-considerations-and-optimizations) +- [Scaling Event-Sourced Systems](#scaling-event-sourced-systems) +- [Integration with Existing Systems](#integration-with-existing-systems) +- [Testing Strategies and Best Practices](#testing-strategies-and-best-practices) +- [Common Pitfalls and How to Avoid Them](#common-pitfalls-and-how-to-avoid-them) +- [Comparison with Other Event Sourcing Frameworks](#comparison-with-other-event-sourcing-frameworks) +- [Additional Questions](#additional-questions) + +## When to Use Event Sourcing and CQRS + +### Q: When should I consider using event sourcing? + +Event sourcing is particularly valuable in the following scenarios: + +- When you need a complete audit trail of all changes to your system +- When the business logic depends on the history of events, not just the current state +- When you need to reconstruct the state of the system at any point in time +- When you need to support complex business processes that evolve over time +- When you need to support multiple projections of the same data for different purposes + +### Q: Is event sourcing suitable for all applications? + +No, event sourcing introduces additional complexity that may not be justified for simple CRUD applications. Consider the following factors: + +- The complexity of your domain model +- The need for an audit trail +- The need for temporal queries +- The performance requirements of your application +- The expertise of your development team + +### Q: When should I use CQRS with event sourcing? + +CQRS (Command Query Responsibility Segregation) is a natural fit for event sourcing because: + +- Event sourcing already separates the write model (events) from the read model (projections) +- CQRS allows you to optimize the read and write sides independently +- CQRS provides a clear separation of concerns between commands and queries + +However, CQRS adds complexity, so it's most beneficial when: + +- Read and write workloads have significantly different requirements +- You need to scale read and write operations independently +- You need to support multiple read models for different purposes + +## Performance Considerations and Optimizations + +### Q: How does event sourcing affect performance? + +Event sourcing can impact performance in several ways: + +- **Write Performance**: Writing events is typically fast because events are simply appended to the event store +- **Read Performance**: Reading an aggregate requires loading and replaying all its events, which can be slow for aggregates with many events +- **Query Performance**: Queries are performed against read models, which can be optimized for specific query patterns + +### Q: How can I optimize the performance of event-sourced systems? + +Several strategies can improve performance: + +- **Snapshots**: Periodically save the state of an aggregate to avoid replaying all events +- **Read Models**: Create specialized read models optimized for specific query patterns +- **Caching**: Cache aggregates and read models to reduce database load +- **Event Store Optimization**: Use an event store optimized for event sourcing, like EventStoreDB +- **Asynchronous Processing**: Process non-critical operations asynchronously +- **Batching**: Batch operations where possible to reduce overhead + +### Q: How do snapshots work in Reactive Domain? + +Snapshots in Reactive Domain work as follows: + +1. Implement the `ISnapshotSource` interface on your aggregate +2. Periodically save a snapshot of the aggregate's state +3. When loading the aggregate, first check for a snapshot +4. If a snapshot exists, load it and then apply only the events that occurred after the snapshot + +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + // ... existing code ... + + public void RestoreFromSnapshot(object snapshot) + { + var accountSnapshot = (AccountSnapshot)snapshot; + _balance = accountSnapshot.Balance; + ExpectedVersion = accountSnapshot.Version; + } + + public object TakeSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + Version = ExpectedVersion + }; + } +} +``` + +## Scaling Event-Sourced Systems + +### Q: How do event-sourced systems scale? + +Event-sourced systems can scale in several ways: + +- **Write Scaling**: Event stores can be partitioned by aggregate ID to scale writes +- **Read Scaling**: Read models can be replicated and distributed to scale reads +- **Projection Scaling**: Projections can be parallelized to process events faster +- **Event Processing Scaling**: Event processors can be distributed across multiple nodes + +### Q: How does Reactive Domain support scaling? + +Reactive Domain supports scaling through: + +- **Separation of Concerns**: Clear separation between command and query sides +- **Message-Based Architecture**: Loose coupling between components +- **Asynchronous Processing**: Non-blocking operations for better resource utilization +- **Distributed Event Processing**: Support for distributed event processing + +### Q: What are the challenges of scaling event-sourced systems? + +Scaling event-sourced systems presents several challenges: + +- **Consistency**: Ensuring consistency across distributed components +- **Ordering**: Maintaining the order of events in a distributed environment +- **Versioning**: Managing event schema evolution in a distributed system +- **Monitoring**: Monitoring the health and performance of distributed components +- **Deployment**: Coordinating deployments across distributed components + +## Integration with Existing Systems + +### Q: How can I integrate Reactive Domain with existing systems? + +Several approaches can be used to integrate Reactive Domain with existing systems: + +- **Event Integration**: Publish events to external systems or subscribe to events from external systems +- **Command Integration**: Accept commands from external systems or send commands to external systems +- **API Integration**: Expose APIs for external systems to interact with your event-sourced system +- **Data Integration**: Synchronize data between your event-sourced system and external systems +- **Adapter Pattern**: Use adapters to translate between different message formats + +### Q: How can I migrate an existing system to Reactive Domain? + +Migrating to Reactive Domain can be done incrementally: + +1. **Identify Bounded Contexts**: Identify the bounded contexts in your existing system +2. **Select a Bounded Context**: Choose a bounded context to migrate first +3. **Design the Domain Model**: Design the domain model for the selected bounded context +4. **Implement Event Sourcing**: Implement event sourcing for the domain model +5. **Create Read Models**: Create read models for the domain model +6. **Integrate with Existing System**: Integrate the event-sourced bounded context with the existing system +7. **Migrate Data**: Migrate data from the existing system to the event-sourced system +8. **Repeat for Other Bounded Contexts**: Repeat the process for other bounded contexts + +### Q: Can I use Reactive Domain with a relational database? + +Yes, you can use Reactive Domain with a relational database in several ways: + +- **Event Store**: Some relational databases can be used as event stores, although specialized event stores like EventStoreDB are recommended +- **Read Models**: Read models can be stored in relational databases +- **Hybrid Approach**: Use an event store for events and a relational database for read models + +## Testing Strategies and Best Practices + +### Q: How do I test event-sourced systems? + +Testing event-sourced systems involves several types of tests: + +- **Unit Tests**: Test aggregates and domain logic in isolation +- **Integration Tests**: Test the interaction between components +- **Projection Tests**: Test that projections correctly update read models +- **End-to-End Tests**: Test the complete flow from commands to events to read models + +### Q: What testing utilities does Reactive Domain provide? + +Reactive Domain provides several testing utilities: + +- **MockStreamStoreConnection**: An in-memory implementation of `IStreamStoreConnection` for testing +- **TestFixture**: A base class for testing aggregates +- **EventSourcedAggregateTest**: A base class for testing event-sourced aggregates +- **TestRepository**: A repository implementation for testing + +### Q: How do I test projections? + +Testing projections involves: + +1. Creating a projection instance +2. Feeding it events +3. Verifying that the read model is updated correctly + +```csharp +[Fact] +public void ProjectionUpdatesReadModel() +{ + // Arrange + var accountId = Guid.NewGuid(); + var readModelRepository = new InMemoryReadModelRepository(); + var projection = new AccountBalanceProjection(readModelRepository); + + // Act + projection.Handle(new AmountDeposited(accountId, 100)); + projection.Handle(new AmountDeposited(accountId, 50)); + projection.Handle(new AmountWithdrawn(accountId, 30)); + + // Assert + var accountBalance = readModelRepository.GetById(accountId); + Assert.NotNull(accountBalance); + Assert.Equal(120, accountBalance.Balance); +} +``` + +## Common Pitfalls and How to Avoid Them + +### Q: What are common pitfalls when implementing event sourcing? + +Common pitfalls include: + +- **Event Schema Evolution**: Not planning for event schema evolution +- **Large Aggregates**: Creating aggregates that are too large +- **Complex Event Application**: Making event application logic too complex +- **Ignoring Versioning**: Not handling event versioning +- **Overusing Snapshots**: Using snapshots too frequently or not frequently enough +- **Tight Coupling**: Creating tight coupling between components +- **Insufficient Testing**: Not testing all aspects of the event-sourced system + +### Q: How do I handle event schema evolution? + +Several strategies can be used to handle event schema evolution: + +- **Backward Compatibility**: Ensure new event versions can process old events +- **Forward Compatibility**: Design events to be forward compatible where possible +- **Event Upcasting**: Transform old events to new formats when loading +- **Versioned Events**: Explicitly version events +- **Event Adapters**: Use adapters to translate between different event versions + +### Q: How do I handle large aggregates? + +Strategies for handling large aggregates include: + +- **Aggregate Splitting**: Split large aggregates into smaller ones +- **Snapshots**: Use snapshots to improve loading performance +- **Event Pruning**: Archive old events that are no longer needed for business logic +- **Bounded Contexts**: Ensure aggregates are properly bounded by context +- **Command Validation**: Validate commands before processing to avoid unnecessary event generation + +## Comparison with Other Event Sourcing Frameworks + +### Q: How does Reactive Domain compare to other event sourcing frameworks? + +Reactive Domain has several distinguishing features: + +- **Opinionated Design**: Reactive Domain provides a clear path for implementing event sourcing +- **Simplicity**: The API is designed to be intuitive and easy to use +- **Integration with EventStoreDB**: Built-in support for EventStoreDB +- **Correlation and Causation Tracking**: Built-in support for tracking correlation and causation IDs +- **Testing Utilities**: Comprehensive testing utilities + +### Q: What are alternatives to Reactive Domain? + +Alternative event sourcing frameworks include: + +- **EventFlow**: A .NET event sourcing library with a focus on DDD +- **NEventStore**: A persistence library for event-sourced domain models +- **Axon Framework**: A Java framework for event-driven microservices +- **Lagom**: A microservice framework with support for event sourcing +- **Akka.NET Persistence**: Event sourcing support for Akka.NET actors + +### Q: When should I choose Reactive Domain over alternatives? + +Consider Reactive Domain when: + +- You want an opinionated framework that provides a clear path for implementing event sourcing +- You're using .NET and want a framework designed specifically for .NET +- You're using EventStoreDB or plan to use it +- You need built-in support for correlation and causation tracking +- You value simplicity and ease of use over flexibility + +## Additional Questions + +### Q: How do I handle eventual consistency in my UI? + +Strategies for handling eventual consistency in the UI include: + +- **Optimistic UI Updates**: Update the UI optimistically and handle failures gracefully +- **Command Queuing**: Queue commands and update the UI when they're processed +- **Polling**: Poll for updates to the read model +- **WebSockets**: Use WebSockets for real-time updates +- **Event Sourcing in the UI**: Apply events directly in the UI for immediate feedback + +### Q: How do I handle security in event-sourced systems? + +Security considerations for event-sourced systems include: + +- **Command Authorization**: Authorize commands before processing them +- **Event Authorization**: Ensure events contain only authorized data +- **Read Model Authorization**: Authorize access to read models +- **Sensitive Data**: Handle sensitive data carefully in events +- **Audit Logging**: Use events for audit logging + +### Q: How do I monitor event-sourced systems? + +Monitoring event-sourced systems involves: + +- **Event Store Monitoring**: Monitor the health and performance of the event store +- **Command Processing Monitoring**: Monitor command processing rates and failures +- **Projection Monitoring**: Monitor projection processing rates and failures +- **Read Model Monitoring**: Monitor read model query performance +- **End-to-End Monitoring**: Monitor the complete flow from commands to events to read models + +### Q: How do I handle distributed transactions in event-sourced systems? + +Distributed transactions in event-sourced systems can be handled through: + +- **Saga Pattern**: Implement sagas to coordinate distributed transactions +- **Process Manager Pattern**: Use process managers to coordinate distributed processes +- **Compensating Actions**: Implement compensating actions for failed operations +- **Event-Driven Architecture**: Use events to coordinate distributed components +- **Eventual Consistency**: Accept eventual consistency where appropriate + +[↑ Back to Top](#frequently-asked-questions) | [← Back to Table of Contents](README.md) diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 00000000..181d326c --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,148 @@ +# Glossary of Terms + +[← Back to Table of Contents](README.md) + +This glossary provides definitions for key terms used in Reactive Domain and event sourcing. + +## Table of Contents + +- [Event Sourcing Terminology](#event-sourcing-terminology) +- [CQRS Terminology](#cqrs-terminology) +- [Domain-Driven Design Terminology](#domain-driven-design-terminology) +- [Reactive Programming Terminology](#reactive-programming-terminology) +- [Reactive Domain-Specific Terminology](#reactive-domain-specific-terminology) +- [Related Concepts](#related-concepts) + +## Event Sourcing Terminology + +### Aggregate +A cluster of domain objects treated as a single unit for data changes. In event sourcing, aggregates are the primary entities that generate events. + +### Command +A request for the system to perform an action. Commands are named in the imperative form (e.g., "CreateAccount", "DepositMoney"). + +### Event +An immutable record of something that happened in the system. Events are named in the past tense (e.g., "AccountCreated", "MoneyDeposited"). + +### Event Store +A specialized database designed to store events. Reactive Domain uses EventStoreDB as its event store. + +### Event Stream +A sequence of events for a particular aggregate, ordered by version. + +### Projection +A component that transforms events into queryable state. Projections are used to build read models. + +### Read Model +A representation of data optimized for querying. Read models are built by projections from events. + +### Snapshot +A point-in-time capture of an aggregate's state, used to optimize loading performance. + +### Stream +In EventStoreDB, a stream is a named sequence of events. In Reactive Domain, each aggregate has its own stream. + +## CQRS Terminology + +### CQRS +Command Query Responsibility Segregation, a pattern that separates read and write operations. + +### Command Side +The part of the system responsible for processing commands and generating events. + +### Query Side +The part of the system responsible for handling queries and returning results. + +### Command Handler +A component that processes commands and updates aggregates. + +### Query Handler +A component that processes queries and returns results from read models. + +## Domain-Driven Design Terminology + +### Bounded Context +An explicit boundary within which a particular domain model applies. + +### Domain Event +An event that represents something significant that happened in the domain. + +### Entity +An object with a distinct identity that persists over time. + +### Repository +A component that provides access to aggregates, abstracting the details of how they are stored and retrieved. + +### Value Object +An object that represents a descriptive aspect of the domain with no conceptual identity. + +## Reactive Programming Terminology + +### Asynchronous +Operations that don't block the execution thread, allowing other operations to proceed. + +### Event-Driven +A programming paradigm where the flow of the program is determined by events. + +### Message +A data structure that is passed between components to communicate. + +### Non-Blocking +Operations that don't halt the execution of the program while waiting for a result. + +### Reactive +A programming paradigm that focuses on asynchronous data streams and the propagation of changes. + +## Reactive Domain-Specific Terminology + +### AggregateRoot +The base class for aggregates in Reactive Domain, providing common functionality for event sourcing. + +### Correlation ID +An identifier that tracks a business transaction across multiple messages. + +### Causation ID +An identifier that indicates which message caused the current message. + +### EventRecorder +A component that records events generated by an aggregate. + +### IEventSource +The core interface for event-sourced entities in Reactive Domain. + +### IRepository +The interface for repositories in Reactive Domain, providing methods for loading and saving aggregates. + +### ICorrelatedRepository +A repository that supports correlation and causation tracking. + +### ISnapshotSource +An interface for entities that support snapshots. + +### StreamNameBuilder +A component that generates consistent stream names for aggregates. + +### StreamStoreConnection +A component that provides a connection to EventStoreDB. + +### StreamStoreRepository +A repository implementation that stores and retrieves events from EventStoreDB. + +## Related Concepts + +### Idempotence +The property of certain operations in mathematics and computer science whereby they can be applied multiple times without changing the result beyond the initial application. + +### Eventual Consistency +A consistency model used in distributed computing to achieve high availability that informally guarantees that, if no new updates are made to a given data item, eventually all accesses to that item will return the last updated value. + +### Optimistic Concurrency +A concurrency control method that assumes conflicts are rare and allows operations to proceed without locking, but checks for conflicts before committing changes. + +### Temporal Query +A query that retrieves the state of the system at a specific point in time. + +### Versioning +The process of assigning version numbers to unique states of a software or data structure. + +[↑ Back to Top](#glossary-of-terms) | [← Back to Table of Contents](README.md) diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 00000000..98250d10 --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,801 @@ +# Integration Guide + +[← Back to Table of Contents](README.md) + +This guide provides strategies and best practices for integrating Reactive Domain with other systems and frameworks. + +## Table of Contents + +- [Integration with ASP.NET Core](#integration-with-aspnet-core) +- [Integration with Other .NET Frameworks](#integration-with-other-net-frameworks) +- [Integration with Non-.NET Systems](#integration-with-non-net-systems) +- [API Design for Event-Sourced Systems](#api-design-for-event-sourced-systems) +- [Message Contracts and Versioning](#message-contracts-and-versioning) +- [Integration Testing Strategies](#integration-testing-strategies) + +## Integration with ASP.NET Core + +### Dependency Injection + +Register Reactive Domain services with ASP.NET Core's dependency injection container: + +```csharp +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + // Configure EventStoreDB connection + services.AddSingleton(provider => + { + var connectionString = Configuration.GetConnectionString("EventStore"); + var connection = new EventStoreConnection(connectionString); + connection.Connect(); + return connection; + }); + + // Register repositories + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register message buses + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register command handlers + services.AddTransient, CreateAccountHandler>(); + services.AddTransient, DepositFundsHandler>(); + services.AddTransient, WithdrawFundsHandler>(); + + // Register event handlers + services.AddTransient, AccountCreatedHandler>(); + services.AddTransient, FundsDepositedHandler>(); + services.AddTransient, FundsWithdrawnHandler>(); + + // Register query handlers + services.AddTransient, GetAccountBalanceHandler>(); + services.AddTransient, GetAccountDetailsHandler>(); + + // Register controllers + services.AddControllers(); + } +} +``` + +### API Controllers + +Create API controllers that use Reactive Domain services: + +```csharp +[ApiController] +[Route("api/accounts")] +public class AccountsController : ControllerBase +{ + private readonly ICommandBus _commandBus; + private readonly IQueryBus _queryBus; + + public AccountsController(ICommandBus commandBus, IQueryBus queryBus) + { + _commandBus = commandBus; + _queryBus = queryBus; + } + + [HttpPost] + public IActionResult CreateAccount([FromBody] CreateAccountRequest request) + { + var accountId = Guid.NewGuid(); + + _commandBus.Send(new CreateAccount(accountId, request.Owner, request.InitialBalance)); + + return CreatedAtAction(nameof(GetAccount), new { id = accountId }, new { Id = accountId }); + } + + [HttpGet("{id}")] + public IActionResult GetAccount(Guid id) + { + var details = _queryBus.Query(new GetAccountDetails(id)); + + if (details == null) + return NotFound(); + + return Ok(details); + } + + [HttpPost("{id}/deposit")] + public IActionResult Deposit(Guid id, [FromBody] DepositRequest request) + { + _commandBus.Send(new DepositFunds(id, request.Amount)); + return NoContent(); + } + + [HttpPost("{id}/withdraw")] + public IActionResult Withdraw(Guid id, [FromBody] WithdrawRequest request) + { + try + { + _commandBus.Send(new WithdrawFunds(id, request.Amount)); + return NoContent(); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Error = ex.Message }); + } + } +} +``` + +### Background Services + +Implement background services for event processing: + +```csharp +public class EventProcessorService : BackgroundService +{ + private readonly IStreamStoreConnection _connection; + private readonly IEventBus _eventBus; + private readonly IEventSerializer _serializer; + private readonly ILogger _logger; + + public EventProcessorService( + IStreamStoreConnection connection, + IEventBus eventBus, + IEventSerializer serializer, + ILogger logger) + { + _connection = connection; + _eventBus = eventBus; + _serializer = serializer; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Subscribe to all events + var subscription = _connection.SubscribeToAll( + (subscription, @event) => + { + try + { + // Deserialize the event + var deserializedEvent = _serializer.Deserialize(@event); + + // Publish to event bus + _eventBus.Publish(deserializedEvent); + + // Acknowledge the event + subscription.Acknowledge(@event); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing event"); + subscription.Fail(@event, SubscriptionNakEventAction.Retry, ex.Message); + } + }, + subscriptionDropped: (subscription, reason, exception) => + { + _logger.LogWarning(exception, "Subscription dropped: {Reason}", reason); + + // Reconnect after a delay + Task.Delay(1000, stoppingToken) + .ContinueWith(_ => subscription.Reconnect(), stoppingToken); + }); + + // Wait for cancellation + await Task.Delay(Timeout.Infinite, stoppingToken); + } +} +``` + +## Integration with Other .NET Frameworks + +### .NET Framework Integration + +Integrate with .NET Framework applications: + +```csharp +public class ReactiveModule : Module +{ + protected override void Load(ContainerBuilder builder) + { + // Configure EventStoreDB connection + builder.Register(c => + { + var connectionString = ConfigurationManager.ConnectionStrings["EventStore"].ConnectionString; + var connection = new EventStoreConnection(connectionString); + connection.Connect(); + return connection; + }).As().SingleInstance(); + + // Register repositories + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + + // Register message buses + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + + // Register command handlers + builder.RegisterType().As>().InstancePerDependency(); + builder.RegisterType().As>().InstancePerDependency(); + builder.RegisterType().As>().InstancePerDependency(); + + // Register event handlers + builder.RegisterType().As>().InstancePerDependency(); + builder.RegisterType().As>().InstancePerDependency(); + builder.RegisterType().As>().InstancePerDependency(); + + // Register query handlers + builder.RegisterType().As>().InstancePerDependency(); + builder.RegisterType().As>().InstancePerDependency(); + } +} +``` + +### Xamarin/MAUI Integration + +Integrate with mobile applications: + +```csharp +public class ReactiveDomainService : IReactiveDomainService +{ + private readonly ICommandBus _commandBus; + private readonly IQueryBus _queryBus; + + public ReactiveDomainService(ICommandBus commandBus, IQueryBus queryBus) + { + _commandBus = commandBus; + _queryBus = queryBus; + } + + public async Task CreateAccount(string owner, decimal initialBalance) + { + var accountId = Guid.NewGuid(); + + await Task.Run(() => _commandBus.Send(new CreateAccount(accountId, owner, initialBalance))); + + return accountId; + } + + public async Task GetAccount(Guid id) + { + return await Task.Run(() => _queryBus.Query(new GetAccountDetails(id))); + } + + public async Task Deposit(Guid id, decimal amount) + { + await Task.Run(() => _commandBus.Send(new DepositFunds(id, amount))); + } + + public async Task Withdraw(Guid id, decimal amount) + { + await Task.Run(() => _commandBus.Send(new WithdrawFunds(id, amount))); + } +} +``` + +## Integration with Non-.NET Systems + +### REST API + +Expose a REST API for non-.NET clients: + +```csharp +[ApiController] +[Route("api/v1/accounts")] +public class AccountsApiController : ControllerBase +{ + private readonly ICommandBus _commandBus; + private readonly IQueryBus _queryBus; + + public AccountsApiController(ICommandBus commandBus, IQueryBus queryBus) + { + _commandBus = commandBus; + _queryBus = queryBus; + } + + [HttpPost] + [ProducesResponseType(typeof(AccountCreatedResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public IActionResult CreateAccount([FromBody] CreateAccountRequest request) + { + var accountId = Guid.NewGuid(); + + _commandBus.Send(new CreateAccount(accountId, request.Owner, request.InitialBalance)); + + var response = new AccountCreatedResponse + { + Id = accountId, + Links = new Dictionary + { + ["self"] = Url.ActionLink(nameof(GetAccount), values: new { id = accountId }), + ["deposit"] = Url.ActionLink(nameof(Deposit), values: new { id = accountId }), + ["withdraw"] = Url.ActionLink(nameof(Withdraw), values: new { id = accountId }) + } + }; + + return CreatedAtAction(nameof(GetAccount), new { id = accountId }, response); + } + + // ... other actions ... +} +``` + +### Message Queue Integration + +Integrate with message queues for asynchronous communication: + +```csharp +public class RabbitMqEventPublisher : IEventPublisher +{ + private readonly IConnection _connection; + private readonly IModel _channel; + private readonly string _exchangeName; + + public RabbitMqEventPublisher(string connectionString, string exchangeName) + { + _exchangeName = exchangeName; + + var factory = new ConnectionFactory { Uri = new Uri(connectionString) }; + _connection = factory.CreateConnection(); + _channel = _connection.CreateModel(); + + _channel.ExchangeDeclare(_exchangeName, ExchangeType.Topic, durable: true); + } + + public void Publish(object @event) + { + var eventType = @event.GetType().Name; + var routingKey = $"event.{eventType.ToLowerInvariant()}"; + var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(@event)); + + var properties = _channel.CreateBasicProperties(); + properties.Persistent = true; + properties.ContentType = "application/json"; + properties.Type = eventType; + + _channel.BasicPublish(_exchangeName, routingKey, properties, body); + } + + public void Dispose() + { + _channel?.Dispose(); + _connection?.Dispose(); + } +} +``` + +### gRPC Integration + +Implement gRPC services for efficient communication: + +```csharp +public class AccountGrpcService : AccountService.AccountServiceBase +{ + private readonly ICommandBus _commandBus; + private readonly IQueryBus _queryBus; + + public AccountGrpcService(ICommandBus commandBus, IQueryBus queryBus) + { + _commandBus = commandBus; + _queryBus = queryBus; + } + + public override Task CreateAccount(CreateAccountRequest request, ServerCallContext context) + { + var accountId = Guid.NewGuid(); + + _commandBus.Send(new CreateAccount( + accountId, + request.Owner, + (decimal)request.InitialBalance)); + + return Task.FromResult(new CreateAccountResponse + { + AccountId = accountId.ToString() + }); + } + + public override Task GetAccount(GetAccountRequest request, ServerCallContext context) + { + var accountId = Guid.Parse(request.AccountId); + + var details = _queryBus.Query( + new GetAccountDetails(accountId)); + + if (details == null) + { + throw new RpcException(new Status(StatusCode.NotFound, "Account not found")); + } + + return Task.FromResult(new GetAccountResponse + { + AccountId = details.Id.ToString(), + Owner = details.Owner, + Balance = (double)details.Balance, + CreatedAt = Timestamp.FromDateTime(details.CreatedAt.ToUniversalTime()) + }); + } + + // ... other methods ... +} +``` + +## API Design for Event-Sourced Systems + +### Command API Design + +Design command APIs for event-sourced systems: + +1. **Use Command DTOs**: Create dedicated DTOs for command requests +2. **Return Minimal Data**: Return only essential data from command endpoints +3. **Use HTTP Status Codes**: Use appropriate status codes for command results +4. **Include Resource Links**: Include links to related resources in responses + +```csharp +// Command DTO +public class CreateAccountRequest +{ + [Required] + public string Owner { get; set; } + + [Range(0, double.MaxValue)] + public decimal InitialBalance { get; set; } +} + +// Command Response +public class AccountCreatedResponse +{ + public Guid Id { get; set; } + public Dictionary Links { get; set; } +} +``` + +### Query API Design + +Design query APIs for event-sourced systems: + +1. **Use Query Parameters**: Use query parameters for filtering and pagination +2. **Support Projections**: Allow clients to request specific fields +3. **Support Sorting**: Allow clients to specify sort order +4. **Implement Pagination**: Support pagination for large result sets + +```csharp +[HttpGet] +public IActionResult GetAccounts( + [FromQuery] string ownerFilter = null, + [FromQuery] decimal? minBalance = null, + [FromQuery] decimal? maxBalance = null, + [FromQuery] string sortBy = "createdAt", + [FromQuery] string sortOrder = "desc", + [FromQuery] int page = 1, + [FromQuery] int pageSize = 10) +{ + var query = new GetAccountsQuery + { + OwnerFilter = ownerFilter, + MinBalance = minBalance, + MaxBalance = maxBalance, + SortBy = sortBy, + SortOrder = sortOrder, + Page = page, + PageSize = pageSize + }; + + var result = _queryBus.Query>(query); + + return Ok(new + { + Items = result.Items, + TotalItems = result.TotalItems, + Page = result.Page, + PageSize = result.PageSize, + TotalPages = result.TotalPages, + Links = new + { + Self = Url.Action(nameof(GetAccounts), new { ownerFilter, minBalance, maxBalance, sortBy, sortOrder, page, pageSize }), + First = Url.Action(nameof(GetAccounts), new { ownerFilter, minBalance, maxBalance, sortBy, sortOrder, page = 1, pageSize }), + Previous = result.Page > 1 ? Url.Action(nameof(GetAccounts), new { ownerFilter, minBalance, maxBalance, sortBy, sortOrder, page = result.Page - 1, pageSize }) : null, + Next = result.Page < result.TotalPages ? Url.Action(nameof(GetAccounts), new { ownerFilter, minBalance, maxBalance, sortBy, sortOrder, page = result.Page + 1, pageSize }) : null, + Last = Url.Action(nameof(GetAccounts), new { ownerFilter, minBalance, maxBalance, sortBy, sortOrder, page = result.TotalPages, pageSize }) + } + }); +} +``` + +### Event Subscription API + +Design APIs for event subscriptions: + +1. **Server-Sent Events**: Use SSE for real-time event notifications +2. **WebSockets**: Use WebSockets for bidirectional communication +3. **Webhooks**: Use webhooks for push notifications + +```csharp +[HttpGet("events")] +public async Task GetEvents() +{ + var response = Response; + response.Headers.Add("Content-Type", "text/event-stream"); + response.Headers.Add("Cache-Control", "no-cache"); + response.Headers.Add("Connection", "keep-alive"); + + var subscription = _eventBus.Subscribe( + @event => WriteEvent(response, @event)); + + try + { + await Task.Delay(Timeout.Infinite, HttpContext.RequestAborted); + } + catch (TaskCanceledException) + { + // Client disconnected + } + finally + { + subscription.Dispose(); + } +} + +private async Task WriteEvent(HttpResponse response, object @event) +{ + var eventType = @event.GetType().Name; + var eventData = JsonConvert.SerializeObject(@event); + + await response.WriteAsync($"event: {eventType}\n"); + await response.WriteAsync($"data: {eventData}\n\n"); + await response.Body.FlushAsync(); +} +``` + +## Message Contracts and Versioning + +### Message Contract Design + +Design message contracts for integration: + +1. **Use Immutable Messages**: Make message classes immutable +2. **Include Metadata**: Include metadata such as correlation IDs +3. **Use Explicit Types**: Use explicit types rather than dynamic objects +4. **Document Contracts**: Document message contracts for consumers + +```csharp +public class AccountCreated : IEvent +{ + public Guid Id { get; } + public Guid AccountId { get; } + public string Owner { get; } + public decimal InitialBalance { get; } + public DateTime CreatedAt { get; } + + public AccountCreated(Guid id, Guid accountId, string owner, decimal initialBalance, DateTime createdAt) + { + Id = id; + AccountId = accountId; + Owner = owner; + InitialBalance = initialBalance; + CreatedAt = createdAt; + } +} +``` + +### Message Versioning + +Implement message versioning: + +1. **Version in Namespace**: Include version in namespace or package +2. **Version in Type Name**: Include version in type name +3. **Version in Message**: Include version in message properties +4. **Use Compatibility Attributes**: Use attributes to indicate compatibility + +```csharp +namespace MyApp.Messages.V1 +{ + public class AccountCreated : IEvent + { + public Guid Id { get; } + public Guid AccountId { get; } + public string Owner { get; } + public decimal InitialBalance { get; } + public DateTime CreatedAt { get; } + + public AccountCreated(Guid id, Guid accountId, string owner, decimal initialBalance, DateTime createdAt) + { + Id = id; + AccountId = accountId; + Owner = owner; + InitialBalance = initialBalance; + CreatedAt = createdAt; + } + } +} + +namespace MyApp.Messages.V2 +{ + [CompatibleWith(typeof(V1.AccountCreated))] + public class AccountCreated : IEvent + { + public Guid Id { get; } + public Guid AccountId { get; } + public string Owner { get; } + public decimal InitialBalance { get; } + public DateTime CreatedAt { get; } + public string Email { get; } // New field in V2 + + public AccountCreated(Guid id, Guid accountId, string owner, decimal initialBalance, DateTime createdAt, string email) + { + Id = id; + AccountId = accountId; + Owner = owner; + InitialBalance = initialBalance; + CreatedAt = createdAt; + Email = email; + } + + // Convert from V1 to V2 + public static AccountCreated FromV1(V1.AccountCreated v1) + { + return new AccountCreated( + v1.Id, + v1.AccountId, + v1.Owner, + v1.InitialBalance, + v1.CreatedAt, + null); // No email in V1 + } + } +} +``` + +### Message Transformation + +Implement message transformation for version compatibility: + +```csharp +public class MessageVersionTransformer : IMessageTransformer +{ + private readonly Dictionary> _transformers = new Dictionary>(); + + public void RegisterTransformer(Func transformer) + where TSource : class + where TTarget : class + { + _transformers[typeof(TSource)] = source => transformer((TSource)source); + } + + public object Transform(object message) + { + var messageType = message.GetType(); + + if (_transformers.TryGetValue(messageType, out var transformer)) + { + return transformer(message); + } + + return message; + } +} +``` + +## Integration Testing Strategies + +### API Integration Tests + +Test API integrations: + +```csharp +[Fact] +public async Task CanCreateAndRetrieveAccount() +{ + // Arrange + var client = _factory.CreateClient(); + + var createRequest = new + { + Owner = "John Doe", + InitialBalance = 100.0 + }; + + // Act - Create account + var createResponse = await client.PostAsJsonAsync("/api/accounts", createRequest); + + // Assert - Create response + createResponse.EnsureSuccessStatusCode(); + var createResult = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotEqual(Guid.Empty, createResult.Id); + + // Act - Get account + var getResponse = await client.GetAsync($"/api/accounts/{createResult.Id}"); + + // Assert - Get response + getResponse.EnsureSuccessStatusCode(); + var account = await getResponse.Content.ReadFromJsonAsync(); + Assert.Equal(createResult.Id, account.Id); + Assert.Equal("John Doe", account.Owner); + Assert.Equal(100.0m, account.Balance); +} +``` + +### Message Queue Integration Tests + +Test message queue integrations: + +```csharp +[Fact] +public async Task PublishedEventsAreReceivedByConsumer() +{ + // Arrange + var publisher = new RabbitMqEventPublisher(_connectionString, "test-exchange"); + var consumer = new RabbitMqEventConsumer(_connectionString, "test-exchange", "test-queue"); + + var receivedEvents = new List(); + var completionSource = new TaskCompletionSource(); + + consumer.Subscribe(@event => + { + receivedEvents.Add(@event); + + if (receivedEvents.Count >= 1) + { + completionSource.SetResult(true); + } + }); + + // Act + var @event = new AccountCreated( + Guid.NewGuid(), + Guid.NewGuid(), + "John Doe", + 100, + DateTime.UtcNow); + + publisher.Publish(@event); + + // Wait for event to be received + await Task.WhenAny(completionSource.Task, Task.Delay(5000)); + + // Assert + Assert.True(completionSource.Task.IsCompleted, "Event was not received within timeout"); + Assert.Single(receivedEvents); + + var receivedEvent = receivedEvents[0] as AccountCreated; + Assert.NotNull(receivedEvent); + Assert.Equal(@event.AccountId, receivedEvent.AccountId); + Assert.Equal(@event.Owner, receivedEvent.Owner); + Assert.Equal(@event.InitialBalance, receivedEvent.InitialBalance); +} +``` + +### Contract Testing + +Implement contract testing for message contracts: + +```csharp +[Fact] +public void MessageContractIsCompatible() +{ + // Arrange + var v1Event = new V1.AccountCreated( + Guid.NewGuid(), + Guid.NewGuid(), + "John Doe", + 100, + DateTime.UtcNow); + + // Act + var serialized = JsonConvert.SerializeObject(v1Event); + var deserialized = JsonConvert.DeserializeObject(serialized); + + // Assert + Assert.Equal(v1Event.Id, deserialized.Id); + Assert.Equal(v1Event.AccountId, deserialized.AccountId); + Assert.Equal(v1Event.Owner, deserialized.Owner); + Assert.Equal(v1Event.InitialBalance, deserialized.InitialBalance); + Assert.Equal(v1Event.CreatedAt, deserialized.CreatedAt); + Assert.Null(deserialized.Email); // New field in V2 +} +``` + +[↑ Back to Top](#integration-guide) | [← Back to Table of Contents](README.md) diff --git a/docs/interfaces/README.md b/docs/interfaces/README.md new file mode 100644 index 00000000..f3d2abe5 --- /dev/null +++ b/docs/interfaces/README.md @@ -0,0 +1,36 @@ +# Reactive Domain Interfaces + +[← Back to Table of Contents](../README.md) + +This section provides detailed documentation for the key interfaces in the Reactive Domain library. These interfaces define the contracts that components must adhere to and form the foundation of the library's architecture. + +## Table of Contents + +### Core Interfaces + +1. [IEventSource](event-source.md) - The core interface for event-sourced entities +2. [IRepository](repository.md) - The repository pattern implementation for event-sourced aggregates +3. [ICorrelatedRepository](correlated-repository.md) - The repository with correlation support +4. [IListener](listener.md) - The event stream listener interface +5. [IMetadataSource](metadata-source.md) - The metadata handling interface +6. [ISnapshotSource](snapshot-source.md) - The snapshot mechanism interface +7. [IStreamStoreConnection](stream-store-connection.md) - The event store connection interface +8. [IEventSerializer](event-serializer.md) - The event serialization interface + +### Message Interfaces + +9. [IMessage](message.md) - The base message interface +10. [ICommand](command.md) - The command message interface +11. [IEvent](event.md) - The event message interface +12. [ICorrelatedMessage](correlated-message.md) - The correlation tracking interface +13. [ICorrelatedEventSource](correlated-event-source.md) - The correlation tracking for event sources + +Each interface documentation includes: + +- Purpose and responsibility +- Method and property descriptions +- Usage patterns and best practices +- Implementation considerations +- Common pitfalls and how to avoid them + +[↑ Back to Top](#reactive-domain-interfaces) | [← Back to Table of Contents](../README.md) diff --git a/docs/interfaces/event-source.md b/docs/interfaces/event-source.md new file mode 100644 index 00000000..9fa98bf3 --- /dev/null +++ b/docs/interfaces/event-source.md @@ -0,0 +1,272 @@ +# IEventSource Interface + +[← Back to Interfaces](README.md) | [← Back to Table of Contents](../README.md) + +The `IEventSource` interface is the cornerstone of event sourcing in Reactive Domain. It represents a source of events from the perspective of restoring from and taking events, and is primarily used by infrastructure code. + +## Table of Contents + +- [Purpose and Responsibility](#purpose-and-responsibility) +- [Interface Definition](#interface-definition) +- [Method and Property Descriptions](#method-and-property-descriptions) + - [Id](#id) + - [ExpectedVersion](#expectedversion) + - [RestoreFromEvents](#restorefromevents) + - [UpdateWithEvents](#updatewithevents) + - [TakeEvents](#takeevents) +- [Usage Patterns and Best Practices](#usage-patterns-and-best-practices) + - [Implementing IEventSource](#implementing-ieventsource) + - [Example Implementation](#example-implementation) +- [Implementation Considerations](#implementation-considerations) +- [Common Pitfalls and How to Avoid Them](#common-pitfalls-and-how-to-avoid-them) +- [Related Interfaces](#related-interfaces) +- [Conclusion](#conclusion) + +## Purpose and Responsibility + +The primary purpose of the `IEventSource` interface is to define the contract for entities that: + +1. Can be uniquely identified +2. Maintain version information for optimistic concurrency +3. Can be restored from a sequence of events +4. Can be updated with new events +5. Can provide the events they have recorded + +This interface is fundamental to the event sourcing pattern, where the state of an entity is determined by the sequence of events that have occurred, rather than by its current state. + +## Interface Definition + +```csharp +namespace ReactiveDomain +{ + /// + /// Represents a source of events from the perspective of restoring from and taking events. + /// To be used by infrastructure code only. + /// + public interface IEventSource + { + /// + /// Gets the unique identifier for this EventSource + /// This must be provided by the implementing class + /// + Guid Id { get; } + + /// + /// Gets or Sets the expected version this instance is at. + /// + long ExpectedVersion { get; set; } + + /// + /// Restores this instance from the history of events. + /// + /// The events to restore from. + /// Thrown when is null. + void RestoreFromEvents(IEnumerable events); + + /// + /// Updates this instance with the provided events, starting from the expected version. + /// + /// The events to update with. + /// The expected version to start from. + /// Thrown when is null. + /// Thrown when this instance does not have historical events or expected version mismatch + void UpdateWithEvents(IEnumerable events, long expectedVersion); + + /// + /// Takes the recorded history of events from this instance (CQS violation, beware). + /// + /// The recorded events. + object[] TakeEvents(); + } +} +``` + +## Method and Property Descriptions + +### Id + +```csharp +Guid Id { get; } +``` + +The `Id` property provides a unique identifier for the event source. This is typically a GUID that is assigned when the entity is created. The identifier must be immutable and must be provided by the implementing class. + +### ExpectedVersion + +```csharp +long ExpectedVersion { get; set; } +``` + +The `ExpectedVersion` property tracks the version of the event source for optimistic concurrency control. It represents the version that the event source is expected to be at. When events are applied to the event source, the version is incremented. When the event source is saved to a repository, the repository checks that the expected version matches the actual version in the event store. + +### RestoreFromEvents + +```csharp +void RestoreFromEvents(IEnumerable events); +``` + +The `RestoreFromEvents` method rebuilds the state of the event source from a sequence of events. This is typically called when loading an entity from a repository. The method applies each event in sequence to rebuild the entity's state. + +### UpdateWithEvents + +```csharp +void UpdateWithEvents(IEnumerable events, long expectedVersion); +``` + +The `UpdateWithEvents` method updates the state of the event source with new events, starting from the expected version. This is typically called when loading additional events for an entity that has already been partially loaded. The method checks that the expected version matches the entity's current version, and then applies each event in sequence. + +### TakeEvents + +```csharp +object[] TakeEvents(); +``` + +The `TakeEvents` method retrieves the events that have been recorded by the event source since it was last saved. This is typically called when saving an entity to a repository. The method returns the recorded events and clears the entity's record of those events. + +## Usage Patterns and Best Practices + +### Implementing IEventSource + +When implementing the `IEventSource` interface, follow these best practices: + +1. **Use an EventRecorder**: The `EventRecorder` class in `ReactiveDomain.Foundation` provides a convenient way to record events. + +2. **Implement Apply Methods**: For each event type, implement a private `Apply` method that updates the entity's state based on the event. + +3. **Check Version in UpdateWithEvents**: Always check that the expected version matches the entity's current version in the `UpdateWithEvents` method. + +4. **Keep Events Immutable**: Events should be immutable value objects that represent something that happened in the past. + +5. **Separate Command and Query Methods**: Follow the Command Query Separation (CQS) principle by separating methods that change state (commands) from methods that return state (queries). + +### Example Implementation + +```csharp +public class Account : IEventSource +{ + private readonly EventRecorder _recorder = new EventRecorder(); + private decimal _balance; + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + public Account(Guid id) + { + Id = id; + ExpectedVersion = -1; + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + _recorder.Record(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + _recorder.Record(new AmountWithdrawn(Id, amount)); + } + + public decimal GetBalance() + { + return _balance; + } + + public void RestoreFromEvents(IEnumerable events) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public void UpdateWithEvents(IEnumerable events, long expectedVersion) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + + if (ExpectedVersion != expectedVersion) + throw new InvalidOperationException($"Expected version {expectedVersion} but was {ExpectedVersion}"); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public object[] TakeEvents() + { + var events = _recorder.RecordedEvents.ToArray(); + _recorder.Reset(); + return events; + } + + private void Apply(object @event) + { + switch (@event) + { + case AmountDeposited e: + _balance += e.Amount; + break; + + case AmountWithdrawn e: + _balance -= e.Amount; + break; + + default: + throw new InvalidOperationException($"Unknown event type: {@event.GetType().Name}"); + } + } +} +``` + +## Implementation Considerations + +When implementing the `IEventSource` interface, consider the following: + +1. **Event Application Logic**: The logic for applying events should be simple and focused on updating the entity's state. + +2. **Version Management**: The `ExpectedVersion` property is critical for optimistic concurrency control. Make sure it's properly managed. + +3. **Event Recording**: Use the `EventRecorder` class to record events, rather than implementing this functionality yourself. + +4. **Event Types**: Events should be simple value objects that represent something that happened in the past. They should be immutable and contain all the data needed to understand what happened. + +5. **Performance**: The `RestoreFromEvents` and `UpdateWithEvents` methods may be called with a large number of events. Make sure they're efficient. + +## Common Pitfalls and How to Avoid Them + +1. **Mutating Events**: Events should be immutable, but the `IEventSource` interface doesn't enforce this. Make sure your events are immutable. + +2. **Ignoring Version Checks**: Always check the `ExpectedVersion` in the `UpdateWithEvents` method to prevent concurrency issues. + +3. **Complex Event Application**: Keep the logic for applying events simple and focused. Avoid complex business logic in the `Apply` methods. + +4. **Leaking Implementation Details**: The `IEventSource` interface should hide implementation details from clients. Don't expose internal state or behavior. + +5. **Not Clearing Events**: The `TakeEvents` method should clear the entity's record of events after returning them. Otherwise, the same events may be saved multiple times. + +## Related Interfaces + +- [IRepository](repository.md): Provides methods for loading and saving event sources. +- [ICorrelatedEventSource](correlated-event-source.md): Extends `IEventSource` with correlation tracking. +- [ISnapshotSource](snapshot-source.md): Provides methods for creating and restoring from snapshots. + +## Conclusion + +The `IEventSource` interface is the foundation of event sourcing in Reactive Domain. By implementing this interface, entities can participate in the event sourcing pattern, where state is determined by the sequence of events that have occurred. This enables powerful features like complete audit trails, temporal queries, and event replay. + +[↑ Back to Top](#ieventsource-interface) | [← Back to Interfaces](README.md) | [← Back to Table of Contents](../README.md) diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 00000000..55f6c2f3 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,640 @@ +# Migration Guide + +[← Back to Table of Contents](README.md) + +This guide provides detailed information about migrating between different versions of Reactive Domain, including breaking changes, new features, and recommended migration strategies. + +## Table of Contents + +- [Breaking Changes and Deprecations](#breaking-changes-and-deprecations) +- [New Features and Enhancements](#new-features-and-enhancements) +- [Migration Strategies](#migration-strategies) +- [Backward Compatibility Considerations](#backward-compatibility-considerations) +- [Testing Strategies for Migrations](#testing-strategies-for-migrations) + +## Breaking Changes and Deprecations + +### Version 2.0.0 + +#### Removal of .NET 6 Support + +As of version 2.0.0, Reactive Domain no longer supports .NET 6. Applications must be upgraded to .NET 7 or later. + +**Migration Path:** +- Upgrade your application to target .NET 7 or later +- Update your project file to use the new target framework: + ```xml + net7.0 + ``` + +#### Changes to IEventSource Interface + +The `IEventSource` interface has been modified to include a new `ExpectedVersion` property: + +**Before:** +```csharp +public interface IEventSource +{ + Guid Id { get; } + void RestoreFromEvents(IEnumerable events); + void UpdateWithEvents(IEnumerable events); + object[] TakeEvents(); +} +``` + +**After:** +```csharp +public interface IEventSource +{ + Guid Id { get; } + long ExpectedVersion { get; set; } + void RestoreFromEvents(IEnumerable events); + void UpdateWithEvents(IEnumerable events, long expectedVersion); + object[] TakeEvents(); +} +``` + +**Migration Path:** +- Update all implementations of `IEventSource` to include the new `ExpectedVersion` property +- Modify `UpdateWithEvents` method to accept the `expectedVersion` parameter +- If you're using the `AggregateRoot` base class, these changes are handled automatically + +#### Repository API Changes + +The repository API has been updated to use the new `ExpectedVersion` property: + +**Before:** +```csharp +public interface IRepository +{ + TAggregate GetById(Guid id) where TAggregate : class, IEventSource; + void Save(IEventSource aggregate); + void Delete(IEventSource aggregate); +} +``` + +**After:** +```csharp +public interface IRepository +{ + bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) where TAggregate : class, IEventSource; + void Save(IEventSource aggregate); + void Delete(IEventSource aggregate); +} +``` + +**Migration Path:** +- Update all code that calls `GetById` to use `TryGetById` instead +- Handle the case where `TryGetById` returns `false` +- Example: + ```csharp + // Before + var aggregate = repository.GetById(id); + + // After + if (!repository.TryGetById(id, out var aggregate)) + { + throw new AggregateNotFoundException(typeof(MyAggregate), id); + } + ``` + +#### Message Bus API Changes + +The message bus API has been simplified: + +**Before:** +```csharp +public interface ICommandBus +{ + void Send(TCommand command) where TCommand : class, ICommand; + void RegisterHandler(Action handler) where TCommand : class, ICommand; +} +``` + +**After:** +```csharp +public interface ICommandBus +{ + void Send(TCommand command) where TCommand : class, ICommand; +} + +public interface ICommandHandler where TCommand : class, ICommand +{ + void Handle(TCommand command); +} +``` + +**Migration Path:** +- Convert all message handlers to implement the new handler interfaces +- Register handlers using the new registration mechanism +- Example: + ```csharp + // Before + commandBus.RegisterHandler(cmd => + { + var account = new Account(cmd.AccountId); + repository.Save(account); + }); + + // After + public class CreateAccountHandler : ICommandHandler + { + private readonly IRepository _repository; + + public CreateAccountHandler(IRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccount command) + { + var account = new Account(command.AccountId); + _repository.Save(account); + } + } + + // Registration + services.AddTransient, CreateAccountHandler>(); + ``` + +### Version 1.5.0 + +#### Deprecated Methods + +The following methods have been deprecated in version 1.5.0 and will be removed in version 2.0.0: + +- `AggregateRoot.Apply(object)` - Use `AggregateRoot.RaiseEvent(object)` instead +- `Repository.GetByIdAsync(Guid)` - Use `Repository.TryGetByIdAsync(Guid, out TAggregate)` instead +- `EventStore.AppendEvents(string, IEnumerable)` - Use `EventStore.AppendToStream(string, long, IEnumerable)` instead + +**Migration Path:** +- Replace all calls to deprecated methods with their replacements +- Update unit tests to use the new methods +- Run static code analysis to find all usages of deprecated methods + +## New Features and Enhancements + +### Version 2.0.0 + +#### Improved Correlation and Causation Tracking + +Version 2.0.0 introduces enhanced correlation and causation tracking: + +```csharp +public interface ICorrelatedMessage : IMessage +{ + Guid CorrelationId { get; } + Guid CausationId { get; } +} + +public interface ICorrelatedRepository : IRepository +{ + bool TryGetById(Guid id, out TAggregate aggregate, ICorrelatedMessage source) where TAggregate : AggregateRoot, IEventSource; + void Save(IEventSource aggregate); +} +``` + +**Usage:** +```csharp +public class CreateAccountHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + + public CreateAccountHandler(ICorrelatedRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccount command) + { + var account = new Account(command.AccountId); + _repository.TryGetById(command.AccountId, out account, command); + account.Initialize(command.InitialBalance); + _repository.Save(account); + } +} +``` + +#### Enhanced Snapshot Support + +Version 2.0.0 introduces improved snapshot support: + +```csharp +public interface ISnapshotSource +{ + void RestoreFromSnapshot(object snapshot); + object TakeSnapshot(); +} + +public interface ISnapshotStore +{ + void SaveSnapshot(Guid aggregateId, Type aggregateType, object snapshot, long version); + SnapshotEnvelope GetSnapshot(Guid aggregateId, Type aggregateType); +} +``` + +**Usage:** +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + private decimal _balance; + + // ... existing code ... + + public void RestoreFromSnapshot(object snapshot) + { + var accountSnapshot = (AccountSnapshot)snapshot; + _balance = accountSnapshot.Balance; + ExpectedVersion = accountSnapshot.Version; + } + + public object TakeSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + Version = ExpectedVersion + }; + } +} + +public class AccountSnapshot +{ + public decimal Balance { get; set; } + public long Version { get; set; } +} +``` + +#### Improved Testing Support + +Version 2.0.0 introduces enhanced testing support: + +```csharp +public class AggregateTest where TAggregate : AggregateRoot, new() +{ + protected TAggregate Aggregate { get; } + + public AggregateTest() + { + Aggregate = new TAggregate(); + } + + protected void Given(params object[] events) + { + Aggregate.RestoreFromEvents(events); + } + + protected object[] When(Action action) + { + action(Aggregate); + return Aggregate.TakeEvents(); + } +} +``` + +**Usage:** +```csharp +public class AccountTests : AggregateTest +{ + [Fact] + public void CanDepositMoney() + { + // Given + Given(new AccountCreated(Guid.NewGuid(), "John Doe")); + + // When + var events = When(a => a.Deposit(100)); + + // Then + var @event = Assert.Single(events); + var depositEvent = Assert.IsType(@event); + Assert.Equal(100, depositEvent.Amount); + } +} +``` + +### Version 1.5.0 + +#### Asynchronous Command Handling + +Version 1.5.0 introduces asynchronous command handling: + +```csharp +public interface IAsyncCommandHandler where TCommand : class, ICommand +{ + Task HandleAsync(TCommand command, CancellationToken cancellationToken = default); +} +``` + +**Usage:** +```csharp +public class CreateAccountHandler : IAsyncCommandHandler +{ + private readonly IRepository _repository; + + public CreateAccountHandler(IRepository repository) + { + _repository = repository; + } + + public async Task HandleAsync(CreateAccount command, CancellationToken cancellationToken = default) + { + var account = new Account(command.AccountId); + await Task.Delay(100, cancellationToken); // Simulate async work + _repository.Save(account); + } +} +``` + +#### Improved Event Store Connection Management + +Version 1.5.0 introduces improved event store connection management: + +```csharp +public interface IStreamStoreConnectionFactory +{ + IStreamStoreConnection Create(string connectionString); +} +``` + +**Usage:** +```csharp +public class StreamStoreRepository : IRepository +{ + private readonly IStreamStoreConnection _connection; + + public StreamStoreRepository(IStreamStoreConnectionFactory connectionFactory, string connectionString) + { + _connection = connectionFactory.Create(connectionString); + _connection.Connect(); + } + + // ... repository implementation ... +} +``` + +## Migration Strategies + +### Incremental Migration + +For large applications, an incremental migration approach is recommended: + +1. **Update Dependencies**: Update to the latest version of Reactive Domain +2. **Address Compiler Errors**: Fix any compiler errors related to breaking changes +3. **Update Core Components**: Migrate core components first (repositories, event handlers) +4. **Update Aggregates**: Migrate aggregates to implement new interfaces +5. **Update Command Handlers**: Migrate command handlers to the new pattern +6. **Update Event Handlers**: Migrate event handlers to the new pattern +7. **Update Tests**: Update tests to use the new APIs +8. **Verify Functionality**: Verify that all functionality works as expected + +### Big Bang Migration + +For smaller applications, a big bang migration approach may be feasible: + +1. **Create a New Branch**: Create a new branch for the migration +2. **Update Dependencies**: Update to the latest version of Reactive Domain +3. **Update All Code**: Update all code to use the new APIs +4. **Run Tests**: Run all tests to verify functionality +5. **Deploy**: Deploy the updated application + +### Parallel Deployment + +For critical applications, a parallel deployment approach may be appropriate: + +1. **Create a New Application**: Create a new application using the latest version +2. **Implement Core Functionality**: Implement core functionality in the new application +3. **Migrate Data**: Migrate data from the old application to the new one +4. **Validate**: Validate that the new application works as expected +5. **Switch Over**: Switch traffic from the old application to the new one + +## Backward Compatibility Considerations + +### Event Compatibility + +When migrating to a new version, it's important to maintain compatibility with existing events: + +1. **Never Delete Events**: Once events are in production, never delete them +2. **Add Fields, Don't Remove**: When modifying events, add new fields rather than removing existing ones +3. **Provide Defaults**: When adding new fields, provide sensible defaults +4. **Use Event Upcasting**: Transform old events to new versions during deserialization + +Example of event upcasting: + +```csharp +public class EventUpcastingSerializer : IEventSerializer +{ + private readonly IEventSerializer _innerSerializer; + + public EventUpcastingSerializer(IEventSerializer innerSerializer) + { + _innerSerializer = innerSerializer; + } + + public object Deserialize(RecordedEvent recordedEvent) + { + var deserialized = _innerSerializer.Deserialize(recordedEvent); + + // Upcast old event versions to new versions + if (deserialized is AccountCreatedV1 v1) + { + return new AccountCreatedV2(v1.AccountId, v1.Owner, null); + } + + return deserialized; + } + + public IEventData Serialize(object @event, Guid eventId) + { + return _innerSerializer.Serialize(@event, eventId); + } +} +``` + +### Command Compatibility + +When migrating to a new version, it's important to maintain compatibility with existing commands: + +1. **Versioned Commands**: Use versioned commands to support both old and new clients +2. **Command Translation**: Translate old commands to new commands +3. **Command Validation**: Validate commands before processing + +Example of command translation: + +```csharp +public class CommandTranslator : ICommandHandler +{ + private readonly ICommandBus _commandBus; + + public CommandTranslator(ICommandBus commandBus) + { + _commandBus = commandBus; + } + + public void Handle(CreateAccountV1 command) + { + // Translate old command to new command + var newCommand = new CreateAccountV2(command.AccountId, command.Owner, null); + + // Send new command + _commandBus.Send(newCommand); + } +} +``` + +### Read Model Compatibility + +When migrating to a new version, it's important to maintain compatibility with existing read models: + +1. **Versioned APIs**: Use versioned APIs to support both old and new clients +2. **API Translation**: Translate old API calls to new API calls +3. **Data Migration**: Migrate data from old read models to new read models + +Example of API translation: + +```csharp +[ApiController] +[Route("api/v1/accounts")] +public class AccountsControllerV1 : ControllerBase +{ + private readonly AccountsControllerV2 _v2Controller; + + public AccountsControllerV1(AccountsControllerV2 v2Controller) + { + _v2Controller = v2Controller; + } + + [HttpGet("{id}")] + public async Task GetAccount(Guid id) + { + // Forward to v2 controller + var result = await _v2Controller.GetAccountV2(id); + + // Transform result to v1 format + if (result is OkObjectResult okResult) + { + var accountV2 = (AccountDto)okResult.Value; + var accountV1 = new AccountDtoV1 + { + Id = accountV2.Id, + Owner = accountV2.Owner, + Balance = accountV2.Balance + }; + + return Ok(accountV1); + } + + return result; + } +} +``` + +## Testing Strategies for Migrations + +### Unit Testing + +When migrating to a new version, it's important to have comprehensive unit tests: + +1. **Test Old Behavior**: Verify that existing behavior still works +2. **Test New Behavior**: Verify that new behavior works as expected +3. **Test Migration Paths**: Verify that migration paths work as expected + +Example of testing migration paths: + +```csharp +[Fact] +public void CanUpcastOldEvents() +{ + // Arrange + var oldEvent = new AccountCreatedV1(Guid.NewGuid(), "John Doe"); + var serializer = new JsonMessageSerializer(); + var eventData = serializer.Serialize(oldEvent, Guid.NewGuid()); + var recordedEvent = new RecordedEvent("account-1", 0, eventData.EventId, eventData.Type, eventData.Data, eventData.Metadata, true, DateTime.UtcNow); + + var upcastingSerializer = new EventUpcastingSerializer(serializer); + + // Act + var upcastedEvent = upcastingSerializer.Deserialize(recordedEvent); + + // Assert + var v2Event = Assert.IsType(upcastedEvent); + Assert.Equal(oldEvent.AccountId, v2Event.AccountId); + Assert.Equal(oldEvent.Owner, v2Event.Owner); + Assert.Null(v2Event.Email); +} +``` + +### Integration Testing + +When migrating to a new version, it's important to have comprehensive integration tests: + +1. **Test End-to-End Flows**: Verify that end-to-end flows still work +2. **Test Integration Points**: Verify that integration points still work +3. **Test Performance**: Verify that performance is acceptable + +Example of testing end-to-end flows: + +```csharp +[Fact] +public async Task CanCreateAndRetrieveAccount() +{ + // Arrange + var accountId = Guid.NewGuid(); + var commandBus = _fixture.GetService(); + var queryBus = _fixture.GetService(); + + // Act - Create account + commandBus.Send(new CreateAccount(accountId, "John Doe", 100)); + + // Wait for read model to be updated + await Task.Delay(100); + + // Act - Retrieve account + var query = new GetAccountQuery(accountId); + var account = await queryBus.Query(query); + + // Assert + Assert.NotNull(account); + Assert.Equal(accountId, account.Id); + Assert.Equal("John Doe", account.Owner); + Assert.Equal(100, account.Balance); +} +``` + +### Performance Testing + +When migrating to a new version, it's important to verify performance: + +1. **Benchmark Old Version**: Establish performance baselines +2. **Benchmark New Version**: Measure performance of the new version +3. **Identify Bottlenecks**: Identify and address performance bottlenecks +4. **Optimize**: Optimize critical paths + +Example of performance testing: + +```csharp +[Fact] +public void CanHandleHighVolumeCommands() +{ + // Arrange + var commandBus = _fixture.GetService(); + var stopwatch = new Stopwatch(); + const int commandCount = 1000; + + // Act + stopwatch.Start(); + + for (int i = 0; i < commandCount; i++) + { + commandBus.Send(new CreateAccount(Guid.NewGuid(), $"User {i}", 100)); + } + + stopwatch.Stop(); + + // Assert + Console.WriteLine($"Processed {commandCount} commands in {stopwatch.ElapsedMilliseconds}ms"); + Assert.True(stopwatch.ElapsedMilliseconds < 5000, "Command processing should be faster than 5 seconds"); +} +``` + +[↑ Back to Top](#migration-guide) | [← Back to Table of Contents](README.md) diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 00000000..5bad351a --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,71 @@ +# Reactive Domain Overview + +## Introduction + +Reactive Domain is an open-source framework for implementing event sourcing in .NET projects using reactive programming principles. It provides a comprehensive set of tools and patterns for building event-sourced applications with a focus on domain-driven design (DDD) and Command Query Responsibility Segregation (CQRS). + +The framework integrates with [EventStoreDB](https://eventstore.com) to provide robust event storage and retrieval capabilities, while also offering a messaging framework and other utilities for implementing CQRS effectively. + +## Philosophy and Design Principles + +Reactive Domain is built on several key design principles: + +1. **Simplicity and Consistency**: The framework emphasizes a small number of consistent patterns and design principles in its public interfaces, making it easier for developers to learn and use. + +2. **Developer Experience**: Ease of use and "design for code review" have been the driving forces behind the framework's evolution. The API is designed to be intuitive and self-documenting. + +3. **Opinionated Design**: The framework makes opinionated choices about implementation details to provide a clear path for developers to follow. + +4. **Pragmatic Trade-offs**: Where trade-offs have been necessary, the framework prioritizes developer experience and code clarity over performance optimizations. + +## Key Features + +Reactive Domain offers a rich set of features for building event-sourced applications: + +- **Event Sourcing**: A complete implementation of event sourcing patterns, including aggregates, events, and repositories. +- **CQRS Support**: Tools for implementing Command Query Responsibility Segregation, including command handlers and projections. +- **EventStoreDB Integration**: Built-in support for storing and retrieving events using EventStoreDB. +- **Messaging Framework**: A comprehensive messaging system for handling commands, events, and queries. +- **Testing Utilities**: Tools for testing event-sourced applications, including in-memory event stores and test fixtures. +- **Correlation and Causation Tracking**: Built-in support for tracking correlation and causation IDs across message flows. +- **Snapshotting**: Support for creating and restoring from snapshots to improve performance. + +## Architecture Overview + +Reactive Domain is organized into several key components: + +```mermaid +graph TD + A[Client Application] --> B[ReactiveDomain.Messaging] + B --> C[ReactiveDomain.Foundation] + C --> D[ReactiveDomain.Core] + C --> E[ReactiveDomain.Persistence] + E --> F[EventStoreDB] + B --> G[ReactiveDomain.Transport] + H[ReactiveDomain.Testing] --> C + I[ReactiveDomain.Tools] --> C +``` + +- **ReactiveDomain.Core**: Contains the fundamental interfaces and abstractions. +- **ReactiveDomain.Foundation**: Implements the domain layer, including aggregates and repositories. +- **ReactiveDomain.Messaging**: Provides the messaging framework for commands, events, and queries. +- **ReactiveDomain.Persistence**: Handles event storage and retrieval. +- **ReactiveDomain.Transport**: Manages message transport between components. +- **ReactiveDomain.Testing**: Offers utilities for testing event-sourced applications. +- **ReactiveDomain.Tools**: Provides additional tools and utilities. + +## When to Use Reactive Domain + +Reactive Domain is particularly well-suited for: + +- Complex business domains with rich behavior +- Applications requiring a complete audit trail of all changes +- Systems with complex business rules and validations +- Applications needing high scalability for read operations +- Projects where team alignment around DDD principles is important + +## Getting Started + +To get started with Reactive Domain, see the [Core Concepts](core-concepts.md) section for an introduction to the fundamental principles, and the [Usage Patterns](usage-patterns.md) section for practical guidance on using the framework. + +For code examples, see the [Code Examples](code-examples/README.md) section. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 00000000..21094313 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,949 @@ +# Performance Optimization Guide + +[← Back to Table of Contents](README.md) + +This guide provides strategies and techniques for optimizing the performance of applications built with Reactive Domain. + +## Table of Contents + +- [Event Store Performance Considerations](#event-store-performance-considerations) +- [Snapshot Strategies](#snapshot-strategies) +- [Read Model Optimization Techniques](#read-model-optimization-techniques) +- [Message Handling Performance](#message-handling-performance) +- [Scaling Strategies for High-Throughput Systems](#scaling-strategies-for-high-throughput-systems) +- [Monitoring and Profiling Techniques](#monitoring-and-profiling-techniques) +- [Benchmarking and Performance Testing](#benchmarking-and-performance-testing) + +## Event Store Performance Considerations + +### Hardware Recommendations + +EventStoreDB performance is influenced by hardware choices: + +1. **CPU**: Multi-core processors for parallel event processing +2. **Memory**: Sufficient RAM for caching frequently accessed events +3. **Storage**: Fast SSDs for low-latency event storage and retrieval +4. **Network**: High-bandwidth, low-latency network for cluster communication + +### Configuration Optimization + +Optimize EventStoreDB configuration for your workload: + +```bash +# Example EventStoreDB configuration +EVENTSTORE_DB="/var/lib/eventstore" +EVENTSTORE_INDEX="/var/lib/eventstore/index" +EVENTSTORE_LOG="/var/log/eventstore" +EVENTSTORE_RUN_PROJECTIONS=All +EVENTSTORE_START_STANDARD_PROJECTIONS=true +EVENTSTORE_DISABLE_HTTP_CACHING=false +EVENTSTORE_DISABLE_ADMIN_UI=false +EVENTSTORE_WORKER_THREADS=10 +EVENTSTORE_READER_THREADS_COUNT=5 +``` + +### Stream Naming Strategies + +Efficient stream naming improves performance: + +```csharp +public class OptimizedStreamNameBuilder : IStreamNameBuilder +{ + public string GenerateForAggregate(Type aggregateType, Guid aggregateId) + { + // Use short, consistent names for better performance + var typeName = aggregateType.Name.ToLowerInvariant(); + return $"agg-{typeName}-{aggregateId}"; + } +} +``` + +### Batch Operations + +Use batch operations to reduce round trips: + +```csharp +public void SaveMultipleAggregates(IEnumerable aggregates) +{ + foreach (var aggregate in aggregates) + { + var events = aggregate.TakeEvents(); + var streamName = _streamNameBuilder.GenerateForAggregate(aggregate.GetType(), aggregate.Id); + + _connection.AppendToStream(streamName, aggregate.ExpectedVersion, + events.Select(e => _serializer.Serialize(e, Guid.NewGuid()))); + } +} +``` + +## Snapshot Strategies + +### When to Use Snapshots + +Snapshots are beneficial when: + +1. Aggregates have many events (>100) +2. Aggregates are frequently loaded +3. Event replay is computationally expensive + +### Implementing Snapshots + +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + private decimal _balance; + private List _recentTransactions = new List(); + + // ... existing code ... + + public void RestoreFromSnapshot(object snapshot) + { + var accountSnapshot = (AccountSnapshot)snapshot; + _balance = accountSnapshot.Balance; + _recentTransactions = accountSnapshot.RecentTransactions; + ExpectedVersion = accountSnapshot.Version; + } + + public object TakeSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + RecentTransactions = _recentTransactions.ToList(), + Version = ExpectedVersion + }; + } +} + +public class AccountSnapshot +{ + public decimal Balance { get; set; } + public List RecentTransactions { get; set; } + public long Version { get; set; } +} +``` + +### Snapshot Frequency + +Optimize snapshot frequency: + +```csharp +public class SnapshotRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly ISnapshotStore _snapshotStore; + private readonly int _snapshotFrequency; + + public SnapshotRepository( + IRepository innerRepository, + ISnapshotStore snapshotStore, + int snapshotFrequency = 100) + { + _innerRepository = innerRepository; + _snapshotStore = snapshotStore; + _snapshotFrequency = snapshotFrequency; + } + + public void Save(IEventSource aggregate) + { + _innerRepository.Save(aggregate); + + // Take a snapshot if the aggregate supports it and has enough events + if (aggregate is ISnapshotSource snapshotSource && + aggregate.ExpectedVersion % _snapshotFrequency == 0) + { + var snapshot = snapshotSource.TakeSnapshot(); + _snapshotStore.SaveSnapshot( + aggregate.Id, + aggregate.GetType(), + snapshot, + aggregate.ExpectedVersion); + } + } + + // ... other methods ... +} +``` + +### Snapshot Storage Optimization + +Optimize snapshot storage: + +1. **Compression**: Compress snapshots to reduce storage requirements +2. **Serialization**: Use efficient serialization formats (e.g., Protocol Buffers) +3. **Storage Tier**: Store snapshots on appropriate storage tiers based on access patterns + +## Read Model Optimization Techniques + +### Specialized Read Models + +Create specialized read models for specific query patterns: + +```csharp +public class AccountBalanceReadModel +{ + private readonly Dictionary _balances = new Dictionary(); + + public void Handle(AccountCreated @event) + { + _balances[@event.AccountId] = @event.InitialBalance; + } + + public void Handle(AmountDeposited @event) + { + _balances[@event.AccountId] += @event.Amount; + } + + public void Handle(AmountWithdrawn @event) + { + _balances[@event.AccountId] -= @event.Amount; + } + + public decimal GetBalance(Guid accountId) + { + return _balances.TryGetValue(accountId, out var balance) ? balance : 0; + } +} +``` + +### Denormalization Strategies + +Denormalize data for efficient queries: + +```csharp +public class AccountSummaryReadModel +{ + private readonly Dictionary _summaries = new Dictionary(); + + public void Handle(AccountCreated @event) + { + _summaries[@event.AccountId] = new AccountSummary + { + Id = @event.AccountId, + Owner = @event.Owner, + Balance = @event.InitialBalance, + TransactionCount = 0, + LastActivity = DateTime.UtcNow + }; + } + + public void Handle(AmountDeposited @event) + { + var summary = _summaries[@event.AccountId]; + summary.Balance += @event.Amount; + summary.TransactionCount++; + summary.LastActivity = DateTime.UtcNow; + } + + // ... other handlers ... + + public AccountSummary GetSummary(Guid accountId) + { + return _summaries.TryGetValue(accountId, out var summary) ? summary : null; + } + + public IEnumerable GetRecentlyActiveAccounts(int count) + { + return _summaries.Values + .OrderByDescending(s => s.LastActivity) + .Take(count); + } +} + +public class AccountSummary +{ + public Guid Id { get; set; } + public string Owner { get; set; } + public decimal Balance { get; set; } + public int TransactionCount { get; set; } + public DateTime LastActivity { get; set; } +} +``` + +### Database Optimization + +Optimize database storage for read models: + +1. **Indexing**: Create appropriate indexes for query patterns +2. **Partitioning**: Partition large tables for better performance +3. **Caching**: Implement caching for frequently accessed data + +```csharp +public class CachedAccountReadModel : IDisposable +{ + private readonly IAccountReadModel _innerReadModel; + private readonly IMemoryCache _cache; + private readonly TimeSpan _cacheDuration; + + public CachedAccountReadModel( + IAccountReadModel innerReadModel, + IMemoryCache cache, + TimeSpan? cacheDuration = null) + { + _innerReadModel = innerReadModel; + _cache = cache; + _cacheDuration = cacheDuration ?? TimeSpan.FromMinutes(5); + } + + public AccountSummary GetSummary(Guid accountId) + { + var cacheKey = $"account-summary-{accountId}"; + + return _cache.GetOrCreate(cacheKey, entry => + { + entry.SlidingExpiration = _cacheDuration; + return _innerReadModel.GetSummary(accountId); + }); + } + + public void Handle(AccountCreated @event) + { + _innerReadModel.Handle(@event); + InvalidateCache(@event.AccountId); + } + + public void Handle(AmountDeposited @event) + { + _innerReadModel.Handle(@event); + InvalidateCache(@event.AccountId); + } + + // ... other handlers ... + + private void InvalidateCache(Guid accountId) + { + _cache.Remove($"account-summary-{accountId}"); + } + + public void Dispose() + { + (_innerReadModel as IDisposable)?.Dispose(); + } +} +``` + +## Message Handling Performance + +### Command Batching + +Batch related commands for efficiency: + +```csharp +public class BatchingCommandBus : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly ConcurrentQueue _commandQueue = new ConcurrentQueue(); + private readonly int _batchSize; + private readonly Timer _batchTimer; + + public BatchingCommandBus(ICommandBus innerBus, int batchSize = 100, int batchIntervalMs = 100) + { + _innerBus = innerBus; + _batchSize = batchSize; + _batchTimer = new Timer(ProcessBatch, null, batchIntervalMs, batchIntervalMs); + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + _commandQueue.Enqueue(command); + + // Process immediately if queue exceeds batch size + if (_commandQueue.Count >= _batchSize) + { + ProcessBatch(null); + } + } + + private void ProcessBatch(object state) + { + var commands = new List(); + + // Dequeue up to batch size commands + while (commands.Count < _batchSize && _commandQueue.TryDequeue(out var command)) + { + commands.Add(command); + } + + // Process commands in batch + foreach (var command in commands) + { + _innerBus.Send(command); + } + } + + public void Dispose() + { + _batchTimer.Dispose(); + } +} +``` + +### Parallel Processing + +Process commands and events in parallel: + +```csharp +public class ParallelEventProcessor : IEventHandler +{ + private readonly IEventHandler _innerHandler; + private readonly ParallelOptions _parallelOptions; + + public ParallelEventProcessor( + IEventHandler innerHandler, + int maxDegreeOfParallelism = -1) + { + _innerHandler = innerHandler; + _parallelOptions = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism + }; + } + + public void Handle(IEnumerable events) + { + Parallel.ForEach(events, _parallelOptions, @event => + { + _innerHandler.Handle(@event); + }); + } + + public void Handle(TEvent @event) + { + _innerHandler.Handle(@event); + } +} +``` + +### Asynchronous Processing + +Use asynchronous processing for non-blocking operations: + +```csharp +public class AsyncCommandBus : IAsyncCommandBus +{ + private readonly ConcurrentDictionary> _handlers = + new ConcurrentDictionary>(); + + public void RegisterHandler(Func handler) + where TCommand : class, ICommand + { + _handlers[typeof(TCommand)] = cmd => handler((TCommand)cmd); + } + + public async Task SendAsync(TCommand command) + where TCommand : class, ICommand + { + if (_handlers.TryGetValue(typeof(TCommand), out var handler)) + { + await handler(command); + } + else + { + throw new InvalidOperationException($"No handler registered for {typeof(TCommand).Name}"); + } + } +} +``` + +## Scaling Strategies for High-Throughput Systems + +### Horizontal Scaling + +Scale out command and query processors: + +1. **Load Balancing**: Distribute commands across multiple processors +2. **Stateless Design**: Design processors to be stateless for easy scaling +3. **Consistent Hashing**: Route related commands to the same processor + +### Event Partitioning + +Partition events for parallel processing: + +```csharp +public class PartitionedEventProcessor : IEventProcessor +{ + private readonly IEventProcessor[] _processors; + private readonly IPartitionStrategy _partitionStrategy; + + public PartitionedEventProcessor( + IEnumerable processors, + IPartitionStrategy partitionStrategy) + { + _processors = processors.ToArray(); + _partitionStrategy = partitionStrategy; + } + + public void Process(object @event) + { + var partition = _partitionStrategy.GetPartition(@event, _processors.Length); + _processors[partition].Process(@event); + } +} + +public interface IPartitionStrategy +{ + int GetPartition(object @event, int partitionCount); +} + +public class AggregateIdPartitionStrategy : IPartitionStrategy +{ + public int GetPartition(object @event, int partitionCount) + { + // Extract aggregate ID from event + var aggregateId = ExtractAggregateId(@event); + + // Use consistent hashing + return Math.Abs(aggregateId.GetHashCode()) % partitionCount; + } + + private Guid ExtractAggregateId(object @event) + { + // Extract aggregate ID based on event type + var property = @event.GetType().GetProperties() + .FirstOrDefault(p => + p.Name == "AggregateId" || + p.Name == "Id" || + p.Name.EndsWith("Id")); + + return property != null + ? (Guid)property.GetValue(@event) + : Guid.Empty; + } +} +``` + +### Competing Consumers + +Implement competing consumers for event processing: + +```csharp +public class CompetingConsumerManager +{ + private readonly IStreamStoreConnection _connection; + private readonly IEventProcessor _processor; + private readonly string _consumerGroup; + private readonly int _consumerCount; + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private readonly List _consumerTasks = new List(); + + public CompetingConsumerManager( + IStreamStoreConnection connection, + IEventProcessor processor, + string consumerGroup, + int consumerCount = 4) + { + _connection = connection; + _processor = processor; + _consumerGroup = consumerGroup; + _consumerCount = consumerCount; + } + + public void Start() + { + for (int i = 0; i < _consumerCount; i++) + { + var consumerId = $"{_consumerGroup}-{i}"; + _consumerTasks.Add(Task.Run(() => RunConsumer(consumerId, _cancellationTokenSource.Token))); + } + } + + public async Task StopAsync() + { + _cancellationTokenSource.Cancel(); + await Task.WhenAll(_consumerTasks); + } + + private async Task RunConsumer(string consumerId, CancellationToken cancellationToken) + { + // Connect to persistent subscription + var subscription = _connection.ConnectToPersistentSubscription( + "$all", + _consumerGroup, + (subscription, @event) => + { + try + { + // Process the event + _processor.Process(@event); + + // Acknowledge successful processing + subscription.Acknowledge(@event); + } + catch (Exception ex) + { + // Nack the event for retry + subscription.Fail(@event, PersistentSubscriptionNakEventAction.Retry, ex.Message); + } + }); + + // Wait for cancellation + await Task.Delay(-1, cancellationToken); + + // Disconnect on cancellation + subscription.Dispose(); + } +} +``` + +## Monitoring and Profiling Techniques + +### Performance Metrics + +Track key performance metrics: + +```csharp +public class MetricsCommandBus : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly IMetrics _metrics; + + public MetricsCommandBus(ICommandBus innerBus, IMetrics metrics) + { + _innerBus = innerBus; + _metrics = metrics; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + var commandType = typeof(TCommand).Name; + + // Track command count + _metrics.IncrementCounter($"commands.{commandType}.count"); + + // Measure command processing time + using (_metrics.MeasureDuration($"commands.{commandType}.duration")) + { + try + { + _innerBus.Send(command); + + // Track successful commands + _metrics.IncrementCounter($"commands.{commandType}.success"); + } + catch + { + // Track failed commands + _metrics.IncrementCounter($"commands.{commandType}.failure"); + throw; + } + } + } +} + +public interface IMetrics +{ + void IncrementCounter(string name, long value = 1); + IDisposable MeasureDuration(string name); +} +``` + +### Profiling + +Profile application performance: + +1. **Application Profiling**: Use tools like dotTrace or Visual Studio Profiler +2. **Database Profiling**: Monitor database performance with query analyzers +3. **Event Store Profiling**: Monitor EventStoreDB performance metrics + +### Logging Performance Data + +Log performance data for analysis: + +```csharp +public class PerformanceLoggingRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly ILogger _logger; + + public PerformanceLoggingRepository( + IRepository innerRepository, + ILogger logger) + { + _innerRepository = innerRepository; + _logger = logger; + } + + public bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) + where TAggregate : class, IEventSource + { + var stopwatch = Stopwatch.StartNew(); + var result = _innerRepository.TryGetById(id, out aggregate, version); + stopwatch.Stop(); + + _logger.LogInformation( + "Repository.GetById<{AggregateType}>({AggregateId}) took {ElapsedMs}ms", + typeof(TAggregate).Name, + id, + stopwatch.ElapsedMilliseconds); + + return result; + } + + public void Save(IEventSource aggregate) + { + var stopwatch = Stopwatch.StartNew(); + var eventCount = aggregate.TakeEvents().Length; + + _innerRepository.Save(aggregate); + + stopwatch.Stop(); + + _logger.LogInformation( + "Repository.Save<{AggregateType}>({AggregateId}) with {EventCount} events took {ElapsedMs}ms", + aggregate.GetType().Name, + aggregate.Id, + eventCount, + stopwatch.ElapsedMilliseconds); + } + + // ... other methods ... +} +``` + +## Benchmarking and Performance Testing + +### Benchmark Framework + +Create a benchmark framework for performance testing: + +```csharp +public class RepositoryBenchmark +{ + private readonly IRepository _repository; + private readonly int _iterations; + + public RepositoryBenchmark(IRepository repository, int iterations = 1000) + { + _repository = repository; + _iterations = iterations; + } + + public BenchmarkResult RunSaveBenchmark() + where TAggregate : AggregateRoot, new() + { + var stopwatch = Stopwatch.StartNew(); + + for (int i = 0; i < _iterations; i++) + { + var aggregate = new TAggregate(); + + // Generate some events + typeof(TAggregate).GetMethod("Initialize")?.Invoke(aggregate, new object[] { Guid.NewGuid().ToString() }); + + // Save the aggregate + _repository.Save(aggregate); + } + + stopwatch.Stop(); + + return new BenchmarkResult + { + OperationName = $"Save<{typeof(TAggregate).Name}>", + Iterations = _iterations, + TotalTimeMs = stopwatch.ElapsedMilliseconds, + AverageTimeMs = (double)stopwatch.ElapsedMilliseconds / _iterations + }; + } + + public BenchmarkResult RunGetByIdBenchmark(IEnumerable ids) + where TAggregate : class, IEventSource, new() + { + var idArray = ids.ToArray(); + var stopwatch = Stopwatch.StartNew(); + + for (int i = 0; i < _iterations; i++) + { + var id = idArray[i % idArray.Length]; + _repository.TryGetById(id, out var aggregate); + } + + stopwatch.Stop(); + + return new BenchmarkResult + { + OperationName = $"GetById<{typeof(TAggregate).Name}>", + Iterations = _iterations, + TotalTimeMs = stopwatch.ElapsedMilliseconds, + AverageTimeMs = (double)stopwatch.ElapsedMilliseconds / _iterations + }; + } +} + +public class BenchmarkResult +{ + public string OperationName { get; set; } + public int Iterations { get; set; } + public long TotalTimeMs { get; set; } + public double AverageTimeMs { get; set; } + + public override string ToString() + { + return $"{OperationName}: {Iterations} iterations, {TotalTimeMs}ms total, {AverageTimeMs:F2}ms average"; + } +} +``` + +### Load Testing + +Implement load testing for performance validation: + +```csharp +public class CommandLoadTest +{ + private readonly ICommandBus _commandBus; + private readonly int _commandCount; + private readonly int _concurrencyLevel; + + public CommandLoadTest( + ICommandBus commandBus, + int commandCount = 10000, + int concurrencyLevel = 8) + { + _commandBus = commandBus; + _commandCount = commandCount; + _concurrencyLevel = concurrencyLevel; + } + + public async Task RunAsync(Func commandFactory) + where TCommand : class, ICommand + { + var stopwatch = Stopwatch.StartNew(); + var commandsPerTask = _commandCount / _concurrencyLevel; + var tasks = new List(); + + for (int i = 0; i < _concurrencyLevel; i++) + { + var taskId = i; + tasks.Add(Task.Run(() => + { + for (int j = 0; j < commandsPerTask; j++) + { + var commandIndex = (taskId * commandsPerTask) + j; + var command = commandFactory(commandIndex); + _commandBus.Send(command); + } + })); + } + + await Task.WhenAll(tasks); + stopwatch.Stop(); + + return new LoadTestResult + { + TestName = $"CommandLoadTest<{typeof(TCommand).Name}>", + CommandCount = _commandCount, + ConcurrencyLevel = _concurrencyLevel, + TotalTimeMs = stopwatch.ElapsedMilliseconds, + CommandsPerSecond = (double)_commandCount / stopwatch.ElapsedMilliseconds * 1000 + }; + } +} + +public class LoadTestResult +{ + public string TestName { get; set; } + public int CommandCount { get; set; } + public int ConcurrencyLevel { get; set; } + public long TotalTimeMs { get; set; } + public double CommandsPerSecond { get; set; } + + public override string ToString() + { + return $"{TestName}: {CommandCount} commands, {ConcurrencyLevel} threads, {TotalTimeMs}ms total, {CommandsPerSecond:F2} commands/sec"; + } +} +``` + +### Performance Regression Testing + +Implement performance regression testing: + +1. **Baseline Measurements**: Establish performance baselines +2. **Automated Testing**: Automate performance tests in CI/CD pipeline +3. **Regression Detection**: Compare results against baselines to detect regressions + +```csharp +public class PerformanceRegressionTest +{ + private readonly string _baselineFilePath; + private readonly double _regressionThreshold; + + public PerformanceRegressionTest( + string baselineFilePath = "performance-baseline.json", + double regressionThreshold = 0.1) + { + _baselineFilePath = baselineFilePath; + _regressionThreshold = regressionThreshold; + } + + public void SaveBaseline(Dictionary metrics) + { + File.WriteAllText(_baselineFilePath, JsonConvert.SerializeObject(metrics, Formatting.Indented)); + } + + public Dictionary CompareWithBaseline(Dictionary currentMetrics) + { + var baseline = File.Exists(_baselineFilePath) + ? JsonConvert.DeserializeObject>(File.ReadAllText(_baselineFilePath)) + : new Dictionary(); + + var comparisons = new Dictionary(); + + foreach (var metric in currentMetrics) + { + if (baseline.TryGetValue(metric.Key, out var baselineValue)) + { + var percentChange = (metric.Value - baselineValue) / baselineValue; + var isRegression = percentChange > _regressionThreshold; + + comparisons[metric.Key] = new PerformanceComparison + { + MetricName = metric.Key, + BaselineValue = baselineValue, + CurrentValue = metric.Value, + PercentChange = percentChange, + IsRegression = isRegression + }; + } + else + { + comparisons[metric.Key] = new PerformanceComparison + { + MetricName = metric.Key, + BaselineValue = null, + CurrentValue = metric.Value, + PercentChange = null, + IsRegression = false + }; + } + } + + return comparisons; + } +} + +public class PerformanceComparison +{ + public string MetricName { get; set; } + public double? BaselineValue { get; set; } + public double CurrentValue { get; set; } + public double? PercentChange { get; set; } + public bool IsRegression { get; set; } + + public override string ToString() + { + if (BaselineValue.HasValue) + { + var changeDirection = PercentChange > 0 ? "slower" : "faster"; + var changePercent = Math.Abs(PercentChange.Value) * 100; + return $"{MetricName}: {CurrentValue:F2} ({changePercent:F2}% {changeDirection} than baseline {BaselineValue:F2})"; + } + else + { + return $"{MetricName}: {CurrentValue:F2} (no baseline)"; + } + } +} +``` + +[↑ Back to Top](#performance-optimization-guide) | [← Back to Table of Contents](README.md) diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 00000000..1033fc4f --- /dev/null +++ b/docs/security.md @@ -0,0 +1,990 @@ +# Security Guide + +[← Back to Table of Contents](README.md) + +This guide provides best practices and considerations for securing applications built with Reactive Domain. + +## Table of Contents + +- [Authentication and Authorization](#authentication-and-authorization) +- [Data Protection and Privacy](#data-protection-and-privacy) +- [Audit Logging and Compliance](#audit-logging-and-compliance) +- [Secure Deployment Practices](#secure-deployment-practices) +- [Threat Modeling](#threat-modeling) +- [Security Testing Strategies](#security-testing-strategies) + +## Authentication and Authorization + +### Command Authorization + +Implement authorization for commands: + +```csharp +public class AuthorizedCommandBus : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly IAuthorizationService _authorizationService; + private readonly IUserContext _userContext; + + public AuthorizedCommandBus( + ICommandBus innerBus, + IAuthorizationService authorizationService, + IUserContext userContext) + { + _innerBus = innerBus; + _authorizationService = authorizationService; + _userContext = userContext; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + // Authorize the command + if (!_authorizationService.IsAuthorized(_userContext.CurrentUser, command)) + { + throw new UnauthorizedAccessException($"User is not authorized to execute {typeof(TCommand).Name}"); + } + + // Forward to inner bus + _innerBus.Send(command); + } +} +``` + +### Query Authorization + +Implement authorization for queries: + +```csharp +public class AuthorizedQueryBus : IQueryBus +{ + private readonly IQueryBus _innerBus; + private readonly IAuthorizationService _authorizationService; + private readonly IUserContext _userContext; + + public AuthorizedQueryBus( + IQueryBus innerBus, + IAuthorizationService authorizationService, + IUserContext userContext) + { + _innerBus = innerBus; + _authorizationService = authorizationService; + _userContext = userContext; + } + + public TResult Query(TQuery query) where TQuery : class, IQuery + { + // Authorize the query + if (!_authorizationService.IsAuthorized(_userContext.CurrentUser, query)) + { + throw new UnauthorizedAccessException($"User is not authorized to execute {typeof(TQuery).Name}"); + } + + // Forward to inner bus + return _innerBus.Query(query); + } +} +``` + +### Role-Based Access Control + +Implement role-based access control: + +```csharp +public class RoleBasedAuthorizationService : IAuthorizationService +{ + private readonly Dictionary _commandRoles = new Dictionary(); + private readonly Dictionary _queryRoles = new Dictionary(); + + public void RegisterCommandRoles(params string[] roles) where TCommand : ICommand + { + _commandRoles[typeof(TCommand)] = roles; + } + + public void RegisterQueryRoles(params string[] roles) where TQuery : IQuery + { + _queryRoles[typeof(TQuery)] = roles; + } + + public bool IsAuthorized(IUser user, object message) + { + if (message is ICommand command) + { + return IsAuthorizedForCommand(user, command); + } + else if (message is IQuery query) + { + return IsAuthorizedForQuery(user, query); + } + + return false; + } + + private bool IsAuthorizedForCommand(IUser user, ICommand command) + { + if (_commandRoles.TryGetValue(command.GetType(), out var roles)) + { + return roles.Any(role => user.IsInRole(role)); + } + + // By default, deny access if no roles are specified + return false; + } + + private bool IsAuthorizedForQuery(IUser user, IQuery query) + { + if (_queryRoles.TryGetValue(query.GetType(), out var roles)) + { + return roles.Any(role => user.IsInRole(role)); + } + + // By default, deny access if no roles are specified + return false; + } +} +``` + +### Integration with ASP.NET Core Identity + +Integrate with ASP.NET Core Identity: + +```csharp +public class AspNetCoreUserContext : IUserContext +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public AspNetCoreUserContext(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public IUser CurrentUser => new AspNetCoreUser(_httpContextAccessor.HttpContext?.User); +} + +public class AspNetCoreUser : IUser +{ + private readonly ClaimsPrincipal _claimsPrincipal; + + public AspNetCoreUser(ClaimsPrincipal claimsPrincipal) + { + _claimsPrincipal = claimsPrincipal; + } + + public string Id => _claimsPrincipal?.FindFirstValue(ClaimTypes.NameIdentifier); + + public string Name => _claimsPrincipal?.FindFirstValue(ClaimTypes.Name); + + public bool IsAuthenticated => _claimsPrincipal?.Identity?.IsAuthenticated ?? false; + + public bool IsInRole(string role) + { + return _claimsPrincipal?.IsInRole(role) ?? false; + } + + public IEnumerable Roles + { + get + { + return _claimsPrincipal?.Claims + .Where(c => c.Type == ClaimTypes.Role) + .Select(c => c.Value) + .ToList() ?? new List(); + } + } +} +``` + +## Data Protection and Privacy + +### Event Data Encryption + +Encrypt sensitive event data: + +```csharp +public class EncryptingEventSerializer : IEventSerializer +{ + private readonly IEventSerializer _innerSerializer; + private readonly IEncryptionService _encryptionService; + private readonly HashSet _sensitiveEventTypes; + + public EncryptingEventSerializer( + IEventSerializer innerSerializer, + IEncryptionService encryptionService, + IEnumerable sensitiveEventTypes) + { + _innerSerializer = innerSerializer; + _encryptionService = encryptionService; + _sensitiveEventTypes = new HashSet(sensitiveEventTypes); + } + + public object Deserialize(RecordedEvent recordedEvent) + { + // Decrypt event data if necessary + if (IsSensitiveEventType(recordedEvent.EventType)) + { + var decryptedData = _encryptionService.Decrypt(recordedEvent.Data); + var decryptedEvent = new RecordedEvent( + recordedEvent.EventStreamId, + recordedEvent.EventNumber, + recordedEvent.EventId, + recordedEvent.EventType, + decryptedData, + recordedEvent.Metadata, + recordedEvent.IsJson, + recordedEvent.Created); + + return _innerSerializer.Deserialize(decryptedEvent); + } + + return _innerSerializer.Deserialize(recordedEvent); + } + + public IEventData Serialize(object @event, Guid eventId) + { + var eventData = _innerSerializer.Serialize(@event, eventId); + + // Encrypt event data if necessary + if (IsSensitiveEventType(@event.GetType().Name)) + { + var encryptedData = _encryptionService.Encrypt(eventData.Data); + return new EventData( + eventData.EventId, + eventData.Type, + eventData.IsJson, + encryptedData, + eventData.Metadata); + } + + return eventData; + } + + private bool IsSensitiveEventType(string eventType) + { + return _sensitiveEventTypes.Contains(eventType); + } +} +``` + +### Encryption Service + +Implement an encryption service: + +```csharp +public interface IEncryptionService +{ + byte[] Encrypt(byte[] data); + byte[] Decrypt(byte[] encryptedData); +} + +public class AesEncryptionService : IEncryptionService +{ + private readonly byte[] _key; + private readonly byte[] _iv; + + public AesEncryptionService(string keyBase64, string ivBase64) + { + _key = Convert.FromBase64String(keyBase64); + _iv = Convert.FromBase64String(ivBase64); + } + + public byte[] Encrypt(byte[] data) + { + using (var aes = Aes.Create()) + { + aes.Key = _key; + aes.IV = _iv; + + using (var encryptor = aes.CreateEncryptor(aes.Key, aes.IV)) + using (var ms = new MemoryStream()) + { + using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) + { + cs.Write(data, 0, data.Length); + cs.FlushFinalBlock(); + } + + return ms.ToArray(); + } + } + } + + public byte[] Decrypt(byte[] encryptedData) + { + using (var aes = Aes.Create()) + { + aes.Key = _key; + aes.IV = _iv; + + using (var decryptor = aes.CreateDecryptor(aes.Key, aes.IV)) + using (var ms = new MemoryStream(encryptedData)) + using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) + using (var output = new MemoryStream()) + { + cs.CopyTo(output); + return output.ToArray(); + } + } + } +} +``` + +### Personally Identifiable Information (PII) Handling + +Handle PII securely: + +```csharp +public class PiiEvent +{ + public Guid SubjectId { get; } + public PiiData PiiData { get; } + + public PiiEvent(Guid subjectId, PiiData piiData) + { + SubjectId = subjectId; + PiiData = piiData; + } +} + +[Serializable] +public class PiiData +{ + public string Name { get; set; } + public string Email { get; set; } + public string Address { get; set; } + public string PhoneNumber { get; set; } + + // Add other PII fields as needed +} + +public class PiiRepository +{ + private readonly IEncryptionService _encryptionService; + private readonly IDatabase _database; + + public PiiRepository(IEncryptionService encryptionService, IDatabase database) + { + _encryptionService = encryptionService; + _database = database; + } + + public void Store(Guid subjectId, PiiData piiData) + { + // Serialize PII data + var serialized = JsonConvert.SerializeObject(piiData); + var bytes = Encoding.UTF8.GetBytes(serialized); + + // Encrypt PII data + var encrypted = _encryptionService.Encrypt(bytes); + + // Store encrypted data with subject ID + _database.Store(subjectId.ToString(), Convert.ToBase64String(encrypted)); + } + + public PiiData Retrieve(Guid subjectId) + { + // Retrieve encrypted data + var encryptedBase64 = _database.Retrieve(subjectId.ToString()); + + if (string.IsNullOrEmpty(encryptedBase64)) + return null; + + // Decrypt PII data + var encrypted = Convert.FromBase64String(encryptedBase64); + var decrypted = _encryptionService.Decrypt(encrypted); + var serialized = Encoding.UTF8.GetString(decrypted); + + // Deserialize PII data + return JsonConvert.DeserializeObject(serialized); + } + + public void Delete(Guid subjectId) + { + // Delete PII data + _database.Delete(subjectId.ToString()); + } +} +``` + +### Data Masking + +Implement data masking for logs: + +```csharp +public class DataMaskingLogger : ILogger +{ + private readonly ILogger _innerLogger; + private readonly IEnumerable _maskers; + + public DataMaskingLogger(ILogger innerLogger, IEnumerable maskers) + { + _innerLogger = innerLogger; + _maskers = maskers; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception exception, + Func formatter) + { + var message = formatter(state, exception); + var maskedMessage = ApplyMasking(message); + + _innerLogger.Log(logLevel, eventId, state, exception, (s, e) => maskedMessage); + } + + private string ApplyMasking(string message) + { + var result = message; + + foreach (var masker in _maskers) + { + result = masker.Mask(result); + } + + return result; + } + + public bool IsEnabled(LogLevel logLevel) + { + return _innerLogger.IsEnabled(logLevel); + } + + public IDisposable BeginScope(TState state) + { + return _innerLogger.BeginScope(state); + } +} + +public interface IDataMasker +{ + string Mask(string input); +} + +public class CreditCardMasker : IDataMasker +{ + private static readonly Regex CreditCardRegex = new Regex(@"\b(?:\d{4}[ -]?){3}\d{4}\b"); + + public string Mask(string input) + { + return CreditCardRegex.Replace(input, match => + { + var card = match.Value.Replace(" ", "").Replace("-", ""); + return $"{card.Substring(0, 4)}********{card.Substring(12)}"; + }); + } +} + +public class EmailMasker : IDataMasker +{ + private static readonly Regex EmailRegex = new Regex(@"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"); + + public string Mask(string input) + { + return EmailRegex.Replace(input, match => + { + var parts = match.Value.Split('@'); + var username = parts[0]; + var domain = parts[1]; + + var maskedUsername = username.Length <= 2 + ? username + : $"{username[0]}***{username[username.Length - 1]}"; + + return $"{maskedUsername}@{domain}"; + }); + } +} +``` + +## Audit Logging and Compliance + +### Command Audit Logging + +Log all commands for audit purposes: + +```csharp +public class AuditingCommandBus : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly IAuditLogger _auditLogger; + private readonly IUserContext _userContext; + + public AuditingCommandBus( + ICommandBus innerBus, + IAuditLogger auditLogger, + IUserContext userContext) + { + _innerBus = innerBus; + _auditLogger = auditLogger; + _userContext = userContext; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + // Log the command for audit purposes + _auditLogger.LogCommand( + command.GetType().Name, + JsonConvert.SerializeObject(command), + _userContext.CurrentUser?.Id, + DateTime.UtcNow); + + // Forward to inner bus + _innerBus.Send(command); + } +} +``` + +### Event Audit Logging + +Log all events for audit purposes: + +```csharp +public class AuditingEventStore : IEventStore +{ + private readonly IEventStore _innerEventStore; + private readonly IAuditLogger _auditLogger; + + public AuditingEventStore(IEventStore innerEventStore, IAuditLogger auditLogger) + { + _innerEventStore = innerEventStore; + _auditLogger = auditLogger; + } + + public void AppendToStream(string streamName, long expectedVersion, IEnumerable events) + { + // Log events for audit purposes + foreach (var @event in events) + { + _auditLogger.LogEvent( + streamName, + @event.Type, + Convert.ToBase64String(@event.Data), + DateTime.UtcNow); + } + + // Forward to inner event store + _innerEventStore.AppendToStream(streamName, expectedVersion, events); + } + + // ... other methods ... +} +``` + +### Audit Logger Implementation + +Implement an audit logger: + +```csharp +public interface IAuditLogger +{ + void LogCommand(string commandType, string commandData, string userId, DateTime timestamp); + void LogEvent(string streamName, string eventType, string eventData, DateTime timestamp); + void LogQuery(string queryType, string queryData, string userId, DateTime timestamp); +} + +public class DatabaseAuditLogger : IAuditLogger +{ + private readonly IDbConnection _connection; + + public DatabaseAuditLogger(IDbConnection connection) + { + _connection = connection; + } + + public void LogCommand(string commandType, string commandData, string userId, DateTime timestamp) + { + const string sql = @" + INSERT INTO AuditLog (Type, Action, Data, UserId, Timestamp) + VALUES (@Type, @Action, @Data, @UserId, @Timestamp)"; + + _connection.Execute(sql, new + { + Type = "Command", + Action = commandType, + Data = commandData, + UserId = userId, + Timestamp = timestamp + }); + } + + public void LogEvent(string streamName, string eventType, string eventData, DateTime timestamp) + { + const string sql = @" + INSERT INTO AuditLog (Type, Action, StreamName, Data, Timestamp) + VALUES (@Type, @Action, @StreamName, @Data, @Timestamp)"; + + _connection.Execute(sql, new + { + Type = "Event", + Action = eventType, + StreamName = streamName, + Data = eventData, + Timestamp = timestamp + }); + } + + public void LogQuery(string queryType, string queryData, string userId, DateTime timestamp) + { + const string sql = @" + INSERT INTO AuditLog (Type, Action, Data, UserId, Timestamp) + VALUES (@Type, @Action, @Data, @UserId, @Timestamp)"; + + _connection.Execute(sql, new + { + Type = "Query", + Action = queryType, + Data = queryData, + UserId = userId, + Timestamp = timestamp + }); + } +} +``` + +### GDPR Compliance + +Implement GDPR compliance features: + +```csharp +public class GdprService +{ + private readonly IEventStore _eventStore; + private readonly PiiRepository _piiRepository; + + public GdprService(IEventStore eventStore, PiiRepository piiRepository) + { + _eventStore = eventStore; + _piiRepository = piiRepository; + } + + public void DeletePersonalData(Guid subjectId) + { + // Delete PII data + _piiRepository.Delete(subjectId); + + // Anonymize events + AnonymizeEvents(subjectId); + } + + private void AnonymizeEvents(Guid subjectId) + { + // Find all streams related to the subject + var streamName = $"subject-{subjectId}"; + + // Read all events from the stream + var events = _eventStore.ReadStreamForward(streamName, 0, int.MaxValue); + + // Create anonymized events + var anonymizedEvents = events.Select(e => AnonymizeEvent(e)).ToList(); + + // Delete the original stream + _eventStore.DeleteStream(streamName, events.Last().EventNumber); + + // Create a new anonymized stream + var anonymizedStreamName = $"anonymized-{Guid.NewGuid()}"; + _eventStore.AppendToStream(anonymizedStreamName, ExpectedVersion.NoStream, anonymizedEvents); + } + + private IEventData AnonymizeEvent(RecordedEvent @event) + { + // Deserialize the event + var eventData = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(@event.Data)); + + // Anonymize personal data + if (eventData.Name != null) eventData.Name = "REDACTED"; + if (eventData.Email != null) eventData.Email = "REDACTED"; + if (eventData.Address != null) eventData.Address = "REDACTED"; + if (eventData.PhoneNumber != null) eventData.PhoneNumber = "REDACTED"; + + // Serialize the anonymized event + var anonymizedData = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(eventData)); + + // Create a new event data + return new EventData( + @event.EventId, + @event.EventType, + @event.IsJson, + anonymizedData, + @event.Metadata); + } + + public PiiData ExportPersonalData(Guid subjectId) + { + // Retrieve PII data + return _piiRepository.Retrieve(subjectId); + } +} +``` + +## Secure Deployment Practices + +### Secure Configuration + +Implement secure configuration management: + +```csharp +public class SecureConfigurationProvider : IConfigurationProvider +{ + private readonly IConfiguration _configuration; + private readonly IEncryptionService _encryptionService; + + public SecureConfigurationProvider(IConfiguration configuration, IEncryptionService encryptionService) + { + _configuration = configuration; + _encryptionService = encryptionService; + } + + public string GetConnectionString(string name) + { + var encryptedConnectionString = _configuration.GetConnectionString(name); + + if (string.IsNullOrEmpty(encryptedConnectionString)) + return null; + + // Decrypt connection string + var encrypted = Convert.FromBase64String(encryptedConnectionString); + var decrypted = _encryptionService.Decrypt(encrypted); + + return Encoding.UTF8.GetString(decrypted); + } + + public T GetSection(string sectionName) where T : class, new() + { + var section = _configuration.GetSection(sectionName); + + if (!section.Exists()) + return new T(); + + // Bind section to object + var result = new T(); + section.Bind(result); + + return result; + } +} +``` + +### Secret Management + +Implement secure secret management: + +```csharp +public interface ISecretManager +{ + string GetSecret(string secretName); + void SetSecret(string secretName, string secretValue); +} + +public class AzureKeyVaultSecretManager : ISecretManager +{ + private readonly SecretClient _secretClient; + + public AzureKeyVaultSecretManager(string keyVaultUrl, TokenCredential credential) + { + _secretClient = new SecretClient(new Uri(keyVaultUrl), credential); + } + + public string GetSecret(string secretName) + { + var response = _secretClient.GetSecret(secretName); + return response.Value.Value; + } + + public void SetSecret(string secretName, string secretValue) + { + _secretClient.SetSecret(secretName, secretValue); + } +} +``` + +### Secure Communication + +Implement secure communication: + +```csharp +public class SecureStreamStoreConnection : IStreamStoreConnection +{ + private readonly IStreamStoreConnection _innerConnection; + private readonly X509Certificate2 _clientCertificate; + + public SecureStreamStoreConnection( + IStreamStoreConnection innerConnection, + X509Certificate2 clientCertificate) + { + _innerConnection = innerConnection; + _clientCertificate = clientCertificate; + } + + public void Connect() + { + // Configure secure connection + var settings = new ConnectionSettings(); + settings.UseSslConnection(_clientCertificate); + + // Connect with secure settings + _innerConnection.Connect(); + } + + // ... other methods ... +} +``` + +## Threat Modeling + +### Identify Assets + +Identify valuable assets in your system: + +1. **Event Data**: The events stored in the event store +2. **User Data**: Personal information of users +3. **Authentication Credentials**: User credentials and access tokens +4. **Business Logic**: The business rules implemented in aggregates +5. **Configuration**: Sensitive configuration settings + +### Identify Threats + +Identify potential threats to those assets: + +1. **Unauthorized Access**: Attackers gaining access to sensitive data +2. **Data Tampering**: Modification of events or other data +3. **Information Disclosure**: Leakage of sensitive information +4. **Denial of Service**: Attacks that make the system unavailable +5. **Elevation of Privilege**: Gaining higher privileges than authorized + +### Assess Risks + +Assess the risks of each threat: + +1. **Risk = Likelihood × Impact** +2. **Likelihood**: Probability of the threat occurring +3. **Impact**: Potential damage if the threat occurs +4. **Risk Level**: High, Medium, or Low + +### Mitigate Risks + +Implement controls to mitigate risks: + +1. **Preventive Controls**: Prevent threats from occurring +2. **Detective Controls**: Detect when threats occur +3. **Corrective Controls**: Recover from threats that have occurred + +## Security Testing Strategies + +### Static Analysis + +Use static analysis tools to identify security issues: + +1. **Code Scanning**: Use tools like SonarQube or Microsoft Security Code Analysis +2. **Dependency Scanning**: Check for vulnerabilities in dependencies +3. **Secret Scanning**: Detect hardcoded secrets in code + +### Dynamic Analysis + +Use dynamic analysis to identify runtime security issues: + +1. **Penetration Testing**: Simulate attacks to identify vulnerabilities +2. **Fuzzing**: Test with unexpected inputs to find weaknesses +3. **Runtime Monitoring**: Monitor for security events during execution + +### Security Unit Tests + +Write security-focused unit tests: + +```csharp +[Fact] +public void CannotExecuteCommandWithoutAuthorization() +{ + // Arrange + var user = new TestUser { Id = "user1", Roles = new[] { "User" } }; + var userContext = new TestUserContext(user); + + var authorizationService = new RoleBasedAuthorizationService(); + authorizationService.RegisterCommandRoles("Admin"); + + var innerBus = new InMemoryCommandBus(); + var authorizedBus = new AuthorizedCommandBus(innerBus, authorizationService, userContext); + + var command = new CreateAccount(Guid.NewGuid(), "John Doe", 100); + + // Act & Assert + Assert.Throws(() => authorizedBus.Send(command)); +} + +[Fact] +public void CanExecuteCommandWithAuthorization() +{ + // Arrange + var user = new TestUser { Id = "user1", Roles = new[] { "Admin" } }; + var userContext = new TestUserContext(user); + + var authorizationService = new RoleBasedAuthorizationService(); + authorizationService.RegisterCommandRoles("Admin"); + + var innerBus = new InMemoryCommandBus(); + var authorizedBus = new AuthorizedCommandBus(innerBus, authorizationService, userContext); + + var command = new CreateAccount(Guid.NewGuid(), "John Doe", 100); + + // Act + authorizedBus.Send(command); + + // Assert + // No exception thrown +} +``` + +### Security Integration Tests + +Write security-focused integration tests: + +```csharp +[Fact] +public async Task CannotAccessProtectedApiWithoutAuthentication() +{ + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync("/api/accounts"); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); +} + +[Fact] +public async Task CannotAccessAdminApiWithUserRole() +{ + // Arrange + var client = _factory.CreateClient(); + + // Authenticate as user + var token = await GetUserToken(client, "user@example.com", "password"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await client.GetAsync("/api/admin/accounts"); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); +} + +[Fact] +public async Task CanAccessAdminApiWithAdminRole() +{ + // Arrange + var client = _factory.CreateClient(); + + // Authenticate as admin + var token = await GetAdminToken(client, "admin@example.com", "password"); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + var response = await client.GetAsync("/api/admin/accounts"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); +} +``` + +[↑ Back to Top](#security-guide) | [← Back to Table of Contents](README.md) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..9b3af925 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,770 @@ +# Troubleshooting Guide + +[← Back to Table of Contents](README.md) + +This guide addresses common issues and challenges when working with Reactive Domain, providing solutions and workarounds for each problem. + +## Table of Contents + +- [Event Versioning and Schema Evolution](#event-versioning-and-schema-evolution) +- [Handling Concurrency Conflicts](#handling-concurrency-conflicts) +- [Debugging Event-Sourced Systems](#debugging-event-sourced-systems) +- [Performance Issues and Optimization](#performance-issues-and-optimization) +- [Integration Challenges](#integration-challenges) +- [Testing Strategies and Common Issues](#testing-strategies-and-common-issues) +- [Deployment Considerations](#deployment-considerations) +- [Monitoring and Observability](#monitoring-and-observability) + +## Event Versioning and Schema Evolution + +### Problem: Events Need to Change Over Time + +As your system evolves, you'll need to modify your event schemas to add, remove, or change properties. + +### Solution: Event Versioning Strategies + +1. **Backward Compatible Changes** + + When adding new fields to events, make them optional with sensible defaults: + + ```csharp + // Original event + public class CustomerCreated : Event + { + public readonly Guid CustomerId; + public readonly string Name; + + public CustomerCreated(Guid customerId, string name) + { + CustomerId = customerId; + Name = name; + } + } + + // Updated event with backward compatibility + public class CustomerCreated : Event + { + public readonly Guid CustomerId; + public readonly string Name; + public readonly string Email; // New field + + public CustomerCreated(Guid customerId, string name, string email = null) + { + CustomerId = customerId; + Name = name; + Email = email; // Optional with null default + } + } + ``` + +2. **Explicit Versioning** + + Create new event types with version numbers: + + ```csharp + public class CustomerCreatedV1 : Event + { + public readonly Guid CustomerId; + public readonly string Name; + + public CustomerCreatedV1(Guid customerId, string name) + { + CustomerId = customerId; + Name = name; + } + } + + public class CustomerCreatedV2 : Event + { + public readonly Guid CustomerId; + public readonly string Name; + public readonly string Email; + + public CustomerCreatedV2(Guid customerId, string name, string email) + { + CustomerId = customerId; + Name = name; + Email = email; + } + } + ``` + +3. **Event Upcasting** + + Transform old events to new versions during deserialization: + + ```csharp + public class EventUpcastingSerializer : IEventSerializer + { + private readonly IEventSerializer _innerSerializer; + + public EventUpcastingSerializer(IEventSerializer innerSerializer) + { + _innerSerializer = innerSerializer; + } + + public object Deserialize(RecordedEvent recordedEvent) + { + var deserialized = _innerSerializer.Deserialize(recordedEvent); + + // Upcast old event versions to new versions + if (deserialized is CustomerCreatedV1 v1) + { + return new CustomerCreatedV2(v1.CustomerId, v1.Name, null); + } + + return deserialized; + } + + public IEventData Serialize(object @event, Guid eventId) + { + return _innerSerializer.Serialize(@event, eventId); + } + } + ``` + +### Best Practices + +1. **Never Delete Events**: Once events are in production, never delete them. Always maintain backward compatibility. +2. **Design for Evolution**: Anticipate changes and design your events to be extensible. +3. **Use Event Versioning**: Explicitly version your events when making breaking changes. +4. **Document Changes**: Maintain a changelog of event schema changes. +5. **Test Migration Paths**: Ensure that old events can be processed by new code. + +## Handling Concurrency Conflicts + +### Problem: Concurrent Updates to the Same Aggregate + +When multiple processes attempt to update the same aggregate simultaneously, concurrency conflicts can occur. + +### Solution: Optimistic Concurrency Control + +Reactive Domain uses optimistic concurrency control through the `ExpectedVersion` property: + +```csharp +try +{ + var account = repository.GetById(accountId); + account.Withdraw(100); + repository.Save(account); +} +catch (AggregateVersionException ex) +{ + // Handle concurrency conflict + Console.WriteLine($"Concurrency conflict: Expected version {ex.ExpectedVersion}, but was {ex.ActualVersion}"); +} +``` + +### Retry Pattern + +Implement a retry pattern for handling concurrency conflicts: + +```csharp +public void WithdrawWithRetry(Guid accountId, decimal amount, int maxRetries = 3) +{ + int retries = 0; + while (true) + { + try + { + var account = repository.GetById(accountId); + account.Withdraw(amount); + repository.Save(account); + return; // Success + } + catch (AggregateVersionException) + { + if (++retries > maxRetries) + throw; // Give up after max retries + + // Exponential backoff + Thread.Sleep(100 * (int)Math.Pow(2, retries - 1)); + } + } +} +``` + +### Command Queueing + +For high-contention aggregates, consider using a command queue to serialize updates: + +```csharp +public class CommandQueue where TAggregate : AggregateRoot +{ + private readonly IRepository _repository; + private readonly ConcurrentDictionary _locks = new ConcurrentDictionary(); + + public CommandQueue(IRepository repository) + { + _repository = repository; + } + + public async Task Execute(Guid aggregateId, Action command) + { + var lockObj = _locks.GetOrAdd(aggregateId, _ => new SemaphoreSlim(1, 1)); + + await lockObj.WaitAsync(); + try + { + var aggregate = _repository.GetById(aggregateId); + command(aggregate); + _repository.Save(aggregate); + } + finally + { + lockObj.Release(); + } + } +} +``` + +### Best Practices + +1. **Minimize Aggregate Size**: Keep aggregates small to reduce contention. +2. **Use Retry Logic**: Implement retry logic for handling concurrency conflicts. +3. **Consider Command Queueing**: For high-contention aggregates, use command queueing. +4. **Monitor Conflicts**: Track and monitor concurrency conflicts to identify hotspots. +5. **Design for Concurrency**: Design your domain model to minimize contention. + +## Debugging Event-Sourced Systems + +### Problem: Difficult to Debug Event-Sourced Systems + +Event-sourced systems can be challenging to debug due to their asynchronous and event-driven nature. + +### Solution: Event Store Exploration + +Use EventStoreDB's admin UI to explore events: + +1. Navigate to `http://localhost:2113` (default EventStoreDB admin UI) +2. Log in with default credentials (admin/changeit) +3. Browse streams and inspect events + +### Event Logging + +Add comprehensive logging for events: + +```csharp +public class LoggingEventStore : IEventStore +{ + private readonly IEventStore _innerEventStore; + private readonly ILogger _logger; + + public LoggingEventStore(IEventStore innerEventStore, ILogger logger) + { + _innerEventStore = innerEventStore; + _logger = logger; + } + + public void AppendToStream(string streamName, long expectedVersion, IEnumerable events) + { + _logger.LogInformation($"Appending to stream {streamName} at version {expectedVersion}"); + foreach (var @event in events) + { + _logger.LogInformation($"Event: {JsonConvert.SerializeObject(@event)}"); + } + + _innerEventStore.AppendToStream(streamName, expectedVersion, events); + } + + // Implement other methods similarly +} +``` + +### Event Replay Tool + +Create a tool to replay events for debugging: + +```csharp +public class EventReplayTool +{ + private readonly IStreamStoreConnection _connection; + private readonly IEventSerializer _serializer; + + public EventReplayTool(IStreamStoreConnection connection, IEventSerializer serializer) + { + _connection = connection; + _serializer = serializer; + } + + public void ReplayEvents(Guid aggregateId) where TAggregate : AggregateRoot, new() + { + var streamName = $"aggregate-{typeof(TAggregate).Name.ToLower()}-{aggregateId}"; + var events = ReadAllEvents(streamName); + + Console.WriteLine($"Replaying {events.Count} events for {typeof(TAggregate).Name} {aggregateId}"); + + var aggregate = new TAggregate(); + foreach (var @event in events) + { + Console.WriteLine($"Applying event: {@event.GetType().Name}"); + try + { + aggregate.RestoreFromEvents(new[] { @event }); + Console.WriteLine("Event applied successfully"); + } + catch (Exception ex) + { + Console.WriteLine($"Error applying event: {ex.Message}"); + } + } + } + + private List ReadAllEvents(string streamName) + { + var events = new List(); + var sliceStart = 0L; + const int sliceCount = 100; + + while (true) + { + var slice = _connection.ReadStreamForward(streamName, sliceStart, sliceCount); + + if (slice is StreamNotFoundSlice || slice is StreamDeletedSlice) + break; + + foreach (var @event in slice.Events) + { + events.Add(_serializer.Deserialize(@event)); + } + + if (slice.IsEndOfStream) + break; + + sliceStart = slice.NextEventNumber; + } + + return events; + } +} +``` + +### Best Practices + +1. **Comprehensive Logging**: Log all events and commands with correlation IDs. +2. **Use Event Store UI**: Leverage EventStoreDB's admin UI for exploring events. +3. **Create Debugging Tools**: Build tools for replaying and inspecting events. +4. **Monitor Event Flows**: Use distributed tracing to monitor event flows. +5. **Test with Real Events**: Use real event sequences in tests for debugging. + +## Performance Issues and Optimization + +### Problem: Slow Aggregate Loading + +Loading aggregates with many events can be slow. + +### Solution: Snapshots + +Implement snapshots to optimize loading: + +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + private decimal _balance; + + // ... existing code ... + + public void RestoreFromSnapshot(object snapshot) + { + var accountSnapshot = (AccountSnapshot)snapshot; + _balance = accountSnapshot.Balance; + ExpectedVersion = accountSnapshot.Version; + } + + public object TakeSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + Version = ExpectedVersion + }; + } +} + +public class AccountSnapshot +{ + public decimal Balance { get; set; } + public long Version { get; set; } +} +``` + +### Snapshot Repository + +Create a repository that uses snapshots: + +```csharp +public class SnapshotRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly ISnapshotStore _snapshotStore; + + public SnapshotRepository(IRepository innerRepository, ISnapshotStore snapshotStore) + { + _innerRepository = innerRepository; + _snapshotStore = snapshotStore; + } + + public TAggregate GetById(Guid id, int version = int.MaxValue) where TAggregate : class, IEventSource + { + if (typeof(ISnapshotSource).IsAssignableFrom(typeof(TAggregate))) + { + // Try to load from snapshot + var snapshot = _snapshotStore.GetSnapshot(id, typeof(TAggregate)); + if (snapshot != null) + { + var aggregate = (TAggregate)Activator.CreateInstance(typeof(TAggregate), id); + ((ISnapshotSource)aggregate).RestoreFromSnapshot(snapshot.Data); + + // Load events after the snapshot + _innerRepository.Update(ref aggregate, version); + return aggregate; + } + } + + // Fall back to loading all events + return _innerRepository.GetById(id, version); + } + + public void Save(IEventSource aggregate) + { + _innerRepository.Save(aggregate); + + // Take a snapshot if the aggregate supports it + if (aggregate is ISnapshotSource snapshotSource) + { + var snapshot = snapshotSource.TakeSnapshot(); + _snapshotStore.SaveSnapshot(aggregate.Id, aggregate.GetType(), snapshot, aggregate.ExpectedVersion); + } + } + + // Implement other methods +} +``` + +### Problem: Slow Queries + +Read models can become slow as they grow. + +### Solution: Optimized Read Models + +Create specialized read models for specific query patterns: + +```csharp +public class AccountBalanceReadModel +{ + private readonly Dictionary _balances = new Dictionary(); + + public void Handle(AmountDeposited @event) + { + if (!_balances.TryGetValue(@event.AccountId, out var balance)) + balance = 0; + + _balances[@event.AccountId] = balance + @event.Amount; + } + + public void Handle(AmountWithdrawn @event) + { + if (!_balances.TryGetValue(@event.AccountId, out var balance)) + throw new InvalidOperationException("Account not found"); + + _balances[@event.AccountId] = balance - @event.Amount; + } + + public decimal GetBalance(Guid accountId) + { + return _balances.TryGetValue(accountId, out var balance) ? balance : 0; + } +} +``` + +### Best Practices + +1. **Use Snapshots**: Implement snapshots for aggregates with many events. +2. **Optimize Read Models**: Create specialized read models for specific query patterns. +3. **Batch Operations**: Batch operations where possible to reduce overhead. +4. **Use Caching**: Cache aggregates and read models to reduce database load. +5. **Monitor Performance**: Use performance monitoring tools to identify bottlenecks. + +## Integration Challenges + +### Problem: Integrating with External Systems + +Integrating event-sourced systems with external systems can be challenging. + +### Solution: Integration Events + +Use integration events to communicate with external systems: + +```csharp +public class AccountIntegrationEventHandler : IEventHandler, IEventHandler +{ + private readonly IExternalSystem _externalSystem; + + public AccountIntegrationEventHandler(IExternalSystem externalSystem) + { + _externalSystem = externalSystem; + } + + public void Handle(AmountDeposited @event) + { + _externalSystem.NotifyDeposit(@event.AccountId, @event.Amount); + } + + public void Handle(AmountWithdrawn @event) + { + _externalSystem.NotifyWithdrawal(@event.AccountId, @event.Amount); + } +} +``` + +### Outbox Pattern + +Use the outbox pattern to ensure reliable integration: + +```csharp +public class OutboxRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly IOutbox _outbox; + + public OutboxRepository(IRepository innerRepository, IOutbox outbox) + { + _innerRepository = innerRepository; + _outbox = outbox; + } + + public void Save(IEventSource aggregate) + { + using (var transaction = new TransactionScope()) + { + _innerRepository.Save(aggregate); + + // Add integration events to the outbox + foreach (var @event in aggregate.TakeEvents()) + { + _outbox.Add(new OutboxMessage + { + Id = Guid.NewGuid(), + AggregateId = aggregate.Id, + AggregateType = aggregate.GetType().Name, + EventType = @event.GetType().Name, + EventData = JsonConvert.SerializeObject(@event), + CreatedAt = DateTime.UtcNow + }); + } + + transaction.Complete(); + } + } + + // Implement other methods +} +``` + +### Best Practices + +1. **Use Integration Events**: Create specific events for integration purposes. +2. **Implement the Outbox Pattern**: Use the outbox pattern for reliable integration. +3. **Decouple Systems**: Keep external systems decoupled from your domain model. +4. **Use Message Brokers**: Consider using message brokers for asynchronous integration. +5. **Implement Idempotence**: Ensure that integration operations are idempotent. + +## Testing Strategies and Common Issues + +### Problem: Testing Event-Sourced Systems + +Testing event-sourced systems requires different approaches than traditional systems. + +### Solution: Event-Based Testing + +Test aggregates by verifying the events they produce: + +```csharp +[Fact] +public void CanDepositMoney() +{ + // Arrange + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + + // Act + account.Deposit(100); + + // Assert + var events = ((IEventSource)account).TakeEvents(); + Assert.Single(events); + var @event = Assert.IsType(events[0]); + Assert.Equal(accountId, @event.AccountId); + Assert.Equal(100, @event.Amount); +} +``` + +### Given-When-Then Testing + +Use the Given-When-Then pattern for testing: + +```csharp +[Fact] +public void GivenAnAccount_WhenDepositing_ThenBalanceIncreases() +{ + // Given + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + + // When + account.Deposit(100); + + // Then + Assert.Equal(100, account.GetBalance()); +} +``` + +### Testing with Mock Event Store + +Use a mock event store for testing: + +```csharp +[Fact] +public void CanSaveAndLoadAggregate() +{ + // Arrange + var accountId = Guid.NewGuid(); + var mockStore = new MockStreamStoreConnection("testRepo"); + mockStore.Connect(); + var repository = new StreamStoreRepository(new PrefixedCamelCaseStreamNameBuilder(), mockStore, new JsonMessageSerializer()); + + // Act + var account = new Account(accountId); + account.Deposit(100); + repository.Save(account); + + var loadedAccount = repository.GetById(accountId); + + // Assert + Assert.Equal(100, loadedAccount.GetBalance()); +} +``` + +### Best Practices + +1. **Test Events, Not State**: Focus on testing the events produced by aggregates. +2. **Use Given-When-Then**: Structure tests using the Given-When-Then pattern. +3. **Mock Event Store**: Use a mock event store for testing. +4. **Test Projections**: Test projections by feeding them events. +5. **Test Integration**: Test integration with external systems using mocks. + +## Deployment Considerations + +### Problem: Deploying Event-Sourced Systems + +Deploying event-sourced systems requires careful consideration of event schema evolution. + +### Solution: Backward Compatibility + +Ensure backward compatibility when deploying new versions: + +1. **Never Delete Events**: Once events are in production, never delete them. +2. **Add Fields, Don't Remove**: When modifying events, add new fields rather than removing existing ones. +3. **Provide Defaults**: When adding new fields, provide sensible defaults. + +### Versioned Deployments + +Use versioned deployments to manage event schema evolution: + +1. **Deploy Read Side First**: Deploy read-side changes before write-side changes. +2. **Use Feature Flags**: Use feature flags to control the activation of new features. +3. **Implement Backward Compatibility**: Ensure that new code can process old events. + +### Best Practices + +1. **Automate Deployments**: Use automated deployment pipelines. +2. **Test Migrations**: Test event schema migrations before deployment. +3. **Monitor Deployments**: Monitor deployments for errors and performance issues. +4. **Have Rollback Plans**: Prepare rollback plans for failed deployments. +5. **Document Changes**: Document all changes to event schemas. + +## Monitoring and Observability + +### Problem: Monitoring Event-Sourced Systems + +Event-sourced systems can be challenging to monitor due to their asynchronous nature. + +### Solution: Comprehensive Monitoring + +Implement comprehensive monitoring: + +```csharp +public class MonitoredRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly IMetrics _metrics; + + public MonitoredRepository(IRepository innerRepository, IMetrics metrics) + { + _innerRepository = innerRepository; + _metrics = metrics; + } + + public TAggregate GetById(Guid id, int version = int.MaxValue) where TAggregate : class, IEventSource + { + var timer = _metrics.StartTimer($"repository.getbyid.{typeof(TAggregate).Name}"); + try + { + return _innerRepository.GetById(id, version); + } + catch (Exception ex) + { + _metrics.IncrementCounter($"repository.getbyid.{typeof(TAggregate).Name}.error"); + throw; + } + finally + { + timer.Stop(); + } + } + + // Implement other methods similarly +} +``` + +### Distributed Tracing + +Use distributed tracing to monitor event flows: + +```csharp +public class TracingEventHandler : IEventHandler +{ + private readonly IEventHandler _innerHandler; + private readonly ITracer _tracer; + + public TracingEventHandler(IEventHandler innerHandler, ITracer tracer) + { + _innerHandler = innerHandler; + _tracer = tracer; + } + + public void Handle(TEvent @event) + { + using (var scope = _tracer.StartActiveSpan($"handle.{typeof(TEvent).Name}")) + { + if (@event is ICorrelatedMessage correlatedMessage) + { + scope.SetTag("correlation_id", correlatedMessage.CorrelationId.ToString()); + scope.SetTag("causation_id", correlatedMessage.CausationId.ToString()); + } + + _innerHandler.Handle(@event); + } + } +} +``` + +### Best Practices + +1. **Monitor Event Flows**: Use distributed tracing to monitor event flows. +2. **Track Metrics**: Track key metrics like event processing rates and latencies. +3. **Set Up Alerts**: Set up alerts for abnormal conditions. +4. **Log Key Events**: Log key events and errors with correlation IDs. +5. **Implement Health Checks**: Implement health checks for all components. + +[↑ Back to Top](#troubleshooting-guide) | [← Back to Table of Contents](README.md) diff --git a/docs/usage-patterns.md b/docs/usage-patterns.md new file mode 100644 index 00000000..4fb81dc9 --- /dev/null +++ b/docs/usage-patterns.md @@ -0,0 +1,789 @@ +# Usage Patterns for Reactive Domain + +[← Back to Table of Contents](README.md) + +This document outlines common usage patterns and best practices for working with the Reactive Domain library. These patterns will help you implement event sourcing effectively in your applications. + +## Table of Contents + +- [Setting Up a New Reactive Domain Project](#setting-up-a-new-reactive-domain-project) +- [Creating and Working with Aggregates](#creating-and-working-with-aggregates) +- [Implementing Commands and Events](#implementing-commands-and-events) +- [Setting Up Repositories and Event Stores](#setting-up-repositories-and-event-stores) +- [Implementing Projections and Read Models](#implementing-projections-and-read-models) +- [Handling Concurrency and Versioning](#handling-concurrency-and-versioning) +- [Error Handling and Recovery Strategies](#error-handling-and-recovery-strategies) +- [Testing Event-Sourced Systems](#testing-event-sourced-systems) +- [Performance Optimization Techniques](#performance-optimization-techniques) +- [Integration with Other Systems and Frameworks](#integration-with-other-systems-and-frameworks) +- [Conclusion](#conclusion) + +## Setting Up a New Reactive Domain Project + +### Project Structure + +A typical Reactive Domain project consists of the following components: + +``` +MyProject/ +├── Domain/ +│ ├── Aggregates/ +│ ├── Commands/ +│ ├── Events/ +│ └── ValueObjects/ +├── Application/ +│ ├── CommandHandlers/ +│ ├── EventHandlers/ +│ └── Services/ +├── Infrastructure/ +│ ├── Repositories/ +│ ├── Projections/ +│ └── ReadModels/ +└── API/ + ├── Controllers/ + └── DTOs/ +``` + +### NuGet Packages + +Add the following NuGet packages to your project: + +```xml + + +``` + +### Bootstrapping + +Set up the event store connection and repositories in your application startup: + +```csharp +// Create an event store connection +var connectionSettings = ConnectionSettings.Create() + .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")) + .Build(); +var eventStoreConnection = new StreamStoreConnection("MyApp", connectionSettings, "localhost", 1113); +eventStoreConnection.Connect(); + +// Create a repository +var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder(); +var serializer = new JsonMessageSerializer(); +var repository = new StreamStoreRepository(streamNameBuilder, eventStoreConnection, serializer); + +// Create a correlated repository (optional) +var correlatedRepository = new CorrelatedStreamStoreRepository(repository); + +// Register repositories in your DI container +services.AddSingleton(repository); +services.AddSingleton(correlatedRepository); +``` + +## Creating and Working with Aggregates + +### Defining an Aggregate + +Aggregates are the primary entities in your domain. They encapsulate state and behavior, and they're the source of events. + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + + // Constructor for creating a new account + public Account(Guid id) : base(id) + { + } + + // Constructor for creating a new account with correlation + public Account(Guid id, ICorrelatedMessage source) : base(id, source) + { + } + + // Constructor for restoring an account from events + protected Account(Guid id, IEnumerable events) : base(id, events) + { + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + RaiseEvent(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new AmountWithdrawn(Id, amount)); + } + + public decimal GetBalance() + { + return _balance; + } + + private void Apply(AmountDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(AmountWithdrawn @event) + { + _balance -= @event.Amount; + } +} +``` + +### Aggregate Design Principles + +1. **Single Responsibility**: Each aggregate should represent a single concept in your domain. +2. **Encapsulation**: Aggregates should encapsulate their state and behavior. +3. **Consistency Boundaries**: Aggregates define consistency boundaries. Changes within an aggregate are atomic. +4. **Identity**: Each aggregate has a unique identity. +5. **Event-Driven**: Aggregates raise events to represent changes in state. +6. **Command Validation**: Aggregates validate commands before raising events. +7. **Event Application**: Aggregates apply events to update their state. + +## Implementing Commands and Events + +### Defining Commands + +Commands represent requests to change the state of the system. They should be named in the imperative form. + +```csharp +public class CreateAccount : Command +{ + public readonly Guid AccountId; + + public CreateAccount(Guid accountId) + { + AccountId = accountId; + } +} + +public class DepositMoney : Command +{ + public readonly Guid AccountId; + public readonly decimal Amount; + + public DepositMoney(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } +} + +public class WithdrawMoney : Command +{ + public readonly Guid AccountId; + public readonly decimal Amount; + + public WithdrawMoney(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } +} +``` + +### Defining Events + +Events represent something that happened in the system. They should be named in the past tense. + +```csharp +public class AccountCreated : Event +{ + public readonly Guid AccountId; + + public AccountCreated(Guid accountId) + { + AccountId = accountId; + } +} + +public class AmountDeposited : Event +{ + public readonly Guid AccountId; + public readonly decimal Amount; + + public AmountDeposited(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } +} + +public class AmountWithdrawn : Event +{ + public readonly Guid AccountId; + public readonly decimal Amount; + + public AmountWithdrawn(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } +} +``` + +### Command and Event Design Principles + +1. **Immutability**: Commands and events should be immutable. +2. **Intent-Revealing Names**: Use names that clearly express intent. +3. **Complete Information**: Include all information needed to process the command or understand the event. +4. **Validation**: Validate commands before processing them. +5. **Serialization**: Ensure commands and events can be serialized and deserialized. + +## Setting Up Repositories and Event Stores + +### Using the Repository + +The repository provides methods for loading and saving aggregates. + +```csharp +// Load an aggregate +var account = repository.GetById(accountId); + +// Save an aggregate +repository.Save(account); + +// Delete an aggregate +repository.Delete(account); + +// Hard delete an aggregate (use with caution) +repository.HardDelete(account); +``` + +### Using the Correlated Repository + +The correlated repository ensures that correlation and causation IDs are properly propagated. + +```csharp +// Load an aggregate with correlation +var account = correlatedRepository.GetById(accountId, sourceMessage); + +// Save an aggregate +correlatedRepository.Save(account); +``` + +### Repository and Event Store Design Principles + +1. **Abstraction**: Use the repository abstraction to hide the details of event storage. +2. **Optimistic Concurrency**: Use optimistic concurrency control to prevent conflicts. +3. **Correlation**: Use the correlated repository to track correlation and causation. +4. **Stream Naming**: Use a consistent stream naming convention. +5. **Serialization**: Use a consistent serialization format for events. + +## Implementing Projections and Read Models + +### Defining a Projection + +Projections transform events into read models. + +```csharp +public class AccountBalanceProjection : IEventHandler, IEventHandler +{ + private readonly IReadModelRepository _repository; + + public AccountBalanceProjection(IReadModelRepository repository) + { + _repository = repository; + } + + public void Handle(AmountDeposited @event) + { + var accountBalance = _repository.GetById(@event.AccountId) ?? new AccountBalance(@event.AccountId); + accountBalance.Balance += @event.Amount; + _repository.Save(accountBalance); + } + + public void Handle(AmountWithdrawn @event) + { + var accountBalance = _repository.GetById(@event.AccountId); + if (accountBalance == null) + throw new InvalidOperationException("Account not found"); + + accountBalance.Balance -= @event.Amount; + _repository.Save(accountBalance); + } +} +``` + +### Defining a Read Model + +Read models represent the state of the system for querying. + +```csharp +public class AccountBalance +{ + public Guid Id { get; } + public decimal Balance { get; set; } + + public AccountBalance(Guid id) + { + Id = id; + Balance = 0; + } +} +``` + +### Projection and Read Model Design Principles + +1. **Separation of Concerns**: Separate read models from write models. +2. **Denormalization**: Denormalize data for efficient querying. +3. **Eventual Consistency**: Accept that read models may be eventually consistent. +4. **Idempotence**: Ensure projections are idempotent. +5. **Rebuilding**: Design projections to be rebuildable from the event stream. + +## Handling Concurrency and Versioning + +### Optimistic Concurrency Control + +Reactive Domain uses optimistic concurrency control to prevent conflicts. + +```csharp +try +{ + var account = repository.GetById(accountId); + account.Withdraw(amount); + repository.Save(account); +} +catch (AggregateVersionException ex) +{ + // Handle concurrency conflict + // Typically, you would reload the aggregate and retry the operation +} +``` + +### Event Versioning + +As your system evolves, you may need to version your events. + +```csharp +// Version 1 of the event +public class AmountDeposited : Event +{ + public readonly Guid AccountId; + public readonly decimal Amount; + + public AmountDeposited(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } +} + +// Version 2 of the event +public class AmountDepositedV2 : Event +{ + public readonly Guid AccountId; + public readonly decimal Amount; + public readonly string Currency; + + public AmountDepositedV2(Guid accountId, decimal amount, string currency) + { + AccountId = accountId; + Amount = amount; + Currency = currency; + } +} +``` + +### Handling Event Versioning in Aggregates + +```csharp +private void Apply(AmountDeposited @event) +{ + _balance += @event.Amount; +} + +private void Apply(AmountDepositedV2 @event) +{ + // Convert to the account's currency if necessary + _balance += @event.Amount; +} +``` + +### Concurrency and Versioning Design Principles + +1. **Optimistic Concurrency**: Use optimistic concurrency control to prevent conflicts. +2. **Event Upcasting**: Use event upcasting to handle event versioning. +3. **Backward Compatibility**: Maintain backward compatibility for events. +4. **Forward Compatibility**: Design for forward compatibility where possible. +5. **Version Tracking**: Track event versions explicitly. + +## Error Handling and Recovery Strategies + +### Command Validation + +Validate commands before processing them to prevent errors. + +```csharp +public class DepositMoneyValidator : IValidator +{ + public ValidationResult Validate(DepositMoney command) + { + if (command.Amount <= 0) + return ValidationResult.Error("Amount must be positive"); + + return ValidationResult.Success(); + } +} +``` + +### Exception Handling + +Handle exceptions appropriately in command handlers. + +```csharp +public class AccountCommandHandler : ICommandHandler +{ + private readonly IRepository _repository; + + public AccountCommandHandler(IRepository repository) + { + _repository = repository; + } + + public void Handle(DepositMoney command) + { + try + { + var account = _repository.GetById(command.AccountId); + account.Deposit(command.Amount); + _repository.Save(account); + } + catch (AggregateNotFoundException) + { + // Handle the case where the account doesn't exist + throw new CommandHandlingException("Account not found"); + } + catch (AggregateVersionException) + { + // Handle concurrency conflict + throw new CommandHandlingException("Concurrency conflict"); + } + catch (Exception ex) + { + // Handle other exceptions + throw new CommandHandlingException("An error occurred", ex); + } + } +} +``` + +### Retry Strategies + +Implement retry strategies for transient errors. + +```csharp +public void HandleWithRetry(DepositMoney command, int maxRetries = 3) +{ + int retries = 0; + while (true) + { + try + { + Handle(command); + return; + } + catch (AggregateVersionException) + { + if (++retries > maxRetries) + throw; + + // Wait before retrying + Thread.Sleep(100 * retries); + } + } +} +``` + +### Error Handling and Recovery Design Principles + +1. **Validation**: Validate commands before processing them. +2. **Exception Handling**: Handle exceptions appropriately. +3. **Retry Strategies**: Implement retry strategies for transient errors. +4. **Logging**: Log errors for troubleshooting. +5. **Compensation**: Implement compensation logic for failed operations. + +## Testing Event-Sourced Systems + +### Unit Testing Aggregates + +Use the `ReactiveDomain.Testing` package to unit test aggregates. + +```csharp +[Fact] +public void CanDepositMoney() +{ + // Arrange + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + + // Act + account.Deposit(100); + + // Assert + var events = ((IEventSource)account).TakeEvents(); + Assert.Single(events); + var @event = Assert.IsType(events[0]); + Assert.Equal(accountId, @event.AccountId); + Assert.Equal(100, @event.Amount); + Assert.Equal(100, account.GetBalance()); +} +``` + +### Integration Testing with In-Memory Event Store + +Use the `MockStreamStoreConnection` for integration testing. + +```csharp +[Fact] +public void CanSaveAndLoadAggregate() +{ + // Arrange + var accountId = Guid.NewGuid(); + var mockStore = new MockStreamStoreConnection("testRepo"); + mockStore.Connect(); + var repository = new StreamStoreRepository(new PrefixedCamelCaseStreamNameBuilder(), mockStore, new JsonMessageSerializer()); + + // Act + var account = new Account(accountId); + account.Deposit(100); + repository.Save(account); + + var loadedAccount = repository.GetById(accountId); + + // Assert + Assert.Equal(100, loadedAccount.GetBalance()); +} +``` + +### Testing Projections + +Test projections by feeding them events and verifying the read model. + +```csharp +[Fact] +public void ProjectionUpdatesReadModel() +{ + // Arrange + var accountId = Guid.NewGuid(); + var readModelRepository = new InMemoryReadModelRepository(); + var projection = new AccountBalanceProjection(readModelRepository); + + // Act + projection.Handle(new AmountDeposited(accountId, 100)); + projection.Handle(new AmountDeposited(accountId, 50)); + projection.Handle(new AmountWithdrawn(accountId, 30)); + + // Assert + var accountBalance = readModelRepository.GetById(accountId); + Assert.NotNull(accountBalance); + Assert.Equal(120, accountBalance.Balance); +} +``` + +### Testing Design Principles + +1. **Isolation**: Test aggregates in isolation. +2. **Event Verification**: Verify that the correct events are raised. +3. **State Verification**: Verify that the state is updated correctly. +4. **In-Memory Testing**: Use in-memory event stores for testing. +5. **Projection Testing**: Test projections by feeding them events. + +## Performance Optimization Techniques + +### Snapshots + +Use snapshots to improve loading performance for aggregates with many events. + +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + // ... existing code ... + + public void RestoreFromSnapshot(object snapshot) + { + var accountSnapshot = (AccountSnapshot)snapshot; + _balance = accountSnapshot.Balance; + ExpectedVersion = accountSnapshot.Version; + } + + public object TakeSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + Version = ExpectedVersion + }; + } +} + +public class AccountSnapshot +{ + public decimal Balance { get; set; } + public long Version { get; set; } +} +``` + +### Read Model Optimization + +Optimize read models for the queries they need to support. + +```csharp +public class AccountBalanceReadModel +{ + public Dictionary AccountBalances { get; } = new Dictionary(); + + public void Handle(AmountDeposited @event) + { + if (!AccountBalances.TryGetValue(@event.AccountId, out var balance)) + balance = 0; + + AccountBalances[@event.AccountId] = balance + @event.Amount; + } + + public void Handle(AmountWithdrawn @event) + { + if (!AccountBalances.TryGetValue(@event.AccountId, out var balance)) + throw new InvalidOperationException("Account not found"); + + AccountBalances[@event.AccountId] = balance - @event.Amount; + } +} +``` + +### Performance Optimization Design Principles + +1. **Snapshots**: Use snapshots for aggregates with many events. +2. **Read Model Optimization**: Optimize read models for the queries they need to support. +3. **Caching**: Use caching to improve performance. +4. **Batching**: Batch operations where possible. +5. **Asynchronous Processing**: Use asynchronous processing for non-critical operations. + +## Integration with Other Systems and Frameworks + +### ASP.NET Core Integration + +Integrate Reactive Domain with ASP.NET Core. + +```csharp +public class AccountsController : ControllerBase +{ + private readonly ICommandBus _commandBus; + private readonly IReadModelRepository _readModelRepository; + + public AccountsController(ICommandBus commandBus, IReadModelRepository readModelRepository) + { + _commandBus = commandBus; + _readModelRepository = readModelRepository; + } + + [HttpPost] + public IActionResult CreateAccount() + { + var accountId = Guid.NewGuid(); + _commandBus.Send(new CreateAccount(accountId)); + return CreatedAtAction(nameof(GetAccount), new { id = accountId }, null); + } + + [HttpPost("{id}/deposit")] + public IActionResult Deposit(Guid id, [FromBody] DepositRequest request) + { + _commandBus.Send(new DepositMoney(id, request.Amount)); + return NoContent(); + } + + [HttpPost("{id}/withdraw")] + public IActionResult Withdraw(Guid id, [FromBody] WithdrawRequest request) + { + _commandBus.Send(new WithdrawMoney(id, request.Amount)); + return NoContent(); + } + + [HttpGet("{id}")] + public IActionResult GetAccount(Guid id) + { + var accountBalance = _readModelRepository.GetById(id); + if (accountBalance == null) + return NotFound(); + + return Ok(new AccountResponse + { + Id = accountBalance.Id, + Balance = accountBalance.Balance + }); + } +} + +public class DepositRequest +{ + public decimal Amount { get; set; } +} + +public class WithdrawRequest +{ + public decimal Amount { get; set; } +} + +public class AccountResponse +{ + public Guid Id { get; set; } + public decimal Balance { get; set; } +} +``` + +### Integration with Other Event Sourcing Systems + +Integrate Reactive Domain with other event sourcing systems. + +```csharp +public class ExternalEventAdapter : IEventHandler +{ + private readonly ICommandBus _commandBus; + + public ExternalEventAdapter(ICommandBus commandBus) + { + _commandBus = commandBus; + } + + public void Handle(ExternalEvent @event) + { + // Map the external event to a command + var command = MapToCommand(@event); + + // Send the command + _commandBus.Send(command); + } + + private ICommand MapToCommand(ExternalEvent @event) + { + // Map the external event to a command + // ... + } +} +``` + +### Integration Design Principles + +1. **Loose Coupling**: Use loose coupling to integrate with other systems. +2. **Message-Based Integration**: Use message-based integration where possible. +3. **Adapters**: Use adapters to translate between different message formats. +4. **Idempotence**: Ensure idempotent processing of messages. +5. **Correlation**: Use correlation IDs to track messages across systems. + +## Conclusion + +These usage patterns provide a foundation for implementing event sourcing with Reactive Domain. By following these patterns and best practices, you can build robust, scalable, and maintainable event-sourced applications. + +For more detailed information on specific components, see the [Component Documentation](components/README.md) section. For code examples, see the [Code Examples](code-examples/README.md) section. + +[↑ Back to Top](#usage-patterns-for-reactive-domain) | [← Back to Table of Contents](README.md) diff --git a/docs/video-tutorial-script.md b/docs/video-tutorial-script.md new file mode 100644 index 00000000..a41cf9a5 --- /dev/null +++ b/docs/video-tutorial-script.md @@ -0,0 +1,626 @@ +# Video Tutorial Script + +[← Back to Table of Contents](README.md) + +This document provides scripts and outlines for creating video tutorials about the Reactive Domain library. + +## Tutorial Series Overview + +A comprehensive series of video tutorials covering Reactive Domain from basics to advanced topics: + +1. **Introduction to Reactive Domain and Event Sourcing** (15 minutes) +2. **Setting Up Your First Reactive Domain Project** (20 minutes) +3. **Creating Aggregates, Commands, and Events** (25 minutes) +4. **Working with Repositories and Event Stores** (20 minutes) +5. **Building Read Models and Projections** (25 minutes) +6. **Testing Event-Sourced Applications** (20 minutes) +7. **Advanced Patterns and Best Practices** (30 minutes) + +## Tutorial 1: Introduction to Reactive Domain and Event Sourcing + +### Opening (1 minute) +``` +Welcome to this tutorial series on Reactive Domain, an open-source framework for implementing event sourcing in .NET projects. + +I'm [Your Name], and in this series, we'll explore how to build robust, scalable applications using event sourcing and reactive programming principles. + +In this first video, we'll cover the fundamentals of event sourcing and introduce the Reactive Domain library. +``` + +### What is Event Sourcing? (3 minutes) +``` +Before diving into Reactive Domain, let's understand what event sourcing is. + +Traditional applications store the current state of entities in a database. When you need to change something, you directly update that state. + +[SHOW DIAGRAM: Traditional CRUD model] + +Event sourcing takes a different approach. Instead of storing the current state, we store a sequence of events that led to that state. + +[SHOW DIAGRAM: Event Sourcing model] + +Think of it like a ledger in accounting. Rather than just knowing your current bank balance, you have a record of every deposit and withdrawal that led to that balance. + +Key benefits of event sourcing include: + +1. Complete Audit Trail: You have a history of every change +2. Temporal Queries: You can determine the state at any point in time +3. Business Insights: Events represent business activities directly +4. Separation of Concerns: Clear separation between write and read models +``` + +### Introduction to Reactive Domain (4 minutes) +``` +Reactive Domain is a .NET framework that makes implementing event sourcing straightforward. + +[SHOW DIAGRAM: Reactive Domain architecture] + +The library provides several key components: + +1. AggregateRoot: Base class for your domain aggregates +2. IEventSource: Interface for event-sourced entities +3. IRepository: Interface for storing and retrieving aggregates +4. Event Store Integration: Built-in support for EventStoreDB +5. Messaging Framework: Support for commands, events, and queries + +What sets Reactive Domain apart is its focus on developer experience and practical implementations of event sourcing patterns. + +Let's take a quick look at some code to get a feel for how Reactive Domain works. +``` + +### Code Overview (5 minutes) +``` +[SHOW CODE: Basic aggregate example] + +Here's a simple example of an account aggregate in Reactive Domain: + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + + public Account(Guid id) : base(id) + { + } + + public void Create(string owner, decimal initialBalance) + { + if (initialBalance < 0) + throw new ArgumentException("Initial balance cannot be negative"); + + RaiseEvent(new AccountCreated(Id, owner, initialBalance)); + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + RaiseEvent(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new AmountWithdrawn(Id, amount)); + } + + private void Apply(AccountCreated @event) + { + _balance = @event.InitialBalance; + } + + private void Apply(AmountDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(AmountWithdrawn @event) + { + _balance -= @event.Amount; + } +} +``` + +Let's break down what's happening here: + +1. Our Account class extends AggregateRoot, which provides event sourcing capabilities +2. We define methods that represent business operations (Create, Deposit, Withdraw) +3. These methods validate business rules and then raise events +4. Private Apply methods update the aggregate's state based on events +5. The state (_balance) is never directly modified, only through events + +This pattern ensures that all state changes are captured as events, giving us a complete history. +``` + +### Setting Up for the Next Tutorial (2 minutes) +``` +In the next tutorial, we'll set up a new project with Reactive Domain and create our first working example. + +To prepare, you'll need: +1. Visual Studio or Visual Studio Code +2. .NET 7.0 or later +3. Docker (for running EventStoreDB) + +You can find all the code examples and documentation at our GitHub repository: [URL] + +Thanks for watching this introduction to Reactive Domain. If you have any questions, please leave them in the comments below. + +See you in the next tutorial! +``` + +## Tutorial 2: Setting Up Your First Reactive Domain Project + +### Opening (1 minute) +``` +Welcome back to our Reactive Domain tutorial series. In this video, we'll set up a new project with Reactive Domain and create a simple working example. + +By the end of this tutorial, you'll have a functioning event-sourced application that can create accounts, deposit and withdraw funds, and query account balances. +``` + +### Project Setup (5 minutes) +``` +Let's start by creating a new solution with multiple projects: + +[SHOW TERMINAL/IDE] + +```bash +dotnet new sln -n BankingApp +dotnet new classlib -n BankingApp.Domain +dotnet new classlib -n BankingApp.Infrastructure +dotnet new classlib -n BankingApp.Application +dotnet new console -n BankingApp.Console +dotnet new xunit -n BankingApp.Tests + +dotnet sln add BankingApp.Domain +dotnet sln add BankingApp.Infrastructure +dotnet sln add BankingApp.Application +dotnet sln add BankingApp.Console +dotnet sln add BankingApp.Tests +``` + +Now, let's add the Reactive Domain packages: + +```bash +dotnet add BankingApp.Domain package ReactiveDomain.Core +dotnet add BankingApp.Domain package ReactiveDomain.Foundation +dotnet add BankingApp.Infrastructure package ReactiveDomain.Persistence +dotnet add BankingApp.Application package ReactiveDomain.Messaging +dotnet add BankingApp.Tests package ReactiveDomain.Testing +``` + +Let's also set up the project references: + +```bash +dotnet add BankingApp.Infrastructure reference BankingApp.Domain +dotnet add BankingApp.Application reference BankingApp.Domain +dotnet add BankingApp.Application reference BankingApp.Infrastructure +dotnet add BankingApp.Console reference BankingApp.Application +dotnet add BankingApp.Tests reference BankingApp.Domain +dotnet add BankingApp.Tests reference BankingApp.Infrastructure +dotnet add BankingApp.Tests reference BankingApp.Application +``` +``` + +### Setting Up EventStoreDB (3 minutes) +``` +Before we start coding, let's set up EventStoreDB using Docker: + +[SHOW TERMINAL] + +```bash +docker run --name eventstore -it -p 2113:2113 -p 1113:1113 \ + -e EVENTSTORE_CLUSTER_SIZE=1 \ + -e EVENTSTORE_RUN_PROJECTIONS=All \ + -e EVENTSTORE_START_STANDARD_PROJECTIONS=true \ + -e EVENTSTORE_EXT_TCP_PORT=1113 \ + -e EVENTSTORE_HTTP_PORT=2113 \ + -e EVENTSTORE_INSECURE=true \ + -e EVENTSTORE_ENABLE_EXTERNAL_TCP=true \ + -e EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true \ + eventstore/eventstore:latest +``` + +Once it's running, you can access the EventStore UI at http://localhost:2113 with the default credentials admin/changeit. + +[SHOW BROWSER WITH EVENTSTORE UI] + +This is where we'll be able to see all the events that our application generates. +``` + +### Creating the Domain Model (5 minutes) +``` +Now, let's create our domain model in the BankingApp.Domain project: + +[SHOW IDE] + +First, let's define our events: + +```csharp +// BankingApp.Domain/Events/AccountEvents.cs +using System; + +namespace BankingApp.Domain.Events +{ + public class AccountCreated + { + public Guid AccountId { get; } + public string Owner { get; } + public decimal InitialBalance { get; } + + public AccountCreated(Guid accountId, string owner, decimal initialBalance) + { + AccountId = accountId; + Owner = owner; + InitialBalance = initialBalance; + } + } + + public class AmountDeposited + { + public Guid AccountId { get; } + public decimal Amount { get; } + + public AmountDeposited(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } + } + + public class AmountWithdrawn + { + public Guid AccountId { get; } + public decimal Amount { get; } + + public AmountWithdrawn(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } + } +} +``` + +Next, let's create our Account aggregate: + +```csharp +// BankingApp.Domain/Aggregates/Account.cs +using System; +using BankingApp.Domain.Events; +using ReactiveDomain.Foundation; + +namespace BankingApp.Domain.Aggregates +{ + public class Account : AggregateRoot + { + private decimal _balance; + private bool _isCreated; + + public Account(Guid id) : base(id) + { + } + + public void Create(string owner, decimal initialBalance) + { + if (_isCreated) + throw new InvalidOperationException("Account already created"); + + if (initialBalance < 0) + throw new ArgumentException("Initial balance cannot be negative"); + + RaiseEvent(new AccountCreated(Id, owner, initialBalance)); + } + + public void Deposit(decimal amount) + { + EnsureCreated(); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + RaiseEvent(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + EnsureCreated(); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new AmountWithdrawn(Id, amount)); + } + + public decimal GetBalance() + { + EnsureCreated(); + return _balance; + } + + private void EnsureCreated() + { + if (!_isCreated) + throw new InvalidOperationException("Account not created"); + } + + private void Apply(AccountCreated @event) + { + _balance = @event.InitialBalance; + _isCreated = true; + } + + private void Apply(AmountDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(AmountWithdrawn @event) + { + _balance -= @event.Amount; + } + } +} +``` +``` + +### Setting Up the Infrastructure (3 minutes) +``` +Now, let's set up the infrastructure to connect to EventStoreDB: + +[SHOW IDE] + +```csharp +// BankingApp.Infrastructure/Repositories/AccountRepository.cs +using System; +using BankingApp.Domain.Aggregates; +using ReactiveDomain.Foundation; +using ReactiveDomain.Persistence; +using ReactiveDomain.Messaging; + +namespace BankingApp.Infrastructure.Repositories +{ + public class AccountRepository : IRepository + { + private readonly StreamStoreRepository _repository; + + public AccountRepository(IStreamStoreConnection connection) + { + var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder(); + var serializer = new JsonMessageSerializer(); + _repository = new StreamStoreRepository(streamNameBuilder, connection, serializer); + } + + public bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) + where TAggregate : class, IEventSource + { + return _repository.TryGetById(id, out aggregate, version); + } + + public void Save(IEventSource aggregate) + { + _repository.Save(aggregate); + } + + public void Delete(IEventSource aggregate) + { + _repository.Delete(aggregate); + } + } +} +``` + +Now, let's create a connection factory: + +```csharp +// BankingApp.Infrastructure/EventStore/EventStoreConnectionFactory.cs +using System; +using ReactiveDomain.Persistence; + +namespace BankingApp.Infrastructure.EventStore +{ + public class EventStoreConnectionFactory + { + public static IStreamStoreConnection Create(string connectionString) + { + var connection = new EventStoreConnection(connectionString); + connection.Connect(); + return connection; + } + } +} +``` +``` + +### Creating the Application Services (3 minutes) +``` +Let's create our application services: + +[SHOW IDE] + +```csharp +// BankingApp.Application/Services/AccountService.cs +using System; +using BankingApp.Domain.Aggregates; +using ReactiveDomain.Foundation; + +namespace BankingApp.Application.Services +{ + public class AccountService + { + private readonly IRepository _repository; + + public AccountService(IRepository repository) + { + _repository = repository; + } + + public Guid CreateAccount(string owner, decimal initialBalance) + { + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + + account.Create(owner, initialBalance); + _repository.Save(account); + + return accountId; + } + + public void Deposit(Guid accountId, decimal amount) + { + if (!_repository.TryGetById(accountId, out var account)) + throw new InvalidOperationException($"Account {accountId} not found"); + + account.Deposit(amount); + _repository.Save(account); + } + + public void Withdraw(Guid accountId, decimal amount) + { + if (!_repository.TryGetById(accountId, out var account)) + throw new InvalidOperationException($"Account {accountId} not found"); + + account.Withdraw(amount); + _repository.Save(account); + } + + public decimal GetBalance(Guid accountId) + { + if (!_repository.TryGetById(accountId, out var account)) + throw new InvalidOperationException($"Account {accountId} not found"); + + return account.GetBalance(); + } + } +} +``` +``` + +### Creating the Console Application (3 minutes) +``` +Finally, let's create a simple console application to test our implementation: + +[SHOW IDE] + +```csharp +// BankingApp.Console/Program.cs +using System; +using BankingApp.Application.Services; +using BankingApp.Infrastructure.EventStore; +using BankingApp.Infrastructure.Repositories; + +namespace BankingApp.Console +{ + class Program + { + static void Main(string[] args) + { + // Create connection to EventStoreDB + var connectionString = "tcp://admin:changeit@localhost:1113"; + var connection = EventStoreConnectionFactory.Create(connectionString); + + // Create repository + var repository = new AccountRepository(connection); + + // Create account service + var accountService = new AccountService(repository); + + // Create a new account + var accountId = accountService.CreateAccount("John Doe", 100); + System.Console.WriteLine($"Created account {accountId} with balance {accountService.GetBalance(accountId)}"); + + // Deposit funds + accountService.Deposit(accountId, 50); + System.Console.WriteLine($"Deposited 50, new balance: {accountService.GetBalance(accountId)}"); + + // Withdraw funds + accountService.Withdraw(accountId, 30); + System.Console.WriteLine($"Withdrew 30, new balance: {accountService.GetBalance(accountId)}"); + + // Try to withdraw too much + try + { + accountService.Withdraw(accountId, 1000); + } + catch (InvalidOperationException ex) + { + System.Console.WriteLine($"Error: {ex.Message}"); + } + + System.Console.WriteLine("Press any key to exit..."); + System.Console.ReadKey(); + } + } +} +``` +``` + +### Running the Application (3 minutes) +``` +Now, let's run our application and see it in action: + +[SHOW TERMINAL] + +```bash +cd BankingApp.Console +dotnet run +``` + +[SHOW CONSOLE OUTPUT] + +Great! Our application is working as expected. Let's check EventStoreDB to see the events that were generated: + +[SHOW BROWSER WITH EVENTSTORE UI] + +Here we can see all the events that our application generated: +1. AccountCreated +2. AmountDeposited +3. AmountWithdrawn + +This demonstrates the power of event sourcing - we have a complete history of all changes to our account. +``` + +### Conclusion and Next Steps (2 minutes) +``` +In this tutorial, we've set up a basic Reactive Domain project and implemented a simple banking application using event sourcing. + +We've covered: +1. Setting up a multi-project solution +2. Installing Reactive Domain packages +3. Running EventStoreDB with Docker +4. Creating domain events and aggregates +5. Implementing a repository +6. Creating application services +7. Building a console application to test our implementation + +In the next tutorial, we'll dive deeper into creating more complex aggregates, commands, and events, and explore how to handle more sophisticated business rules. + +Thanks for watching, and see you in the next video! +``` + +## Additional Tutorials + +The remaining tutorials would follow a similar structure, building on the foundation established in the first two tutorials: + +- **Tutorial 3**: Creating more complex aggregates, commands, and events +- **Tutorial 4**: Working with repositories and event stores in more detail +- **Tutorial 5**: Building read models and projections for querying data +- **Tutorial 6**: Testing event-sourced applications with Reactive Domain +- **Tutorial 7**: Advanced patterns and best practices for production applications + +Each tutorial would include: +- Clear learning objectives +- Step-by-step code examples +- Explanations of key concepts +- Practical demonstrations +- Suggestions for further exploration + +[↑ Back to Top](#video-tutorial-script) | [← Back to Table of Contents](README.md) diff --git a/docs/workshop-materials.md b/docs/workshop-materials.md new file mode 100644 index 00000000..44a54d46 --- /dev/null +++ b/docs/workshop-materials.md @@ -0,0 +1,825 @@ +# Workshop Materials + +[← Back to Table of Contents](README.md) + +This document provides materials for conducting workshops and training sessions on Reactive Domain. + +## Table of Contents + +- [Workshop Overview](#workshop-overview) +- [Prerequisites](#prerequisites) +- [Workshop Modules](#workshop-modules) +- [Exercises](#exercises) +- [Sample Solutions](#sample-solutions) +- [Presentation Slides](#presentation-slides) +- [Additional Resources](#additional-resources) + +## Workshop Overview + +This workshop is designed to provide hands-on experience with Reactive Domain and event sourcing concepts. It can be delivered as a one-day intensive workshop or spread across multiple sessions. + +### Learning Objectives + +By the end of this workshop, participants will be able to: + +1. Understand the core concepts of event sourcing and CQRS +2. Implement event-sourced aggregates using Reactive Domain +3. Create and use repositories for storing and retrieving aggregates +4. Build read models and projections for querying data +5. Test event-sourced applications effectively +6. Apply best practices for production applications + +### Target Audience + +- .NET developers interested in event sourcing +- Software architects evaluating event sourcing for their projects +- Teams transitioning to event-sourced architectures + +## Prerequisites + +### Knowledge Prerequisites + +- Familiarity with C# and .NET development +- Basic understanding of domain-driven design concepts +- Experience with object-oriented programming + +### Technical Prerequisites + +- .NET 7.0 SDK or later +- Visual Studio 2022, JetBrains Rider, or Visual Studio Code +- Docker for running EventStoreDB +- Git for accessing workshop materials + +### Setup Instructions + +Before the workshop, participants should: + +1. Clone the workshop repository: + ```bash + git clone https://github.com/linedata/reactive-domain-workshop.git + ``` + +2. Install the .NET 7.0 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com/download) + +3. Install Docker from [docker.com](https://www.docker.com/products/docker-desktop) + +4. Run EventStoreDB using Docker: + ```bash + docker run --name eventstore -d -p 2113:2113 -p 1113:1113 \ + -e EVENTSTORE_CLUSTER_SIZE=1 \ + -e EVENTSTORE_RUN_PROJECTIONS=All \ + -e EVENTSTORE_START_STANDARD_PROJECTIONS=true \ + -e EVENTSTORE_EXT_TCP_PORT=1113 \ + -e EVENTSTORE_HTTP_PORT=2113 \ + -e EVENTSTORE_INSECURE=true \ + -e EVENTSTORE_ENABLE_EXTERNAL_TCP=true \ + -e EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true \ + eventstore/eventstore:latest + ``` + +5. Verify EventStoreDB is running by accessing http://localhost:2113 (credentials: admin/changeit) + +## Workshop Modules + +### Module 1: Introduction to Event Sourcing and Reactive Domain (1 hour) + +#### Topics +- Event sourcing fundamentals +- Benefits and challenges of event sourcing +- Introduction to CQRS +- Overview of Reactive Domain +- Key components and architecture + +#### Activities +- Presentation on event sourcing concepts +- Discussion of use cases for event sourcing +- Demonstration of Reactive Domain basics +- Q&A session + +### Module 2: Setting Up a Reactive Domain Project (1 hour) + +#### Topics +- Project structure for event-sourced applications +- Installing and configuring Reactive Domain +- Setting up EventStoreDB +- Basic configuration options + +#### Activities +- Guided setup of a new project +- Installing required packages +- Configuring EventStoreDB connection +- Verifying the setup + +### Module 3: Creating Aggregates, Commands, and Events (2 hours) + +#### Topics +- Designing aggregates +- Defining events +- Creating commands +- Implementing business logic +- Handling validation + +#### Activities +- Designing a domain model for a banking application +- Implementing the Account aggregate +- Creating events for account operations +- Implementing command handlers +- Testing the aggregate behavior + +### Module 4: Working with Repositories and Event Stores (1.5 hours) + +#### Topics +- Repository pattern in event sourcing +- Connecting to EventStoreDB +- Saving and loading aggregates +- Handling concurrency +- Stream naming strategies + +#### Activities +- Implementing a repository +- Connecting to EventStoreDB +- Saving and loading aggregates +- Exploring events in EventStoreDB UI +- Handling concurrency conflicts + +### Module 5: Building Read Models and Projections (2 hours) + +#### Topics +- CQRS principles +- Designing read models +- Implementing projections +- Handling eventual consistency +- Optimizing for query performance + +#### Activities +- Designing read models for the banking application +- Implementing event handlers for updating read models +- Creating query handlers +- Testing read model consistency +- Exploring projection strategies + +### Module 6: Testing Event-Sourced Applications (1.5 hours) + +#### Topics +- Unit testing aggregates +- Testing command handlers +- Testing event handlers +- Integration testing with EventStoreDB +- Test fixtures and helpers + +#### Activities +- Writing unit tests for aggregates +- Testing command handlers +- Testing event handlers +- Setting up integration tests +- Using test fixtures and helpers + +### Module 7: Advanced Patterns and Production Considerations (1 hour) + +#### Topics +- Snapshots for performance optimization +- Versioning events +- Handling event schema evolution +- Deployment strategies +- Monitoring and observability + +#### Activities +- Implementing snapshots +- Handling event versioning +- Discussing deployment strategies +- Planning for production readiness +- Q&A and wrap-up + +## Exercises + +### Exercise 1: Creating Your First Aggregate + +#### Objective +Implement a simple `Account` aggregate with basic operations. + +#### Tasks +1. Create an `Account` aggregate class that extends `AggregateRoot` +2. Define events: `AccountCreated`, `AmountDeposited`, `AmountWithdrawn` +3. Implement methods: `Create`, `Deposit`, `Withdraw` +4. Implement `Apply` methods for each event +5. Add business rules validation + +#### Expected Outcome +A working `Account` aggregate that maintains its state through events. + +#### Starter Code +```csharp +public class Account : AggregateRoot +{ + // TODO: Implement private state + + public Account(Guid id) : base(id) + { + } + + public void Create(string owner, decimal initialBalance) + { + // TODO: Implement validation and event raising + } + + public void Deposit(decimal amount) + { + // TODO: Implement validation and event raising + } + + public void Withdraw(decimal amount) + { + // TODO: Implement validation and event raising + } + + // TODO: Implement Apply methods +} +``` + +### Exercise 2: Implementing a Repository + +#### Objective +Create a repository for storing and retrieving Account aggregates. + +#### Tasks +1. Create an `AccountRepository` class that implements `IRepository` +2. Configure connection to EventStoreDB +3. Implement methods to save and load aggregates +4. Test the repository with the Account aggregate + +#### Expected Outcome +A working repository that can save and load Account aggregates. + +#### Starter Code +```csharp +public class AccountRepository : IRepository +{ + // TODO: Implement repository fields + + public AccountRepository(IStreamStoreConnection connection) + { + // TODO: Initialize repository + } + + public bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) + where TAggregate : class, IEventSource + { + // TODO: Implement loading aggregate + } + + public void Save(IEventSource aggregate) + { + // TODO: Implement saving aggregate + } + + public void Delete(IEventSource aggregate) + { + // TODO: Implement deleting aggregate + } +} +``` + +### Exercise 3: Building a Read Model + +#### Objective +Create a read model for account balances and transactions. + +#### Tasks +1. Define read model classes: `AccountDetails`, `TransactionSummary` +2. Create event handlers for updating read models +3. Implement a query service for retrieving account information +4. Store read models in memory (optional: use a database) + +#### Expected Outcome +A working read model that provides account details and transaction history. + +#### Starter Code +```csharp +public class AccountDetails +{ + public Guid Id { get; set; } + public string Owner { get; set; } + public decimal Balance { get; set; } + public DateTime CreatedAt { get; set; } + public List Transactions { get; set; } = new List(); +} + +public class TransactionSummary +{ + public Guid Id { get; set; } + public string Type { get; set; } + public decimal Amount { get; set; } + public DateTime Timestamp { get; set; } +} + +public class AccountDetailsHandler : + IEventHandler, + IEventHandler, + IEventHandler +{ + // TODO: Implement event handlers +} +``` + +### Exercise 4: Testing Aggregates + +#### Objective +Write unit tests for the Account aggregate. + +#### Tasks +1. Create a test class for the Account aggregate +2. Write tests for the Create, Deposit, and Withdraw methods +3. Test business rule validations +4. Verify events are raised correctly + +#### Expected Outcome +A comprehensive test suite for the Account aggregate. + +#### Starter Code +```csharp +public class AccountTests +{ + [Fact] + public void CanCreateAccount() + { + // TODO: Implement test + } + + [Fact] + public void CanDepositMoney() + { + // TODO: Implement test + } + + [Fact] + public void CanWithdrawMoney() + { + // TODO: Implement test + } + + [Fact] + public void CannotWithdrawMoreThanBalance() + { + // TODO: Implement test + } +} +``` + +### Exercise 5: Implementing Snapshots + +#### Objective +Implement snapshots for the Account aggregate to improve performance. + +#### Tasks +1. Create a snapshot class for the Account aggregate +2. Implement the `ISnapshotSource` interface on the Account aggregate +3. Create a snapshot repository +4. Test loading and saving with snapshots + +#### Expected Outcome +A working snapshot implementation that improves aggregate loading performance. + +#### Starter Code +```csharp +public class AccountSnapshot +{ + public decimal Balance { get; set; } + public long Version { get; set; } +} + +public class Account : AggregateRoot, ISnapshotSource +{ + // Existing implementation... + + public void RestoreFromSnapshot(object snapshot) + { + // TODO: Implement snapshot restoration + } + + public object TakeSnapshot() + { + // TODO: Implement snapshot creation + } +} +``` + +## Sample Solutions + +### Solution for Exercise 1: Creating Your First Aggregate + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + private bool _isCreated; + + public Account(Guid id) : base(id) + { + } + + public void Create(string owner, decimal initialBalance) + { + if (_isCreated) + throw new InvalidOperationException("Account already created"); + + if (initialBalance < 0) + throw new ArgumentException("Initial balance cannot be negative"); + + RaiseEvent(new AccountCreated(Id, owner, initialBalance)); + } + + public void Deposit(decimal amount) + { + EnsureCreated(); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + RaiseEvent(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + EnsureCreated(); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new AmountWithdrawn(Id, amount)); + } + + public decimal GetBalance() + { + EnsureCreated(); + return _balance; + } + + private void EnsureCreated() + { + if (!_isCreated) + throw new InvalidOperationException("Account not created"); + } + + private void Apply(AccountCreated @event) + { + _balance = @event.InitialBalance; + _isCreated = true; + } + + private void Apply(AmountDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(AmountWithdrawn @event) + { + _balance -= @event.Amount; + } +} +``` + +### Solution for Exercise 2: Implementing a Repository + +```csharp +public class AccountRepository : IRepository +{ + private readonly StreamStoreRepository _repository; + + public AccountRepository(IStreamStoreConnection connection) + { + var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder(); + var serializer = new JsonMessageSerializer(); + _repository = new StreamStoreRepository(streamNameBuilder, connection, serializer); + } + + public bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) + where TAggregate : class, IEventSource + { + return _repository.TryGetById(id, out aggregate, version); + } + + public void Save(IEventSource aggregate) + { + _repository.Save(aggregate); + } + + public void Delete(IEventSource aggregate) + { + _repository.Delete(aggregate); + } +} +``` + +### Solution for Exercise 3: Building a Read Model + +```csharp +public class AccountDetailsHandler : + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly Dictionary _accounts = new Dictionary(); + + public void Handle(AccountCreated @event) + { + var account = new AccountDetails + { + Id = @event.AccountId, + Owner = @event.Owner, + Balance = @event.InitialBalance, + CreatedAt = DateTime.UtcNow + }; + + account.Transactions.Add(new TransactionSummary + { + Id = Guid.NewGuid(), + Type = "Create", + Amount = @event.InitialBalance, + Timestamp = DateTime.UtcNow + }); + + _accounts[@event.AccountId] = account; + } + + public void Handle(AmountDeposited @event) + { + if (_accounts.TryGetValue(@event.AccountId, out var account)) + { + account.Balance += @event.Amount; + + account.Transactions.Add(new TransactionSummary + { + Id = Guid.NewGuid(), + Type = "Deposit", + Amount = @event.Amount, + Timestamp = DateTime.UtcNow + }); + } + } + + public void Handle(AmountWithdrawn @event) + { + if (_accounts.TryGetValue(@event.AccountId, out var account)) + { + account.Balance -= @event.Amount; + + account.Transactions.Add(new TransactionSummary + { + Id = Guid.NewGuid(), + Type = "Withdraw", + Amount = @event.Amount, + Timestamp = DateTime.UtcNow + }); + } + } + + public AccountDetails GetAccount(Guid accountId) + { + return _accounts.TryGetValue(accountId, out var account) ? account : null; + } + + public IEnumerable GetAllAccounts() + { + return _accounts.Values; + } +} +``` + +### Solution for Exercise 4: Testing Aggregates + +```csharp +public class AccountTests +{ + [Fact] + public void CanCreateAccount() + { + // Arrange + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + + // Act + account.Create("John Doe", 100); + + // Assert + Assert.Equal(100, account.GetBalance()); + + // Verify events + var events = ((IEventSource)account).TakeEvents(); + Assert.Single(events); + var @event = Assert.IsType(events[0]); + Assert.Equal(accountId, @event.AccountId); + Assert.Equal("John Doe", @event.Owner); + Assert.Equal(100, @event.InitialBalance); + } + + [Fact] + public void CanDepositMoney() + { + // Arrange + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + account.Create("John Doe", 100); + ((IEventSource)account).TakeEvents(); // Clear events + + // Act + account.Deposit(50); + + // Assert + Assert.Equal(150, account.GetBalance()); + + // Verify events + var events = ((IEventSource)account).TakeEvents(); + Assert.Single(events); + var @event = Assert.IsType(events[0]); + Assert.Equal(accountId, @event.AccountId); + Assert.Equal(50, @event.Amount); + } + + [Fact] + public void CanWithdrawMoney() + { + // Arrange + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + account.Create("John Doe", 100); + ((IEventSource)account).TakeEvents(); // Clear events + + // Act + account.Withdraw(30); + + // Assert + Assert.Equal(70, account.GetBalance()); + + // Verify events + var events = ((IEventSource)account).TakeEvents(); + Assert.Single(events); + var @event = Assert.IsType(events[0]); + Assert.Equal(accountId, @event.AccountId); + Assert.Equal(30, @event.Amount); + } + + [Fact] + public void CannotWithdrawMoreThanBalance() + { + // Arrange + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + account.Create("John Doe", 100); + + // Act & Assert + var exception = Assert.Throws(() => account.Withdraw(150)); + Assert.Equal("Insufficient funds", exception.Message); + } +} +``` + +### Solution for Exercise 5: Implementing Snapshots + +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + private decimal _balance; + private bool _isCreated; + + // Existing implementation... + + public void RestoreFromSnapshot(object snapshot) + { + var accountSnapshot = (AccountSnapshot)snapshot; + _balance = accountSnapshot.Balance; + _isCreated = true; + ExpectedVersion = accountSnapshot.Version; + } + + public object TakeSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + Version = ExpectedVersion + }; + } +} + +public class SnapshotRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly ISnapshotStore _snapshotStore; + + public SnapshotRepository(IRepository innerRepository, ISnapshotStore snapshotStore) + { + _innerRepository = innerRepository; + _snapshotStore = snapshotStore; + } + + public bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) + where TAggregate : class, IEventSource + { + if (typeof(ISnapshotSource).IsAssignableFrom(typeof(TAggregate))) + { + // Try to load from snapshot + var snapshot = _snapshotStore.GetSnapshot(id, typeof(TAggregate)); + if (snapshot != null) + { + aggregate = (TAggregate)Activator.CreateInstance(typeof(TAggregate), id); + ((ISnapshotSource)aggregate).RestoreFromSnapshot(snapshot.Data); + + // Load events after the snapshot + _innerRepository.Update(ref aggregate, version); + return true; + } + } + + // Fall back to loading all events + return _innerRepository.TryGetById(id, out aggregate, version); + } + + public void Save(IEventSource aggregate) + { + _innerRepository.Save(aggregate); + + // Take a snapshot if the aggregate supports it + if (aggregate is ISnapshotSource snapshotSource) + { + var snapshot = snapshotSource.TakeSnapshot(); + _snapshotStore.SaveSnapshot(aggregate.Id, aggregate.GetType(), snapshot, aggregate.ExpectedVersion); + } + } + + public void Delete(IEventSource aggregate) + { + _innerRepository.Delete(aggregate); + } +} +``` + +## Presentation Slides + +The workshop includes a set of presentation slides covering the key concepts and techniques. These slides are available in the workshop repository in both PowerPoint and PDF formats. + +### Slide Decks + +1. **Introduction to Event Sourcing and Reactive Domain** + - Event sourcing fundamentals + - Benefits and challenges + - CQRS overview + - Reactive Domain architecture + +2. **Aggregates, Commands, and Events** + - Designing aggregates + - Event design principles + - Command handling patterns + - Business rule implementation + +3. **Repositories and Event Stores** + - Repository pattern + - EventStoreDB integration + - Stream naming strategies + - Concurrency handling + +4. **Read Models and Projections** + - CQRS implementation + - Projection patterns + - Read model design + - Eventual consistency + +5. **Testing Event-Sourced Applications** + - Unit testing strategies + - Integration testing + - Test fixtures and helpers + - Test-driven development + +6. **Advanced Patterns and Production Considerations** + - Snapshots + - Event versioning + - Deployment strategies + - Monitoring and observability + +## Additional Resources + +### Code Samples + +The workshop repository includes complete code samples for all exercises and additional examples: + +- **BankingApp**: A simple banking application demonstrating basic event sourcing concepts +- **ECommerce**: A more complex e-commerce application showing advanced patterns +- **Snapshots**: Examples of implementing and using snapshots +- **Testing**: Comprehensive testing examples + +### Reference Documentation + +- [Reactive Domain Documentation](https://github.com/linedata/reactive-domain/docs) +- [EventStoreDB Documentation](https://developers.eventstore.com/server/v21.10/docs/) +- [CQRS Journey by Microsoft](https://docs.microsoft.com/en-us/previous-versions/msp-n-p/jj554200(v=pandp.10)) + +### Recommended Reading + +- **Domain-Driven Design** by Eric Evans +- **Implementing Domain-Driven Design** by Vaughn Vernon +- **CQRS Documents** by Greg Young +- **Event Sourcing and CQRS** by Martin Fowler + +[↑ Back to Top](#workshop-materials) | [← Back to Table of Contents](README.md) From 773483387093fa5229fa80f5b4bfe3c4120540d8 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Wed, 16 Apr 2025 08:23:36 -0400 Subject: [PATCH 03/41] Improve documentation navigation with learning path and next/previous links --- docs/README.md | 43 ++++++++++++++++++++++++++++++++++++ docs/api-reference/README.md | 7 ++++++ docs/architecture.md | 7 +++++- docs/code-examples/README.md | 7 +++++- docs/core-concepts.md | 7 +++++- docs/usage-patterns.md | 7 ++++++ 6 files changed, 75 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index b321abc5..09218da4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,17 @@ Reactive Domain is built on several key design principles: - **Correlation and Causation Tracking**: Built-in support for tracking correlation and causation IDs across message flows. - **Snapshotting**: Support for creating and restoring from snapshots to improve performance. +## Learning Path + +For the best learning experience, we recommend following this progression: + +1. **Start Here**: [Core Concepts](core-concepts.md) - Understand the fundamentals of event sourcing and CQRS +2. **Next**: [Usage Patterns](usage-patterns.md) - Learn how to apply these concepts in practice +3. **Then**: [Code Examples](code-examples/README.md) - See concrete implementations +4. **Explore**: [API Reference](api-reference/README.md) - Dive into the details of specific components +5. **Advanced**: [Architecture Guide](architecture.md) - Understand the system architecture +6. **Production**: [Deployment Guide](deployment.md) and [Performance Optimization](performance.md) - Prepare for production + ## Table of Contents 1. [Core Concepts](core-concepts.md) @@ -167,6 +178,34 @@ Reactive Domain is built on several key design principles: - Message Contracts and Versioning - Integration Testing Strategies +16. [Video Tutorial Script](video-tutorial-script.md) + - Introduction to Reactive Domain + - Setting Up Your First Project + - Creating Aggregates and Events + - Working with Repositories + - Building Read Models + - Testing Your Application + +17. [Workshop Materials](workshop-materials.md) + - Workshop Overview + - Prerequisites + - Exercises + - Sample Solutions + - Presentation Materials + +## Quick Reference + +| If you want to... | Go to... | +|-------------------|----------| +| Understand event sourcing | [Core Concepts](core-concepts.md) | +| Start a new project | [Usage Patterns](usage-patterns.md#setting-up-a-new-reactive-domain-project) | +| Create an aggregate | [Code Examples](code-examples/README.md#creating-a-new-aggregate-root) | +| Fix a common issue | [Troubleshooting Guide](troubleshooting.md) | +| Optimize performance | [Performance Optimization Guide](performance.md) | +| Secure your application | [Security Guide](security.md) | +| Deploy to production | [Deployment Guide](deployment.md) | +| Learn key terminology | [Glossary](glossary.md) | + ## Getting Started If you're new to Reactive Domain, we recommend starting with the [Core Concepts](core-concepts.md) section to understand the fundamental principles behind event sourcing and how they're implemented in Reactive Domain. @@ -192,3 +231,7 @@ graph TD ## Contributing Contributions to this documentation are welcome! Please see the [CONTRIBUTING.md](../CONTRIBUTING.md) file for guidelines. + +--- + +**Next**: [Core Concepts](core-concepts.md) diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md index ae0bef2c..216f34f2 100644 --- a/docs/api-reference/README.md +++ b/docs/api-reference/README.md @@ -89,4 +89,11 @@ The Reactive Domain library consists of the following assemblies: - [ReactiveDomain.IdentityStorage](assemblies/reactivedomain-identitystorage.md) - Identity storage - [ReactiveDomain.Tools](assemblies/reactivedomain-tools.md) - Developer tools +--- + +**Navigation**: +- [← Previous: Code Examples](../code-examples/README.md) +- [↑ Back to Top](#reactive-domain-api-reference) +- [→ Next: Architecture Guide](../architecture.md) + [↑ Back to Top](#reactive-domain-api-reference) | [← Back to Table of Contents](../README.md) diff --git a/docs/architecture.md b/docs/architecture.md index 8f5d6e7e..72c9703f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -905,4 +905,9 @@ Recommendations: - Implement controls based on risk assessment - Test controls for effectiveness -[↑ Back to Top](#architecture-guide) | [← Back to Table of Contents](README.md) +--- + +**Navigation**: +- [← Previous: API Reference](api-reference/README.md) +- [↑ Back to Top](#architecture-guide) +- [→ Next: Migration Guide](migration.md) diff --git a/docs/code-examples/README.md b/docs/code-examples/README.md index 34195a33..045773ca 100644 --- a/docs/code-examples/README.md +++ b/docs/code-examples/README.md @@ -47,4 +47,9 @@ If you're new to Reactive Domain, we recommend starting with the [Creating a New For more advanced scenarios, check out the [Complete Sample Applications](sample-applications.md) section. -[↑ Back to Top](#reactive-domain-code-examples) | [← Back to Table of Contents](../README.md) +--- + +**Navigation**: +- [← Previous: Usage Patterns](../usage-patterns.md) +- [↑ Back to Top](#reactive-domain-code-examples) +- [→ Next: API Reference](../api-reference/README.md) diff --git a/docs/core-concepts.md b/docs/core-concepts.md index ec34b3b9..ee602110 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -173,4 +173,9 @@ These core concepts form the foundation of the Reactive Domain library. Understa For more detailed information on specific components, see the [Component Documentation](components/README.md) section. For practical guidance on using these concepts, see the [Usage Patterns](usage-patterns.md) section. -[↑ Back to Top](#core-concepts-of-reactive-domain) | [← Back to Table of Contents](README.md) +--- + +**Navigation**: +- [← Previous: Documentation Home](README.md) +- [↑ Back to Top](#core-concepts-of-reactive-domain) +- [→ Next: Usage Patterns](usage-patterns.md) diff --git a/docs/usage-patterns.md b/docs/usage-patterns.md index 4fb81dc9..3eb44a42 100644 --- a/docs/usage-patterns.md +++ b/docs/usage-patterns.md @@ -786,4 +786,11 @@ These usage patterns provide a foundation for implementing event sourcing with R For more detailed information on specific components, see the [Component Documentation](components/README.md) section. For code examples, see the [Code Examples](code-examples/README.md) section. +--- + +**Navigation**: +- [← Previous: Core Concepts](core-concepts.md) +- [↑ Back to Top](#usage-patterns-for-reactive-domain) +- [→ Next: Code Examples](code-examples/README.md) + [↑ Back to Top](#usage-patterns-for-reactive-domain) | [← Back to Table of Contents](README.md) From ba93c45b1cd89491a8e818c315377161243579f8 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Wed, 16 Apr 2025 08:28:03 -0400 Subject: [PATCH 04/41] Add component navigation with next/previous links --- docs/components/README.md | 21 ++- docs/components/core.md | 9 +- docs/components/foundation.md | 333 ++++++++++++++++++++++++++++++++++ docs/components/messaging.md | 318 ++++++++++++++++++++++++++++++++ 4 files changed, 679 insertions(+), 2 deletions(-) create mode 100644 docs/components/foundation.md create mode 100644 docs/components/messaging.md diff --git a/docs/components/README.md b/docs/components/README.md index b6583c5c..dc64c762 100644 --- a/docs/components/README.md +++ b/docs/components/README.md @@ -4,6 +4,20 @@ This section provides detailed documentation for each of the major components of the Reactive Domain library. +## Component Navigation + +Navigate through the components in logical order: + +1. [ReactiveDomain.Core](core.md) → +2. [ReactiveDomain.Foundation](foundation.md) → +3. [ReactiveDomain.Messaging](messaging.md) → +4. [ReactiveDomain.Persistence](persistence.md) → +5. [ReactiveDomain.Transport](transport.md) → +6. [ReactiveDomain.Testing](testing.md) → +7. [ReactiveDomain.Policy](policy.md) → +8. [ReactiveDomain.IdentityStorage](identity-storage.md) → +9. [ReactiveDomain.Tools](tools.md) + ## Table of Contents 1. [ReactiveDomain.Core](core.md) - Fundamental interfaces and abstractions @@ -24,4 +38,9 @@ Each component documentation includes: - Usage examples - Integration with other components -[↑ Back to Top](#reactive-domain-components) | [← Back to Table of Contents](../README.md) +--- + +**Navigation**: +- [← Previous: Core Concepts](../core-concepts.md) +- [↑ Back to Top](#reactive-domain-components) +- [→ Next: ReactiveDomain.Core](core.md) diff --git a/docs/components/core.md b/docs/components/core.md index 46ca08f7..f75de655 100644 --- a/docs/components/core.md +++ b/docs/components/core.md @@ -2,6 +2,8 @@ [← Back to Components](README.md) | [← Back to Table of Contents](../README.md) +**Component Navigation**: [← Components](README.md) | [Next: ReactiveDomain.Foundation →](foundation.md) + The `ReactiveDomain.Core` component provides the fundamental interfaces and abstractions that form the foundation of the Reactive Domain library. These core interfaces define the contract for event sourcing and are used throughout the library. ## Table of Contents @@ -193,4 +195,9 @@ Some common issues to avoid when working with the `ReactiveDomain.Core` componen 3. **Complex event application**: Keep the logic for applying events simple and focused 4. **Leaking implementation details**: The core interfaces should hide implementation details from clients -[↑ Back to Top](#reactivedomaincore) | [← Back to Components](README.md) | [← Back to Table of Contents](../README.md) +--- + +**Component Navigation**: +- [← Back to Components](README.md) +- [↑ Back to Top](#reactivedomaincore) +- [Next: ReactiveDomain.Foundation →](foundation.md) diff --git a/docs/components/foundation.md b/docs/components/foundation.md new file mode 100644 index 00000000..6eb24a78 --- /dev/null +++ b/docs/components/foundation.md @@ -0,0 +1,333 @@ +# ReactiveDomain.Foundation + +[← Back to Components](README.md) | [← Back to Table of Contents](../README.md) + +**Component Navigation**: [← ReactiveDomain.Core](core.md) | [Next: ReactiveDomain.Messaging →](messaging.md) + +The `ReactiveDomain.Foundation` component builds on the core interfaces to provide concrete implementations for domain aggregates, repositories, and other foundational elements of event sourcing. + +## Table of Contents + +- [Purpose and Responsibility](#purpose-and-responsibility) +- [Key Classes](#key-classes) + - [AggregateRoot](#aggregateroot) + - [StreamStoreRepository](#streamstorerepository) + - [CorrelatedStreamStoreRepository](#correlatedstreamstorerepository) +- [Implementation Details](#implementation-details) +- [Usage Examples](#usage-examples) + - [Creating an Aggregate](#creating-an-aggregate) + - [Using Repositories](#using-repositories) +- [Integration with Other Components](#integration-with-other-components) +- [Best Practices](#best-practices) +- [Common Pitfalls](#common-pitfalls) + +## Purpose and Responsibility + +The primary purpose of the `ReactiveDomain.Foundation` component is to provide concrete implementations of the core interfaces defined in `ReactiveDomain.Core`. It serves as the foundation for building event-sourced applications with Reactive Domain, including: + +- Aggregate roots that implement the `IEventSource` interface +- Repositories for storing and retrieving aggregates +- Support for correlation and causation tracking +- Event handling and processing + +## Key Classes + +### AggregateRoot + +The `AggregateRoot` class is the base class for domain aggregates in Reactive Domain. It implements the `IEventSource` interface and provides common functionality for event sourcing. + +```csharp +public abstract class AggregateRoot : IEventSource +{ + private readonly EventRecorder _recorder = new EventRecorder(); + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + protected AggregateRoot(Guid id) + { + Id = id; + ExpectedVersion = -1; + } + + protected void RaiseEvent(object @event) + { + _recorder.Record(@event); + Apply(@event); + ExpectedVersion++; + } + + public void RestoreFromEvents(IEnumerable events) + { + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public void UpdateWithEvents(IEnumerable events, long expectedVersion) + { + if (ExpectedVersion != expectedVersion) + throw new InvalidOperationException("Version mismatch"); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public object[] TakeEvents() + { + var events = _recorder.RecordedEvents.ToArray(); + _recorder.Reset(); + return events; + } + + protected abstract void Apply(object @event); +} +``` + +**Key Features:** + +- **Event Recording**: Automatically records events raised by the aggregate +- **State Management**: Applies events to update the aggregate's state +- **Concurrency Control**: Enforces optimistic concurrency through version checking +- **Event Sourcing**: Supports rebuilding state from events + +### StreamStoreRepository + +The `StreamStoreRepository` class implements the `IRepository` interface and provides a concrete implementation for storing and retrieving aggregates from an event store. + +```csharp +public class StreamStoreRepository : IRepository +{ + private readonly IStreamNameBuilder _streamNameBuilder; + private readonly IStreamStoreConnection _connection; + private readonly IEventSerializer _serializer; + + public StreamStoreRepository( + IStreamNameBuilder streamNameBuilder, + IStreamStoreConnection connection, + IEventSerializer serializer) + { + _streamNameBuilder = streamNameBuilder; + _connection = connection; + _serializer = serializer; + } + + public bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) + where TAggregate : class, IEventSource + { + // Implementation details omitted for brevity + } + + public void Save(IEventSource aggregate) + { + // Implementation details omitted for brevity + } + + public void Delete(IEventSource aggregate) + { + // Implementation details omitted for brevity + } +} +``` + +**Key Features:** + +- **Stream Naming**: Uses a stream name builder to generate consistent stream names +- **Event Serialization**: Serializes and deserializes events for storage +- **Aggregate Retrieval**: Loads aggregates by ID and version +- **Aggregate Persistence**: Saves aggregates to the event store +- **Aggregate Deletion**: Marks aggregates as deleted in the event store + +### CorrelatedStreamStoreRepository + +The `CorrelatedStreamStoreRepository` extends the `StreamStoreRepository` to support correlation and causation tracking. + +```csharp +public class CorrelatedStreamStoreRepository : ICorrelatedRepository +{ + private readonly IRepository _innerRepository; + + public CorrelatedStreamStoreRepository(IRepository innerRepository) + { + _innerRepository = innerRepository; + } + + public bool TryGetById(Guid id, out TAggregate aggregate, ICorrelatedMessage source) + where TAggregate : AggregateRoot, IEventSource + { + // Implementation details omitted for brevity + } + + public void Save(IEventSource aggregate) + { + // Implementation details omitted for brevity + } +} +``` + +**Key Features:** + +- **Correlation Tracking**: Tracks correlation IDs across message flows +- **Causation Tracking**: Tracks causation IDs to establish causal relationships +- **Decorator Pattern**: Decorates an existing repository to add correlation support + +## Implementation Details + +The `ReactiveDomain.Foundation` component is built on several key design principles: + +- **Separation of Concerns**: Each class has a single responsibility +- **Composition Over Inheritance**: Uses composition to build complex behaviors +- **Immutability**: Events are treated as immutable records +- **Optimistic Concurrency**: Uses versioning to prevent conflicts + +The component is designed to be: + +- **Extensible**: Provides base classes and interfaces that can be extended +- **Flexible**: Supports different event store implementations and serialization formats +- **Robust**: Includes error handling and validation +- **Testable**: Designed for easy testing with minimal dependencies + +## Usage Examples + +### Creating an Aggregate + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + + public Account(Guid id) : base(id) + { + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + RaiseEvent(new AmountDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new AmountWithdrawn(Id, amount)); + } + + protected override void Apply(object @event) + { + switch (@event) + { + case AmountDeposited e: + _balance += e.Amount; + break; + + case AmountWithdrawn e: + _balance -= e.Amount; + break; + } + } +} + +public class AmountDeposited +{ + public readonly Guid AccountId; + public readonly decimal Amount; + + public AmountDeposited(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } +} + +public class AmountWithdrawn +{ + public readonly Guid AccountId; + public readonly decimal Amount; + + public AmountWithdrawn(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } +} +``` + +### Using Repositories + +```csharp +// Create an event store connection +var connectionSettings = ConnectionSettings.Create() + .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")) + .Build(); +var eventStoreConnection = new StreamStoreConnection("MyApp", connectionSettings, "localhost", 1113); +eventStoreConnection.Connect(); + +// Create a repository +var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder(); +var serializer = new JsonMessageSerializer(); +var repository = new StreamStoreRepository(streamNameBuilder, eventStoreConnection, serializer); + +// Create a correlated repository (optional) +var correlatedRepository = new CorrelatedStreamStoreRepository(repository); + +// Create and save an aggregate +var accountId = Guid.NewGuid(); +var account = new Account(accountId); +account.Deposit(100); +repository.Save(account); + +// Load an aggregate +if (repository.TryGetById(accountId, out var loadedAccount)) +{ + loadedAccount.Withdraw(50); + repository.Save(loadedAccount); +} +``` + +## Integration with Other Components + +The `ReactiveDomain.Foundation` component integrates with several other components in the Reactive Domain library: + +- **ReactiveDomain.Core**: Implements the core interfaces defined in this component +- **ReactiveDomain.Messaging**: Uses the messaging framework for command and event handling +- **ReactiveDomain.Persistence**: Uses the persistence layer for event storage +- **ReactiveDomain.Testing**: Provides testing utilities for aggregates and repositories + +## Best Practices + +When working with the `ReactiveDomain.Foundation` component: + +1. **Keep aggregates focused**: Each aggregate should represent a single concept in your domain +2. **Use value objects**: Use value objects to represent concepts that don't have identity +3. **Validate commands**: Validate commands before generating events +4. **Keep events simple**: Events should be simple data structures with no behavior +5. **Use correlation**: Use correlation and causation tracking for complex workflows + +## Common Pitfalls + +Some common issues to avoid when working with the `ReactiveDomain.Foundation` component: + +1. **Large aggregates**: Avoid creating large aggregates that do too much +2. **Mutable events**: Ensure events are immutable +3. **Ignoring concurrency**: Always handle concurrency conflicts appropriately +4. **Complex event application**: Keep the logic for applying events simple +5. **Missing correlation**: Don't forget to use correlation tracking for complex workflows + +--- + +**Component Navigation**: +- [← Previous: ReactiveDomain.Core](core.md) +- [↑ Back to Top](#reactivedomainfoundation) +- [Next: ReactiveDomain.Messaging →](messaging.md) diff --git a/docs/components/messaging.md b/docs/components/messaging.md new file mode 100644 index 00000000..b8be8edf --- /dev/null +++ b/docs/components/messaging.md @@ -0,0 +1,318 @@ +# ReactiveDomain.Messaging + +[← Back to Components](README.md) | [← Back to Table of Contents](../README.md) + +**Component Navigation**: [← ReactiveDomain.Foundation](foundation.md) | [Next: ReactiveDomain.Persistence →](persistence.md) + +The `ReactiveDomain.Messaging` component provides a comprehensive messaging framework for handling commands, events, and queries in Reactive Domain applications. It implements the messaging infrastructure that connects different parts of your application. + +## Table of Contents + +- [Purpose and Responsibility](#purpose-and-responsibility) +- [Key Interfaces and Classes](#key-interfaces-and-classes) + - [IMessage, ICommand, IEvent](#imessage-icommand-ievent) + - [IMessageHandler, ICommandHandler, IEventHandler](#imessagehandler-icommandhandler-ieventhandler) + - [IMessageBus, ICommandBus, IEventBus](#imessagebus-icommandbus-ieventbus) +- [Implementation Details](#implementation-details) +- [Usage Examples](#usage-examples) + - [Defining Messages](#defining-messages) + - [Implementing Handlers](#implementing-handlers) + - [Using Message Buses](#using-message-buses) +- [Integration with Other Components](#integration-with-other-components) +- [Best Practices](#best-practices) +- [Common Pitfalls](#common-pitfalls) + +## Purpose and Responsibility + +The primary purpose of the `ReactiveDomain.Messaging` component is to provide a messaging infrastructure for event-sourced applications, including: + +- Message definitions for commands, events, and queries +- Message handlers for processing different types of messages +- Message buses for routing messages to handlers +- Support for correlation and causation tracking +- Message serialization and deserialization + +This component enables the implementation of the Command Query Responsibility Segregation (CQRS) pattern by providing separate channels for commands (write operations) and queries (read operations). + +## Key Interfaces and Classes + +### IMessage, ICommand, IEvent + +These interfaces define the contract for different types of messages in the system: + +```csharp +public interface IMessage +{ + Guid MsgId { get; } +} + +public interface ICommand : IMessage +{ +} + +public interface IEvent : IMessage +{ +} + +public interface IQuery : IMessage +{ +} +``` + +**Key Features:** + +- **IMessage**: Base interface for all messages with a unique identifier +- **ICommand**: Represents a request to perform an action +- **IEvent**: Represents something that has happened +- **IQuery**: Represents a request for information + +### IMessageHandler, ICommandHandler, IEventHandler + +These interfaces define the contract for message handlers: + +```csharp +public interface IMessageHandler where TMessage : IMessage +{ + void Handle(TMessage message); +} + +public interface ICommandHandler : IMessageHandler where TCommand : ICommand +{ +} + +public interface IEventHandler : IMessageHandler where TEvent : IEvent +{ +} + +public interface IQueryHandler where TQuery : IQuery +{ + TResult Handle(TQuery query); +} +``` + +**Key Features:** + +- **IMessageHandler**: Generic handler for any message type +- **ICommandHandler**: Specialized handler for commands +- **IEventHandler**: Specialized handler for events +- **IQueryHandler**: Specialized handler for queries that returns a result + +### IMessageBus, ICommandBus, IEventBus + +These interfaces define the contract for message buses: + +```csharp +public interface IMessageBus +{ + void Subscribe(IMessageHandler handler) where TMessage : IMessage; + void Unsubscribe(IMessageHandler handler) where TMessage : IMessage; + void Publish(TMessage message) where TMessage : IMessage; +} + +public interface ICommandBus +{ + void Subscribe(ICommandHandler handler) where TCommand : ICommand; + void Unsubscribe(ICommandHandler handler) where TCommand : ICommand; + void Send(TCommand command) where TCommand : ICommand; +} + +public interface IEventBus +{ + void Subscribe(IEventHandler handler) where TEvent : IEvent; + void Unsubscribe(IEventHandler handler) where TEvent : IEvent; + void Publish(TEvent @event) where TEvent : IEvent; +} +``` + +**Key Features:** + +- **IMessageBus**: Generic bus for any message type +- **ICommandBus**: Specialized bus for commands +- **IEventBus**: Specialized bus for events +- **Subscribe/Unsubscribe**: Register and unregister handlers +- **Send/Publish**: Route messages to appropriate handlers + +## Implementation Details + +The `ReactiveDomain.Messaging` component is built on several key design principles: + +- **Message-Based Communication**: All communication is done through messages +- **Strong Typing**: Messages and handlers are strongly typed +- **Loose Coupling**: Components communicate through message buses without direct dependencies +- **Single Responsibility**: Each handler has a single responsibility + +The component provides several implementations of the message bus interfaces: + +- **InProcessMessageBus**: Routes messages within a single process +- **DistributedMessageBus**: Routes messages across process boundaries +- **CorrelatedMessageBus**: Adds correlation and causation tracking to messages + +## Usage Examples + +### Defining Messages + +```csharp +public class CreateAccount : ICommand +{ + public Guid MsgId { get; } + public readonly Guid AccountId; + + public CreateAccount(Guid accountId) + { + MsgId = Guid.NewGuid(); + AccountId = accountId; + } +} + +public class AccountCreated : IEvent +{ + public Guid MsgId { get; } + public readonly Guid AccountId; + + public AccountCreated(Guid accountId) + { + MsgId = Guid.NewGuid(); + AccountId = accountId; + } +} + +public class GetAccountBalance : IQuery +{ + public Guid MsgId { get; } + public readonly Guid AccountId; + + public GetAccountBalance(Guid accountId) + { + MsgId = Guid.NewGuid(); + AccountId = accountId; + } +} + +public class AccountBalanceResult +{ + public readonly Guid AccountId; + public readonly decimal Balance; + + public AccountBalanceResult(Guid accountId, decimal balance) + { + AccountId = accountId; + Balance = balance; + } +} +``` + +### Implementing Handlers + +```csharp +public class CreateAccountHandler : ICommandHandler +{ + private readonly IRepository _repository; + + public CreateAccountHandler(IRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccount command) + { + var account = new Account(command.AccountId); + _repository.Save(account); + } +} + +public class AccountCreatedHandler : IEventHandler +{ + private readonly IReadModelRepository _readModelRepository; + + public AccountCreatedHandler(IReadModelRepository readModelRepository) + { + _readModelRepository = readModelRepository; + } + + public void Handle(AccountCreated @event) + { + var accountBalance = new AccountBalance(@event.AccountId, 0); + _readModelRepository.Save(accountBalance); + } +} + +public class GetAccountBalanceHandler : IQueryHandler +{ + private readonly IReadModelRepository _readModelRepository; + + public GetAccountBalanceHandler(IReadModelRepository readModelRepository) + { + _readModelRepository = readModelRepository; + } + + public AccountBalanceResult Handle(GetAccountBalance query) + { + var accountBalance = _readModelRepository.GetById(query.AccountId); + if (accountBalance == null) + throw new InvalidOperationException("Account not found"); + + return new AccountBalanceResult(accountBalance.Id, accountBalance.Balance); + } +} +``` + +### Using Message Buses + +```csharp +// Create message buses +var commandBus = new InProcessCommandBus(); +var eventBus = new InProcessEventBus(); +var queryBus = new InProcessQueryBus(); + +// Register handlers +commandBus.Subscribe(new CreateAccountHandler(repository)); +eventBus.Subscribe(new AccountCreatedHandler(readModelRepository)); +queryBus.Subscribe(new GetAccountBalanceHandler(readModelRepository)); + +// Send a command +var accountId = Guid.NewGuid(); +commandBus.Send(new CreateAccount(accountId)); + +// Publish an event +eventBus.Publish(new AccountCreated(accountId)); + +// Send a query +var result = queryBus.Send(new GetAccountBalance(accountId)); +Console.WriteLine($"Account balance: {result.Balance}"); +``` + +## Integration with Other Components + +The `ReactiveDomain.Messaging` component integrates with several other components in the Reactive Domain library: + +- **ReactiveDomain.Core**: Uses the core interfaces for event sourcing +- **ReactiveDomain.Foundation**: Integrates with aggregates and repositories +- **ReactiveDomain.Persistence**: Uses the persistence layer for event storage +- **ReactiveDomain.Transport**: Uses the transport layer for distributed messaging + +## Best Practices + +When working with the `ReactiveDomain.Messaging` component: + +1. **Keep messages simple**: Messages should be simple data structures with no behavior +2. **Single responsibility handlers**: Each handler should have a single responsibility +3. **Use correlation**: Use correlation and causation tracking for complex workflows +4. **Handle failures gracefully**: Implement error handling and retry strategies +5. **Avoid circular dependencies**: Be careful not to create circular dependencies between handlers + +## Common Pitfalls + +Some common issues to avoid when working with the `ReactiveDomain.Messaging` component: + +1. **Mutable messages**: Ensure messages are immutable +2. **Complex handlers**: Keep handlers simple and focused +3. **Missing error handling**: Always handle errors in handlers +4. **Tight coupling**: Avoid direct dependencies between handlers +5. **Synchronous processing bottlenecks**: Consider asynchronous processing for long-running operations + +--- + +**Component Navigation**: +- [← Previous: ReactiveDomain.Foundation](foundation.md) +- [↑ Back to Top](#reactivedomainmessaging) +- [Next: ReactiveDomain.Persistence →](persistence.md) From 3f6bcfc3b042ce5e6b0877891874703e2778be32 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Wed, 16 Apr 2025 09:16:02 -0400 Subject: [PATCH 05/41] Add documentation and project status badges to README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 3982ebd4..584b7dbe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ [![Build status](https://ci.appveyor.com/api/projects/status/oir89k5nyyouqtsm?svg=true)](https://ci.appveyor.com/project/jageall/reactive-domain) [![Build Status](https://travis-ci.org/linedata/reactive-domain.svg?branch=master)](https://travis-ci.org/linedata/reactive-domain) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md) +[![Documentation](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://leopoldodonnell.github.io/reactive-domain/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![NuGet](https://img.shields.io/nuget/v/ReactiveDomain.svg)](https://www.nuget.org/packages/ReactiveDomain/) +[![GitHub stars](https://img.shields.io/github/stars/leopoldodonnell/reactive-domain.svg)](https://github.com/leopoldodonnell/reactive-domain/stargazers) +[![GitHub issues](https://img.shields.io/github/issues/leopoldodonnell/reactive-domain.svg)](https://github.com/leopoldodonnell/reactive-domain/issues) # Reactive Domain From e91ef8b12ea2626ed14beb14fcbab27e55759761 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sat, 3 May 2025 12:00:02 -0400 Subject: [PATCH 06/41] Address PR #169 review feedback: Update repository references and enhance documentation - Fix GitHub badges to point to ReactiveDomain organization - Add detailed documentation for ReadModelBase component - Create comprehensive documentation for MessageBuilder factory - Enhance documentation for Command and Event classes - Add documentation for ICorrelatedMessage interface - Update repository references in workshop materials - Improve documentation structure and navigation --- .gitignore | 1 + README.md | 8 +- doc-prompt.md | 39 ++-- docs/api-reference/types/command.md | 163 ++++++++++++++ docs/api-reference/types/event.md | 208 ++++++++++++++++++ .../types/icorrelated-message.md | 135 ++++++++++++ docs/api-reference/types/message-builder.md | 121 ++++++++++ docs/api-reference/types/read-model-base.md | 123 +++++++++++ docs/workshop-materials.md | 4 +- 9 files changed, 776 insertions(+), 26 deletions(-) create mode 100644 docs/api-reference/types/command.md create mode 100644 docs/api-reference/types/event.md create mode 100644 docs/api-reference/types/icorrelated-message.md create mode 100644 docs/api-reference/types/message-builder.md create mode 100644 docs/api-reference/types/read-model-base.md diff --git a/.gitignore b/.gitignore index 1f6efd04..37b1a244 100644 --- a/.gitignore +++ b/.gitignore @@ -199,6 +199,7 @@ ClientBin/ *.publishsettings node_modules/ orleans.codegen.cs +todo.md # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) diff --git a/README.md b/README.md index 584b7dbe..3f528039 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ [![Build status](https://ci.appveyor.com/api/projects/status/oir89k5nyyouqtsm?svg=true)](https://ci.appveyor.com/project/jageall/reactive-domain) -[![Build Status](https://travis-ci.org/linedata/reactive-domain.svg?branch=master)](https://travis-ci.org/linedata/reactive-domain) +[![Build Status](https://travis-ci.org/ReactiveDomain/reactive-domain.svg?branch=master)](https://travis-ci.org/ReactiveDomain/reactive-domain) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](code_of_conduct.md) -[![Documentation](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://leopoldodonnell.github.io/reactive-domain/) +[![Documentation](https://img.shields.io/badge/docs-latest-brightgreen.svg)](https://reactivedomain.github.io/reactive-domain/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![NuGet](https://img.shields.io/nuget/v/ReactiveDomain.svg)](https://www.nuget.org/packages/ReactiveDomain/) -[![GitHub stars](https://img.shields.io/github/stars/leopoldodonnell/reactive-domain.svg)](https://github.com/leopoldodonnell/reactive-domain/stargazers) -[![GitHub issues](https://img.shields.io/github/issues/leopoldodonnell/reactive-domain.svg)](https://github.com/leopoldodonnell/reactive-domain/issues) +[![GitHub stars](https://img.shields.io/github/stars/ReactiveDomain/reactive-domain.svg)](https://github.com/ReactiveDomain/reactive-domain/stargazers) +[![GitHub issues](https://img.shields.io/github/issues/ReactiveDomain/reactive-domain.svg)](https://github.com/ReactiveDomain/reactive-domain/issues) # Reactive Domain diff --git a/doc-prompt.md b/doc-prompt.md index d8e1fb33..7e424fda 100644 --- a/doc-prompt.md +++ b/doc-prompt.md @@ -10,26 +10,25 @@ Use the following prompts to create comprehensive documentation for the Reactive Create a table of contents for the documentation, including: -1. Overview -2. Core Concepts -3. Component Documentation -4. Interface Documentation -5. Usage Patterns -6. Code Examples -7. Troubleshooting Guide -8. API Reference -9. Architecture Guide -10. Migration Guide -11. Glossary -12. FAQ -13. Deployment Guide -14. Performance Optimization Guide -15. Security Guide -16. Integration Guide -17. Video Tutorial Script -18. Workshop Materials -19. Documentation Structure - +1. [x] Overview +2. [x] Core Concepts +3. [x] Component Documentation (partially complete) +4. [x] Interface Documentation (partially complete) +5. [x] Usage Patterns +6. [x] Code Examples (structure only) +7. [x] Troubleshooting Guide +8. [x] API Reference (partially complete) +9. [x] Architecture Guide +10. [x] Migration Guide +11. [x] Glossary +12. [x] FAQ +13. [x] Deployment Guide +14. [x] Performance Optimization Guide +15. [x] Security Guide +16. [x] Integration Guide +17. [x] Video Tutorial Script +18. [x] Workshop Materials +19. [x] Documentation Structure ## Overview Prompt diff --git a/docs/api-reference/types/command.md b/docs/api-reference/types/command.md new file mode 100644 index 00000000..6245579f --- /dev/null +++ b/docs/api-reference/types/command.md @@ -0,0 +1,163 @@ +# Command + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`Command` is a base class in Reactive Domain that implements the `ICorrelatedMessage` interface and serves as the foundation for all command messages in the system. + +## Overview + +Commands in Reactive Domain represent requests for the system to perform an action. They are part of the write side of the CQRS pattern and typically result in state changes. The `Command` base class provides common functionality for all command implementations, including correlation and causation tracking. + +## Class Definition + +```csharp +public abstract class Command : ICommand, ICorrelatedMessage +{ + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + protected Command() + { + MsgId = Guid.NewGuid(); + CorrelationId = MsgId; + CausationId = MsgId; + } + + protected Command(Guid correlationId, Guid causationId) + { + MsgId = Guid.NewGuid(); + CorrelationId = correlationId; + CausationId = causationId; + } +} +``` + +## Key Features + +- **Message Identity**: Provides a unique `MsgId` for each command +- **Correlation Tracking**: Implements `ICorrelatedMessage` for tracking related messages +- **Immutability**: Ensures commands are immutable after creation +- **Type Safety**: Provides a type-safe base for all command implementations + +## Usage + +### Defining a Command + +To create a new command type, inherit from the `Command` base class: + +```csharp +public class CreateAccount : Command +{ + public readonly Guid AccountId; + public readonly string AccountNumber; + public readonly string CustomerName; + + public CreateAccount(Guid accountId, string accountNumber, string customerName) + : base() + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + + // Constructor for correlated commands + public CreateAccount(Guid accountId, string accountNumber, string customerName, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } +} +``` + +### Using MessageBuilder with Commands + +It's recommended to use the `MessageBuilder` factory to create commands with proper correlation: + +```csharp +// Create a new command that starts a correlation chain +var createCommand = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "ACC-123", + "John Doe" +)); + +// Create a command from an existing message +var depositCommand = MessageBuilder.From(createCommand, () => new DepositFunds( + ((CreateAccount)createCommand).AccountId, + 100.00m +)); +``` + +### Handling Commands + +Commands are typically handled by command handlers: + +```csharp +public class CreateAccountHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + + public CreateAccountHandler(ICorrelatedRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccount command) + { + var account = new Account(command.AccountId, command); + _repository.Save(account, command); + } +} +``` + +## Integration with Aggregates + +Commands are used to modify aggregates, which then produce events: + +```csharp +public class Account : AggregateRoot +{ + public Account(Guid id, ICorrelatedMessage source) : base(id) + { + Apply(MessageBuilder.From(source, () => new AccountCreated(id, source.CorrelationId, source.MsgId))); + } + + public void Deposit(decimal amount, ICorrelatedMessage source) + { + Apply(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + } +} +``` + +## Best Practices + +1. **Immutable Commands**: Make all command properties read-only +2. **Descriptive Names**: Use verb-noun naming convention (e.g., `CreateAccount`, `DepositFunds`) +3. **Minimal Data**: Include only the data needed to perform the action +4. **Use MessageBuilder**: Always use `MessageBuilder` to create commands with proper correlation +5. **Validation**: Validate commands before processing them + +## Common Pitfalls + +1. **Mutable Commands**: Avoid mutable properties in commands +2. **Business Logic in Commands**: Commands should be simple data carriers without business logic +3. **Missing Correlation**: Ensure correlation information is properly maintained +4. **Large Commands**: Keep commands focused and minimal + +## Related Components + +- [ICommand](./icommand.md): Interface for command messages +- [ICorrelatedMessage](./icorrelated-message.md): Interface for messages with correlation information +- [MessageBuilder](./message-builder.md): Factory for creating correlated messages +- [ICommandHandler](./icommand-handler.md): Interface for handling commands + +--- + +**Navigation**: +- [← Previous: MessageBuilder](./message-builder.md) +- [↑ Back to Top](#command) +- [→ Next: Event](./event.md) diff --git a/docs/api-reference/types/event.md b/docs/api-reference/types/event.md new file mode 100644 index 00000000..4c4f9b14 --- /dev/null +++ b/docs/api-reference/types/event.md @@ -0,0 +1,208 @@ +# Event + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`Event` is a base class in Reactive Domain that implements the `ICorrelatedMessage` interface and serves as the foundation for all event messages in the system. + +## Overview + +Events in Reactive Domain represent facts that have occurred in the system. They are immutable records of something that happened and form the basis of event sourcing. The `Event` base class provides common functionality for all event implementations, including correlation and causation tracking. + +## Class Definition + +```csharp +public abstract class Event : IEvent, ICorrelatedMessage +{ + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + protected Event() + { + MsgId = Guid.NewGuid(); + CorrelationId = MsgId; + CausationId = MsgId; + } + + protected Event(Guid correlationId, Guid causationId) + { + MsgId = Guid.NewGuid(); + CorrelationId = correlationId; + CausationId = causationId; + } +} +``` + +## Key Features + +- **Message Identity**: Provides a unique `MsgId` for each event +- **Correlation Tracking**: Implements `ICorrelatedMessage` for tracking related messages +- **Immutability**: Ensures events are immutable after creation +- **Type Safety**: Provides a type-safe base for all event implementations + +## Usage + +### Defining an Event + +To create a new event type, inherit from the `Event` base class: + +```csharp +public class AccountCreated : Event +{ + public readonly Guid AccountId; + public readonly string AccountNumber; + public readonly string CustomerName; + + public AccountCreated(Guid accountId, string accountNumber, string customerName) + : base() + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + + // Constructor for correlated events + public AccountCreated(Guid accountId, string accountNumber, string customerName, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } +} +``` + +### Using MessageBuilder with Events + +It's recommended to use the `MessageBuilder` factory to create events with proper correlation: + +```csharp +// Create an event from a command +ICorrelatedMessage command = // ... existing command +var createdEvent = MessageBuilder.From(command, () => new AccountCreated( + Guid.NewGuid(), + "ACC-123", + "John Doe" +)); +``` + +### Handling Events + +Events are typically handled by event handlers: + +```csharp +public class AccountCreatedHandler : IEventHandler +{ + private readonly IReadModelRepository _repository; + + public AccountCreatedHandler(IReadModelRepository repository) + { + _repository = repository; + } + + public void Handle(AccountCreated @event) + { + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, 0); + _repository.Save(accountSummary); + } +} +``` + +## Integration with Aggregates + +Events are produced by aggregates in response to commands: + +```csharp +public class Account : AggregateRoot +{ + private string _accountNumber; + private string _customerName; + private decimal _balance; + + public Account(Guid id, ICorrelatedMessage source) : base(id) + { + Apply(MessageBuilder.From(source, () => new AccountCreated(id, "ACC-" + id.ToString().Substring(0, 8), "New Customer"))); + } + + private void Apply(AccountCreated @event) + { + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + _balance = 0; + } + + public void Deposit(decimal amount, ICorrelatedMessage source) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + Apply(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } +} +``` + +## Event Sourcing + +Events are the foundation of event sourcing, where the state of an aggregate is reconstructed by replaying events: + +```csharp +public void LoadFromHistory(IEnumerable history) +{ + foreach (var @event in history) + { + Dispatch(@event); + } +} + +private void Dispatch(IEvent @event) +{ + // Use reflection or a dictionary to find the appropriate Apply method + // This is a simplified example + var method = GetType().GetMethod("Apply", + BindingFlags.NonPublic | BindingFlags.Instance, + null, + new[] { @event.GetType() }, + null); + + if (method != null) + { + method.Invoke(this, new object[] { @event }); + } +} +``` + +## Best Practices + +1. **Immutable Events**: Make all event properties read-only +2. **Past Tense Names**: Use past tense naming convention (e.g., `AccountCreated`, `FundsDeposited`) +3. **Complete Data**: Include all data needed to understand what happened +4. **Use MessageBuilder**: Always use `MessageBuilder` to create events with proper correlation +5. **Versioning Strategy**: Plan for event schema evolution + +## Common Pitfalls + +1. **Mutable Events**: Avoid mutable properties in events +2. **Business Logic in Events**: Events should be simple data carriers without business logic +3. **Missing Correlation**: Ensure correlation information is properly maintained +4. **Insufficient Data**: Include enough data to fully understand what happened + +## Related Components + +- [IEvent](./ievent.md): Interface for event messages +- [ICorrelatedMessage](./icorrelated-message.md): Interface for messages with correlation information +- [MessageBuilder](./message-builder.md): Factory for creating correlated messages +- [IEventHandler](./ievent-handler.md): Interface for handling events + +--- + +**Navigation**: +- [← Previous: Command](./command.md) +- [↑ Back to Top](#event) +- [→ Next: ICorrelatedMessage](./icorrelated-message.md) diff --git a/docs/api-reference/types/icorrelated-message.md b/docs/api-reference/types/icorrelated-message.md new file mode 100644 index 00000000..68481a1e --- /dev/null +++ b/docs/api-reference/types/icorrelated-message.md @@ -0,0 +1,135 @@ +# ICorrelatedMessage + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`ICorrelatedMessage` is a core interface in Reactive Domain that extends the base `IMessage` interface to add correlation and causation tracking capabilities. + +## Overview + +In complex event-driven systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `ICorrelatedMessage` interface provides a standard way to track correlation and causation across message flows. + +## Interface Definition + +```csharp +public interface ICorrelatedMessage : IMessage +{ + Guid MsgId { get; } + Guid CorrelationId { get; } + Guid CausationId { get; } +} +``` + +## Key Properties + +- **MsgId**: A unique identifier for the message +- **CorrelationId**: An identifier that groups related messages together +- **CausationId**: The identifier of the message that caused this message + +## Correlation and Causation Concepts + +### Correlation ID + +The correlation ID tracks a business transaction across multiple messages. All messages that are part of the same logical transaction share the same correlation ID, even if they are processed by different components or services. + +### Causation ID + +The causation ID establishes a direct cause-and-effect relationship between messages. It contains the message ID of the message that directly caused the current message to be created. + +## Message Flow Example + +Consider the following message flow: + +1. A client sends a `CreateAccount` command (ID: A, CorrelationID: A, CausationID: A) +2. The command handler processes the command and creates an `AccountCreated` event (ID: B, CorrelationID: A, CausationID: A) +3. An event handler processes the event and sends a `SendWelcomeEmail` command (ID: C, CorrelationID: A, CausationID: B) +4. The email service processes the command and creates an `EmailSent` event (ID: D, CorrelationID: A, CausationID: C) + +In this flow: +- All messages share the same correlation ID (A), indicating they are part of the same business transaction +- Each message's causation ID points to the ID of the message that caused it, creating a chain of causality + +## Usage + +### Implementing the Interface + +Classes that implement `ICorrelatedMessage` must provide values for all three properties: + +```csharp +public class CreateAccount : ICommand, ICorrelatedMessage +{ + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + public readonly Guid AccountId; + + public CreateAccount(Guid accountId) + { + MsgId = Guid.NewGuid(); + CorrelationId = MsgId; // Start a new correlation + CausationId = MsgId; // No previous cause + AccountId = accountId; + } + + public CreateAccount(Guid accountId, Guid correlationId, Guid causationId) + { + MsgId = Guid.NewGuid(); + CorrelationId = correlationId; + CausationId = causationId; + AccountId = accountId; + } +} +``` + +### Using MessageBuilder + +The recommended way to create correlated messages is to use the `MessageBuilder` factory: + +```csharp +// Create a new message that starts a correlation chain +var createCommand = MessageBuilder.New(() => new CreateAccount(Guid.NewGuid())); + +// Create a message from an existing message +var createdEvent = MessageBuilder.From(createCommand, () => new AccountCreated( + ((CreateAccount)createCommand).AccountId +)); +``` + +### Propagating Correlation in Repositories + +The `ICorrelatedRepository` interface extends the standard repository pattern to propagate correlation information: + +```csharp +public interface ICorrelatedRepository +{ + void Save(T aggregate, ICorrelatedMessage source) where T : AggregateRoot; + T GetById(Guid id, ICorrelatedMessage source) where T : AggregateRoot; +} +``` + +## Best Practices + +1. **Always Use MessageBuilder**: Use the `MessageBuilder` factory to ensure proper correlation +2. **Preserve Correlation Chains**: Pass correlation information through the entire message flow +3. **Log Correlation IDs**: Include correlation IDs in logs for easier debugging +4. **Query by Correlation**: Support querying messages by correlation ID for auditing + +## Common Pitfalls + +1. **Manual ID Setting**: Avoid manually setting correlation and causation IDs +2. **Breaking Correlation Chains**: Ensure correlation information is passed through all message flows +3. **Reusing Message IDs**: Always generate new message IDs for each message +4. **Ignoring Causation**: Track both correlation and causation for complete traceability + +## Related Components + +- [Command](./command.md): Base class for commands that implements `ICorrelatedMessage` +- [Event](./event.md): Base class for events that implements `ICorrelatedMessage` +- [MessageBuilder](./message-builder.md): Factory for creating correlated messages +- [ICorrelatedRepository](./icorrelated-repository.md): Repository that preserves correlation information + +--- + +**Navigation**: +- [← Previous: Event](./event.md) +- [↑ Back to Top](#icorrelatedmessage) +- [→ Next: MessageBuilder](./message-builder.md) diff --git a/docs/api-reference/types/message-builder.md b/docs/api-reference/types/message-builder.md new file mode 100644 index 00000000..98deb591 --- /dev/null +++ b/docs/api-reference/types/message-builder.md @@ -0,0 +1,121 @@ +# MessageBuilder + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`MessageBuilder` is a factory class in Reactive Domain that facilitates the creation of correlated messages, ensuring proper tracking of correlation and causation IDs across message flows. + +## Overview + +In event-sourced systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `MessageBuilder` factory provides a consistent way to create messages with properly set correlation and causation IDs. + +## Class Definition + +```csharp +public static class MessageBuilder +{ + public static TMessage New(Func messageFactory) + where TMessage : ICorrelatedMessage; + + public static TMessage From(ICorrelatedMessage source, Func messageFactory) + where TMessage : ICorrelatedMessage; +} +``` + +## Key Features + +- **Message Creation**: Simplifies the creation of new messages with unique IDs +- **Correlation Tracking**: Automatically sets correlation IDs for tracking related messages +- **Causation Tracking**: Establishes causation links between messages +- **Type Safety**: Provides type-safe message creation through generic methods + +## Usage + +### Creating a New Message + +To create a new message that starts a new correlation chain: + +```csharp +// Create a new command with a new correlation ID +ICorrelatedMessage command = MessageBuilder.New(() => new CreateAccount(Guid.NewGuid())); +``` + +### Creating a Message from an Existing Message + +To create a message that continues an existing correlation chain: + +```csharp +// Create a command with correlation information from an existing message +ICorrelatedMessage existingCommand = // ... existing command +ICorrelatedMessage newCommand = MessageBuilder.From(existingCommand, () => new DepositFunds(accountId, amount)); +``` + +### In an Aggregate + +Messages are often created within aggregates in response to commands: + +```csharp +public class Account : AggregateRoot +{ + public Account(Guid id, ICorrelatedMessage source) : base(id) + { + // Create a new event from the source command + Apply(MessageBuilder.From(source, () => new AccountCreated(id))); + } + + public void Deposit(decimal amount, ICorrelatedMessage source) + { + // Create a new event from the source command + Apply(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + } +} +``` + +## Integration with ICorrelatedMessage + +The `MessageBuilder` works with any message that implements the `ICorrelatedMessage` interface: + +```csharp +public interface ICorrelatedMessage : IMessage +{ + Guid MsgId { get; } + Guid CorrelationId { get; } + Guid CausationId { get; } +} +``` + +When using `MessageBuilder.New()`, it sets: +- `MsgId` to a new GUID +- `CorrelationId` to the same value as `MsgId` +- `CausationId` to the same value as `MsgId` + +When using `MessageBuilder.From()`, it sets: +- `MsgId` to a new GUID +- `CorrelationId` to the same value as the source message's `CorrelationId` +- `CausationId` to the same value as the source message's `MsgId` + +## Best Practices + +1. **Always Use MessageBuilder**: Consistently use `MessageBuilder` for creating correlated messages +2. **Preserve Correlation Chains**: Pass correlation information through the entire message flow +3. **Command-Event Flow**: Use `From()` to create events from commands +4. **Event-Command Flow**: Use `From()` to create commands from events in process managers + +## Common Pitfalls + +1. **Manual ID Setting**: Avoid manually setting correlation and causation IDs +2. **Breaking Correlation Chains**: Ensure correlation information is passed through all message flows +3. **Missing Source Messages**: Always provide a source message when continuing a correlation chain + +## Related Components + +- [ICorrelatedMessage](./icorrelated-message.md): Interface for messages with correlation information +- [Command](./command.md): Base class for commands that implements `ICorrelatedMessage` +- [Event](./event.md): Base class for events that implements `ICorrelatedMessage` +- [ICorrelatedRepository](./icorrelated-repository.md): Repository that preserves correlation information + +--- + +**Navigation**: +- [← Previous: ICorrelatedMessage](./icorrelated-message.md) +- [↑ Back to Top](#messagebuilder) +- [→ Next: Command](./command.md) diff --git a/docs/api-reference/types/read-model-base.md b/docs/api-reference/types/read-model-base.md new file mode 100644 index 00000000..510a2b98 --- /dev/null +++ b/docs/api-reference/types/read-model-base.md @@ -0,0 +1,123 @@ +# ReadModelBase + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`ReadModelBase` is a foundational class in Reactive Domain that provides core functionality for implementing read models in a CQRS architecture. + +## Overview + +Read models in Reactive Domain represent the query side of the CQRS pattern. They are optimized for querying and provide a denormalized view of the domain data. The `ReadModelBase` class provides a common foundation for implementing read models with consistent behavior. + +## Class Definition + +```csharp +public abstract class ReadModelBase +{ + public Guid Id { get; protected set; } + + protected ReadModelBase(Guid id) + { + Id = id; + } + + protected ReadModelBase() + { + } +} +``` + +## Key Features + +- **Identity Management**: Provides a standard `Id` property for uniquely identifying read models +- **Base Functionality**: Serves as a foundation for all read model implementations +- **Consistency**: Ensures consistent implementation patterns across different read models + +## Usage + +To create a read model, inherit from `ReadModelBase` and add properties specific to your domain: + +```csharp +public class AccountSummary : ReadModelBase +{ + public string AccountNumber { get; private set; } + public string CustomerName { get; private set; } + public decimal Balance { get; private set; } + public DateTime LastUpdated { get; private set; } + + public AccountSummary(Guid id) : base(id) + { + } + + public void Update(string accountNumber, string customerName, decimal balance) + { + AccountNumber = accountNumber; + CustomerName = customerName; + Balance = balance; + LastUpdated = DateTime.UtcNow; + } +} +``` + +## Integration with Event Handlers + +Read models are typically updated by event handlers that subscribe to domain events: + +```csharp +public class AccountEventHandler : IEventHandler, IEventHandler +{ + private readonly IReadModelRepository _repository; + + public AccountEventHandler(IReadModelRepository repository) + { + _repository = repository; + } + + public void Handle(AccountCreated @event) + { + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, 0); + _repository.Save(accountSummary); + } + + public void Handle(FundsDeposited @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.Update( + accountSummary.AccountNumber, + accountSummary.CustomerName, + accountSummary.Balance + @event.Amount); + _repository.Save(accountSummary); + } + } +} +``` + +## Best Practices + +1. **Keep Read Models Focused**: Each read model should serve a specific query scenario +2. **Immutable Properties**: Make properties private set to ensure they are only modified through well-defined methods +3. **Denormalization**: Denormalize data to optimize for query performance +4. **Eventual Consistency**: Remember that read models are eventually consistent with the write model +5. **Versioning**: Consider adding version information to handle schema evolution + +## Common Pitfalls + +1. **Business Logic in Read Models**: Avoid putting business logic in read models +2. **Complex Read Models**: Keep read models simple and focused on query requirements +3. **Missing Event Handlers**: Ensure all relevant events have handlers to update read models +4. **Ignoring Performance**: Design read models with query performance in mind + +## Related Components + +- [IReadModelRepository](./iread-model-repository.md): Interface for storing and retrieving read models +- [EventHandler](./event-handler.md): Handlers for updating read models based on domain events +- [IEvent](./ievent.md): Interface for domain events that trigger read model updates + +--- + +**Navigation**: +- [← Previous: IRepository](./irepository.md) +- [↑ Back to Top](#readmodelbase) +- [→ Next: IReadModelRepository](./iread-model-repository.md) diff --git a/docs/workshop-materials.md b/docs/workshop-materials.md index 44a54d46..8fc1cddf 100644 --- a/docs/workshop-materials.md +++ b/docs/workshop-materials.md @@ -56,7 +56,7 @@ Before the workshop, participants should: 1. Clone the workshop repository: ```bash - git clone https://github.com/linedata/reactive-domain-workshop.git + git clone https://github.com/ReactiveDomain/reactive-domain-workshop.git ``` 2. Install the .NET 7.0 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com/download) @@ -811,7 +811,7 @@ The workshop repository includes complete code samples for all exercises and add ### Reference Documentation -- [Reactive Domain Documentation](https://github.com/linedata/reactive-domain/docs) +- [Reactive Domain Documentation](https://github.com/ReactiveDomain/reactive-domain/docs) - [EventStoreDB Documentation](https://developers.eventstore.com/server/v21.10/docs/) - [CQRS Journey by Microsoft](https://docs.microsoft.com/en-us/previous-versions/msp-n-p/jj554200(v=pandp.10)) From 8677ee53dcab278d224016e6bd8dc7edace173d1 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sat, 3 May 2025 12:10:46 -0400 Subject: [PATCH 07/41] Removed the doc-prompt ai todo list from the project --- doc-prompt.md | 257 -------------------------------------------------- 1 file changed, 257 deletions(-) delete mode 100644 doc-prompt.md diff --git a/doc-prompt.md b/doc-prompt.md deleted file mode 100644 index 7e424fda..00000000 --- a/doc-prompt.md +++ /dev/null @@ -1,257 +0,0 @@ -# Prompts for creating comprehensive documentation for the Reactive Domain library - -{{ ... }} - -Use Mermaid to generate diagrams and flowcharts. - -Use the following prompts to create comprehensive documentation for the Reactive Domain library: - -## Table of Contents Prompt - -Create a table of contents for the documentation, including: - -1. [x] Overview -2. [x] Core Concepts -3. [x] Component Documentation (partially complete) -4. [x] Interface Documentation (partially complete) -5. [x] Usage Patterns -6. [x] Code Examples (structure only) -7. [x] Troubleshooting Guide -8. [x] API Reference (partially complete) -9. [x] Architecture Guide -10. [x] Migration Guide -11. [x] Glossary -12. [x] FAQ -13. [x] Deployment Guide -14. [x] Performance Optimization Guide -15. [x] Security Guide -16. [x] Integration Guide -17. [x] Video Tutorial Script -18. [x] Workshop Materials -19. [x] Documentation Structure - -## Overview Prompt - -Generate comprehensive documentation for the Reactive Domain library, which is an open-source framework for implementing event sourcing in .NET projects using reactive programming principles. The documentation should cover all aspects of the library, including its core concepts, components, interfaces, and usage patterns. The documentation should be accessible to developers of all skill levels, from beginners to experts. - -## Core Concepts Prompt - -Document the core concepts of event sourcing as implemented in Reactive Domain, including: - -1. Event sourcing fundamentals and how they're implemented in Reactive Domain -2. The event store architecture and integration with EventStoreDB -3. The CQRS (Command Query Responsibility Segregation) pattern implementation -4. The reactive programming principles used throughout the library -5. The domain-driven design concepts that underpin the framework -6. How events, commands, and messages flow through the system -7. The correlation and causation tracking mechanisms - -## Component Documentation Prompt - -Create detailed documentation for each of the following components, including their purpose, interfaces, implementation details, and usage examples: - -1. **ReactiveDomain.Core**: Document the fundamental interfaces like `IEventSource`, `IMetadataSource`, and other core abstractions. -2. **ReactiveDomain.Foundation**: Document the domain implementation including `AggregateRoot`, `EventRecorder`, and repository patterns. -3. **ReactiveDomain.Messaging**: Document the messaging framework, including message types, handlers, and routing. -4. **ReactiveDomain.Persistence**: Document the event storage mechanisms, including `EventData`, `EventReadResult`, and stream operations. -5. **ReactiveDomain.Transport**: Document the transport layer for messages. -6. **ReactiveDomain.Testing**: Document the testing utilities and frameworks for event-sourced systems. -7. **ReactiveDomain.Policy**: Document the policy implementation and enforcement mechanisms. -8. **ReactiveDomain.IdentityStorage**: Document the identity storage mechanisms. -9. **ReactiveDomain.Tools**: Document the developer tools and utilities. - -## Interface Documentation Prompt - -Generate detailed documentation for all public interfaces in the library, including: - -1. `IEventSource` - The core interface for event-sourced entities -2. `IRepository` - The repository pattern implementation for event-sourced aggregates -3. `ICorrelatedRepository` - The repository with correlation support -4. `IListener` - The event stream listener interface -5. `IMetadataSource` - The metadata handling interface -6. `ISnapshotSource` - The snapshot mechanism interface -7. `IStreamStoreConnection` - The event store connection interface -8. `IEventSerializer` - The event serialization interface -9. `IMessage`, `ICommand`, `IEvent` - The message type interfaces -10. `ICorrelatedMessage`, `ICorrelatedEventSource` - The correlation tracking interfaces - -For each interface, include: -- Purpose and responsibility -- Method and property descriptions -- Usage patterns and best practices -- Implementation considerations -- Common pitfalls and how to avoid them - -## Usage Patterns Prompt - -Document the common usage patterns and best practices for Reactive Domain, including: - -1. Setting up a new Reactive Domain project -2. Creating and working with aggregates -3. Implementing commands and events -4. Setting up repositories and event stores -5. Implementing projections and read models -6. Handling concurrency and versioning -7. Error handling and recovery strategies -8. Testing event-sourced systems -9. Performance optimization techniques -10. Integration with other systems and frameworks - -## Code Examples Prompt - -Create practical code examples that demonstrate: - -1. Creating a new aggregate root -2. Handling commands and generating events -3. Saving and retrieving aggregates from repositories -4. Setting up event listeners and subscribers -5. Implementing projections for read models -6. Handling correlation and causation -7. Implementing snapshots for performance -8. Testing aggregates and event handlers -9. Integration with ASP.NET Core or other .NET applications -10. Complete sample applications demonstrating end-to-end workflows - -## Troubleshooting Guide Prompt - -Create a troubleshooting guide that addresses common issues and challenges when working with Reactive Domain, including: - -1. Event versioning and schema evolution -2. Handling concurrency conflicts -3. Debugging event-sourced systems -4. Performance issues and optimization -5. Integration challenges with existing systems -6. Testing strategies and common testing issues -7. Deployment considerations and best practices -8. Monitoring and observability - -## API Reference Prompt - -Generate a complete API reference for all public types, methods, properties, and events in the Reactive Domain library, organized by namespace and assembly. The reference should include: - -1. Type signatures and inheritance hierarchies -2. Method signatures, parameters, and return types -3. Property types and accessibility -4. Event patterns and subscription models -5. Extension points and customization options -6. Deprecation notices and migration paths - -## Architecture Guide Prompt - -Create an architecture guide that explains: - -1. The high-level architecture of Reactive Domain -2. The design principles and patterns used -3. The component interactions and dependencies -4. The extension points and customization options -5. The integration patterns with other systems -6. Scaling and performance considerations -7. Security considerations and best practices - -## Migration Guide Prompt - -Create a migration guide for users upgrading from previous versions of Reactive Domain, including: - -1. Breaking changes and deprecations -2. New features and enhancements -3. Migration strategies and patterns -4. Backward compatibility considerations -5. Testing strategies for migrations - -## Glossary Prompt - -Create a comprehensive glossary of terms used in Reactive Domain and event sourcing, including: - -1. Event sourcing terminology -2. CQRS terminology -3. Domain-driven design terminology -4. Reactive programming terminology -5. Reactive Domain-specific terminology - -## FAQ Prompt - -Generate a frequently asked questions section that addresses common questions about Reactive Domain, including: - -1. When to use event sourcing and CQRS -2. Performance considerations and optimizations -3. Scaling event-sourced systems -4. Integration with existing systems -5. Testing strategies and best practices -6. Common pitfalls and how to avoid them -7. Comparison with other event sourcing frameworks - -## Deployment Guide Prompt - -Create a deployment guide that covers: - -1. Development environment setup -2. Testing environment configuration -3. Production deployment considerations -4. Scaling strategies -5. Monitoring and observability -6. Backup and recovery strategies -7. Security considerations - -## Performance Optimization Guide Prompt - -Generate a performance optimization guide that covers: - -1. Event store performance considerations -2. Snapshot strategies for performance -3. Read model optimization techniques -4. Message handling performance -5. Scaling strategies for high-throughput systems -6. Monitoring and profiling techniques -7. Benchmarking and performance testing - -## Security Guide Prompt - -Create a security guide that addresses: - -1. Authentication and authorization in event-sourced systems -2. Data protection and privacy considerations -3. Audit logging and compliance -4. Secure deployment practices -5. Threat modeling for event-sourced systems -6. Security testing strategies - -## Integration Guide Prompt - -Generate an integration guide that covers: - -1. Integration with ASP.NET Core -2. Integration with other .NET frameworks and libraries -3. Integration with non-.NET systems -4. API design for event-sourced systems -5. Message contracts and versioning -6. Integration testing strategies - -## Video Tutorial Script Prompt - -Create scripts for video tutorials that demonstrate: - -1. Getting started with Reactive Domain -2. Building a complete application with Reactive Domain -3. Advanced usage patterns and techniques -4. Performance optimization and scaling -5. Testing strategies and best practices - -## Workshop Materials Prompt - -Generate workshop materials for training developers on Reactive Domain, including: - -1. Presentation slides -2. Hands-on exercises -3. Code samples and starter projects -4. Discussion questions and activities -5. Assessment materials - -## Documentation Structure Prompt - -Organize all the documentation into a coherent structure with: - -1. A logical hierarchy of topics -2. Clear navigation paths for different user journeys -3. Cross-references between related topics -4. Progressive disclosure of complexity -5. Search-friendly organization and metadata From aa05447e5f6baed4cad4ed4e5fd14dd28f9a19ab Mon Sep 17 00:00:00 2001 From: Leo O'Donnell Date: Sat, 3 May 2025 12:17:40 -0400 Subject: [PATCH 08/41] Delete doc-prompt.md this shouldn't be part of the documentation, it is an artifact of for creating the documentation --- doc-prompt.md | 257 -------------------------------------------------- 1 file changed, 257 deletions(-) delete mode 100644 doc-prompt.md diff --git a/doc-prompt.md b/doc-prompt.md deleted file mode 100644 index 7e424fda..00000000 --- a/doc-prompt.md +++ /dev/null @@ -1,257 +0,0 @@ -# Prompts for creating comprehensive documentation for the Reactive Domain library - -{{ ... }} - -Use Mermaid to generate diagrams and flowcharts. - -Use the following prompts to create comprehensive documentation for the Reactive Domain library: - -## Table of Contents Prompt - -Create a table of contents for the documentation, including: - -1. [x] Overview -2. [x] Core Concepts -3. [x] Component Documentation (partially complete) -4. [x] Interface Documentation (partially complete) -5. [x] Usage Patterns -6. [x] Code Examples (structure only) -7. [x] Troubleshooting Guide -8. [x] API Reference (partially complete) -9. [x] Architecture Guide -10. [x] Migration Guide -11. [x] Glossary -12. [x] FAQ -13. [x] Deployment Guide -14. [x] Performance Optimization Guide -15. [x] Security Guide -16. [x] Integration Guide -17. [x] Video Tutorial Script -18. [x] Workshop Materials -19. [x] Documentation Structure - -## Overview Prompt - -Generate comprehensive documentation for the Reactive Domain library, which is an open-source framework for implementing event sourcing in .NET projects using reactive programming principles. The documentation should cover all aspects of the library, including its core concepts, components, interfaces, and usage patterns. The documentation should be accessible to developers of all skill levels, from beginners to experts. - -## Core Concepts Prompt - -Document the core concepts of event sourcing as implemented in Reactive Domain, including: - -1. Event sourcing fundamentals and how they're implemented in Reactive Domain -2. The event store architecture and integration with EventStoreDB -3. The CQRS (Command Query Responsibility Segregation) pattern implementation -4. The reactive programming principles used throughout the library -5. The domain-driven design concepts that underpin the framework -6. How events, commands, and messages flow through the system -7. The correlation and causation tracking mechanisms - -## Component Documentation Prompt - -Create detailed documentation for each of the following components, including their purpose, interfaces, implementation details, and usage examples: - -1. **ReactiveDomain.Core**: Document the fundamental interfaces like `IEventSource`, `IMetadataSource`, and other core abstractions. -2. **ReactiveDomain.Foundation**: Document the domain implementation including `AggregateRoot`, `EventRecorder`, and repository patterns. -3. **ReactiveDomain.Messaging**: Document the messaging framework, including message types, handlers, and routing. -4. **ReactiveDomain.Persistence**: Document the event storage mechanisms, including `EventData`, `EventReadResult`, and stream operations. -5. **ReactiveDomain.Transport**: Document the transport layer for messages. -6. **ReactiveDomain.Testing**: Document the testing utilities and frameworks for event-sourced systems. -7. **ReactiveDomain.Policy**: Document the policy implementation and enforcement mechanisms. -8. **ReactiveDomain.IdentityStorage**: Document the identity storage mechanisms. -9. **ReactiveDomain.Tools**: Document the developer tools and utilities. - -## Interface Documentation Prompt - -Generate detailed documentation for all public interfaces in the library, including: - -1. `IEventSource` - The core interface for event-sourced entities -2. `IRepository` - The repository pattern implementation for event-sourced aggregates -3. `ICorrelatedRepository` - The repository with correlation support -4. `IListener` - The event stream listener interface -5. `IMetadataSource` - The metadata handling interface -6. `ISnapshotSource` - The snapshot mechanism interface -7. `IStreamStoreConnection` - The event store connection interface -8. `IEventSerializer` - The event serialization interface -9. `IMessage`, `ICommand`, `IEvent` - The message type interfaces -10. `ICorrelatedMessage`, `ICorrelatedEventSource` - The correlation tracking interfaces - -For each interface, include: -- Purpose and responsibility -- Method and property descriptions -- Usage patterns and best practices -- Implementation considerations -- Common pitfalls and how to avoid them - -## Usage Patterns Prompt - -Document the common usage patterns and best practices for Reactive Domain, including: - -1. Setting up a new Reactive Domain project -2. Creating and working with aggregates -3. Implementing commands and events -4. Setting up repositories and event stores -5. Implementing projections and read models -6. Handling concurrency and versioning -7. Error handling and recovery strategies -8. Testing event-sourced systems -9. Performance optimization techniques -10. Integration with other systems and frameworks - -## Code Examples Prompt - -Create practical code examples that demonstrate: - -1. Creating a new aggregate root -2. Handling commands and generating events -3. Saving and retrieving aggregates from repositories -4. Setting up event listeners and subscribers -5. Implementing projections for read models -6. Handling correlation and causation -7. Implementing snapshots for performance -8. Testing aggregates and event handlers -9. Integration with ASP.NET Core or other .NET applications -10. Complete sample applications demonstrating end-to-end workflows - -## Troubleshooting Guide Prompt - -Create a troubleshooting guide that addresses common issues and challenges when working with Reactive Domain, including: - -1. Event versioning and schema evolution -2. Handling concurrency conflicts -3. Debugging event-sourced systems -4. Performance issues and optimization -5. Integration challenges with existing systems -6. Testing strategies and common testing issues -7. Deployment considerations and best practices -8. Monitoring and observability - -## API Reference Prompt - -Generate a complete API reference for all public types, methods, properties, and events in the Reactive Domain library, organized by namespace and assembly. The reference should include: - -1. Type signatures and inheritance hierarchies -2. Method signatures, parameters, and return types -3. Property types and accessibility -4. Event patterns and subscription models -5. Extension points and customization options -6. Deprecation notices and migration paths - -## Architecture Guide Prompt - -Create an architecture guide that explains: - -1. The high-level architecture of Reactive Domain -2. The design principles and patterns used -3. The component interactions and dependencies -4. The extension points and customization options -5. The integration patterns with other systems -6. Scaling and performance considerations -7. Security considerations and best practices - -## Migration Guide Prompt - -Create a migration guide for users upgrading from previous versions of Reactive Domain, including: - -1. Breaking changes and deprecations -2. New features and enhancements -3. Migration strategies and patterns -4. Backward compatibility considerations -5. Testing strategies for migrations - -## Glossary Prompt - -Create a comprehensive glossary of terms used in Reactive Domain and event sourcing, including: - -1. Event sourcing terminology -2. CQRS terminology -3. Domain-driven design terminology -4. Reactive programming terminology -5. Reactive Domain-specific terminology - -## FAQ Prompt - -Generate a frequently asked questions section that addresses common questions about Reactive Domain, including: - -1. When to use event sourcing and CQRS -2. Performance considerations and optimizations -3. Scaling event-sourced systems -4. Integration with existing systems -5. Testing strategies and best practices -6. Common pitfalls and how to avoid them -7. Comparison with other event sourcing frameworks - -## Deployment Guide Prompt - -Create a deployment guide that covers: - -1. Development environment setup -2. Testing environment configuration -3. Production deployment considerations -4. Scaling strategies -5. Monitoring and observability -6. Backup and recovery strategies -7. Security considerations - -## Performance Optimization Guide Prompt - -Generate a performance optimization guide that covers: - -1. Event store performance considerations -2. Snapshot strategies for performance -3. Read model optimization techniques -4. Message handling performance -5. Scaling strategies for high-throughput systems -6. Monitoring and profiling techniques -7. Benchmarking and performance testing - -## Security Guide Prompt - -Create a security guide that addresses: - -1. Authentication and authorization in event-sourced systems -2. Data protection and privacy considerations -3. Audit logging and compliance -4. Secure deployment practices -5. Threat modeling for event-sourced systems -6. Security testing strategies - -## Integration Guide Prompt - -Generate an integration guide that covers: - -1. Integration with ASP.NET Core -2. Integration with other .NET frameworks and libraries -3. Integration with non-.NET systems -4. API design for event-sourced systems -5. Message contracts and versioning -6. Integration testing strategies - -## Video Tutorial Script Prompt - -Create scripts for video tutorials that demonstrate: - -1. Getting started with Reactive Domain -2. Building a complete application with Reactive Domain -3. Advanced usage patterns and techniques -4. Performance optimization and scaling -5. Testing strategies and best practices - -## Workshop Materials Prompt - -Generate workshop materials for training developers on Reactive Domain, including: - -1. Presentation slides -2. Hands-on exercises -3. Code samples and starter projects -4. Discussion questions and activities -5. Assessment materials - -## Documentation Structure Prompt - -Organize all the documentation into a coherent structure with: - -1. A logical hierarchy of topics -2. Clear navigation paths for different user journeys -3. Cross-references between related topics -4. Progressive disclosure of complexity -5. Search-friendly organization and metadata From 24f0f1ac22506b4baeeb5e6f7969ac0d7d40140e Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sat, 3 May 2025 12:29:08 -0400 Subject: [PATCH 09/41] Add index for key types including ReadModelBase, MessageBuilder, Command, and Event --- docs/README.md | 13 +++++++++++++ docs/api-reference/README.md | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/docs/README.md b/docs/README.md index 09218da4..af48442a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -206,6 +206,19 @@ For the best learning experience, we recommend following this progression: | Deploy to production | [Deployment Guide](deployment.md) | | Learn key terminology | [Glossary](glossary.md) | +### Key Types Reference + +| Type | Description | Documentation | +|------|-------------|---------------| +| ReadModelBase | Base class for read models in CQRS architecture | [API Reference](api-reference/types/read-model-base.md) | +| MessageBuilder | Factory for creating correlated messages | [API Reference](api-reference/types/message-builder.md) | +| Command | Base class for command messages | [API Reference](api-reference/types/command.md) | +| Event | Base class for event messages | [API Reference](api-reference/types/event.md) | +| ICorrelatedMessage | Interface for messages with correlation tracking | [API Reference](api-reference/types/icorrelated-message.md) | +| AggregateRoot | Base class for domain aggregates | [API Reference](api-reference/types/aggregate-root.md) | +| IRepository | Interface for repositories | [API Reference](api-reference/types/irepository.md) | +| IEventSource | Core interface for event-sourced entities | [API Reference](api-reference/types/ievent-source.md) | + ## Getting Started If you're new to Reactive Domain, we recommend starting with the [Core Concepts](core-concepts.md) section to understand the fundamental principles behind event sourcing and how they're implemented in Reactive Domain. diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md index 216f34f2..5bc0a13f 100644 --- a/docs/api-reference/README.md +++ b/docs/api-reference/README.md @@ -40,6 +40,7 @@ Each type is documented with: - [AggregateRoot](types/aggregate-root.md) - Base class for domain aggregates - [EventRecorder](types/event-recorder.md) - Utility for recording events +- [ReadModelBase](types/read-model-base.md) - Base class for read models in CQRS architecture ### Message Types @@ -47,6 +48,12 @@ Each type is documented with: - [ICommand](types/icommand.md) - Interface for commands - [IEvent](types/ievent.md) - Interface for events - [ICorrelatedMessage](types/icorrelated-message.md) - Interface for correlated messages +- [Command](types/command.md) - Base class for command messages +- [Event](types/event.md) - Base class for event messages + +### Utilities + +- [MessageBuilder](types/message-builder.md) - Factory for creating correlated messages ### Repositories From 601a90338e2b1e4b0612d284f1f5c178450c0c5b Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sat, 3 May 2025 13:21:34 -0400 Subject: [PATCH 10/41] Enhanced documentation with detailed descriptions and examples, added diagrams for key concepts --- docs/api-reference/types/aggregate-root.md | 296 +++++++-- docs/api-reference/types/command.md | 605 +++++++++++++++++- docs/api-reference/types/event.md | 62 +- .../types/icorrelated-message.md | 77 ++- docs/api-reference/types/ievent-source.md | 362 ++++++++++- docs/api-reference/types/irepository.md | 234 ++++++- docs/api-reference/types/message-builder.md | 307 ++++++++- docs/api-reference/types/read-model-base.md | 273 +++++++- docs/diagrams/aggregate-root-lifecycle.md | 171 +++++ docs/diagrams/correlation-tracking.md | 74 +++ docs/diagrams/cqrs-architecture.md | 101 +++ docs/diagrams/event-sourcing-flow.md | 105 +++ docs/diagrams/message-builder-usage.md | 141 ++++ docs/diagrams/repository-pattern.md | 237 +++++++ 14 files changed, 2885 insertions(+), 160 deletions(-) create mode 100644 docs/diagrams/aggregate-root-lifecycle.md create mode 100644 docs/diagrams/correlation-tracking.md create mode 100644 docs/diagrams/cqrs-architecture.md create mode 100644 docs/diagrams/event-sourcing-flow.md create mode 100644 docs/diagrams/message-builder-usage.md create mode 100644 docs/diagrams/repository-pattern.md diff --git a/docs/api-reference/types/aggregate-root.md b/docs/api-reference/types/aggregate-root.md index b62b7323..9807f398 100644 --- a/docs/api-reference/types/aggregate-root.md +++ b/docs/api-reference/types/aggregate-root.md @@ -4,34 +4,15 @@ ## Overview -The `AggregateRoot` class is a base class for domain aggregates in Reactive Domain. It implements the `IEventSource` interface and provides common functionality for event sourcing. +The `AggregateRoot` class is a base class for domain aggregates in Reactive Domain. It implements the `IEventSource` interface and provides common functionality for event sourcing. Aggregates are the central building blocks in Domain-Driven Design (DDD) and serve as the primary consistency boundary for business rules and invariants. -**Namespace**: `ReactiveDomain.Foundation` -**Assembly**: `ReactiveDomain.Foundation.dll` - -```csharp -public abstract class AggregateRoot : IEventSource -{ - protected AggregateRoot(Guid id); - protected AggregateRoot(Guid id, ICorrelatedMessage source); - protected AggregateRoot(Guid id, IEnumerable events); - - public Guid Id { get; } - public long ExpectedVersion { get; set; } - - protected void RaiseEvent(object @event); - - public void RestoreFromEvents(IEnumerable events); - public void UpdateWithEvents(IEnumerable events, long expectedVersion); - public object[] TakeEvents(); -} -``` +In event-sourced systems, aggregates don't store their state directly but instead derive it from a sequence of events. The `AggregateRoot` class provides the infrastructure to record, apply, and retrieve these events, making it easier to implement event-sourced aggregates. ## Constructors ### AggregateRoot(Guid) -Initializes a new instance of the `AggregateRoot` class with the specified ID. +Initializes a new instance of the `AggregateRoot` class with the specified ID. This constructor is typically used when creating a new aggregate. ```csharp protected AggregateRoot(Guid id); @@ -40,9 +21,21 @@ protected AggregateRoot(Guid id); **Parameters**: - `id` (`System.Guid`): The unique identifier for the aggregate. +**Example**: +```csharp +public class Account : AggregateRoot +{ + public Account(Guid id) : base(id) + { + // Initialize a new account + RaiseEvent(new AccountCreated(id)); + } +} +``` + ### AggregateRoot(Guid, ICorrelatedMessage) -Initializes a new instance of the `AggregateRoot` class with the specified ID and correlation source. +Initializes a new instance of the `AggregateRoot` class with the specified ID and correlation source. This constructor is used when creating a new aggregate in response to a command, ensuring proper correlation tracking. ```csharp protected AggregateRoot(Guid id, ICorrelatedMessage source); @@ -52,9 +45,21 @@ protected AggregateRoot(Guid id, ICorrelatedMessage source); - `id` (`System.Guid`): The unique identifier for the aggregate. - `source` (`ReactiveDomain.ICorrelatedMessage`): The source message for correlation. +**Example**: +```csharp +public class Account : AggregateRoot +{ + public Account(Guid id, ICorrelatedMessage source) : base(id, source) + { + // Initialize a new account with correlation + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(id))); + } +} +``` + ### AggregateRoot(Guid, IEnumerable) -Initializes a new instance of the `AggregateRoot` class with the specified ID and restores it from the provided events. +Initializes a new instance of the `AggregateRoot` class with the specified ID and restores it from the provided events. This constructor is typically used by repositories when reconstituting an aggregate from its event history. ```csharp protected AggregateRoot(Guid id, IEnumerable events); @@ -64,11 +69,29 @@ protected AggregateRoot(Guid id, IEnumerable events); - `id` (`System.Guid`): The unique identifier for the aggregate. - `events` (`System.Collections.Generic.IEnumerable`): The events to restore from. +**Example**: +```csharp +// Inside a repository implementation +public TAggregate GetById(Guid id) where TAggregate : AggregateRoot +{ + // Retrieve events from the event store + var events = _eventStore.GetEvents(id); + + // Create an instance of the aggregate with its history + return (TAggregate)Activator.CreateInstance( + typeof(TAggregate), + BindingFlags.NonPublic | BindingFlags.Instance, + null, + new object[] { id, events }, + null); +} +``` + ## Properties ### Id -Gets the unique identifier for this aggregate. +Gets the unique identifier for this aggregate. This property is crucial for identifying the aggregate in the system and is used as the stream identifier in event stores. ```csharp public Guid Id { get; } @@ -79,7 +102,7 @@ public Guid Id { get; } ### ExpectedVersion -Gets or sets the expected version this aggregate is at. This is used for optimistic concurrency control. +Gets or sets the expected version this aggregate is at. This is used for optimistic concurrency control when saving the aggregate to an event store, preventing lost updates in concurrent scenarios. ```csharp public long ExpectedVersion { get; set; } @@ -88,11 +111,33 @@ public long ExpectedVersion { get; set; } **Property Type**: `System.Int64` **Accessibility**: `get`, `set` +**Example**: +```csharp +// Inside a repository implementation +public void Save(AggregateRoot aggregate) +{ + var events = aggregate.TakeEvents(); + + try { + _eventStore.AppendToStream( + aggregate.Id, + aggregate.ExpectedVersion, + events); + } + catch (ConcurrencyException ex) { + // Handle the case where another process has modified the aggregate + throw new AggregateVersionException( + $"Aggregate {aggregate.Id} has been modified concurrently", + ex); + } +} +``` + ## Methods ### RaiseEvent -Raises an event, which will be recorded and applied to the aggregate. +Raises an event, which will be recorded and applied to the aggregate. This is the primary method for changing the state of an aggregate in an event-sourced system. ```csharp protected void RaiseEvent(object @event); @@ -101,11 +146,31 @@ protected void RaiseEvent(object @event); **Parameters**: - `event` (`System.Object`): The event to raise. -**Remarks**: This method records the event and applies it to the aggregate by calling the appropriate `Apply` method. +**Example**: +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + // Record and apply the event + RaiseEvent(new AmountDeposited(Id, amount)); + } + + private void Apply(AmountDeposited @event) + { + _balance += @event.Amount; + } +} +``` ### RestoreFromEvents -Restores this aggregate from the history of events. +Restores this aggregate from the history of events. This method is typically called by the constructor or by a repository when reconstituting an aggregate. ```csharp public void RestoreFromEvents(IEnumerable events); @@ -114,14 +179,29 @@ public void RestoreFromEvents(IEnumerable events); **Parameters**: - `events` (`System.Collections.Generic.IEnumerable`): The events to restore from. -**Exceptions**: -- `System.ArgumentNullException`: Thrown when `events` is `null`. - -**Remarks**: This method applies each event in sequence to rebuild the aggregate's state. +**Example**: +```csharp +// Inside a repository implementation +public TAggregate GetById(Guid id) where TAggregate : AggregateRoot, new() +{ + var events = _eventStore.GetEvents(id); + var aggregate = new TAggregate(); + + // Use reflection to set the Id property + typeof(TAggregate) + .GetProperty("Id", BindingFlags.Public | BindingFlags.Instance) + .SetValue(aggregate, id); + + // Restore the aggregate state from events + aggregate.RestoreFromEvents(events); + + return aggregate; +} +``` ### UpdateWithEvents -Updates this aggregate with the provided events, starting from the expected version. +Updates this aggregate with the provided events, starting from the expected version. This method is used when new events need to be applied to an existing aggregate, such as when handling concurrent modifications. ```csharp public void UpdateWithEvents(IEnumerable events, long expectedVersion); @@ -131,15 +211,9 @@ public void UpdateWithEvents(IEnumerable events, long expectedVersion); - `events` (`System.Collections.Generic.IEnumerable`): The events to update with. - `expectedVersion` (`System.Int64`): The expected version to start from. -**Exceptions**: -- `System.ArgumentNullException`: Thrown when `events` is `null`. -- `System.InvalidOperationException`: Thrown when this aggregate does not have historical events or expected version mismatch. - -**Remarks**: This method checks that the expected version matches the aggregate's current version, and then applies each event in sequence. - ### TakeEvents -Takes the recorded history of events from this aggregate. +Takes the recorded history of events from this aggregate. This method is typically called by a repository when saving the aggregate to extract the new events that need to be persisted. ```csharp public object[] TakeEvents(); @@ -147,15 +221,36 @@ public object[] TakeEvents(); **Returns**: `System.Object[]` - The recorded events. -**Remarks**: This method returns the recorded events and clears the aggregate's record of those events. +**Example**: +```csharp +// Inside a repository implementation +public void Save(AggregateRoot aggregate) +{ + // Extract the new events from the aggregate + var events = aggregate.TakeEvents(); + + if (events.Length > 0) + { + // Persist the events to the event store + _eventStore.AppendToStream( + aggregate.Id, + aggregate.ExpectedVersion, + events); + + // Update the expected version for next save + aggregate.ExpectedVersion += events.Length; + } +} +``` ## Usage The `AggregateRoot` class is designed to be subclassed by domain aggregates. Subclasses should: -1. Define private `Apply` methods for each event type -2. Use the `RaiseEvent` method to record and apply events -3. Define public methods that represent domain operations +1. Define private `Apply` methods for each event type to update the aggregate's state +2. Use the `RaiseEvent` method to record and apply events when handling commands +3. Define public methods that represent domain operations and enforce business rules +4. Keep the aggregate's state private and expose it through controlled methods ## Example Implementation @@ -163,58 +258,159 @@ The `AggregateRoot` class is designed to be subclassed by domain aggregates. Sub public class Account : AggregateRoot { private decimal _balance; + private bool _isClosed; + private string _accountNumber; + private string _customerName; // Constructor for creating a new account public Account(Guid id) : base(id) { + // Initialize with default values + RaiseEvent(new AccountCreated(id, "ACC-" + id.ToString().Substring(0, 8), "New Customer")); } // Constructor for creating a new account with correlation public Account(Guid id, ICorrelatedMessage source) : base(id, source) { + // Initialize with default values and maintain correlation + RaiseEvent(MessageBuilder.From(source, () => + new AccountCreated(id, "ACC-" + id.ToString().Substring(0, 8), "New Customer"))); } // Constructor for restoring an account from events protected Account(Guid id, IEnumerable events) : base(id, events) { + // The base constructor will call RestoreFromEvents } - public void Deposit(decimal amount) + // Command handler for deposit + public void Deposit(decimal amount, ICorrelatedMessage source = null) { + // Enforce business rules + if (_isClosed) + throw new InvalidOperationException("Cannot deposit to a closed account"); + if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); - - RaiseEvent(new AmountDeposited(Id, amount)); + + // Create and apply the event + if (source != null) + { + RaiseEvent(MessageBuilder.From(source, () => new AmountDeposited(Id, amount))); + } + else + { + RaiseEvent(new AmountDeposited(Id, amount)); + } } - public void Withdraw(decimal amount) + // Command handler for withdrawal + public void Withdraw(decimal amount, ICorrelatedMessage source = null) { + // Enforce business rules + if (_isClosed) + throw new InvalidOperationException("Cannot withdraw from a closed account"); + if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); if (_balance < amount) throw new InvalidOperationException("Insufficient funds"); + + // Create and apply the event + if (source != null) + { + RaiseEvent(MessageBuilder.From(source, () => new AmountWithdrawn(Id, amount))); + } + else + { + RaiseEvent(new AmountWithdrawn(Id, amount)); + } + } + + // Command handler for closing the account + public void Close(ICorrelatedMessage source = null) + { + // Enforce business rules + if (_isClosed) + throw new InvalidOperationException("Account is already closed"); - RaiseEvent(new AmountWithdrawn(Id, amount)); + if (_balance > 0) + throw new InvalidOperationException("Cannot close account with positive balance"); + + // Create and apply the event + if (source != null) + { + RaiseEvent(MessageBuilder.From(source, () => new AccountClosed(Id))); + } + else + { + RaiseEvent(new AccountClosed(Id)); + } } + // Query method for balance public decimal GetBalance() { return _balance; } + // Query method for account status + public bool IsClosed() + { + return _isClosed; + } + + // Event handler for AccountCreated + private void Apply(AccountCreated @event) + { + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + _balance = 0; + _isClosed = false; + } + + // Event handler for AmountDeposited private void Apply(AmountDeposited @event) { _balance += @event.Amount; } + // Event handler for AmountWithdrawn private void Apply(AmountWithdrawn @event) { _balance -= @event.Amount; } + + // Event handler for AccountClosed + private void Apply(AccountClosed @event) + { + _isClosed = true; + } } ``` +## Best Practices + +1. **Keep Aggregates Small**: Focus on a single business concept and limit the number of properties and methods +2. **Enforce Invariants**: Use command methods to enforce business rules and maintain consistency +3. **Event-First Design**: Design your events before your commands to focus on the business outcomes +4. **Private State**: Keep aggregate state private and expose it through controlled methods +5. **Idempotent Apply Methods**: Ensure that applying the same event multiple times doesn't cause issues +6. **Immutable Events**: Use immutable events to ensure the event history remains unchanged +7. **Correlation Tracking**: Use the correlation-aware constructor when creating aggregates from commands +8. **Optimistic Concurrency**: Use the ExpectedVersion property to prevent lost updates + +## Common Pitfalls + +1. **Large Aggregates**: Avoid creating aggregates that are too large or contain too many responsibilities +2. **Public State Modification**: Don't allow direct modification of aggregate state from outside +3. **Missing Business Rules**: Ensure all business rules are enforced in command methods +4. **Ignoring Version Conflicts**: Always handle optimistic concurrency exceptions properly +5. **Complex Apply Methods**: Keep event handlers (Apply methods) simple and focused +6. **Side Effects in Apply Methods**: Avoid side effects like I/O operations in Apply methods +7. **Circular Event References**: Avoid raising events from within Apply methods + ## Inheritance Hierarchy - `System.Object` diff --git a/docs/api-reference/types/command.md b/docs/api-reference/types/command.md index 6245579f..ef4f7a9a 100644 --- a/docs/api-reference/types/command.md +++ b/docs/api-reference/types/command.md @@ -8,15 +8,31 @@ Commands in Reactive Domain represent requests for the system to perform an action. They are part of the write side of the CQRS pattern and typically result in state changes. The `Command` base class provides common functionality for all command implementations, including correlation and causation tracking. +In the Command Query Responsibility Segregation (CQRS) pattern, commands represent intentions to change the system state. Unlike events, which represent facts that have occurred, commands can be rejected if they violate business rules or if the system is in an inappropriate state to handle them. + ## Class Definition ```csharp public abstract class Command : ICommand, ICorrelatedMessage { + /// + /// Gets the unique identifier for this message. + /// public Guid MsgId { get; } + + /// + /// Gets the correlation identifier that links related messages together. + /// public Guid CorrelationId { get; } + + /// + /// Gets the causation identifier that indicates which message caused this one. + /// public Guid CausationId { get; } + /// + /// Initializes a new instance of the Command class with new correlation information. + /// protected Command() { MsgId = Guid.NewGuid(); @@ -24,6 +40,11 @@ public abstract class Command : ICommand, ICorrelatedMessage CausationId = MsgId; } + /// + /// Initializes a new instance of the Command class with existing correlation information. + /// + /// The correlation ID to use. + /// The causation ID to use. protected Command(Guid correlationId, Guid causationId) { MsgId = Guid.NewGuid(); @@ -39,6 +60,113 @@ public abstract class Command : ICommand, ICorrelatedMessage - **Correlation Tracking**: Implements `ICorrelatedMessage` for tracking related messages - **Immutability**: Ensures commands are immutable after creation - **Type Safety**: Provides a type-safe base for all command implementations +- **Intent Communication**: Clearly communicates the intention to change system state +- **Validation Support**: Facilitates validation before state changes occur +- **Audit Trail**: Contributes to a complete audit trail when combined with events + +## Command Types + +In Reactive Domain, commands typically fall into several categories: + +### Creation Commands + +Commands that create new entities in the system: + +```csharp +public class CreateCustomer : Command +{ + public readonly Guid CustomerId; + public readonly string FirstName; + public readonly string LastName; + public readonly string Email; + public readonly DateTime DateOfBirth; + + public CreateCustomer(Guid customerId, string firstName, string lastName, string email, DateTime dateOfBirth) + : base() + { + CustomerId = customerId; + FirstName = firstName; + LastName = lastName; + Email = email; + DateOfBirth = dateOfBirth; + } +} +``` + +### Modification Commands + +Commands that modify existing entities: + +```csharp +public class ChangeCustomerAddress : Command +{ + public readonly Guid CustomerId; + public readonly string StreetAddress; + public readonly string City; + public readonly string State; + public readonly string PostalCode; + public readonly string Country; + + public ChangeCustomerAddress(Guid customerId, string streetAddress, string city, + string state, string postalCode, string country) + : base() + { + CustomerId = customerId; + StreetAddress = streetAddress; + City = city; + State = state; + PostalCode = postalCode; + Country = country; + } +} +``` + +### Deletion Commands + +Commands that delete or deactivate entities: + +```csharp +public class DeactivateCustomer : Command +{ + public readonly Guid CustomerId; + public readonly string Reason; + + public DeactivateCustomer(Guid customerId, string reason) + : base() + { + CustomerId = customerId; + Reason = reason; + } +} +``` + +### Process Commands + +Commands that trigger business processes: + +```csharp +public class PlaceOrder : Command +{ + public readonly Guid OrderId; + public readonly Guid CustomerId; + public readonly IReadOnlyList Items; + public readonly string ShippingAddress; + public readonly string BillingAddress; + public readonly PaymentMethod PaymentMethod; + + public PlaceOrder(Guid orderId, Guid customerId, IReadOnlyList items, + string shippingAddress, string billingAddress, PaymentMethod paymentMethod) + : base() + { + OrderId = orderId; + CustomerId = customerId; + Items = items; + ShippingAddress = shippingAddress; + BillingAddress = billingAddress; + PaymentMethod = paymentMethod; + } +} +``` ## Usage @@ -52,25 +180,41 @@ public class CreateAccount : Command public readonly Guid AccountId; public readonly string AccountNumber; public readonly string CustomerName; + public readonly decimal InitialDeposit; + public readonly AccountType AccountType; - public CreateAccount(Guid accountId, string accountNumber, string customerName) + public CreateAccount(Guid accountId, string accountNumber, string customerName, + decimal initialDeposit, AccountType accountType) : base() { AccountId = accountId; AccountNumber = accountNumber; CustomerName = customerName; + InitialDeposit = initialDeposit; + AccountType = accountType; } // Constructor for correlated commands public CreateAccount(Guid accountId, string accountNumber, string customerName, + decimal initialDeposit, AccountType accountType, Guid correlationId, Guid causationId) : base(correlationId, causationId) { AccountId = accountId; AccountNumber = accountNumber; CustomerName = customerName; + InitialDeposit = initialDeposit; + AccountType = accountType; } } + +public enum AccountType +{ + Checking, + Savings, + MoneyMarket, + CertificateOfDeposit +} ``` ### Using MessageBuilder with Commands @@ -82,16 +226,71 @@ It's recommended to use the `MessageBuilder` factory to create commands with pro var createCommand = MessageBuilder.New(() => new CreateAccount( Guid.NewGuid(), "ACC-123", - "John Doe" + "John Doe", + 1000.00m, + AccountType.Checking )); // Create a command from an existing message var depositCommand = MessageBuilder.From(createCommand, () => new DepositFunds( ((CreateAccount)createCommand).AccountId, - 100.00m + 500.00m, + "Initial deposit" +)); + +// Create another command in the same correlation chain +var setOverdraftCommand = MessageBuilder.From(createCommand, () => new SetOverdraftLimit( + ((CreateAccount)createCommand).AccountId, + 250.00m )); ``` +### Command Validation + +Commands should be validated before they are processed. This can be done in the command handler or using a validation framework: + +```csharp +public class CreateAccountValidator : ICommandValidator +{ + private readonly ICustomerRepository _customerRepository; + + public CreateAccountValidator(ICustomerRepository customerRepository) + { + _customerRepository = customerRepository; + } + + public ValidationResult Validate(CreateAccount command) + { + var result = new ValidationResult(); + + // Validate account number format + if (!Regex.IsMatch(command.AccountNumber, @"^ACC-\d{3,6}$")) + { + result.AddError("AccountNumber", "Account number must be in the format ACC-XXXXXX"); + } + + // Validate initial deposit + if (command.InitialDeposit < 0) + { + result.AddError("InitialDeposit", "Initial deposit cannot be negative"); + } + + if (command.AccountType == AccountType.Savings && command.InitialDeposit < 100) + { + result.AddError("InitialDeposit", "Savings accounts require a minimum initial deposit of $100"); + } + + // Validate customer exists + if (!_customerRepository.Exists(command.CustomerName)) + { + result.AddError("CustomerName", "Customer does not exist"); + } + + return result; + } +} +``` + ### Handling Commands Commands are typically handled by command handlers: @@ -100,16 +299,93 @@ Commands are typically handled by command handlers: public class CreateAccountHandler : ICommandHandler { private readonly ICorrelatedRepository _repository; + private readonly ICommandValidator _validator; + private readonly ILogger _logger; - public CreateAccountHandler(ICorrelatedRepository repository) + public CreateAccountHandler( + ICorrelatedRepository repository, + ICommandValidator validator, + ILogger logger) { _repository = repository; + _validator = validator; + _logger = logger; } public void Handle(CreateAccount command) { - var account = new Account(command.AccountId, command); - _repository.Save(account, command); + _logger.LogInformation($"Handling CreateAccount command for {command.CustomerName}", command); + + // Validate the command + var validationResult = _validator.Validate(command); + if (!validationResult.IsValid) + { + _logger.LogWarning($"Command validation failed: {string.Join(", ", validationResult.Errors)}", command); + throw new CommandValidationException(validationResult.Errors); + } + + try + { + // Create and save the aggregate + var account = new Account(command.AccountId, command); + + // If initial deposit is provided, perform the deposit + if (command.InitialDeposit > 0) + { + account.Deposit(command.InitialDeposit, command); + } + + // Save the aggregate with correlation information + _repository.Save(account, command); + + _logger.LogInformation($"Account {command.AccountNumber} created successfully", command); + } + catch (Exception ex) + { + _logger.LogError($"Error creating account: {ex.Message}", command); + throw; + } + } +} +``` + +### Command Bus + +Commands are typically sent through a command bus, which routes them to the appropriate handlers: + +```csharp +public class CommandBusExample +{ + private readonly ICommandBus _commandBus; + + public CommandBusExample(ICommandBus commandBus) + { + _commandBus = commandBus; + } + + public void SendCommands() + { + // Create a new command + var createCommand = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "ACC-12345", + "Jane Smith", + 1000.00m, + AccountType.Checking + )); + + // Send the command + _commandBus.Send(createCommand); + + // Create a related command + var depositCommand = MessageBuilder.From(createCommand, () => new DepositFunds( + ((CreateAccount)createCommand).AccountId, + 500.00m, + "Bonus deposit" + )); + + // Send the related command + _commandBus.Send(depositCommand); } } ``` @@ -121,32 +397,326 @@ Commands are used to modify aggregates, which then produce events: ```csharp public class Account : AggregateRoot { + private decimal _balance; + private bool _isActive; + private decimal _overdraftLimit; + private List _transactions; + public Account(Guid id, ICorrelatedMessage source) : base(id) { - Apply(MessageBuilder.From(source, () => new AccountCreated(id, source.CorrelationId, source.MsgId))); + // Validate the ID + if (id == Guid.Empty) + throw new ArgumentException("Account ID cannot be empty", nameof(id)); + + // Apply the creation event + Apply(MessageBuilder.From(source, () => new AccountCreated( + id, + ((CreateAccount)source).AccountNumber, + ((CreateAccount)source).CustomerName, + ((CreateAccount)source).AccountType + ))); } public void Deposit(decimal amount, ICorrelatedMessage source) { - Apply(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + // Validate state and parameters + if (!_isActive) + throw new InvalidOperationException("Cannot deposit to an inactive account"); + + if (amount <= 0) + throw new ArgumentException("Deposit amount must be positive", nameof(amount)); + + // Apply the event + Apply(MessageBuilder.From(source, () => new FundsDeposited( + Id, + amount, + _balance + amount, + DateTime.UtcNow + ))); + } + + public void Withdraw(decimal amount, ICorrelatedMessage source) + { + // Validate state and parameters + if (!_isActive) + throw new InvalidOperationException("Cannot withdraw from an inactive account"); + + if (amount <= 0) + throw new ArgumentException("Withdrawal amount must be positive", nameof(amount)); + + if (_balance + _overdraftLimit < amount) + throw new InsufficientFundsException($"Insufficient funds. Balance: {_balance}, Overdraft Limit: {_overdraftLimit}"); + + // Apply the event + Apply(MessageBuilder.From(source, () => new FundsWithdrawn( + Id, + amount, + _balance - amount, + DateTime.UtcNow + ))); + } + + public void SetOverdraftLimit(decimal limit, ICorrelatedMessage source) + { + // Validate state and parameters + if (!_isActive) + throw new InvalidOperationException("Cannot set overdraft limit on an inactive account"); + + if (limit < 0) + throw new ArgumentException("Overdraft limit cannot be negative", nameof(limit)); + + // Apply the event + Apply(MessageBuilder.From(source, () => new OverdraftLimitSet( + Id, + limit + ))); + } + + public void Close(ICorrelatedMessage source) + { + // Validate state + if (!_isActive) + throw new InvalidOperationException("Account is already closed"); + + if (_balance < 0) + throw new InvalidOperationException("Cannot close account with negative balance"); + + // Apply the event + Apply(MessageBuilder.From(source, () => new AccountClosed( + Id, + DateTime.UtcNow + ))); + } + + // Event application methods + private void Apply(AccountCreated @event) + { + _isActive = true; + _balance = 0; + _overdraftLimit = 0; + _transactions = new List(); + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + _transactions.Add(new Transaction( + TransactionType.Deposit, + @event.Amount, + @event.Timestamp + )); + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + _transactions.Add(new Transaction( + TransactionType.Withdrawal, + @event.Amount, + @event.Timestamp + )); + } + + private void Apply(OverdraftLimitSet @event) + { + _overdraftLimit = @event.Limit; + } + + private void Apply(AccountClosed @event) + { + _isActive = false; + _overdraftLimit = 0; + } +} + +public class Transaction +{ + public TransactionType Type { get; } + public decimal Amount { get; } + public DateTime Timestamp { get; } + + public Transaction(TransactionType type, decimal amount, DateTime timestamp) + { + Type = type; + Amount = amount; + Timestamp = timestamp; + } +} + +public enum TransactionType +{ + Deposit, + Withdrawal, + Fee, + Interest +} + +public class InsufficientFundsException : Exception +{ + public InsufficientFundsException(string message) : base(message) { } +} +``` + +## Command Versioning + +As your system evolves, you may need to version your commands. Here's an approach to handle command versioning: + +```csharp +// Version 1 of the command +public class CreateAccountV1 : Command +{ + public readonly Guid AccountId; + public readonly string AccountNumber; + public readonly string CustomerName; + + public CreateAccountV1(Guid accountId, string accountNumber, string customerName) + : base() + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } +} + +// Version 2 of the command with additional fields +public class CreateAccountV2 : Command +{ + public readonly Guid AccountId; + public readonly string AccountNumber; + public readonly string CustomerName; + public readonly AccountType AccountType; // New field + public readonly decimal InitialDeposit; // New field + + public CreateAccountV2(Guid accountId, string accountNumber, string customerName, + AccountType accountType, decimal initialDeposit) + : base() + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + AccountType = accountType; + InitialDeposit = initialDeposit; + } +} + +// Command handler that can handle both versions +public class CreateAccountHandler : + ICommandHandler, + ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + + public CreateAccountHandler(ICorrelatedRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccountV1 command) + { + // Handle version 1 - use default values for missing fields + var account = new Account(command.AccountId, command); + _repository.Save(account, command); + } + + public void Handle(CreateAccountV2 command) + { + // Handle version 2 - use all provided fields + var account = new Account(command.AccountId, command); + + // If initial deposit is provided, perform the deposit + if (command.InitialDeposit > 0) + { + account.Deposit(command.InitialDeposit, command); + } + + _repository.Save(account, command); + } +} +``` + +## Testing Commands + +Commands should be thoroughly tested to ensure they behave as expected: + +```csharp +public class CreateAccountHandlerTests +{ + [Fact] + public void Handle_ValidCommand_CreatesAccount() + { + // Arrange + var repository = new InMemoryCorrelatedRepository(); + var validator = new MockValidator(true); + var logger = new MockLogger(); + var handler = new CreateAccountHandler(repository, validator, logger); + + var command = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "ACC-12345", + "John Doe", + 1000.00m, + AccountType.Checking + )); + + // Act + handler.Handle(command); + + // Assert + var account = repository.GetById(command.AccountId, command); + Assert.NotNull(account); + Assert.Equal(1000.00m, account.GetBalance()); + } + + [Fact] + public void Handle_InvalidCommand_ThrowsValidationException() + { + // Arrange + var repository = new InMemoryCorrelatedRepository(); + var validationErrors = new[] { "Account number is invalid" }; + var validator = new MockValidator(false, validationErrors); + var logger = new MockLogger(); + var handler = new CreateAccountHandler(repository, validator, logger); + + var command = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "INVALID", + "John Doe", + 1000.00m, + AccountType.Checking + )); + + // Act & Assert + var exception = Assert.Throws(() => handler.Handle(command)); + Assert.Contains("Account number is invalid", exception.Message); } } ``` ## Best Practices -1. **Immutable Commands**: Make all command properties read-only -2. **Descriptive Names**: Use verb-noun naming convention (e.g., `CreateAccount`, `DepositFunds`) -3. **Minimal Data**: Include only the data needed to perform the action -4. **Use MessageBuilder**: Always use `MessageBuilder` to create commands with proper correlation -5. **Validation**: Validate commands before processing them +1. **Immutable Commands**: Make all command properties read-only to ensure they cannot be changed after creation +2. **Descriptive Names**: Use verb-noun naming convention (e.g., `CreateAccount`, `DepositFunds`) to clearly communicate intent +3. **Minimal Data**: Include only the data needed to perform the action, avoiding unnecessary information +4. **Use MessageBuilder**: Always use `MessageBuilder` to create commands with proper correlation tracking +5. **Validation**: Validate commands before processing them to ensure they meet business rules +6. **Single Responsibility**: Each command should represent a single action or intention +7. **Versioning Strategy**: Plan for command versioning from the beginning +8. **Error Handling**: Implement proper error handling and reporting for command failures +9. **Logging**: Log command processing with correlation IDs for traceability +10. **Testing**: Thoroughly test command handlers with both valid and invalid commands ## Common Pitfalls -1. **Mutable Commands**: Avoid mutable properties in commands +1. **Mutable Commands**: Avoid mutable properties in commands as they can lead to inconsistent state 2. **Business Logic in Commands**: Commands should be simple data carriers without business logic -3. **Missing Correlation**: Ensure correlation information is properly maintained -4. **Large Commands**: Keep commands focused and minimal +3. **Missing Correlation**: Ensure correlation information is properly maintained throughout the command flow +4. **Large Commands**: Keep commands focused and minimal to avoid complexity +5. **Insufficient Validation**: Failing to validate commands properly can lead to invalid system state +6. **Tight Coupling**: Avoid coupling commands to specific implementations or frameworks +7. **Inconsistent Naming**: Maintain consistent naming conventions across all commands +8. **Command Reuse**: Avoid reusing command instances for multiple operations +9. **Excessive Command Fields**: Include only necessary fields to avoid bloat +10. **Ignoring Command Failures**: Always handle command failures gracefully and provide meaningful feedback ## Related Components @@ -154,6 +724,9 @@ public class Account : AggregateRoot - [ICorrelatedMessage](./icorrelated-message.md): Interface for messages with correlation information - [MessageBuilder](./message-builder.md): Factory for creating correlated messages - [ICommandHandler](./icommand-handler.md): Interface for handling commands +- [Event](./event.md): Base class for event messages that result from commands +- [AggregateRoot](./aggregate-root.md): Base class for domain aggregates that process commands +- [ICommandBus](./icommand-bus.md): Interface for routing commands to handlers --- diff --git a/docs/api-reference/types/event.md b/docs/api-reference/types/event.md index 4c4f9b14..02b5cb12 100644 --- a/docs/api-reference/types/event.md +++ b/docs/api-reference/types/event.md @@ -2,11 +2,11 @@ [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) -`Event` is a base class in Reactive Domain that implements the `ICorrelatedMessage` interface and serves as the foundation for all event messages in the system. +`Event` is a base class in Reactive Domain that implements the `ICorrelatedMessage` interface and serves as the foundation for all event messages in the system. Events represent facts that have occurred in the domain and are a critical component of event-sourced systems. ## Overview -Events in Reactive Domain represent facts that have occurred in the system. They are immutable records of something that happened and form the basis of event sourcing. The `Event` base class provides common functionality for all event implementations, including correlation and causation tracking. +Events in Reactive Domain represent immutable facts that have occurred in the system. They are the historical record of changes to the domain and form the basis of event sourcing. The `Event` base class provides common functionality for all event implementations, including correlation and causation tracking, which is essential for debugging and auditing in distributed systems. ## Class Definition @@ -35,10 +35,10 @@ public abstract class Event : IEvent, ICorrelatedMessage ## Key Features -- **Message Identity**: Provides a unique `MsgId` for each event -- **Correlation Tracking**: Implements `ICorrelatedMessage` for tracking related messages -- **Immutability**: Ensures events are immutable after creation -- **Type Safety**: Provides a type-safe base for all event implementations +- **Message Identity**: Provides a unique `MsgId` for each event instance +- **Correlation Tracking**: Implements `ICorrelatedMessage` for tracking related messages across the system +- **Immutability**: Ensures events are immutable after creation, preserving the historical record +- **Type Safety**: Provides a type-safe base for all event implementations in the domain ## Usage @@ -53,6 +53,7 @@ public class AccountCreated : Event public readonly string AccountNumber; public readonly string CustomerName; + // Constructor for new events (starts a new correlation chain) public AccountCreated(Guid accountId, string accountNumber, string customerName) : base() { @@ -61,7 +62,7 @@ public class AccountCreated : Event CustomerName = customerName; } - // Constructor for correlated events + // Constructor for correlated events (maintains the correlation chain) public AccountCreated(Guid accountId, string accountNumber, string customerName, Guid correlationId, Guid causationId) : base(correlationId, causationId) @@ -75,16 +76,23 @@ public class AccountCreated : Event ### Using MessageBuilder with Events -It's recommended to use the `MessageBuilder` factory to create events with proper correlation: +It's recommended to use the `MessageBuilder` factory to create events with proper correlation information: ```csharp -// Create an event from a command +// Create an event from a command (maintains correlation chain) ICorrelatedMessage command = // ... existing command var createdEvent = MessageBuilder.From(command, () => new AccountCreated( Guid.NewGuid(), "ACC-123", "John Doe" )); + +// Create a new event (starts a new correlation chain) +var newEvent = MessageBuilder.New(() => new AccountCreated( + Guid.NewGuid(), + "ACC-456", + "Jane Smith" +)); ``` ### Handling Events @@ -112,7 +120,7 @@ public class AccountCreatedHandler : IEventHandler ## Integration with Aggregates -Events are produced by aggregates in response to commands: +Events are produced by aggregates in response to commands. This is a core pattern in Domain-Driven Design and CQRS: ```csharp public class Account : AggregateRoot @@ -121,11 +129,18 @@ public class Account : AggregateRoot private string _customerName; private decimal _balance; + // Constructor for creating a new account public Account(Guid id, ICorrelatedMessage source) : base(id) { - Apply(MessageBuilder.From(source, () => new AccountCreated(id, "ACC-" + id.ToString().Substring(0, 8), "New Customer"))); + // Create and apply the AccountCreated event + Apply(MessageBuilder.From(source, () => new AccountCreated( + id, + "ACC-" + id.ToString().Substring(0, 8), + "New Customer" + ))); } + // Event handler for AccountCreated private void Apply(AccountCreated @event) { _accountNumber = @event.AccountNumber; @@ -133,14 +148,17 @@ public class Account : AggregateRoot _balance = 0; } + // Command handler for deposit public void Deposit(decimal amount, ICorrelatedMessage source) { if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); + // Create and apply the FundsDeposited event Apply(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); } + // Event handler for FundsDeposited private void Apply(FundsDeposited @event) { _balance += @event.Amount; @@ -180,18 +198,22 @@ private void Dispatch(IEvent @event) ## Best Practices -1. **Immutable Events**: Make all event properties read-only -2. **Past Tense Names**: Use past tense naming convention (e.g., `AccountCreated`, `FundsDeposited`) -3. **Complete Data**: Include all data needed to understand what happened -4. **Use MessageBuilder**: Always use `MessageBuilder` to create events with proper correlation -5. **Versioning Strategy**: Plan for event schema evolution +1. **Immutable Events**: Make all event properties read-only to preserve the historical record +2. **Past Tense Names**: Use past tense naming convention (e.g., `AccountCreated`, `FundsDeposited`) to indicate that these are facts that have occurred +3. **Complete Data**: Include all data needed to understand what happened, making events self-contained +4. **Use MessageBuilder**: Always use `MessageBuilder` to create events with proper correlation information +5. **Versioning Strategy**: Plan for event schema evolution to handle changes over time +6. **Meaningful Events**: Design events to represent meaningful business occurrences, not just data changes +7. **Event Documentation**: Document the purpose and content of each event type for better understanding ## Common Pitfalls -1. **Mutable Events**: Avoid mutable properties in events -2. **Business Logic in Events**: Events should be simple data carriers without business logic -3. **Missing Correlation**: Ensure correlation information is properly maintained -4. **Insufficient Data**: Include enough data to fully understand what happened +1. **Mutable Events**: Avoid mutable properties in events as they should represent immutable facts +2. **Business Logic in Events**: Events should be simple data carriers without business logic or behavior +3. **Missing Correlation**: Ensure correlation information is properly maintained throughout the system +4. **Insufficient Data**: Include enough data in events to fully understand what happened without external context +5. **Overloaded Events**: Avoid creating events that represent multiple business occurrences +6. **Temporal Coupling**: Ensure events can be processed in any order by making them self-contained ## Related Components diff --git a/docs/api-reference/types/icorrelated-message.md b/docs/api-reference/types/icorrelated-message.md index 68481a1e..b2f129a0 100644 --- a/docs/api-reference/types/icorrelated-message.md +++ b/docs/api-reference/types/icorrelated-message.md @@ -2,11 +2,11 @@ [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) -`ICorrelatedMessage` is a core interface in Reactive Domain that extends the base `IMessage` interface to add correlation and causation tracking capabilities. +`ICorrelatedMessage` is a core interface in Reactive Domain that extends the base `IMessage` interface to add correlation and causation tracking capabilities. This interface is fundamental to tracing message flows and understanding relationships between commands and events in an event-sourced system. ## Overview -In complex event-driven systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `ICorrelatedMessage` interface provides a standard way to track correlation and causation across message flows. +In complex event-driven systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `ICorrelatedMessage` interface provides a standard way to track correlation and causation across message flows, enabling developers to trace the complete path of a business transaction through the system. ## Interface Definition @@ -21,23 +21,23 @@ public interface ICorrelatedMessage : IMessage ## Key Properties -- **MsgId**: A unique identifier for the message -- **CorrelationId**: An identifier that groups related messages together -- **CausationId**: The identifier of the message that caused this message +- **MsgId**: A unique identifier for the message, ensuring each message can be individually identified +- **CorrelationId**: An identifier that groups related messages together, allowing for tracing of complete business transactions +- **CausationId**: The identifier of the message that directly caused this message, establishing a clear cause-effect relationship ## Correlation and Causation Concepts ### Correlation ID -The correlation ID tracks a business transaction across multiple messages. All messages that are part of the same logical transaction share the same correlation ID, even if they are processed by different components or services. +The correlation ID tracks a business transaction across multiple messages. All messages that are part of the same logical transaction share the same correlation ID, even if they are processed by different components or services. This enables end-to-end tracing of business processes, which is invaluable for debugging and auditing in distributed systems. ### Causation ID -The causation ID establishes a direct cause-and-effect relationship between messages. It contains the message ID of the message that directly caused the current message to be created. +The causation ID establishes a direct cause-and-effect relationship between messages. It contains the message ID of the message that directly caused the current message to be created. This creates a chain of causality that can be followed to understand the exact sequence of events that led to a particular state. ## Message Flow Example -Consider the following message flow: +Consider the following message flow in a banking application: 1. A client sends a `CreateAccount` command (ID: A, CorrelationID: A, CausationID: A) 2. The command handler processes the command and creates an `AccountCreated` event (ID: B, CorrelationID: A, CausationID: A) @@ -47,6 +47,7 @@ Consider the following message flow: In this flow: - All messages share the same correlation ID (A), indicating they are part of the same business transaction - Each message's causation ID points to the ID of the message that caused it, creating a chain of causality +- This chain allows for complete tracing of the transaction from initiation to completion ## Usage @@ -62,6 +63,7 @@ public class CreateAccount : ICommand, ICorrelatedMessage public Guid CausationId { get; } public readonly Guid AccountId; + // Constructor for a new command that starts a correlation chain public CreateAccount(Guid accountId) { MsgId = Guid.NewGuid(); @@ -70,6 +72,7 @@ public class CreateAccount : ICommand, ICorrelatedMessage AccountId = accountId; } + // Constructor for a command within an existing correlation chain public CreateAccount(Guid accountId, Guid correlationId, Guid causationId) { MsgId = Guid.NewGuid(); @@ -82,43 +85,79 @@ public class CreateAccount : ICommand, ICorrelatedMessage ### Using MessageBuilder -The recommended way to create correlated messages is to use the `MessageBuilder` factory: +The recommended way to create correlated messages is to use the `MessageBuilder` factory, which handles the correlation and causation IDs automatically: ```csharp // Create a new message that starts a correlation chain var createCommand = MessageBuilder.New(() => new CreateAccount(Guid.NewGuid())); -// Create a message from an existing message +// Create a message from an existing message (maintains correlation) var createdEvent = MessageBuilder.From(createCommand, () => new AccountCreated( - ((CreateAccount)createCommand).AccountId + ((CreateAccount)createCommand).AccountId, + "ACC-123", + "John Doe" +)); + +// Create another message in the same chain +var sendEmailCommand = MessageBuilder.From(createdEvent, () => new SendWelcomeEmail( + ((AccountCreated)createdEvent).AccountId, + "john.doe@example.com" )); ``` ### Propagating Correlation in Repositories -The `ICorrelatedRepository` interface extends the standard repository pattern to propagate correlation information: +The `ICorrelatedRepository` interface extends the standard repository pattern to propagate correlation information, ensuring that all operations maintain the correlation chain: ```csharp public interface ICorrelatedRepository { + // Save an aggregate, maintaining correlation from the source message void Save(T aggregate, ICorrelatedMessage source) where T : AggregateRoot; + + // Retrieve an aggregate by ID, maintaining correlation from the source message T GetById(Guid id, ICorrelatedMessage source) where T : AggregateRoot; } + +// Example usage +public class AccountService +{ + private readonly ICorrelatedRepository _repository; + + public AccountService(ICorrelatedRepository repository) + { + _repository = repository; + } + + public void CreateAccount(CreateAccount command) + { + // Create a new account, passing the command for correlation + var account = new Account(command.AccountId, command); + + // Save the account, maintaining correlation from the command + _repository.Save(account, command); + } +} ``` ## Best Practices -1. **Always Use MessageBuilder**: Use the `MessageBuilder` factory to ensure proper correlation -2. **Preserve Correlation Chains**: Pass correlation information through the entire message flow -3. **Log Correlation IDs**: Include correlation IDs in logs for easier debugging -4. **Query by Correlation**: Support querying messages by correlation ID for auditing +1. **Always Use MessageBuilder**: Use the `MessageBuilder` factory to ensure proper correlation and avoid manual errors +2. **Preserve Correlation Chains**: Pass correlation information through the entire message flow to maintain traceability +3. **Log Correlation IDs**: Include correlation IDs in logs for easier debugging and troubleshooting +4. **Query by Correlation**: Support querying messages by correlation ID for auditing and analysis +5. **Consistent Implementation**: Ensure all messages in your system implement `ICorrelatedMessage` consistently +6. **Documentation**: Document the correlation flow in your system for better understanding +7. **Testing**: Test correlation chains to ensure they are maintained correctly ## Common Pitfalls -1. **Manual ID Setting**: Avoid manually setting correlation and causation IDs -2. **Breaking Correlation Chains**: Ensure correlation information is passed through all message flows -3. **Reusing Message IDs**: Always generate new message IDs for each message +1. **Manual ID Setting**: Avoid manually setting correlation and causation IDs as this is error-prone +2. **Breaking Correlation Chains**: Ensure correlation information is passed through all message flows, including external systems +3. **Reusing Message IDs**: Always generate new message IDs for each message to maintain uniqueness 4. **Ignoring Causation**: Track both correlation and causation for complete traceability +5. **Inconsistent Implementation**: Ensure all parts of your system handle correlation consistently +6. **Missing Correlation in Logs**: Without correlation IDs in logs, debugging becomes much harder ## Related Components diff --git a/docs/api-reference/types/ievent-source.md b/docs/api-reference/types/ievent-source.md index 1c80f461..dfb28f70 100644 --- a/docs/api-reference/types/ievent-source.md +++ b/docs/api-reference/types/ievent-source.md @@ -4,7 +4,9 @@ ## Overview -The `IEventSource` interface is the cornerstone of event sourcing in Reactive Domain. It represents a source of events from the perspective of restoring from and taking events, and is primarily used by infrastructure code. +The `IEventSource` interface is the cornerstone of event sourcing in Reactive Domain. It represents a source of events from the perspective of restoring from and taking events, and is primarily used by infrastructure code. This interface defines the contract that all event-sourced entities must implement, providing the foundation for reconstructing entity state from a sequence of events. + +In event sourcing, the state of an entity is determined by the sequence of events that have occurred, rather than by its current state. The `IEventSource` interface enables this pattern by providing methods to restore an entity from its event history, update it with new events, and extract events that have been recorded but not yet persisted. **Namespace**: `ReactiveDomain` **Assembly**: `ReactiveDomain.Core.dll` @@ -24,7 +26,7 @@ public interface IEventSource ### Id -Gets the unique identifier for this EventSource. This must be provided by the implementing class. +Gets the unique identifier for this EventSource. This must be provided by the implementing class. The ID is used to identify the event stream associated with this entity in the event store. ```csharp Guid Id { get; } @@ -33,9 +35,28 @@ Guid Id { get; } **Property Type**: `System.Guid` **Accessibility**: `get` +**Example**: +```csharp +public class Account : IEventSource +{ + public Guid Id { get; } + + public Account(Guid id) + { + Id = id; + } + + // Implementation of other IEventSource members +} + +// Usage +var account = new Account(Guid.NewGuid()); +Console.WriteLine($"Account ID: {account.Id}"); +``` + ### ExpectedVersion -Gets or sets the expected version this instance is at. This is used for optimistic concurrency control. +Gets or sets the expected version this instance is at. This is used for optimistic concurrency control when saving the entity to an event store. The version represents the number of events that have been applied to the entity. ```csharp long ExpectedVersion { get; set; } @@ -44,11 +65,43 @@ long ExpectedVersion { get; set; } **Property Type**: `System.Int64` **Accessibility**: `get`, `set` +**Example**: +```csharp +public class Account : IEventSource +{ + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + public Account(Guid id) + { + Id = id; + ExpectedVersion = -1; // Initial version is -1, indicating no events have been applied + } + + // Implementation of other IEventSource members +} + +// Usage in a repository +public void Save(IEventSource aggregate) +{ + var events = aggregate.TakeEvents(); + + // Save events to the event store, using the expected version for optimistic concurrency + _eventStore.AppendToStream( + aggregate.Id.ToString(), + aggregate.ExpectedVersion, + events); + + // Update the expected version for the next save + aggregate.ExpectedVersion += events.Length; +} +``` + ## Methods ### RestoreFromEvents -Restores this instance from the history of events. +Restores this instance from the history of events. This method is typically called when loading an entity from an event store, applying each event in sequence to rebuild the entity's state. ```csharp void RestoreFromEvents(IEnumerable events); @@ -60,9 +113,77 @@ void RestoreFromEvents(IEnumerable events); **Exceptions**: - `System.ArgumentNullException`: Thrown when `events` is `null`. +**Example**: +```csharp +public class Account : IEventSource +{ + private readonly EventRecorder _recorder = new EventRecorder(); + private decimal _balance; + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + public Account(Guid id) + { + Id = id; + ExpectedVersion = -1; + } + + public void RestoreFromEvents(IEnumerable events) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + private void Apply(object @event) + { + switch (@event) + { + case AccountCreated e: + // Initialize account properties + break; + + case AmountDeposited e: + _balance += e.Amount; + break; + + case AmountWithdrawn e: + _balance -= e.Amount; + break; + + default: + throw new InvalidOperationException($"Unknown event type: {@event.GetType().Name}"); + } + } + + // Implementation of other IEventSource members +} + +// Usage in a repository +public TAggregate GetById(Guid id) where TAggregate : IEventSource, new() +{ + var events = _eventStore.GetEvents(id.ToString()); + var aggregate = new TAggregate(); + + // Set the ID property using reflection (simplified example) + typeof(TAggregate).GetProperty("Id").SetValue(aggregate, id); + + // Restore the aggregate state from events + aggregate.RestoreFromEvents(events); + + return aggregate; +} +``` + ### UpdateWithEvents -Updates this instance with the provided events, starting from the expected version. +Updates this instance with the provided events, starting from the expected version. This method is used when new events need to be applied to an existing entity, such as when handling concurrent modifications. ```csharp void UpdateWithEvents(IEnumerable events, long expectedVersion); @@ -76,9 +197,66 @@ void UpdateWithEvents(IEnumerable events, long expectedVersion); - `System.ArgumentNullException`: Thrown when `events` is `null`. - `System.InvalidOperationException`: Thrown when this instance does not have historical events or expected version mismatch. +**Example**: +```csharp +public class Account : IEventSource +{ + private readonly EventRecorder _recorder = new EventRecorder(); + private decimal _balance; + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + public Account(Guid id) + { + Id = id; + ExpectedVersion = -1; + } + + public void UpdateWithEvents(IEnumerable events, long expectedVersion) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + + if (ExpectedVersion != expectedVersion) + throw new InvalidOperationException($"Expected version {expectedVersion} but was {ExpectedVersion}"); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + private void Apply(object @event) + { + // Apply the event to update the entity state + // (implementation as shown in RestoreFromEvents example) + } + + // Implementation of other IEventSource members +} + +// Usage in a repository +public void Update(ref TAggregate aggregate) where TAggregate : class, IEventSource +{ + var id = aggregate.Id; + var currentVersion = aggregate.ExpectedVersion; + + // Get events from the event store that are newer than the current version + var newEvents = _eventStore.GetEventsAfterVersion(id.ToString(), currentVersion); + + if (newEvents.Any()) + { + // Update the aggregate with the new events + aggregate.UpdateWithEvents(newEvents, currentVersion); + } +} +``` + ### TakeEvents -Takes the recorded history of events from this instance (CQS violation, beware). +Takes the recorded history of events from this instance (CQS violation, beware). This method returns the events that have been recorded by the entity but not yet persisted to the event store, and clears the entity's record of those events. ```csharp object[] TakeEvents(); @@ -86,9 +264,72 @@ object[] TakeEvents(); **Returns**: `System.Object[]` - The recorded events. +**Example**: +```csharp +public class Account : IEventSource +{ + private readonly EventRecorder _recorder = new EventRecorder(); + private decimal _balance; + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + public Account(Guid id) + { + Id = id; + ExpectedVersion = -1; + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + // Record the event + _recorder.Record(new AmountDeposited(Id, amount)); + + // Apply the event to update the state + _balance += amount; + } + + public object[] TakeEvents() + { + var events = _recorder.RecordedEvents.ToArray(); + _recorder.Reset(); + return events; + } + + // Implementation of other IEventSource members +} + +// Usage in a repository +public void Save(IEventSource aggregate) +{ + // Extract the new events from the aggregate + var events = aggregate.TakeEvents(); + + if (events.Length > 0) + { + // Save the events to the event store + _eventStore.AppendToStream( + aggregate.Id.ToString(), + aggregate.ExpectedVersion, + events); + + // Update the expected version for the next save + aggregate.ExpectedVersion += events.Length; + } +} +``` + ## Remarks -The `IEventSource` interface is fundamental to the event sourcing pattern, where the state of an entity is determined by the sequence of events that have occurred, rather than by its current state. +The `IEventSource` interface is fundamental to the event sourcing pattern, where the state of an entity is determined by the sequence of events that have occurred, rather than by its current state. This approach provides several benefits: + +1. **Complete Audit Trail**: Every change to the entity is recorded as an event, providing a complete history +2. **Temporal Queries**: The ability to reconstruct the entity state at any point in time +3. **Event Replay**: The ability to replay events to rebuild the entity state or to apply new business rules +4. **Event-Driven Architecture**: Natural integration with event-driven systems Implementations of this interface typically: 1. Use an `EventRecorder` to record events @@ -104,6 +345,9 @@ public class Account : IEventSource { private readonly EventRecorder _recorder = new EventRecorder(); private decimal _balance; + private string _accountNumber; + private string _customerName; + private bool _isClosed; public Guid Id { get; } public long ExpectedVersion { get; set; } @@ -114,30 +358,74 @@ public class Account : IEventSource ExpectedVersion = -1; } + // Command methods + + public void Create(string accountNumber, string customerName) + { + if (_accountNumber != null) + throw new InvalidOperationException("Account already created"); + + var @event = new AccountCreated(Id, accountNumber, customerName); + _recorder.Record(@event); + Apply(@event); + } + public void Deposit(decimal amount) { + if (_isClosed) + throw new InvalidOperationException("Cannot deposit to a closed account"); + if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); - _recorder.Record(new AmountDeposited(Id, amount)); + var @event = new AmountDeposited(Id, amount); + _recorder.Record(@event); + Apply(@event); } public void Withdraw(decimal amount) { + if (_isClosed) + throw new InvalidOperationException("Cannot withdraw from a closed account"); + if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); if (_balance < amount) throw new InvalidOperationException("Insufficient funds"); - _recorder.Record(new AmountWithdrawn(Id, amount)); + var @event = new AmountWithdrawn(Id, amount); + _recorder.Record(@event); + Apply(@event); } + public void Close() + { + if (_isClosed) + throw new InvalidOperationException("Account already closed"); + + if (_balance > 0) + throw new InvalidOperationException("Cannot close account with positive balance"); + + var @event = new AccountClosed(Id); + _recorder.Record(@event); + Apply(@event); + } + + // Query methods + public decimal GetBalance() { return _balance; } + public bool IsClosed() + { + return _isClosed; + } + + // IEventSource implementation + public void RestoreFromEvents(IEnumerable events) { if (events == null) @@ -172,25 +460,77 @@ public class Account : IEventSource return events; } + // Event handlers + private void Apply(object @event) { switch (@event) { + case AccountCreated e: + ApplyAccountCreated(e); + break; + case AmountDeposited e: - _balance += e.Amount; + ApplyAmountDeposited(e); break; case AmountWithdrawn e: - _balance -= e.Amount; + ApplyAmountWithdrawn(e); + break; + + case AccountClosed e: + ApplyAccountClosed(e); break; default: throw new InvalidOperationException($"Unknown event type: {@event.GetType().Name}"); } } + + private void ApplyAccountCreated(AccountCreated @event) + { + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + _balance = 0; + _isClosed = false; + } + + private void ApplyAmountDeposited(AmountDeposited @event) + { + _balance += @event.Amount; + } + + private void ApplyAmountWithdrawn(AmountWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void ApplyAccountClosed(AccountClosed @event) + { + _isClosed = true; + } } ``` +## Best Practices + +1. **Separate Command and Query Methods**: Keep methods that change state separate from methods that query state +2. **Immutable Events**: Design events as immutable data structures +3. **Versioning Strategy**: Plan for event schema evolution to handle changes over time +4. **Event Handlers**: Keep event handlers (Apply methods) simple and focused on updating state +5. **Optimistic Concurrency**: Use the ExpectedVersion property to prevent lost updates +6. **Error Handling**: Validate commands before recording events to ensure consistency +7. **Event Documentation**: Document the purpose and content of each event type + +## Common Pitfalls + +1. **Complex Event Handlers**: Avoid complex logic in event handlers that could lead to inconsistent state +2. **Side Effects in Event Handlers**: Event handlers should only update the entity state, not perform side effects +3. **Missing Version Checks**: Not checking versions when updating entities can lead to inconsistent state +4. **Event Ordering**: Be aware that events must be applied in the same order they were created +5. **Large Event Streams**: Performance can degrade with very large event streams +6. **Missing Event Handlers**: Ensure all event types have corresponding handlers + ## Related Types - [AggregateRoot](aggregate-root.md): A base class that implements `IEventSource` diff --git a/docs/api-reference/types/irepository.md b/docs/api-reference/types/irepository.md index 103b683e..3a6da735 100644 --- a/docs/api-reference/types/irepository.md +++ b/docs/api-reference/types/irepository.md @@ -4,7 +4,9 @@ ## Overview -The `IRepository` interface defines the contract for repositories that store and retrieve event-sourced aggregates in Reactive Domain. +The `IRepository` interface defines the contract for repositories that store and retrieve event-sourced aggregates in Reactive Domain. It is a fundamental component in the event sourcing pattern, serving as the bridge between domain aggregates and the underlying event store. + +Repositories in Reactive Domain follow the Repository pattern from Domain-Driven Design (DDD), providing a collection-like interface to access domain aggregates while abstracting away the details of event storage and retrieval. The `IRepository` interface ensures that all implementations provide consistent behavior for storing, retrieving, and managing aggregates. **Namespace**: `ReactiveDomain.Foundation` **Assembly**: `ReactiveDomain.Foundation.dll` @@ -25,14 +27,14 @@ public interface IRepository ### TryGetById -Attempts to retrieve an aggregate by its ID. +Attempts to retrieve an aggregate by its ID. This method provides a non-throwing alternative to `GetById` when you need to check for the existence of an aggregate without handling exceptions. ```csharp bool TryGetById(Guid id, out TAggregate aggregate, int version = int.MaxValue) where TAggregate : class, IEventSource; ``` **Type Parameters**: -- `TAggregate`: The type of the aggregate to retrieve. +- `TAggregate`: The type of the aggregate to retrieve. Must be a class that implements `IEventSource`. **Parameters**: - `id` (`System.Guid`): The ID of the aggregate to retrieve. @@ -41,18 +43,35 @@ bool TryGetById(Guid id, out TAggregate aggregate, int version = int **Returns**: `System.Boolean` - `true` if the aggregate was found; otherwise, `false`. -**Remarks**: This method attempts to retrieve an aggregate by its ID. If the aggregate is not found, it returns `false` and sets `aggregate` to `null`. +**Example**: +```csharp +// Try to retrieve an account by ID +Account account; +if (repository.TryGetById(accountId, out account)) +{ + // Account exists, proceed with operations + decimal balance = account.GetBalance(); + Console.WriteLine($"Account found with balance: {balance}"); +} +else +{ + // Account doesn't exist, handle accordingly + Console.WriteLine("Account not found"); +} +``` + +**Remarks**: This method attempts to retrieve an aggregate by its ID. If the aggregate is not found, it returns `false` and sets `aggregate` to `null`. This is useful when you want to check if an aggregate exists without throwing exceptions. ### GetById -Retrieves an aggregate by its ID. +Retrieves an aggregate by its ID. This is the primary method for loading aggregates from the repository. ```csharp TAggregate GetById(Guid id, int version = int.MaxValue) where TAggregate : class, IEventSource; ``` **Type Parameters**: -- `TAggregate`: The type of the aggregate to retrieve. +- `TAggregate`: The type of the aggregate to retrieve. Must be a class that implements `IEventSource`. **Parameters**: - `id` (`System.Guid`): The ID of the aggregate to retrieve. @@ -64,21 +83,48 @@ TAggregate GetById(Guid id, int version = int.MaxValue) where TAggre - `ReactiveDomain.AggregateNotFoundException`: Thrown when the aggregate with the specified ID is not found. - `ReactiveDomain.AggregateDeletedException`: Thrown when the aggregate with the specified ID has been deleted. -**Remarks**: This method retrieves an aggregate by its ID. If the aggregate is not found, it throws an exception. +**Example**: +```csharp +// Retrieve an account by ID +try +{ + var account = repository.GetById(accountId); + + // Perform operations on the account + decimal balance = account.GetBalance(); + Console.WriteLine($"Account balance: {balance}"); + + if (balance > 0) + { + account.Withdraw(balance); + repository.Save(account); + } +} +catch (AggregateNotFoundException) +{ + Console.WriteLine("Account not found"); +} +catch (AggregateDeletedException) +{ + Console.WriteLine("Account has been deleted"); +} +``` + +**Remarks**: This method retrieves an aggregate by its ID. If the aggregate is not found or has been deleted, it throws an exception. Use this method when you expect the aggregate to exist and want to handle exceptions for specific error cases. ### Update -Updates an aggregate with events from the repository. +Updates an aggregate with events from the repository. This method is used to refresh an aggregate with the latest events from the event store, which is useful in scenarios where the aggregate might have been modified by another process. ```csharp void Update(ref TAggregate aggregate, int version = int.MaxValue) where TAggregate : class, IEventSource; ``` **Type Parameters**: -- `TAggregate`: The type of the aggregate to update. +- `TAggregate`: The type of the aggregate to update. Must be a class that implements `IEventSource`. **Parameters**: -- `aggregate` (`TAggregate`): The aggregate to update. +- `aggregate` (`TAggregate`): The aggregate to update. This parameter is passed by reference and will be updated with the latest events. - `version` (`System.Int32`, optional): The version to update the aggregate to. Defaults to `int.MaxValue`, which updates to the latest version. **Exceptions**: @@ -88,11 +134,38 @@ void Update(ref TAggregate aggregate, int version = int.MaxValue) wh - `ReactiveDomain.AggregateDeletedException`: Thrown when the aggregate with the specified ID has been deleted. - `ReactiveDomain.AggregateVersionException`: Thrown when the specified version does not match the expected version. -**Remarks**: This method updates an aggregate with events from the repository. It loads events from the repository and applies them to the aggregate. +**Example**: +```csharp +// Retrieve an account +var account = repository.GetById(accountId); + +// Perform some long-running operation +PerformLongRunningOperation(); + +// Update the account with the latest events before proceeding +// This ensures we have the most up-to-date state +try +{ + repository.Update(ref account); + + // Now we can safely perform operations on the updated account + if (account.GetBalance() >= amount) + { + account.Withdraw(amount); + repository.Save(account); + } +} +catch (AggregateVersionException) +{ + Console.WriteLine("The account was modified concurrently"); +} +``` + +**Remarks**: This method updates an aggregate with events from the repository. It loads events from the repository and applies them to the aggregate. This is useful when you need to ensure that you're working with the most up-to-date state of an aggregate before performing operations on it. ### Save -Saves an aggregate to the repository. +Saves an aggregate to the repository. This method persists the new events generated by the aggregate to the event store. ```csharp void Save(IEventSource aggregate); @@ -105,11 +178,33 @@ void Save(IEventSource aggregate); - `System.ArgumentNullException`: Thrown when `aggregate` is `null`. - `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. -**Remarks**: This method saves an aggregate to the repository. It takes the events from the aggregate and appends them to the event stream in the repository. +**Example**: +```csharp +// Create a new account +var account = new Account(Guid.NewGuid()); + +// Perform operations on the account +account.Deposit(1000); +account.Withdraw(500); + +// Save the account to the repository +try +{ + repository.Save(account); + Console.WriteLine("Account saved successfully"); +} +catch (AggregateVersionException) +{ + Console.WriteLine("Concurrent modification detected"); + // Handle the concurrency conflict +} +``` + +**Remarks**: This method saves an aggregate to the repository. It takes the events from the aggregate and appends them to the event stream in the repository. If the aggregate's expected version does not match the version in the repository, it throws an `AggregateVersionException`, indicating a concurrent modification. ### Delete -Marks an aggregate as deleted in the repository. +Marks an aggregate as deleted in the repository. This method does not physically remove the aggregate from the event store but appends a deletion event to its stream. ```csharp void Delete(IEventSource aggregate); @@ -122,11 +217,39 @@ void Delete(IEventSource aggregate); - `System.ArgumentNullException`: Thrown when `aggregate` is `null`. - `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. -**Remarks**: This method marks an aggregate as deleted in the repository. It appends a deletion event to the event stream. The aggregate can still be retrieved, but will be marked as deleted. +**Example**: +```csharp +// Retrieve an account +var account = repository.GetById(accountId); + +// Mark the account as deleted +try +{ + repository.Delete(account); + Console.WriteLine("Account marked as deleted"); +} +catch (AggregateVersionException) +{ + Console.WriteLine("Concurrent modification detected"); + // Handle the concurrency conflict +} + +// Attempting to retrieve the account now will throw AggregateDeletedException +try +{ + var deletedAccount = repository.GetById(accountId); +} +catch (AggregateDeletedException) +{ + Console.WriteLine("Account has been deleted"); +} +``` + +**Remarks**: This method marks an aggregate as deleted in the repository. It appends a deletion event to the event stream. The aggregate can still be retrieved, but will be marked as deleted, and attempts to retrieve it with `GetById` will throw an `AggregateDeletedException`. ### HardDelete -Permanently deletes an aggregate from the repository. +Permanently deletes an aggregate from the repository. This method physically removes the aggregate's event stream from the event store. ```csharp void HardDelete(IEventSource aggregate); @@ -139,11 +262,39 @@ void HardDelete(IEventSource aggregate); - `System.ArgumentNullException`: Thrown when `aggregate` is `null`. - `ReactiveDomain.AggregateVersionException`: Thrown when the aggregate's expected version does not match the version in the repository. -**Remarks**: This method permanently deletes an aggregate from the repository. It removes the event stream from the repository. The aggregate cannot be retrieved after this operation. +**Example**: +```csharp +// Retrieve an account +var account = repository.GetById(accountId); + +// Permanently delete the account +try +{ + repository.HardDelete(account); + Console.WriteLine("Account permanently deleted"); +} +catch (AggregateVersionException) +{ + Console.WriteLine("Concurrent modification detected"); + // Handle the concurrency conflict +} + +// Attempting to retrieve the account now will throw AggregateNotFoundException +try +{ + var deletedAccount = repository.GetById(accountId); +} +catch (AggregateNotFoundException) +{ + Console.WriteLine("Account not found (was permanently deleted)"); +} +``` + +**Remarks**: This method permanently deletes an aggregate from the repository. It removes the event stream from the repository. The aggregate cannot be retrieved after this operation. Use this method with caution, as it permanently removes data from the system. ## Usage -The `IRepository` interface is used to store and retrieve event-sourced aggregates. It is typically implemented by the `StreamStoreRepository` class, which stores events in an event store. +The `IRepository` interface is used to store and retrieve event-sourced aggregates. It is typically implemented by the `StreamStoreRepository` class, which stores events in an event store. Here's a comprehensive example of using a repository in a typical application scenario: ```csharp // Create a repository @@ -153,23 +304,58 @@ var serializer = new JsonMessageSerializer(); var repository = new StreamStoreRepository(streamNameBuilder, eventStoreConnection, serializer); // Create a new aggregate -var account = new Account(Guid.NewGuid()); -account.Deposit(100); +var accountId = Guid.NewGuid(); +var account = new Account(accountId); +account.Deposit(1000); // Save the aggregate repository.Save(account); +Console.WriteLine($"Created account {accountId} with initial deposit of $1000"); // Retrieve the aggregate -var retrievedAccount = repository.GetById(account.Id); +var retrievedAccount = repository.GetById(accountId); +Console.WriteLine($"Retrieved account balance: ${retrievedAccount.GetBalance()}"); + +// Perform operations on the aggregate +retrievedAccount.Withdraw(500); +Console.WriteLine($"Withdrew $500, new balance: ${retrievedAccount.GetBalance()}"); -// Update the aggregate -retrievedAccount.Withdraw(50); +// Save the updated aggregate repository.Save(retrievedAccount); +Console.WriteLine("Saved account after withdrawal"); -// Delete the aggregate +// Update the aggregate with the latest events +repository.Update(ref retrievedAccount); +Console.WriteLine($"Updated account balance: ${retrievedAccount.GetBalance()}"); + +// Delete the aggregate (soft delete) repository.Delete(retrievedAccount); +Console.WriteLine("Deleted account (soft delete)"); + +// Hard delete the aggregate (permanent deletion) +// repository.HardDelete(retrievedAccount); +// Console.WriteLine("Permanently deleted account"); ``` +## Best Practices + +1. **Optimistic Concurrency**: Always handle `AggregateVersionException` to manage concurrent modifications +2. **Aggregate Lifecycle**: Use `Delete` for logical deletion and `HardDelete` only when data must be permanently removed +3. **Version Management**: Use the `version` parameter in `GetById` and `Update` to work with specific versions of aggregates +4. **Error Handling**: Implement proper exception handling for repository operations +5. **Transaction Boundaries**: Consider repository operations as transaction boundaries in your domain +6. **Repository Abstraction**: Depend on the `IRepository` interface rather than concrete implementations +7. **Correlation Tracking**: Use `ICorrelatedRepository` when correlation information needs to be maintained + +## Common Pitfalls + +1. **Ignoring Concurrency**: Failing to handle `AggregateVersionException` can lead to lost updates +2. **Large Aggregates**: Storing too many events in a single aggregate can impact performance +3. **Missing Version Checks**: Not checking versions when updating aggregates can lead to inconsistent state +4. **Hard Deletion Overuse**: Using `HardDelete` when `Delete` would be more appropriate +5. **Repository Leakage**: Allowing repository implementation details to leak into the domain model +6. **Missing Error Handling**: Not properly handling repository exceptions + ## Related Types - [IEventSource](ievent-source.md): The interface for event-sourced entities diff --git a/docs/api-reference/types/message-builder.md b/docs/api-reference/types/message-builder.md index 98deb591..70d7ac57 100644 --- a/docs/api-reference/types/message-builder.md +++ b/docs/api-reference/types/message-builder.md @@ -8,14 +8,29 @@ In event-sourced systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `MessageBuilder` factory provides a consistent way to create messages with properly set correlation and causation IDs. +Correlation tracking is essential in distributed systems where a single business transaction might span multiple services, processes, or message handlers. The `MessageBuilder` ensures that all messages related to the same business transaction are properly linked, making it possible to trace the entire transaction flow. + ## Class Definition ```csharp public static class MessageBuilder { + /// + /// Creates a new message with a new correlation chain. + /// + /// The type of message to create. + /// A factory function that creates the message. + /// A new message with a new correlation chain. public static TMessage New(Func messageFactory) where TMessage : ICorrelatedMessage; + /// + /// Creates a new message that continues an existing correlation chain. + /// + /// The type of message to create. + /// The source message that this message is derived from. + /// A factory function that creates the message. + /// A new message that continues the correlation chain from the source message. public static TMessage From(ICorrelatedMessage source, Func messageFactory) where TMessage : ICorrelatedMessage; } @@ -27,16 +42,43 @@ public static class MessageBuilder - **Correlation Tracking**: Automatically sets correlation IDs for tracking related messages - **Causation Tracking**: Establishes causation links between messages - **Type Safety**: Provides type-safe message creation through generic methods +- **Consistent ID Management**: Ensures consistent handling of message, correlation, and causation IDs +- **Debugging Support**: Makes it easier to debug complex message flows by maintaining clear relationships + +## How It Works + +The `MessageBuilder` class manages three important IDs for each message: + +1. **Message ID (`MsgId`)**: A unique identifier for the message itself +2. **Correlation ID (`CorrelationId`)**: An identifier that links all messages in the same business transaction +3. **Causation ID (`CausationId`)**: An identifier that links a message to the message that caused it + +When you create a new message using `MessageBuilder.New()`: +- A new `MsgId` is generated +- The `CorrelationId` is set to the same value as the `MsgId` +- The `CausationId` is set to the same value as the `MsgId` + +When you create a message from an existing message using `MessageBuilder.From()`: +- A new `MsgId` is generated +- The `CorrelationId` is copied from the source message +- The `CausationId` is set to the `MsgId` of the source message + +This approach ensures that all messages in the same business transaction share the same `CorrelationId`, and each message's `CausationId` points to the message that directly caused it. -## Usage +## Usage Examples -### Creating a New Message +### Creating a New Message Chain To create a new message that starts a new correlation chain: ```csharp // Create a new command with a new correlation ID ICorrelatedMessage command = MessageBuilder.New(() => new CreateAccount(Guid.NewGuid())); + +// The resulting command will have: +// - A new MsgId +// - CorrelationId equal to MsgId +// - CausationId equal to MsgId ``` ### Creating a Message from an Existing Message @@ -47,8 +89,65 @@ To create a message that continues an existing correlation chain: // Create a command with correlation information from an existing message ICorrelatedMessage existingCommand = // ... existing command ICorrelatedMessage newCommand = MessageBuilder.From(existingCommand, () => new DepositFunds(accountId, amount)); + +// The resulting command will have: +// - A new MsgId +// - CorrelationId equal to existingCommand.CorrelationId +// - CausationId equal to existingCommand.MsgId +``` + +### Complete Message Flow Example + +Here's a complete example showing how correlation and causation IDs flow through a system: + +```csharp +// 1. Client sends a command +var createAccountCommand = MessageBuilder.New(() => new CreateAccount(Guid.NewGuid(), "John Doe", 100.00m)); +// MsgId: A, CorrelationId: A, CausationId: A + +// 2. Command handler processes the command and creates an aggregate +public void Handle(CreateAccount command) +{ + var account = new Account(command.AccountId, command); + _repository.Save(account, command); +} + +// 3. Aggregate applies an event +public Account(Guid id, ICorrelatedMessage source) : base(id) +{ + Apply(MessageBuilder.From(source, () => new AccountCreated(id, source.CustomerName, source.InitialBalance))); + // Event MsgId: B, CorrelationId: A, CausationId: A +} + +// 4. Event handler processes the event and sends a notification command +public void Handle(AccountCreated @event) +{ + var notifyCommand = MessageBuilder.From(@event, () => new SendWelcomeEmail(@event.CustomerId, @event.CustomerEmail)); + // Command MsgId: C, CorrelationId: A, CausationId: B + _commandBus.Send(notifyCommand); + + // Update read model + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, @event.InitialBalance); + _readModelRepository.Save(accountSummary); +} + +// 5. Notification handler processes the command and creates an event +public void Handle(SendWelcomeEmail command) +{ + // Send email... + + var emailSentEvent = MessageBuilder.From(command, () => new WelcomeEmailSent(command.CustomerId)); + // Event MsgId: D, CorrelationId: A, CausationId: C + _eventBus.Publish(emailSentEvent); +} ``` +In this flow: +- All messages share the same correlation ID (A) +- Each message's causation ID points to the message that directly caused it +- The complete chain of causality is: CreateAccount → AccountCreated → SendWelcomeEmail → WelcomeEmailSent + ### In an Aggregate Messages are often created within aggregates in response to commands: @@ -56,17 +155,159 @@ Messages are often created within aggregates in response to commands: ```csharp public class Account : AggregateRoot { + private decimal _balance; + private bool _isActive; + private string _ownerName; + public Account(Guid id, ICorrelatedMessage source) : base(id) { // Create a new event from the source command - Apply(MessageBuilder.From(source, () => new AccountCreated(id))); + Apply(MessageBuilder.From(source, () => new AccountCreated(id, ((CreateAccount)source).CustomerName))); } public void Deposit(decimal amount, ICorrelatedMessage source) { + if (!_isActive) + throw new InvalidOperationException("Cannot deposit to inactive account"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + // Create a new event from the source command Apply(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); } + + public void Withdraw(decimal amount, ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Cannot withdraw from inactive account"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + // Create a new event from the source command + Apply(MessageBuilder.From(source, () => new FundsWithdrawn(Id, amount))); + } + + public void Close(ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Account already closed"); + + // Create a new event from the source command + Apply(MessageBuilder.From(source, () => new AccountClosed(Id))); + } + + private void Apply(AccountCreated @event) + { + _isActive = true; + _ownerName = @event.CustomerName; + _balance = 0; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void Apply(AccountClosed @event) + { + _isActive = false; + } +} +``` + +### In a Process Manager + +Process managers (also known as sagas) often need to coordinate multiple steps in a business process. The `MessageBuilder` is essential for maintaining correlation across these steps: + +```csharp +public class AccountOpeningProcess : + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + private readonly IProcessRepository _processRepository; + + public AccountOpeningProcess(ICommandBus commandBus, IProcessRepository processRepository) + { + _commandBus = commandBus; + _processRepository = processRepository; + } + + public void Handle(AccountCreated @event) + { + // Start the process + var process = new AccountOpeningProcessState(@event.AccountId); + process.AccountCreated = true; + _processRepository.Save(process); + + // Request customer verification + var verifyCommand = MessageBuilder.From(@event, () => new VerifyCustomer(@event.CustomerId)); + _commandBus.Send(verifyCommand); + } + + public void Handle(CustomerVerified @event) + { + // Update process state + var process = _processRepository.GetByCorrelationId(@event.CorrelationId); + if (process == null || !process.AccountCreated) + return; // Process not found or not in the right state + + process.CustomerVerified = true; + _processRepository.Save(process); + + // Send welcome email + var emailCommand = MessageBuilder.From(@event, () => new SendWelcomeEmail(@event.CustomerId, @event.CustomerEmail)); + _commandBus.Send(emailCommand); + } + + public void Handle(WelcomeEmailSent @event) + { + // Update process state + var process = _processRepository.GetByCorrelationId(@event.CorrelationId); + if (process == null || !process.CustomerVerified) + return; // Process not found or not in the right state + + process.WelcomeEmailSent = true; + _processRepository.Save(process); + + // Complete the process if all steps are done + if (process.IsComplete()) + { + var completeCommand = MessageBuilder.From(@event, () => new CompleteAccountOpening(process.AccountId)); + _commandBus.Send(completeCommand); + } + } +} + +public class AccountOpeningProcessState +{ + public Guid AccountId { get; } + public Guid CorrelationId { get; } + public bool AccountCreated { get; set; } + public bool CustomerVerified { get; set; } + public bool WelcomeEmailSent { get; set; } + + public AccountOpeningProcessState(Guid accountId, Guid correlationId) + { + AccountId = accountId; + CorrelationId = correlationId; + } + + public bool IsComplete() + { + return AccountCreated && CustomerVerified && WelcomeEmailSent; + } } ``` @@ -83,15 +324,45 @@ public interface ICorrelatedMessage : IMessage } ``` -When using `MessageBuilder.New()`, it sets: -- `MsgId` to a new GUID -- `CorrelationId` to the same value as `MsgId` -- `CausationId` to the same value as `MsgId` +## Debugging with Correlation IDs + +Correlation IDs are particularly valuable for debugging distributed systems. By including correlation IDs in logs, you can trace the entire flow of a business transaction: -When using `MessageBuilder.From()`, it sets: -- `MsgId` to a new GUID -- `CorrelationId` to the same value as the source message's `CorrelationId` -- `CausationId` to the same value as the source message's `MsgId` +```csharp +public class CorrelatedLogger : ILogger +{ + private readonly ILogger _innerLogger; + + public CorrelatedLogger(ILogger innerLogger) + { + _innerLogger = innerLogger; + } + + public void Log(LogLevel level, string message, ICorrelatedMessage correlatedMessage) + { + var correlatedMessage = $"[CorrelationId: {correlatedMessage.CorrelationId}, CausationId: {correlatedMessage.CausationId}] {message}"; + _innerLogger.Log(level, correlatedMessage); + } +} + +// Usage in a command handler +public void Handle(CreateAccount command) +{ + _logger.Log(LogLevel.Info, "Handling CreateAccount command", command); + + try + { + var account = new Account(command.AccountId, command); + _repository.Save(account, command); + _logger.Log(LogLevel.Info, "Account created successfully", command); + } + catch (Exception ex) + { + _logger.Log(LogLevel.Error, $"Error creating account: {ex.Message}", command); + throw; + } +} +``` ## Best Practices @@ -99,12 +370,25 @@ When using `MessageBuilder.From()`, it sets: 2. **Preserve Correlation Chains**: Pass correlation information through the entire message flow 3. **Command-Event Flow**: Use `From()` to create events from commands 4. **Event-Command Flow**: Use `From()` to create commands from events in process managers +5. **Include Correlation IDs in Logs**: Add correlation and causation IDs to log messages +6. **Correlation-Aware Repositories**: Use repositories that preserve correlation information +7. **Consistent Naming**: Use consistent naming for correlation-related concepts +8. **Documentation**: Document the correlation flow in complex business processes +9. **Testing**: Test correlation chains to ensure they're maintained correctly +10. **Monitoring**: Monitor correlation chains for breaks or inconsistencies ## Common Pitfalls 1. **Manual ID Setting**: Avoid manually setting correlation and causation IDs 2. **Breaking Correlation Chains**: Ensure correlation information is passed through all message flows 3. **Missing Source Messages**: Always provide a source message when continuing a correlation chain +4. **Correlation Leakage**: Be careful not to mix correlation chains from different business transactions +5. **Overloading Correlation**: Don't use correlation IDs for purposes other than tracking message flow +6. **Ignoring Causation**: Track both correlation and causation for complete traceability +7. **Performance Concerns**: Be aware of the overhead of tracking correlation in high-throughput systems +8. **Missing Correlation in Logs**: Ensure logs include correlation information for effective debugging +9. **Inconsistent Implementation**: Use `MessageBuilder` consistently throughout the system +10. **Lack of Documentation**: Document correlation flows for complex business processes ## Related Components @@ -112,6 +396,7 @@ When using `MessageBuilder.From()`, it sets: - [Command](./command.md): Base class for commands that implements `ICorrelatedMessage` - [Event](./event.md): Base class for events that implements `ICorrelatedMessage` - [ICorrelatedRepository](./icorrelated-repository.md): Repository that preserves correlation information +- [AggregateRoot](./aggregate-root.md): Base class for domain aggregates that work with correlated messages --- diff --git a/docs/api-reference/types/read-model-base.md b/docs/api-reference/types/read-model-base.md index 510a2b98..90231c93 100644 --- a/docs/api-reference/types/read-model-base.md +++ b/docs/api-reference/types/read-model-base.md @@ -8,6 +8,8 @@ Read models in Reactive Domain represent the query side of the CQRS pattern. They are optimized for querying and provide a denormalized view of the domain data. The `ReadModelBase` class provides a common foundation for implementing read models with consistent behavior. +In a CQRS architecture, read models are separate from the write models (aggregates) and are specifically designed to efficiently answer queries. They typically contain denormalized data that is shaped according to the specific needs of the UI or API consumers. + ## Class Definition ```csharp @@ -31,9 +33,13 @@ public abstract class ReadModelBase - **Identity Management**: Provides a standard `Id` property for uniquely identifying read models - **Base Functionality**: Serves as a foundation for all read model implementations - **Consistency**: Ensures consistent implementation patterns across different read models +- **Separation of Concerns**: Facilitates the separation between read and write models in CQRS +- **Optimized for Queries**: Designed to be efficient for read operations ## Usage +### Creating a Basic Read Model + To create a read model, inherit from `ReadModelBase` and add properties specific to your domain: ```csharp @@ -58,39 +64,274 @@ public class AccountSummary : ReadModelBase } ``` +### Creating a More Complex Read Model + +For more complex scenarios, you can create read models that aggregate data from multiple sources: + +```csharp +public class CustomerDashboard : ReadModelBase +{ + public string CustomerName { get; private set; } + public string Email { get; private set; } + public decimal TotalBalance { get; private set; } + public int AccountCount { get; private set; } + public List Accounts { get; private set; } + public List RecentTransactions { get; private set; } + + public CustomerDashboard(Guid customerId) : base(customerId) + { + Accounts = new List(); + RecentTransactions = new List(); + } + + public void UpdateCustomerInfo(string name, string email) + { + CustomerName = name; + Email = email; + } + + public void AddAccount(AccountSummary account) + { + Accounts.Add(account); + AccountCount = Accounts.Count; + RecalculateTotalBalance(); + } + + public void UpdateAccount(AccountSummary updatedAccount) + { + var existingAccount = Accounts.FirstOrDefault(a => a.Id == updatedAccount.Id); + if (existingAccount != null) + { + var index = Accounts.IndexOf(existingAccount); + Accounts[index] = updatedAccount; + RecalculateTotalBalance(); + } + } + + public void AddTransaction(TransactionSummary transaction) + { + RecentTransactions.Add(transaction); + RecentTransactions = RecentTransactions + .OrderByDescending(t => t.Timestamp) + .Take(10) + .ToList(); + } + + private void RecalculateTotalBalance() + { + TotalBalance = Accounts.Sum(a => a.Balance); + } +} +``` + ## Integration with Event Handlers -Read models are typically updated by event handlers that subscribe to domain events: +Read models are typically updated by event handlers that subscribe to domain events. Here's a comprehensive example showing how to update read models in response to various events: ```csharp -public class AccountEventHandler : IEventHandler, IEventHandler +public class AccountEventHandler : + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler { - private readonly IReadModelRepository _repository; + private readonly IReadModelRepository _accountRepository; + private readonly IReadModelRepository _dashboardRepository; - public AccountEventHandler(IReadModelRepository repository) + public AccountEventHandler( + IReadModelRepository accountRepository, + IReadModelRepository dashboardRepository) { - _repository = repository; + _accountRepository = accountRepository; + _dashboardRepository = dashboardRepository; } public void Handle(AccountCreated @event) { + // Update the account summary read model var accountSummary = new AccountSummary(@event.AccountId); - accountSummary.Update(@event.AccountNumber, @event.CustomerName, 0); - _repository.Save(accountSummary); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, @event.InitialBalance); + _accountRepository.Save(accountSummary); + + // Update the customer dashboard read model + var dashboard = _dashboardRepository.GetById(@event.CustomerId) + ?? new CustomerDashboard(@event.CustomerId); + + dashboard.UpdateCustomerInfo(@event.CustomerName, @event.CustomerEmail); + dashboard.AddAccount(accountSummary); + _dashboardRepository.Save(dashboard); } public void Handle(FundsDeposited @event) { - var accountSummary = _repository.GetById(@event.AccountId); + // Update the account summary read model + var accountSummary = _accountRepository.GetById(@event.AccountId); if (accountSummary != null) { accountSummary.Update( accountSummary.AccountNumber, accountSummary.CustomerName, accountSummary.Balance + @event.Amount); - _repository.Save(accountSummary); + _accountRepository.Save(accountSummary); + + // Update the customer dashboard read model + var customerId = GetCustomerIdForAccount(@event.AccountId); + var dashboard = _dashboardRepository.GetById(customerId); + if (dashboard != null) + { + dashboard.UpdateAccount(accountSummary); + + var transaction = new TransactionSummary + { + Id = Guid.NewGuid(), + AccountId = @event.AccountId, + AccountNumber = accountSummary.AccountNumber, + Type = "Deposit", + Amount = @event.Amount, + Balance = accountSummary.Balance, + Timestamp = DateTime.UtcNow + }; + + dashboard.AddTransaction(transaction); + _dashboardRepository.Save(dashboard); + } } } + + public void Handle(FundsWithdrawn @event) + { + // Similar implementation to FundsDeposited + // ... + } + + public void Handle(AccountClosed @event) + { + // Implementation for account closure + // ... + } + + private Guid GetCustomerIdForAccount(Guid accountId) + { + // In a real implementation, this would look up the customer ID + // from a mapping or another read model + // This is just a placeholder for the example + return Guid.Empty; + } +} +``` + +## Querying Read Models + +Read models are designed to be efficiently queried. Here's an example of a query service that uses read models: + +```csharp +public class AccountQueryService +{ + private readonly IReadModelRepository _accountRepository; + private readonly IReadModelRepository _dashboardRepository; + + public AccountQueryService( + IReadModelRepository accountRepository, + IReadModelRepository dashboardRepository) + { + _accountRepository = accountRepository; + _dashboardRepository = dashboardRepository; + } + + public AccountSummary GetAccountSummary(Guid accountId) + { + return _accountRepository.GetById(accountId); + } + + public CustomerDashboard GetCustomerDashboard(Guid customerId) + { + return _dashboardRepository.GetById(customerId); + } + + public IEnumerable FindAccountsByCustomerName(string customerName) + { + // In a real implementation, this would use a more efficient query mechanism + // This is just a placeholder for the example + return _accountRepository.GetAll() + .Where(a => a.CustomerName.Contains(customerName, StringComparison.OrdinalIgnoreCase)); + } + + public decimal GetTotalBalanceForCustomer(Guid customerId) + { + var dashboard = _dashboardRepository.GetById(customerId); + return dashboard?.TotalBalance ?? 0; + } +} +``` + +## Persistence Strategies + +Read models can be persisted in various ways, depending on the query requirements: + +### In-Memory Storage + +```csharp +public class InMemoryReadModelRepository : IReadModelRepository where T : ReadModelBase +{ + private readonly Dictionary _items = new Dictionary(); + + public T GetById(Guid id) + { + return _items.TryGetValue(id, out var item) ? item : null; + } + + public IEnumerable GetAll() + { + return _items.Values; + } + + public void Save(T item) + { + _items[item.Id] = item; + } + + public void Delete(Guid id) + { + _items.Remove(id); + } +} +``` + +### Database Storage + +```csharp +public class SqlReadModelRepository : IReadModelRepository where T : ReadModelBase +{ + private readonly string _connectionString; + + public SqlReadModelRepository(string connectionString) + { + _connectionString = connectionString; + } + + public T GetById(Guid id) + { + // Implementation using ADO.NET, Dapper, Entity Framework, etc. + // ... + } + + public IEnumerable GetAll() + { + // Implementation using ADO.NET, Dapper, Entity Framework, etc. + // ... + } + + public void Save(T item) + { + // Implementation using ADO.NET, Dapper, Entity Framework, etc. + // ... + } + + public void Delete(Guid id) + { + // Implementation using ADO.NET, Dapper, Entity Framework, etc. + // ... + } } ``` @@ -101,6 +342,11 @@ public class AccountEventHandler : IEventHandler, IEventHandler< 3. **Denormalization**: Denormalize data to optimize for query performance 4. **Eventual Consistency**: Remember that read models are eventually consistent with the write model 5. **Versioning**: Consider adding version information to handle schema evolution +6. **Optimize for Reads**: Structure your read models to minimize the need for joins or complex queries +7. **Separate Storage**: Consider using different storage technologies for read and write models +8. **Rebuild Capability**: Design your system to be able to rebuild read models from event streams when needed +9. **Caching Strategy**: Implement appropriate caching for frequently accessed read models +10. **Monitoring**: Add monitoring to track the lag between write model updates and read model updates ## Common Pitfalls @@ -108,12 +354,21 @@ public class AccountEventHandler : IEventHandler, IEventHandler< 2. **Complex Read Models**: Keep read models simple and focused on query requirements 3. **Missing Event Handlers**: Ensure all relevant events have handlers to update read models 4. **Ignoring Performance**: Design read models with query performance in mind +5. **Tight Coupling**: Avoid coupling read models to domain aggregates +6. **Overloading**: Don't try to make a single read model serve too many different query scenarios +7. **Inconsistent Naming**: Maintain consistent naming conventions between events, commands, and read models +8. **Neglecting Indexes**: Ensure appropriate database indexes for efficient querying +9. **Synchronous Updates**: Be cautious about synchronously updating read models in high-throughput systems +10. **Ignoring Eventual Consistency**: Design your UI and API consumers to handle eventual consistency ## Related Components - [IReadModelRepository](./iread-model-repository.md): Interface for storing and retrieving read models - [EventHandler](./event-handler.md): Handlers for updating read models based on domain events - [IEvent](./ievent.md): Interface for domain events that trigger read model updates +- [Command](./command.md): Base class for command messages that trigger state changes +- [Event](./event.md): Base class for event messages that update read models +- [ICorrelatedMessage](./icorrelated-message.md): Interface for tracking message correlation --- diff --git a/docs/diagrams/aggregate-root-lifecycle.md b/docs/diagrams/aggregate-root-lifecycle.md new file mode 100644 index 00000000..52b4000a --- /dev/null +++ b/docs/diagrams/aggregate-root-lifecycle.md @@ -0,0 +1,171 @@ +# AggregateRoot Lifecycle Diagram + +This diagram illustrates the lifecycle of an `AggregateRoot` in Reactive Domain, showing how commands are processed and events are applied. + +## Aggregate Lifecycle Overview + +```mermaid +stateDiagram-v2 + [*] --> New: Create New Aggregate + New --> Active: Apply Initial Events + Active --> Active: Process Commands + Active --> Active: Apply Events + Active --> Deleted: Delete Aggregate + Deleted --> [*] + + state Active { + [*] --> ValidatingCommand + ValidatingCommand --> GeneratingEvents: Command Valid + ValidatingCommand --> RejectedCommand: Command Invalid + GeneratingEvents --> ApplyingEvents + ApplyingEvents --> [*] + RejectedCommand --> [*] + } +``` + +## Detailed Aggregate Processing Flow + +```mermaid +sequenceDiagram + participant Client + participant Repository + participant AggregateRoot + participant EventStore + + Note over Client,EventStore: Creating a New Aggregate + + Client->>AggregateRoot: new Account(id) + AggregateRoot->>AggregateRoot: Initialize with ID + Client->>AggregateRoot: Create(name, initialBalance) + AggregateRoot->>AggregateRoot: Validate Command + AggregateRoot->>AggregateRoot: RaiseEvent(AccountCreated) + AggregateRoot->>AggregateRoot: Apply(AccountCreated) + Client->>Repository: Save(account) + Repository->>EventStore: AppendToStream(events) + + Note over Client,EventStore: Loading an Existing Aggregate + + Client->>Repository: GetById(id) + Repository->>EventStore: GetEvents(id) + EventStore-->>Repository: Return Events + Repository->>AggregateRoot: new Account(id) + Repository->>AggregateRoot: RestoreFromEvents(events) + loop For Each Event + AggregateRoot->>AggregateRoot: Apply(Event) + AggregateRoot->>AggregateRoot: Update ExpectedVersion + end + Repository-->>Client: Return Reconstructed Account + + Note over Client,EventStore: Processing a Command + + Client->>AggregateRoot: Deposit(amount) + AggregateRoot->>AggregateRoot: Validate Command + AggregateRoot->>AggregateRoot: RaiseEvent(FundsDeposited) + AggregateRoot->>AggregateRoot: Apply(FundsDeposited) + Client->>Repository: Save(account) + Repository->>AggregateRoot: TakeEvents() + AggregateRoot-->>Repository: Return New Events + Repository->>EventStore: AppendToStream(events) +``` + +## Key Methods in AggregateRoot + +### RaiseEvent + +```mermaid +flowchart TD + A[RaiseEvent Method] -->|1. Record Event| B[EventRecorder] + A -->|2. Find Apply Method| C[Reflection] + C -->|3. Call Apply Method| D[Apply Method] + D -->|4. Update State| E[Aggregate State] +``` + +### RestoreFromEvents + +```mermaid +flowchart TD + A[RestoreFromEvents Method] -->|1. For Each Event| B[Event Loop] + B -->|2. Find Apply Method| C[Reflection] + C -->|3. Call Apply Method| D[Apply Method] + D -->|4. Update State| E[Aggregate State] + B -->|5. Update Version| F[ExpectedVersion] +``` + +### TakeEvents + +```mermaid +flowchart TD + A[TakeEvents Method] -->|1. Get Recorded Events| B[EventRecorder] + B -->|2. Return Events| C[Event Array] + A -->|3. Clear Recorder| B +``` + +## Implementation Example + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + private bool _isClosed; + + // Constructor for new aggregate + public Account(Guid id) : base(id) + { + // Initialize with default state + } + + // Constructor for loading from history + protected Account(Guid id, IEnumerable events) : base(id, events) + { + // Base constructor will call RestoreFromEvents + } + + // Command method + public void Deposit(decimal amount) + { + // Validate command + if (_isClosed) + throw new InvalidOperationException("Account is closed"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + // Generate and apply event + RaiseEvent(new FundsDeposited(Id, amount)); + } + + // Event handler + private void Apply(FundsDeposited @event) + { + // Update state + _balance += @event.Amount; + } +} +``` + +## Key Concepts Illustrated + +### Aggregate Identity and State + +- Each aggregate has a unique identifier (`Id`) +- State is maintained through private fields (`_balance`, `_isClosed`) +- State is only modified through event application + +### Command Processing + +1. Commands are validated against current state +2. If valid, events are generated using `RaiseEvent` +3. Events are applied to update the aggregate state +4. New events are collected for storage + +### Event Sourcing + +- Events are the source of truth for aggregate state +- State is reconstructed by replaying events +- `ExpectedVersion` tracks the version for optimistic concurrency + +### Invariant Protection + +- Business rules are enforced in command methods +- Events are only generated if commands are valid +- State consistency is maintained through careful validation diff --git a/docs/diagrams/correlation-tracking.md b/docs/diagrams/correlation-tracking.md new file mode 100644 index 00000000..ec96edd7 --- /dev/null +++ b/docs/diagrams/correlation-tracking.md @@ -0,0 +1,74 @@ +# Correlation and Causation Tracking Diagram + +This diagram illustrates how correlation and causation IDs are propagated through a message chain in Reactive Domain. + +## Message Flow with Correlation and Causation IDs + +```mermaid +sequenceDiagram + participant Client + participant CommandHandler + participant Aggregate + participant Repository + participant EventHandler + participant EmailService + + Note over Client,EmailService: Each message has 3 IDs: MsgId, CorrelationId, CausationId + + Client->>CommandHandler: CreateAccount Command
(MsgId: A, CorrelationId: A, CausationId: A) + Note right of Client: New command starts a correlation chain
All IDs are the same + + CommandHandler->>Aggregate: Create Account + Aggregate->>Repository: Save with AccountCreated Event
(MsgId: B, CorrelationId: A, CausationId: A) + Note right of Aggregate: Event keeps the same CorrelationId
CausationId points to the command + + Repository-->>EventHandler: AccountCreated Event
(MsgId: B, CorrelationId: A, CausationId: A) + EventHandler->>EmailService: SendWelcomeEmail Command
(MsgId: C, CorrelationId: A, CausationId: B) + Note right of EventHandler: New command keeps the same CorrelationId
CausationId points to the event + + EmailService->>EmailService: Process Email + EmailService-->>EventHandler: EmailSent Event
(MsgId: D, CorrelationId: A, CausationId: C) + Note right of EmailService: Event keeps the same CorrelationId
CausationId points to the command +``` + +## Explanation + +### Message IDs + +Each message in Reactive Domain has three important identifiers: + +1. **MsgId**: A unique identifier for the message itself +2. **CorrelationId**: An identifier that groups related messages together +3. **CausationId**: The identifier of the message that directly caused this message + +### Correlation Chain + +In the diagram above: + +- All messages share the same **CorrelationId** (A), indicating they are part of the same business transaction +- Each message's **CausationId** points to the **MsgId** of the message that caused it, creating a chain of causality +- This chain allows for complete tracing of the transaction from initiation to completion + +### Implementation with MessageBuilder + +The recommended way to create correlated messages is to use the `MessageBuilder` factory: + +```csharp +// Create a new message that starts a correlation chain +var createCommand = MessageBuilder.New(() => new CreateAccount(accountId)); + +// Create an event from the command (maintains correlation) +var createdEvent = MessageBuilder.From(createCommand, () => + new AccountCreated(accountId, "ACC-123", "John Doe")); + +// Create another command from the event (maintains correlation) +var sendEmailCommand = MessageBuilder.From(createdEvent, () => + new SendWelcomeEmail(accountId, "john.doe@example.com")); +``` + +## Benefits + +- **Debugging**: Easily trace related messages across system boundaries +- **Auditing**: Track the complete flow of a business transaction +- **Monitoring**: Group related operations for performance analysis +- **Error Handling**: Understand the context in which errors occur diff --git a/docs/diagrams/cqrs-architecture.md b/docs/diagrams/cqrs-architecture.md new file mode 100644 index 00000000..34f59257 --- /dev/null +++ b/docs/diagrams/cqrs-architecture.md @@ -0,0 +1,101 @@ +# CQRS Architecture Diagram + +This diagram illustrates the Command Query Responsibility Segregation (CQRS) pattern as implemented in Reactive Domain. + +## CQRS Overview + +```mermaid +flowchart TD + subgraph "Command Side (Write Model)" + A[Client] -->|Commands| B[Command Bus] + B -->|Dispatch| C[Command Handlers] + C -->|Modify| D[Domain Model/Aggregates] + D -->|Generate| E[Events] + E -->|Store| F[Event Store] + end + + subgraph "Query Side (Read Model)" + F -->|Subscribe| G[Event Handlers/Projections] + G -->|Update| H[Read Models] + I[Client] -->|Queries| J[Query Bus] + J -->|Dispatch| K[Query Handlers] + K -->|Read| H + H -->|Results| I + end + + style A fill:#f9f,stroke:#333,stroke-width:2px + style I fill:#f9f,stroke:#333,stroke-width:2px + style F fill:#bbf,stroke:#333,stroke-width:4px + style D fill:#fbb,stroke:#333,stroke-width:2px + style H fill:#bfb,stroke:#333,stroke-width:2px +``` + +## Detailed CQRS Implementation + +```mermaid +sequenceDiagram + participant Client + participant CommandBus + participant CommandHandler + participant Aggregate + participant EventStore + participant Projection + participant ReadModel + participant QueryHandler + + Note over Client,QueryHandler: Command Flow (Write Operations) + + Client->>CommandBus: Send Command (CreateAccount) + CommandBus->>CommandHandler: Dispatch to Handler + CommandHandler->>Aggregate: Create New Aggregate + Aggregate->>Aggregate: Apply Business Rules + Aggregate->>Aggregate: Generate Event (AccountCreated) + CommandHandler->>EventStore: Save Events + + Note over EventStore,QueryHandler: Query Flow (Read Operations) + + EventStore->>Projection: Notify of New Event + Projection->>ReadModel: Update Read Model + + Client->>QueryHandler: Send Query (GetAccountDetails) + QueryHandler->>ReadModel: Retrieve Data + ReadModel->>QueryHandler: Return Data + QueryHandler->>Client: Return Query Result +``` + +## Key Components + +### Command Side (Write Model) + +- **Commands**: Represent intentions to change the system state +- **Command Bus**: Routes commands to appropriate handlers +- **Command Handlers**: Process commands and coordinate with aggregates +- **Domain Model/Aggregates**: Encapsulate business rules and state changes +- **Events**: Represent facts that have occurred in the system +- **Event Store**: Persists events as the source of truth + +### Query Side (Read Model) + +- **Event Handlers/Projections**: Transform events into read models +- **Read Models**: Optimized for querying and reporting +- **Queries**: Requests for information from the system +- **Query Bus**: Routes queries to appropriate handlers +- **Query Handlers**: Process queries against read models + +## Benefits of CQRS in Reactive Domain + +- **Separation of Concerns**: Write and read operations are handled separately +- **Scalability**: Read and write sides can be scaled independently +- **Optimization**: Read models can be optimized for specific query patterns +- **Flexibility**: Multiple read models can be created from the same events +- **Performance**: Read operations don't block write operations and vice versa + +## Implementation in Reactive Domain + +Reactive Domain provides infrastructure for implementing CQRS: + +- **Command and Event Base Classes**: Provide structure for messages +- **Repository Pattern**: Abstracts event storage and retrieval +- **MessageBus**: Routes commands and events to handlers +- **ReadModelBase**: Base class for creating read models +- **Projection Framework**: Tools for transforming events into read models diff --git a/docs/diagrams/event-sourcing-flow.md b/docs/diagrams/event-sourcing-flow.md new file mode 100644 index 00000000..b6c70ccb --- /dev/null +++ b/docs/diagrams/event-sourcing-flow.md @@ -0,0 +1,105 @@ +# Event Sourcing Flow Diagram + +This diagram illustrates the core flow of events in an event-sourced system using Reactive Domain. + +## Basic Event Sourcing Flow + +```mermaid +flowchart TD + subgraph "Command Side" + A[Client] -->|1. Send Command| B[Command Handler] + B -->|2. Load Aggregate| C[Repository] + C -->|3. Retrieve Events| D[Event Store] + D -->|4. Return Events| C + C -->|5. Reconstruct State| E[Aggregate] + B -->|6. Execute Command| E + E -->|7. Generate Events| E + E -->|8. Save Events| C + C -->|9. Append Events| D + end + + subgraph "Query Side" + D -->|10. Publish Events| F[Event Handlers/Projections] + F -->|11. Update| G[Read Models] + H[Client] -->|12. Query| G + G -->|13. Return Data| H + end + + style A fill:#f9f,stroke:#333,stroke-width:2px + style H fill:#f9f,stroke:#333,stroke-width:2px + style D fill:#bbf,stroke:#333,stroke-width:4px + style G fill:#bfb,stroke:#333,stroke-width:4px +``` + +## Detailed Event Flow with State Reconstruction + +```mermaid +sequenceDiagram + participant Client + participant CommandHandler + participant Repository + participant EventStore + participant Aggregate + participant Projections + participant ReadModels + + Client->>CommandHandler: Send Command (e.g., DepositFunds) + CommandHandler->>Repository: GetById(accountId) + Repository->>EventStore: GetEvents(accountId) + EventStore-->>Repository: Return Event Stream + + Note over Repository,Aggregate: State Reconstruction + Repository->>Aggregate: Create Empty Aggregate + Repository->>Aggregate: Apply Event 1: AccountCreated + Repository->>Aggregate: Apply Event 2: FundsDeposited + Repository->>Aggregate: Apply Event 3: FundsWithdrawn + + Repository-->>CommandHandler: Return Reconstructed Aggregate + CommandHandler->>Aggregate: Execute Command (Deposit) + Aggregate->>Aggregate: Validate Command + Aggregate->>Aggregate: Generate Event (FundsDeposited) + CommandHandler->>Repository: Save(aggregate) + Repository->>EventStore: AppendToStream(accountId, events) + + EventStore->>Projections: Publish New Event + Projections->>ReadModels: Update Account Balance + + Client->>ReadModels: Query Account Balance + ReadModels-->>Client: Return Current Balance +``` + +## Key Concepts Illustrated + +### Event Storage and Retrieval + +- Events are the primary source of truth in the system +- All state changes are recorded as immutable events +- The event store maintains the complete history of all events + +### State Reconstruction + +- Aggregates don't store state directly +- State is reconstructed by replaying events in sequence +- Each event is applied to the aggregate to update its state + +### Command Processing + +1. Commands are received from clients +2. The appropriate aggregate is loaded from its event history +3. The command is executed on the aggregate +4. New events are generated to represent state changes +5. The events are saved to the event store + +### Projections and Read Models + +- Events are published to projections +- Projections transform events into read-optimized models +- Clients query read models for information +- Multiple read models can be built from the same events + +## Benefits of Event Sourcing + +- **Complete Audit Trail**: Every change is recorded as an event +- **Temporal Queries**: Ability to determine state at any point in time +- **Separation of Concerns**: Clear separation between write and read operations +- **Event Replay**: Ability to rebuild state or create new projections from existing events diff --git a/docs/diagrams/message-builder-usage.md b/docs/diagrams/message-builder-usage.md new file mode 100644 index 00000000..691fafa0 --- /dev/null +++ b/docs/diagrams/message-builder-usage.md @@ -0,0 +1,141 @@ +# MessageBuilder Usage Diagram + +This diagram illustrates how the `MessageBuilder` factory creates correlated messages in Reactive Domain, showing the flow of correlation and causation IDs. + +## MessageBuilder Methods Overview + +```mermaid +classDiagram + class MessageBuilder { + +New(Func messageFactory) TMessage + +From(ICorrelatedMessage source, Func messageFactory) TMessage + } + + class ICorrelatedMessage { + +Guid MsgId + +Guid CorrelationId + +Guid CausationId + } + + MessageBuilder ..> ICorrelatedMessage : creates +``` + +## MessageBuilder.New() Flow + +```mermaid +flowchart TD + A[MessageBuilder.New()] -->|1. Call Factory| B[Message Factory] + B -->|2. Create Message| C[New Message] + C -->|3. Generate MsgId| D[Guid.NewGuid()] + C -->|4. Set CorrelationId = MsgId| C + C -->|5. Set CausationId = MsgId| C + C -->|6. Return Message| E[Correlated Message] + + style C fill:#bbf,stroke:#333,stroke-width:2px +``` + +## MessageBuilder.From() Flow + +```mermaid +flowchart TD + A[MessageBuilder.From()] -->|1. Get Source IDs| B[Source Message] + A -->|2. Call Factory| C[Message Factory] + C -->|3. Create Message| D[New Message] + D -->|4. Generate MsgId| E[Guid.NewGuid()] + B -->|5. Copy CorrelationId| D + B -->|6. Set CausationId = Source.MsgId| D + D -->|7. Return Message| F[Correlated Message] + + style B fill:#fbb,stroke:#333,stroke-width:2px + style D fill:#bbf,stroke:#333,stroke-width:2px +``` + +## Message Chain Creation Example + +```mermaid +sequenceDiagram + participant Client + participant MessageBuilder + participant Command + participant Event + participant NextCommand + + Client->>MessageBuilder: MessageBuilder.New() + MessageBuilder->>Command: Create CreateAccount Command + Note right of Command: MsgId: A
CorrelationId: A
CausationId: A + + Client->>MessageBuilder: MessageBuilder.From(command) + MessageBuilder->>Event: Create AccountCreated Event + Note right of Event: MsgId: B
CorrelationId: A
CausationId: A + + Client->>MessageBuilder: MessageBuilder.From(event) + MessageBuilder->>NextCommand: Create SendWelcomeEmail Command + Note right of NextCommand: MsgId: C
CorrelationId: A
CausationId: B +``` + +## Code Examples + +### Starting a New Correlation Chain + +```csharp +// Create a new command that starts a correlation chain +var createCommand = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "John Doe", + "john.doe@example.com" +)); + +// Result: +// createCommand.MsgId = new Guid (e.g., "A") +// createCommand.CorrelationId = same as MsgId ("A") +// createCommand.CausationId = same as MsgId ("A") +``` + +### Continuing a Correlation Chain + +```csharp +// Create an event from a command (maintains correlation) +var createdEvent = MessageBuilder.From(createCommand, () => new AccountCreated( + ((CreateAccount)createCommand).AccountId, + "ACC-123", + "John Doe" +)); + +// Result: +// createdEvent.MsgId = new Guid (e.g., "B") +// createdEvent.CorrelationId = createCommand.CorrelationId ("A") +// createdEvent.CausationId = createCommand.MsgId ("A") + +// Create another command from the event (maintains correlation) +var sendEmailCommand = MessageBuilder.From(createdEvent, () => new SendWelcomeEmail( + ((AccountCreated)createdEvent).AccountId, + "john.doe@example.com" +)); + +// Result: +// sendEmailCommand.MsgId = new Guid (e.g., "C") +// sendEmailCommand.CorrelationId = createdEvent.CorrelationId ("A") +// sendEmailCommand.CausationId = createdEvent.MsgId ("B") +``` + +## Key Benefits + +### Automatic Correlation + +- Correlation IDs are automatically maintained across message chains +- No need to manually track and pass correlation information + +### Causation Tracking + +- Each message knows which message caused it +- Creates a complete chain of causality for debugging and auditing + +### Simplified API + +- Factory methods handle all the complexity of correlation +- Developers can focus on the business logic of messages + +### Consistent Implementation + +- Ensures all messages follow the same correlation pattern +- Prevents errors from manual correlation management diff --git a/docs/diagrams/repository-pattern.md b/docs/diagrams/repository-pattern.md new file mode 100644 index 00000000..84be6980 --- /dev/null +++ b/docs/diagrams/repository-pattern.md @@ -0,0 +1,237 @@ +# Repository Pattern Diagram + +This diagram illustrates how repositories interact with aggregates and the event store in Reactive Domain. + +## Repository Pattern Overview + +```mermaid +classDiagram + class IRepository { + +GetById(Guid id) T + +TryGetById(Guid id, out T aggregate) bool + +Update(ref T aggregate) void + +Save(IEventSource aggregate) void + +Delete(IEventSource aggregate) void + +HardDelete(IEventSource aggregate) void + } + + class IEventSource { + +Guid Id + +long ExpectedVersion + +RestoreFromEvents(IEnumerable events) void + +UpdateWithEvents(IEnumerable events, long expectedVersion) void + +object[] TakeEvents() + } + + class StreamStoreRepository { + -IStreamNameBuilder _streamNameBuilder + -IStreamStoreConnection _connection + -IEventSerializer _serializer + +GetById(Guid id) T + +TryGetById(Guid id, out T aggregate) bool + +Update(ref T aggregate) void + +Save(IEventSource aggregate) void + +Delete(IEventSource aggregate) void + +HardDelete(IEventSource aggregate) void + } + + class AggregateRoot { + +Guid Id + +long ExpectedVersion + #RaiseEvent(object event) void + +RestoreFromEvents(IEnumerable events) void + +UpdateWithEvents(IEnumerable events, long expectedVersion) void + +object[] TakeEvents() + } + + IRepository <|.. StreamStoreRepository : implements + IEventSource <|.. AggregateRoot : implements + IRepository --> IEventSource : operates on + StreamStoreRepository --> AggregateRoot : operates on +``` + +## Repository Operations Flow + +```mermaid +flowchart TD + subgraph "GetById Operation" + A1[Repository.GetById] -->|1. Generate Stream Name| B1[Stream Name Builder] + B1 -->|2. Get Events| C1[Event Store] + C1 -->|3. Return Events| D1[Events] + D1 -->|4. Create Aggregate| E1[Aggregate Constructor] + E1 -->|5. Restore From Events| F1[Aggregate] + F1 -->|6. Return Aggregate| G1[Client] + end + + subgraph "Save Operation" + A2[Repository.Save] -->|1. Take Events| B2[Aggregate.TakeEvents] + B2 -->|2. Return New Events| C2[Events] + A2 -->|3. Generate Stream Name| D2[Stream Name Builder] + D2 -->|4. Append To Stream| E2[Event Store] + A2 -->|5. Update Expected Version| F2[Aggregate] + end + + subgraph "Delete Operation" + A3[Repository.Delete] -->|1. Create Delete Event| B3[Delete Event] + A3 -->|2. Append Delete Event| C3[Event Store] + end + + subgraph "HardDelete Operation" + A4[Repository.HardDelete] -->|1. Generate Stream Name| B4[Stream Name Builder] + B4 -->|2. Delete Stream| C4[Event Store] + end +``` + +## Detailed Repository Operations + +### GetById Operation + +```mermaid +sequenceDiagram + participant Client + participant Repository + participant StreamNameBuilder + participant EventStore + participant EventSerializer + participant Aggregate + + Client->>Repository: GetById(accountId) + Repository->>StreamNameBuilder: Build(typeof(Account), accountId) + StreamNameBuilder-->>Repository: Return Stream Name + Repository->>EventStore: ReadStreamEventsForward(streamName) + EventStore-->>Repository: Return Event Data + + loop For Each Event Data + Repository->>EventSerializer: Deserialize(eventData) + EventSerializer-->>Repository: Return Event + end + + Repository->>Aggregate: Create Instance (Reflection) + Repository->>Aggregate: RestoreFromEvents(events) + + loop For Each Event + Aggregate->>Aggregate: Apply(event) + Aggregate->>Aggregate: Update ExpectedVersion + end + + Repository-->>Client: Return Reconstructed Aggregate +``` + +### Save Operation + +```mermaid +sequenceDiagram + participant Client + participant Repository + participant Aggregate + participant StreamNameBuilder + participant EventSerializer + participant EventStore + + Client->>Repository: Save(account) + Repository->>Aggregate: TakeEvents() + Aggregate-->>Repository: Return New Events + + alt Has New Events + Repository->>StreamNameBuilder: Build(aggregate.GetType(), aggregate.Id) + StreamNameBuilder-->>Repository: Return Stream Name + + loop For Each Event + Repository->>EventSerializer: Serialize(event) + EventSerializer-->>Repository: Return Event Data + end + + Repository->>EventStore: AppendToStream(streamName, expectedVersion, eventData) + Repository->>Aggregate: Update ExpectedVersion + end + + Repository-->>Client: Return +``` + +## Correlated Repository Extension + +```mermaid +classDiagram + class ICorrelatedRepository { + +GetById(Guid id, ICorrelatedMessage source) T + +Save(T aggregate, ICorrelatedMessage source) void + } + + class IRepository { + +GetById(Guid id) T + +Save(IEventSource aggregate) void + } + + class CorrelatedStreamStoreRepository { + -IStreamStoreRepository _repository + +GetById(Guid id, ICorrelatedMessage source) T + +Save(T aggregate, ICorrelatedMessage source) void + } + + ICorrelatedRepository <|.. CorrelatedStreamStoreRepository : implements + ICorrelatedRepository --> IRepository : extends + CorrelatedStreamStoreRepository --> IRepository : decorates +``` + +## Implementation Example + +```csharp +// Creating a repository +var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder("MyApp"); +var connection = new StreamStoreConnection(connectionSettings, "localhost", 1113); +var serializer = new JsonMessageSerializer(); +var repository = new StreamStoreRepository(streamNameBuilder, connection, serializer); + +// Using the repository +try { + // Load an aggregate + var account = repository.GetById(accountId); + + // Modify the aggregate + account.Deposit(100); + + // Save the aggregate + repository.Save(account); + + // Delete the aggregate (soft delete) + repository.Delete(account); + + // Hard delete the aggregate (permanent deletion) + repository.HardDelete(account); +} +catch (AggregateNotFoundException ex) { + // Handle not found +} +catch (AggregateDeletedException ex) { + // Handle deleted +} +catch (AggregateVersionException ex) { + // Handle concurrency conflict +} +``` + +## Key Concepts + +### Repository Abstraction + +- Repositories abstract the details of event storage and retrieval +- They provide a collection-like interface for working with aggregates +- They handle the complexities of event sourcing infrastructure + +### Optimistic Concurrency + +- `ExpectedVersion` is used to detect concurrent modifications +- Version conflicts throw `AggregateVersionException` +- This ensures data consistency without locking + +### Aggregate Lifecycle Management + +- Repositories handle the complete lifecycle of aggregates +- Creation, loading, updating, and deletion operations +- Both soft delete (logical) and hard delete (physical) options + +### Event Serialization + +- Events are serialized for storage and deserialized for loading +- The serialization format is abstracted through the `IEventSerializer` interface +- This allows for different serialization strategies (JSON, Protocol Buffers, etc.) From dca86f9a379a6084a0c8554a99ce4f11714ca6ea Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sat, 3 May 2025 13:30:30 -0400 Subject: [PATCH 11/41] Add code example files to fix broken links in documentation --- docs/code-examples/creating-aggregate-root.md | 275 +++++++++++++ .../code-examples/handling-commands-events.md | 389 ++++++++++++++++++ .../saving-retrieving-aggregates.md | 372 +++++++++++++++++ 3 files changed, 1036 insertions(+) create mode 100644 docs/code-examples/creating-aggregate-root.md create mode 100644 docs/code-examples/handling-commands-events.md create mode 100644 docs/code-examples/saving-retrieving-aggregates.md diff --git a/docs/code-examples/creating-aggregate-root.md b/docs/code-examples/creating-aggregate-root.md new file mode 100644 index 00000000..83ad1a63 --- /dev/null +++ b/docs/code-examples/creating-aggregate-root.md @@ -0,0 +1,275 @@ +# Creating a New Aggregate Root + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to create a new aggregate root in Reactive Domain. + +## Basic Aggregate Root Structure + +```csharp +using System; +using ReactiveDomain.Foundation; + +namespace MyApp.Domain +{ + public class Account : AggregateRoot + { + // Private state fields + private decimal _balance; + private string _accountNumber; + private string _customerName; + private bool _isClosed; + + // Constructor for creating a new aggregate + public Account(Guid id) : base(id) + { + // Initialize with default state + } + + // Constructor for loading from history + protected Account(Guid id, IEnumerable events) : base(id, events) + { + // Base constructor will call RestoreFromEvents + } + + // Constructor with correlation + public Account(Guid id, ICorrelatedMessage source) : base(id, source) + { + // Initialize with default state and maintain correlation + } + + // Command methods + public void Create(string accountNumber, string customerName) + { + // Validate command + if (_accountNumber != null) + throw new InvalidOperationException("Account already created"); + + // Generate and apply event + RaiseEvent(new AccountCreated(Id, accountNumber, customerName)); + } + + public void Deposit(decimal amount) + { + // Validate command + if (_isClosed) + throw new InvalidOperationException("Account is closed"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + // Generate and apply event + RaiseEvent(new FundsDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + // Validate command + if (_isClosed) + throw new InvalidOperationException("Account is closed"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + // Generate and apply event + RaiseEvent(new FundsWithdrawn(Id, amount)); + } + + public void Close() + { + // Validate command + if (_isClosed) + throw new InvalidOperationException("Account already closed"); + + if (_balance > 0) + throw new InvalidOperationException("Cannot close account with positive balance"); + + // Generate and apply event + RaiseEvent(new AccountClosed(Id)); + } + + // Query methods + public decimal GetBalance() + { + return _balance; + } + + public bool IsClosed() + { + return _isClosed; + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + _balance = 0; + _isClosed = false; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void Apply(AccountClosed @event) + { + _isClosed = true; + } + } +} +``` + +## Event Definitions + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Messages; + +namespace MyApp.Domain +{ + public class AccountCreated : Event + { + public readonly Guid AccountId; + public readonly string AccountNumber; + public readonly string CustomerName; + + public AccountCreated(Guid accountId, string accountNumber, string customerName) + : base() + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + + public AccountCreated(Guid accountId, string accountNumber, string customerName, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + } + + public class FundsDeposited : Event + { + public readonly Guid AccountId; + public readonly decimal Amount; + + public FundsDeposited(Guid accountId, decimal amount) + : base() + { + AccountId = accountId; + Amount = amount; + } + + public FundsDeposited(Guid accountId, decimal amount, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + Amount = amount; + } + } + + public class FundsWithdrawn : Event + { + public readonly Guid AccountId; + public readonly decimal Amount; + + public FundsWithdrawn(Guid accountId, decimal amount) + : base() + { + AccountId = accountId; + Amount = amount; + } + + public FundsWithdrawn(Guid accountId, decimal amount, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + Amount = amount; + } + } + + public class AccountClosed : Event + { + public readonly Guid AccountId; + + public AccountClosed(Guid accountId) + : base() + { + AccountId = accountId; + } + + public AccountClosed(Guid accountId, Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + } + } +} +``` + +## Key Concepts + +### Aggregate Structure + +- **Private State**: Aggregates maintain their state in private fields +- **Command Methods**: Public methods that validate commands and generate events +- **Query Methods**: Public methods that return information about the aggregate state +- **Event Handlers**: Private `Apply` methods that update the aggregate state + +### Constructors + +- **Default Constructor**: Used when creating a new aggregate +- **History Constructor**: Used when loading an aggregate from its event history +- **Correlated Constructor**: Used when creating an aggregate from a command with correlation information + +### Command Validation + +- Commands are validated against the current state of the aggregate +- Business rules are enforced before generating events +- Exceptions are thrown when commands are invalid + +### Event Application + +- Events are generated using the `RaiseEvent` method +- Each event type has a corresponding `Apply` method +- The `Apply` method updates the aggregate state based on the event + +## Best Practices + +1. **Keep Aggregates Small**: Focus on a single business concept +2. **Validate Commands**: Ensure all commands are valid before generating events +3. **Immutable Events**: Make all event properties read-only +4. **Private State**: Keep aggregate state private and expose it through controlled methods +5. **Descriptive Event Names**: Use past tense for event names (e.g., `AccountCreated`, `FundsDeposited`) +6. **Correlation Support**: Implement constructors that support correlation tracking + +## Common Pitfalls + +1. **Large Aggregates**: Avoid creating aggregates that are too large or contain too many responsibilities +2. **Public State Modification**: Don't allow direct modification of aggregate state from outside +3. **Missing Business Rules**: Ensure all business rules are enforced in command methods +4. **Complex Apply Methods**: Keep event handlers simple and focused on updating state +5. **Side Effects in Apply Methods**: Avoid side effects like I/O operations in Apply methods + +--- + +**Navigation**: +- [← Back to Code Examples](README.md) +- [↑ Back to Top](#creating-a-new-aggregate-root) +- [→ Next: Handling Commands and Generating Events](handling-commands-events.md) diff --git a/docs/code-examples/handling-commands-events.md b/docs/code-examples/handling-commands-events.md new file mode 100644 index 00000000..594a4fd6 --- /dev/null +++ b/docs/code-examples/handling-commands-events.md @@ -0,0 +1,389 @@ +# Handling Commands and Generating Events + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to handle commands and generate events in Reactive Domain. + +## Command Definitions + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Messages; + +namespace MyApp.Domain.Commands +{ + public class CreateAccount : Command + { + public readonly Guid AccountId; + public readonly string AccountNumber; + public readonly string CustomerName; + + public CreateAccount(Guid accountId, string accountNumber, string customerName) + : base() + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + + // Constructor with explicit correlation + public CreateAccount(Guid accountId, string accountNumber, string customerName, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + } + + public class DepositFunds : Command + { + public readonly Guid AccountId; + public readonly decimal Amount; + + public DepositFunds(Guid accountId, decimal amount) + : base() + { + AccountId = accountId; + Amount = amount; + } + + // Constructor with explicit correlation + public DepositFunds(Guid accountId, decimal amount, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + Amount = amount; + } + } + + public class WithdrawFunds : Command + { + public readonly Guid AccountId; + public readonly decimal Amount; + + public WithdrawFunds(Guid accountId, decimal amount) + : base() + { + AccountId = accountId; + Amount = amount; + } + + // Constructor with explicit correlation + public WithdrawFunds(Guid accountId, decimal amount, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + Amount = amount; + } + } + + public class CloseAccount : Command + { + public readonly Guid AccountId; + + public CloseAccount(Guid accountId) + : base() + { + AccountId = accountId; + } + + // Constructor with explicit correlation + public CloseAccount(Guid accountId, Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + } + } +} +``` + +## Command Handlers + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain.Commands; + +namespace MyApp.Domain.Handlers +{ + public class AccountCommandHandler : + IHandleCommand, + IHandleCommand, + IHandleCommand, + IHandleCommand + { + private readonly IRepository _repository; + + public AccountCommandHandler(IRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public void Handle(CreateAccount command) + { + // Create a new account with correlation + var account = new Account(command.AccountId, command); + + // Initialize the account + account.Create(command.AccountNumber, command.CustomerName); + + // Save the account + _repository.Save(account); + } + + public void Handle(DepositFunds command) + { + try + { + // Load the account + var account = _repository.GetById(command.AccountId); + + // Process the command + account.Deposit(command.Amount); + + // Save the changes + _repository.Save(account); + } + catch (AggregateNotFoundException) + { + // Handle not found case + throw new InvalidOperationException($"Account {command.AccountId} not found"); + } + } + + public void Handle(WithdrawFunds command) + { + try + { + // Load the account + var account = _repository.GetById(command.AccountId); + + // Process the command + account.Withdraw(command.Amount); + + // Save the changes + _repository.Save(account); + } + catch (AggregateNotFoundException) + { + // Handle not found case + throw new InvalidOperationException($"Account {command.AccountId} not found"); + } + catch (InvalidOperationException ex) + { + // Rethrow business rule violations + throw; + } + } + + public void Handle(CloseAccount command) + { + try + { + // Load the account + var account = _repository.GetById(command.AccountId); + + // Process the command + account.Close(); + + // Save the changes + _repository.Save(account); + } + catch (AggregateNotFoundException) + { + // Handle not found case + throw new InvalidOperationException($"Account {command.AccountId} not found"); + } + } + } +} +``` + +## Using MessageBuilder for Correlation + +```csharp +using System; +using ReactiveDomain.Messaging; +using MyApp.Domain.Commands; + +namespace MyApp.Domain.Examples +{ + public class MessageBuilderExample + { + public void DemonstrateMessageBuilder() + { + // Create a new command that starts a correlation chain + var createCommand = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "ACC-123", + "John Doe" + )); + + // Create a command from an existing command (maintains correlation) + var depositCommand = MessageBuilder.From(createCommand, () => new DepositFunds( + ((CreateAccount)createCommand).AccountId, + 1000 + )); + + // Create another command in the same chain + var withdrawCommand = MessageBuilder.From(depositCommand, () => new WithdrawFunds( + ((DepositFunds)depositCommand).AccountId, + 500 + )); + + // Correlation IDs are maintained throughout the chain + Console.WriteLine($"Create Command Correlation ID: {createCommand.CorrelationId}"); + Console.WriteLine($"Deposit Command Correlation ID: {depositCommand.CorrelationId}"); + Console.WriteLine($"Withdraw Command Correlation ID: {withdrawCommand.CorrelationId}"); + + // Causation IDs form a chain + Console.WriteLine($"Create Command Causation ID: {createCommand.CausationId}"); + Console.WriteLine($"Deposit Command Causation ID: {depositCommand.CausationId}"); + Console.WriteLine($"Withdraw Command Causation ID: {withdrawCommand.CausationId}"); + } + } +} +``` + +## Registering Command Handlers + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain.Commands; +using MyApp.Domain.Handlers; + +namespace MyApp.Infrastructure +{ + public class CommandBusSetup + { + public ICommandBus ConfigureCommandBus(IRepository repository) + { + // Create a command bus + var commandBus = new CommandBus(); + + // Create command handlers + var accountCommandHandler = new AccountCommandHandler(repository); + + // Register command handlers + commandBus.Subscribe(accountCommandHandler); + commandBus.Subscribe(accountCommandHandler); + commandBus.Subscribe(accountCommandHandler); + commandBus.Subscribe(accountCommandHandler); + + return commandBus; + } + } +} +``` + +## Sending Commands + +```csharp +using System; +using ReactiveDomain.Messaging; +using MyApp.Domain.Commands; + +namespace MyApp.Application +{ + public class AccountService + { + private readonly ICommandBus _commandBus; + + public AccountService(ICommandBus commandBus) + { + _commandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus)); + } + + public Guid CreateAccount(string accountNumber, string customerName) + { + var accountId = Guid.NewGuid(); + + var command = new CreateAccount(accountId, accountNumber, customerName); + _commandBus.Send(command); + + return accountId; + } + + public void DepositFunds(Guid accountId, decimal amount) + { + var command = new DepositFunds(accountId, amount); + _commandBus.Send(command); + } + + public void WithdrawFunds(Guid accountId, decimal amount) + { + var command = new WithdrawFunds(accountId, amount); + _commandBus.Send(command); + } + + public void CloseAccount(Guid accountId) + { + var command = new CloseAccount(accountId); + _commandBus.Send(command); + } + } +} +``` + +## Key Concepts + +### Command Structure + +- Commands represent intentions to change the system state +- Commands are named in the imperative tense (e.g., `CreateAccount`, `DepositFunds`) +- Commands contain all the data needed to perform the operation +- Commands implement the `ICommand` interface or inherit from the `Command` base class + +### Command Handlers + +- Command handlers implement the `IHandleCommand` interface +- They load the appropriate aggregate from the repository +- They invoke the appropriate method on the aggregate +- They save the aggregate back to the repository + +### Correlation and Causation + +- Commands can be correlated using `MessageBuilder` +- `MessageBuilder.New()` starts a new correlation chain +- `MessageBuilder.From()` continues an existing correlation chain +- Correlation IDs track related messages across the system + +### Command Bus + +- The command bus routes commands to their handlers +- Handlers are registered with the bus using the `Subscribe` method +- Commands are sent to the bus using the `Send` method + +## Best Practices + +1. **Single Responsibility**: Each command should represent a single operation +2. **Immutable Commands**: Make all command properties read-only +3. **Validation**: Validate commands before processing them +4. **Error Handling**: Implement proper error handling in command handlers +5. **Correlation**: Use `MessageBuilder` to maintain correlation chains +6. **Command Naming**: Use imperative verb phrases for command names + +## Common Pitfalls + +1. **Complex Commands**: Avoid commands that do too many things +2. **Missing Validation**: Ensure all commands are validated before processing +3. **Ignoring Errors**: Handle errors properly in command handlers +4. **Breaking Correlation**: Ensure correlation information is maintained throughout the system +5. **Business Logic in Handlers**: Keep business logic in aggregates, not in command handlers + +--- + +**Navigation**: +- [← Previous: Creating a New Aggregate Root](creating-aggregate-root.md) +- [↑ Back to Top](#handling-commands-and-generating-events) +- [→ Next: Saving and Retrieving Aggregates](saving-retrieving-aggregates.md) diff --git a/docs/code-examples/saving-retrieving-aggregates.md b/docs/code-examples/saving-retrieving-aggregates.md new file mode 100644 index 00000000..30a8cea4 --- /dev/null +++ b/docs/code-examples/saving-retrieving-aggregates.md @@ -0,0 +1,372 @@ +# Saving and Retrieving Aggregates + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to save and retrieve aggregates using repositories in Reactive Domain. + +## Repository Configuration + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Persistence; +using ReactiveDomain.EventStore; + +namespace MyApp.Infrastructure +{ + public class RepositoryConfiguration + { + public IRepository ConfigureRepository(string connectionString) + { + // Create a stream name builder + var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder("MyApp"); + + // Create an event store connection + var connectionSettings = ConnectionSettings.Create() + .KeepReconnecting() + .KeepRetrying() + .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")); + + var connection = new StreamStoreConnection( + "MyApp", + connectionSettings, + connectionString, + 1113); + + // Create a serializer + var serializer = new JsonMessageSerializer(); + + // Create a repository + var repository = new StreamStoreRepository( + streamNameBuilder, + connection, + serializer); + + return repository; + } + + public ICorrelatedRepository ConfigureCorrelatedRepository(IRepository repository) + { + // Create a correlated repository + var correlatedRepository = new CorrelatedStreamStoreRepository(repository); + + return correlatedRepository; + } + } +} +``` + +## Basic Repository Operations + +```csharp +using System; +using ReactiveDomain.Foundation; +using MyApp.Domain; + +namespace MyApp.Application +{ + public class AccountRepository + { + private readonly IRepository _repository; + + public AccountRepository(IRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public void SaveAccount(Account account) + { + try + { + _repository.Save(account); + Console.WriteLine($"Account {account.Id} saved successfully"); + } + catch (AggregateVersionException ex) + { + Console.WriteLine($"Concurrency conflict: {ex.Message}"); + // Handle concurrency conflict + } + } + + public Account GetAccount(Guid accountId) + { + try + { + var account = _repository.GetById(accountId); + return account; + } + catch (AggregateNotFoundException) + { + Console.WriteLine($"Account {accountId} not found"); + return null; + } + catch (AggregateDeletedException) + { + Console.WriteLine($"Account {accountId} has been deleted"); + return null; + } + } + + public bool TryGetAccount(Guid accountId, out Account account) + { + return _repository.TryGetById(accountId, out account); + } + + public void UpdateAccount(ref Account account) + { + try + { + _repository.Update(ref account); + Console.WriteLine($"Account {account.Id} updated successfully"); + } + catch (AggregateVersionException ex) + { + Console.WriteLine($"Concurrency conflict: {ex.Message}"); + // Handle concurrency conflict + } + } + + public void DeleteAccount(Account account) + { + try + { + _repository.Delete(account); + Console.WriteLine($"Account {account.Id} marked as deleted"); + } + catch (AggregateVersionException ex) + { + Console.WriteLine($"Concurrency conflict: {ex.Message}"); + // Handle concurrency conflict + } + } + + public void HardDeleteAccount(Account account) + { + try + { + _repository.HardDelete(account); + Console.WriteLine($"Account {account.Id} permanently deleted"); + } + catch (AggregateVersionException ex) + { + Console.WriteLine($"Concurrency conflict: {ex.Message}"); + // Handle concurrency conflict + } + } + } +} +``` + +## Correlated Repository Operations + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.Domain.Commands; + +namespace MyApp.Application +{ + public class CorrelatedAccountRepository + { + private readonly ICorrelatedRepository _repository; + + public CorrelatedAccountRepository(ICorrelatedRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public void SaveAccount(Account account, ICorrelatedMessage source) + { + try + { + _repository.Save(account, source); + Console.WriteLine($"Account {account.Id} saved with correlation"); + } + catch (AggregateVersionException ex) + { + Console.WriteLine($"Concurrency conflict: {ex.Message}"); + // Handle concurrency conflict + } + } + + public Account GetAccount(Guid accountId, ICorrelatedMessage source) + { + try + { + var account = _repository.GetById(accountId, source); + return account; + } + catch (AggregateNotFoundException) + { + Console.WriteLine($"Account {accountId} not found"); + return null; + } + catch (AggregateDeletedException) + { + Console.WriteLine($"Account {accountId} has been deleted"); + return null; + } + } + + public void ProcessCreateAccountCommand(CreateAccount command) + { + // Create a new account with correlation + var account = new Account(command.AccountId, command); + + // Initialize the account + account.Create(command.AccountNumber, command.CustomerName); + + // Save the account with correlation + _repository.Save(account, command); + } + + public void ProcessDepositCommand(DepositFunds command) + { + try + { + // Load the account with correlation + var account = _repository.GetById(command.AccountId, command); + + // Process the command + account.Deposit(command.Amount); + + // Save the changes with correlation + _repository.Save(account, command); + } + catch (AggregateNotFoundException) + { + // Handle not found case + throw new InvalidOperationException($"Account {command.AccountId} not found"); + } + } + } +} +``` + +## Complete Example + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.Domain.Commands; +using MyApp.Infrastructure; +using MyApp.Application; + +namespace MyApp.Examples +{ + public class RepositoryExample + { + public void DemonstrateRepositoryOperations() + { + // Configure repository + var config = new RepositoryConfiguration(); + var repository = config.ConfigureRepository("localhost"); + var correlatedRepository = config.ConfigureCorrelatedRepository(repository); + + // Create repositories + var accountRepo = new AccountRepository(repository); + var correlatedAccountRepo = new CorrelatedAccountRepository(correlatedRepository); + + // Create a new account + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + account.Create("ACC-123", "John Doe"); + + // Save the account + accountRepo.SaveAccount(account); + + // Retrieve the account + var retrievedAccount = accountRepo.GetAccount(accountId); + Console.WriteLine($"Retrieved account balance: {retrievedAccount.GetBalance()}"); + + // Update the account + retrievedAccount.Deposit(1000); + accountRepo.SaveAccount(retrievedAccount); + + // Try to get an account + Account anotherAccount; + if (accountRepo.TryGetAccount(Guid.NewGuid(), out anotherAccount)) + { + Console.WriteLine("Account found"); + } + else + { + Console.WriteLine("Account not found"); + } + + // Using correlated repository + var createCommand = new CreateAccount(Guid.NewGuid(), "ACC-456", "Jane Smith"); + correlatedAccountRepo.ProcessCreateAccountCommand(createCommand); + + var depositCommand = MessageBuilder.From(createCommand, () => + new DepositFunds(((CreateAccount)createCommand).AccountId, 500)); + correlatedAccountRepo.ProcessDepositCommand(depositCommand); + + // Delete an account + accountRepo.DeleteAccount(retrievedAccount); + + // Hard delete an account + // accountRepo.HardDeleteAccount(retrievedAccount); + } + } +} +``` + +## Key Concepts + +### Repository Configuration + +- **StreamNameBuilder**: Generates consistent stream names for aggregates +- **StreamStoreConnection**: Connects to the EventStoreDB +- **EventSerializer**: Serializes and deserializes events +- **StreamStoreRepository**: Implements the `IRepository` interface +- **CorrelatedStreamStoreRepository**: Implements the `ICorrelatedRepository` interface + +### Basic Repository Operations + +- **Save**: Persists new events from an aggregate to the event store +- **GetById**: Retrieves an aggregate by its ID +- **TryGetById**: Attempts to retrieve an aggregate by its ID +- **Update**: Updates an aggregate with the latest events from the event store +- **Delete**: Marks an aggregate as deleted (soft delete) +- **HardDelete**: Permanently deletes an aggregate (hard delete) + +### Correlated Repository Operations + +- **Save with Correlation**: Persists new events with correlation information +- **GetById with Correlation**: Retrieves an aggregate with correlation information +- **Command Processing**: Processes commands with correlation tracking + +### Error Handling + +- **AggregateNotFoundException**: Thrown when an aggregate is not found +- **AggregateDeletedException**: Thrown when an aggregate has been deleted +- **AggregateVersionException**: Thrown when there's a concurrency conflict + +## Best Practices + +1. **Error Handling**: Implement proper error handling for repository operations +2. **Correlation Tracking**: Use correlated repositories for better traceability +3. **Optimistic Concurrency**: Handle version conflicts appropriately +4. **Repository Abstraction**: Depend on the repository interfaces, not concrete implementations +5. **Connection Management**: Configure connections with appropriate retry and reconnect settings +6. **Stream Naming**: Use a consistent stream naming strategy + +## Common Pitfalls + +1. **Ignoring Concurrency**: Failing to handle `AggregateVersionException` can lead to lost updates +2. **Missing Error Handling**: Not properly handling repository exceptions +3. **Connection Issues**: Not configuring connections with appropriate retry settings +4. **Breaking Correlation**: Not maintaining correlation information across operations +5. **Hard Delete Overuse**: Using `HardDelete` when `Delete` would be more appropriate + +--- + +**Navigation**: +- [← Previous: Handling Commands and Generating Events](handling-commands-events.md) +- [↑ Back to Top](#saving-and-retrieving-aggregates) +- [→ Next: Setting Up Event Listeners](event-listeners.md) From 2c03ad8ca104a3b38e13ab1e22f10c71b5b66283 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sat, 3 May 2025 13:42:19 -0400 Subject: [PATCH 12/41] Add remaining code examples to complete documentation --- docs/code-examples/aspnet-integration.md | 590 ++++++++++++ docs/code-examples/correlation-causation.md | 843 ++++++++++++++++++ docs/code-examples/event-listeners.md | 561 ++++++++++++ .../code-examples/implementing-projections.md | 737 +++++++++++++++ docs/code-examples/implementing-snapshots.md | 703 +++++++++++++++ docs/code-examples/sample-applications.md | 612 +++++++++++++ docs/code-examples/testing.md | 685 ++++++++++++++ 7 files changed, 4731 insertions(+) create mode 100644 docs/code-examples/aspnet-integration.md create mode 100644 docs/code-examples/correlation-causation.md create mode 100644 docs/code-examples/event-listeners.md create mode 100644 docs/code-examples/implementing-projections.md create mode 100644 docs/code-examples/implementing-snapshots.md create mode 100644 docs/code-examples/sample-applications.md create mode 100644 docs/code-examples/testing.md diff --git a/docs/code-examples/aspnet-integration.md b/docs/code-examples/aspnet-integration.md new file mode 100644 index 00000000..e75203e7 --- /dev/null +++ b/docs/code-examples/aspnet-integration.md @@ -0,0 +1,590 @@ +# Integration with ASP.NET Core + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to integrate Reactive Domain with ASP.NET Core to build event-sourced web applications. + +## Project Setup + +```csharp +// MyApp.Web.csproj + + + + net6.0 + enable + enable + + + + + + + + + + + +``` + +## Dependency Injection Configuration + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain; +using MyApp.Domain.Commands; +using MyApp.Domain.Handlers; +using MyApp.EventHandlers; +using MyApp.Infrastructure; +using MyApp.ReadModels; + +namespace MyApp.Web +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + // Add controllers and API explorer for Swagger + services.AddControllers(); + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + + // Configure EventStore connection + var eventStoreConnectionString = Configuration.GetConnectionString("EventStore"); + + // Register repository + var repositoryConfig = new RepositoryConfiguration(); + var repository = repositoryConfig.ConfigureRepository(eventStoreConnectionString); + services.AddSingleton(repository); + + // Register correlated repository + var correlatedRepository = repositoryConfig.ConfigureCorrelatedRepository(repository); + services.AddSingleton(correlatedRepository); + + // Register command bus and handlers + var commandBus = new CommandBus(); + var accountCommandHandler = new AccountCommandHandler(repository); + + commandBus.Subscribe(accountCommandHandler); + commandBus.Subscribe(accountCommandHandler); + commandBus.Subscribe(accountCommandHandler); + commandBus.Subscribe(accountCommandHandler); + + services.AddSingleton(commandBus); + + // Register event bus + var eventBus = new EventBus(); + services.AddSingleton(eventBus); + + // Register read model repositories + services.AddSingleton, InMemoryReadModelRepository>(); + services.AddSingleton, InMemoryReadModelRepository>(); + + // Register event handlers + services.AddSingleton(); + services.AddSingleton(); + + // Register application services + services.AddSingleton(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider serviceProvider) + { + if (env.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseDeveloperExceptionPage(); + } + else + { + app.UseExceptionHandler("/error"); + app.UseHsts(); + } + + app.UseHttpsRedirection(); + app.UseRouting(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + + // Set up event handlers + ConfigureEventHandlers(serviceProvider); + } + + private void ConfigureEventHandlers(IServiceProvider serviceProvider) + { + var eventBus = serviceProvider.GetRequiredService(); + var accountReadModelUpdater = serviceProvider.GetRequiredService(); + var transactionHistoryUpdater = serviceProvider.GetRequiredService(); + + // Subscribe event handlers to the event bus + eventBus.Subscribe(accountReadModelUpdater); + eventBus.Subscribe(accountReadModelUpdater); + eventBus.Subscribe(accountReadModelUpdater); + eventBus.Subscribe(accountReadModelUpdater); + + eventBus.Subscribe(transactionHistoryUpdater); + eventBus.Subscribe(transactionHistoryUpdater); + eventBus.Subscribe(transactionHistoryUpdater); + eventBus.Subscribe(transactionHistoryUpdater); + + // Set up event store subscription + var repository = serviceProvider.GetRequiredService(); + var eventStoreSubscription = new EventStoreSubscription( + GetStreamStoreConnection(repository), + eventBus, + GetEventSerializer(repository)); + + // Subscribe to all events + eventStoreSubscription.SubscribeToAll(); + } + + private IStreamStoreConnection GetStreamStoreConnection(IRepository repository) + { + // This is a simplified version just for the example + // In a real application, you would use a more robust approach + return repository.GetType() + .GetProperty("Connection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetValue(repository) as IStreamStoreConnection; + } + + private IEventSerializer GetEventSerializer(IRepository repository) + { + // This is a simplified version just for the example + // In a real application, you would use a more robust approach + return repository.GetType() + .GetProperty("Serializer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetValue(repository) as IEventSerializer; + } + } +} +``` + +## Application Service + +```csharp +using System; +using System.Threading.Tasks; +using ReactiveDomain.Messaging; +using MyApp.Domain.Commands; +using MyApp.ReadModels; + +namespace MyApp +{ + public class AccountService + { + private readonly ICommandBus _commandBus; + private readonly IReadModelRepository _accountRepository; + private readonly IReadModelRepository _transactionRepository; + + public AccountService( + ICommandBus commandBus, + IReadModelRepository accountRepository, + IReadModelRepository transactionRepository) + { + _commandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus)); + _accountRepository = accountRepository ?? throw new ArgumentNullException(nameof(accountRepository)); + _transactionRepository = transactionRepository ?? throw new ArgumentNullException(nameof(transactionRepository)); + } + + public async Task CreateAccountAsync(string accountNumber, string customerName) + { + var accountId = Guid.NewGuid(); + + var command = new CreateAccount(accountId, accountNumber, customerName); + await _commandBus.SendAsync(command); + + return accountId; + } + + public async Task DepositFundsAsync(Guid accountId, decimal amount) + { + var command = new DepositFunds(accountId, amount); + await _commandBus.SendAsync(command); + } + + public async Task WithdrawFundsAsync(Guid accountId, decimal amount) + { + var command = new WithdrawFunds(accountId, amount); + await _commandBus.SendAsync(command); + } + + public async Task CloseAccountAsync(Guid accountId) + { + var command = new CloseAccount(accountId); + await _commandBus.SendAsync(command); + } + + public AccountSummary GetAccountSummary(Guid accountId) + { + return _accountRepository.GetById(accountId); + } + + public TransactionHistory GetTransactionHistory(Guid accountId) + { + return _transactionRepository.GetById(accountId); + } + } +} +``` + +## API Controllers + +```csharp +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using MyApp.ReadModels; +using MyApp.Web.Models; + +namespace MyApp.Web.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AccountsController : ControllerBase + { + private readonly AccountService _accountService; + + public AccountsController(AccountService accountService) + { + _accountService = accountService ?? throw new ArgumentNullException(nameof(accountService)); + } + + [HttpPost] + public async Task CreateAccount([FromBody] CreateAccountRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + try + { + var accountId = await _accountService.CreateAccountAsync( + request.AccountNumber, + request.CustomerName); + + return CreatedAtAction(nameof(GetAccount), new { id = accountId }, new { AccountId = accountId }); + } + catch (Exception ex) + { + return StatusCode(500, new { Error = ex.Message }); + } + } + + [HttpGet("{id}")] + public IActionResult GetAccount(Guid id) + { + var account = _accountService.GetAccountSummary(id); + + if (account == null) + { + return NotFound(); + } + + return Ok(new AccountResponse + { + Id = account.Id, + AccountNumber = account.AccountNumber, + CustomerName = account.CustomerName, + Balance = account.Balance, + IsClosed = account.IsClosed, + CreatedAt = account.CreatedAt, + LastUpdatedAt = account.LastUpdatedAt + }); + } + + [HttpPost("{id}/deposit")] + public async Task Deposit(Guid id, [FromBody] DepositRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var account = _accountService.GetAccountSummary(id); + + if (account == null) + { + return NotFound(); + } + + if (account.IsClosed) + { + return BadRequest(new { Error = "Cannot deposit to a closed account" }); + } + + try + { + await _accountService.DepositFundsAsync(id, request.Amount); + return Ok(); + } + catch (Exception ex) + { + return StatusCode(500, new { Error = ex.Message }); + } + } + + [HttpPost("{id}/withdraw")] + public async Task Withdraw(Guid id, [FromBody] WithdrawRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var account = _accountService.GetAccountSummary(id); + + if (account == null) + { + return NotFound(); + } + + if (account.IsClosed) + { + return BadRequest(new { Error = "Cannot withdraw from a closed account" }); + } + + if (account.Balance < request.Amount) + { + return BadRequest(new { Error = "Insufficient funds" }); + } + + try + { + await _accountService.WithdrawFundsAsync(id, request.Amount); + return Ok(); + } + catch (Exception ex) + { + return StatusCode(500, new { Error = ex.Message }); + } + } + + [HttpPost("{id}/close")] + public async Task Close(Guid id) + { + var account = _accountService.GetAccountSummary(id); + + if (account == null) + { + return NotFound(); + } + + if (account.IsClosed) + { + return BadRequest(new { Error = "Account is already closed" }); + } + + try + { + await _accountService.CloseAccountAsync(id); + return Ok(); + } + catch (Exception ex) + { + return StatusCode(500, new { Error = ex.Message }); + } + } + + [HttpGet("{id}/transactions")] + public IActionResult GetTransactions(Guid id) + { + var account = _accountService.GetAccountSummary(id); + + if (account == null) + { + return NotFound(); + } + + var history = _accountService.GetTransactionHistory(id); + + if (history == null) + { + return Ok(Array.Empty()); + } + + var transactions = history.Transactions.Select(t => new TransactionResponse + { + Id = t.Id, + Type = t.Type, + Amount = t.Amount, + Description = t.Description, + Timestamp = t.Timestamp + }).ToArray(); + + return Ok(transactions); + } + } +} +``` + +## Request and Response Models + +```csharp +using System; +using System.ComponentModel.DataAnnotations; + +namespace MyApp.Web.Models +{ + public class CreateAccountRequest + { + [Required] + [StringLength(50)] + public string AccountNumber { get; set; } + + [Required] + [StringLength(100)] + public string CustomerName { get; set; } + } + + public class DepositRequest + { + [Required] + [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")] + public decimal Amount { get; set; } + } + + public class WithdrawRequest + { + [Required] + [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")] + public decimal Amount { get; set; } + } + + public class AccountResponse + { + public Guid Id { get; set; } + public string AccountNumber { get; set; } + public string CustomerName { get; set; } + public decimal Balance { get; set; } + public bool IsClosed { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastUpdatedAt { get; set; } + } + + public class TransactionResponse + { + public Guid Id { get; set; } + public string Type { get; set; } + public decimal Amount { get; set; } + public string Description { get; set; } + public DateTime Timestamp { get; set; } + } +} +``` + +## Program.cs + +```csharp +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; + +namespace MyApp.Web +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} +``` + +## appsettings.json + +```json +{ + "ConnectionStrings": { + "EventStore": "tcp://admin:changeit@localhost:1113" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} +``` + +## Key Concepts + +### Dependency Injection + +- Register Reactive Domain components in the ASP.NET Core DI container +- Configure repositories, command bus, and event bus as singletons +- Register command handlers and event handlers +- Set up event store subscription during application startup + +### Application Services + +- Create application services that encapsulate domain operations +- Use command bus to send commands to the domain +- Use read model repositories to query data +- Provide a clean API for controllers + +### API Controllers + +- Create RESTful API endpoints for domain operations +- Use application services to handle domain logic +- Return appropriate HTTP status codes and responses +- Validate input using model validation + +### Request/Response Models + +- Define clear request and response models for API endpoints +- Use data annotations for validation +- Map domain entities to response models +- Keep domain models separate from API models + +## Best Practices + +1. **Separation of Concerns**: Keep domain logic separate from API controllers +2. **Dependency Injection**: Use the ASP.NET Core DI container to manage dependencies +3. **Validation**: Validate input at the API boundary +4. **Error Handling**: Implement proper error handling and return appropriate status codes +5. **Asynchronous Operations**: Use async/await for command operations +6. **CQRS**: Separate command and query responsibilities +7. **API Design**: Follow RESTful API design principles + +## Common Pitfalls + +1. **Mixing Domain and API Concerns**: Keep domain logic out of controllers +2. **Synchronous Command Handling**: Use async/await for command operations +3. **Missing Validation**: Always validate input at the API boundary +4. **Exposing Domain Models**: Use dedicated response models for API responses +5. **Ignoring Error Handling**: Implement proper error handling for domain operations + +--- + +**Navigation**: +- [← Previous: Testing Aggregates and Event Handlers](testing.md) +- [↑ Back to Top](#integration-with-aspnet-core) +- [→ Next: Complete Sample Applications](sample-applications.md) diff --git a/docs/code-examples/correlation-causation.md b/docs/code-examples/correlation-causation.md new file mode 100644 index 00000000..69ad26d5 --- /dev/null +++ b/docs/code-examples/correlation-causation.md @@ -0,0 +1,843 @@ +# Handling Correlation and Causation + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to implement correlation and causation tracking in Reactive Domain to trace message flows through the system. + +## Understanding Correlation and Causation + +```csharp +/* +Correlation and Causation IDs are essential for tracing message flows: + +- CorrelationId: Identifies a chain of related messages (same for all messages in a chain) +- CausationId: Identifies the direct cause of a message (points to the MessageId of the previous message) +- MessageId: Unique identifier for each message + +Message Chain Example: +1. Command A (MessageId: A, CorrelationId: A, CausationId: 0) +2. Event B (MessageId: B, CorrelationId: A, CausationId: A) +3. Command C (MessageId: C, CorrelationId: A, CausationId: B) +4. Event D (MessageId: D, CorrelationId: A, CausationId: C) + +This creates a traceable chain: A → B → C → D +All sharing the same CorrelationId (A), with CausationId forming the links. +*/ +``` + +## ICorrelatedMessage Interface + +```csharp +using System; + +namespace ReactiveDomain.Messaging +{ + public interface ICorrelatedMessage + { + /// + /// Unique identifier for this message + /// + Guid MessageId { get; } + + /// + /// Identifier used to correlate related messages in a workflow + /// + Guid CorrelationId { get; } + + /// + /// Identifier of the message that caused this message + /// + Guid CausationId { get; } + } +} +``` + +## Correlated Message Base Class + +```csharp +using System; + +namespace ReactiveDomain.Messaging.Messages +{ + public abstract class CorrelatedMessage : ICorrelatedMessage + { + public Guid MessageId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + protected CorrelatedMessage() + { + MessageId = Guid.NewGuid(); + CorrelationId = MessageId; + CausationId = Guid.Empty; + } + + protected CorrelatedMessage(Guid correlationId, Guid causationId) + { + MessageId = Guid.NewGuid(); + CorrelationId = correlationId; + CausationId = causationId; + } + } +} +``` + +## Command and Event Classes + +```csharp +using System; +using ReactiveDomain.Messaging.Messages; + +namespace ReactiveDomain.Messaging.Messages +{ + public abstract class Command : CorrelatedMessage + { + protected Command() : base() + { + } + + protected Command(Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + } + } + + public abstract class Event : CorrelatedMessage + { + protected Event() : base() + { + } + + protected Event(Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + } + } +} +``` + +## MessageBuilder Factory + +```csharp +using System; +using ReactiveDomain.Messaging; + +namespace ReactiveDomain.Messaging +{ + public static class MessageBuilder + { + /// + /// Creates a new message that starts a correlation chain + /// + public static T New(Func messageFactory) where T : ICorrelatedMessage + { + return messageFactory(); + } + + /// + /// Creates a new message that continues a correlation chain from a source message + /// + public static T From(ICorrelatedMessage source, Func messageFactory) where T : ICorrelatedMessage + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + var message = messageFactory(); + + // If the message already has correlation info, don't override it + if (message.CorrelationId == message.MessageId && message.CausationId == Guid.Empty) + { + // Use reflection to set the correlation and causation IDs + var type = message.GetType(); + + var correlationIdProperty = type.GetProperty("CorrelationId"); + if (correlationIdProperty != null && correlationIdProperty.CanWrite) + { + correlationIdProperty.SetValue(message, source.CorrelationId); + } + + var causationIdProperty = type.GetProperty("CausationId"); + if (causationIdProperty != null && causationIdProperty.CanWrite) + { + causationIdProperty.SetValue(message, source.MessageId); + } + } + + return message; + } + } +} +``` + +## Correlated Command Example + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Messages; + +namespace MyApp.Domain.Commands +{ + public class CreateAccount : Command + { + public readonly Guid AccountId; + public readonly string AccountNumber; + public readonly string CustomerName; + + public CreateAccount(Guid accountId, string accountNumber, string customerName) + : base() + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + + public CreateAccount(Guid accountId, string accountNumber, string customerName, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + } + + public class DepositFunds : Command + { + public readonly Guid AccountId; + public readonly decimal Amount; + + public DepositFunds(Guid accountId, decimal amount) + : base() + { + AccountId = accountId; + Amount = amount; + } + + public DepositFunds(Guid accountId, decimal amount, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + Amount = amount; + } + } +} +``` + +## Correlated Event Example + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Messages; + +namespace MyApp.Domain.Events +{ + public class AccountCreated : Event + { + public readonly Guid AccountId; + public readonly string AccountNumber; + public readonly string CustomerName; + + public AccountCreated(Guid accountId, string accountNumber, string customerName) + : base() + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + + public AccountCreated(Guid accountId, string accountNumber, string customerName, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + } + + public class FundsDeposited : Event + { + public readonly Guid AccountId; + public readonly decimal Amount; + + public FundsDeposited(Guid accountId, decimal amount) + : base() + { + AccountId = accountId; + Amount = amount; + } + + public FundsDeposited(Guid accountId, decimal amount, + Guid correlationId, Guid causationId) + : base(correlationId, causationId) + { + AccountId = accountId; + Amount = amount; + } + } +} +``` + +## Aggregate Root with Correlation + +```csharp +using System; +using System.Collections.Generic; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using MyApp.Domain.Events; + +namespace MyApp.Domain +{ + public class Account : AggregateRoot + { + private string _accountNumber; + private string _customerName; + private decimal _balance; + private bool _isClosed; + + public Account(Guid id) : base(id) + { + } + + // Constructor with correlation source + public Account(Guid id, ICorrelatedMessage source) : base(id, source) + { + } + + public void Create(string accountNumber, string customerName) + { + if (string.IsNullOrEmpty(accountNumber)) + throw new ArgumentException("Account number cannot be empty", nameof(accountNumber)); + + if (string.IsNullOrEmpty(customerName)) + throw new ArgumentException("Customer name cannot be empty", nameof(customerName)); + + // Apply the event with correlation + ApplyChange(new AccountCreated(Id, accountNumber, customerName, CorrelationId, CausationId)); + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_isClosed) + throw new InvalidOperationException("Cannot deposit to a closed account"); + + // Apply the event with correlation + ApplyChange(new FundsDeposited(Id, amount, CorrelationId, CausationId)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_isClosed) + throw new InvalidOperationException("Cannot withdraw from a closed account"); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + // Apply the event with correlation + ApplyChange(new FundsWithdrawn(Id, amount, CorrelationId, CausationId)); + } + + public void Close() + { + if (_isClosed) + throw new InvalidOperationException("Account is already closed"); + + // Apply the event with correlation + ApplyChange(new AccountClosed(Id, CorrelationId, CausationId)); + } + + public decimal GetBalance() + { + return _balance; + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + _balance = 0; + _isClosed = false; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void Apply(AccountClosed @event) + { + _isClosed = true; + } + } +} +``` + +## Correlated Repository + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; + +namespace ReactiveDomain.Foundation +{ + public interface ICorrelatedRepository + { + TAggregate GetById(Guid id, ICorrelatedMessage source) where TAggregate : class, IEventSource; + void Save(IEventSource aggregate, ICorrelatedMessage source); + bool TryGetById(Guid id, ICorrelatedMessage source, out TAggregate aggregate) where TAggregate : class, IEventSource; + } + + public class CorrelatedStreamStoreRepository : ICorrelatedRepository + { + private readonly IRepository _repository; + + public CorrelatedStreamStoreRepository(IRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public TAggregate GetById(Guid id, ICorrelatedMessage source) where TAggregate : class, IEventSource + { + var aggregate = _repository.GetById(id); + + // Set correlation on the aggregate + if (aggregate is ICorrelatedEventSource correlatedAggregate) + { + correlatedAggregate.SetCorrelationIds(source.CorrelationId, source.MessageId); + } + + return aggregate; + } + + public void Save(IEventSource aggregate, ICorrelatedMessage source) + { + // Set correlation on the aggregate if not already set + if (aggregate is ICorrelatedEventSource correlatedAggregate && + correlatedAggregate.CorrelationId == Guid.Empty) + { + correlatedAggregate.SetCorrelationIds(source.CorrelationId, source.MessageId); + } + + _repository.Save(aggregate); + } + + public bool TryGetById(Guid id, ICorrelatedMessage source, out TAggregate aggregate) where TAggregate : class, IEventSource + { + if (_repository.TryGetById(id, out aggregate)) + { + // Set correlation on the aggregate + if (aggregate is ICorrelatedEventSource correlatedAggregate) + { + correlatedAggregate.SetCorrelationIds(source.CorrelationId, source.MessageId); + } + + return true; + } + + return false; + } + } +} +``` + +## Correlated Command Handler + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain; +using MyApp.Domain.Commands; + +namespace MyApp.Domain.Handlers +{ + public class CorrelatedAccountCommandHandler : + IHandleCommand, + IHandleCommand, + IHandleCommand, + IHandleCommand + { + private readonly ICorrelatedRepository _repository; + + public CorrelatedAccountCommandHandler(ICorrelatedRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public void Handle(CreateAccount command) + { + // Create a new account with correlation + var account = new Account(command.AccountId, command); + + // Initialize the account + account.Create(command.AccountNumber, command.CustomerName); + + // Save the account with correlation + _repository.Save(account, command); + } + + public void Handle(DepositFunds command) + { + try + { + // Load the account with correlation + var account = _repository.GetById(command.AccountId, command); + + // Process the command + account.Deposit(command.Amount); + + // Save the changes with correlation + _repository.Save(account, command); + } + catch (AggregateNotFoundException) + { + // Handle not found case + throw new InvalidOperationException($"Account {command.AccountId} not found"); + } + } + + public void Handle(WithdrawFunds command) + { + try + { + // Load the account with correlation + var account = _repository.GetById(command.AccountId, command); + + // Process the command + account.Withdraw(command.Amount); + + // Save the changes with correlation + _repository.Save(account, command); + } + catch (AggregateNotFoundException) + { + // Handle not found case + throw new InvalidOperationException($"Account {command.AccountId} not found"); + } + catch (InvalidOperationException ex) + { + // Rethrow business rule violations + throw; + } + } + + public void Handle(CloseAccount command) + { + try + { + // Load the account with correlation + var account = _repository.GetById(command.AccountId, command); + + // Process the command + account.Close(); + + // Save the changes with correlation + _repository.Save(account, command); + } + catch (AggregateNotFoundException) + { + // Handle not found case + throw new InvalidOperationException($"Account {command.AccountId} not found"); + } + } + } +} +``` + +## Using MessageBuilder for Correlation + +```csharp +using System; +using ReactiveDomain.Messaging; +using MyApp.Domain.Commands; + +namespace MyApp.Examples +{ + public class CorrelationExample + { + public void DemonstrateCorrelation() + { + // Create a new command that starts a correlation chain + var createCommand = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "ACC-123", + "John Doe" + )); + + Console.WriteLine("Create Command:"); + Console.WriteLine($" MessageId: {createCommand.MessageId}"); + Console.WriteLine($" CorrelationId: {createCommand.CorrelationId}"); + Console.WriteLine($" CausationId: {createCommand.CausationId}"); + + // Create a command from an existing command (maintains correlation) + var depositCommand = MessageBuilder.From(createCommand, () => new DepositFunds( + ((CreateAccount)createCommand).AccountId, + 1000 + )); + + Console.WriteLine("\nDeposit Command:"); + Console.WriteLine($" MessageId: {depositCommand.MessageId}"); + Console.WriteLine($" CorrelationId: {depositCommand.CorrelationId}"); + Console.WriteLine($" CausationId: {depositCommand.CausationId}"); + + // Create another command in the same chain + var withdrawCommand = MessageBuilder.From(depositCommand, () => new WithdrawFunds( + ((DepositFunds)depositCommand).AccountId, + 500 + )); + + Console.WriteLine("\nWithdraw Command:"); + Console.WriteLine($" MessageId: {withdrawCommand.MessageId}"); + Console.WriteLine($" CorrelationId: {withdrawCommand.CorrelationId}"); + Console.WriteLine($" CausationId: {withdrawCommand.CausationId}"); + + // Verify correlation chain + Console.WriteLine("\nCorrelation Chain:"); + Console.WriteLine($" All messages have the same CorrelationId: {createCommand.CorrelationId == depositCommand.CorrelationId && depositCommand.CorrelationId == withdrawCommand.CorrelationId}"); + Console.WriteLine($" Deposit CausationId matches Create MessageId: {depositCommand.CausationId == createCommand.MessageId}"); + Console.WriteLine($" Withdraw CausationId matches Deposit MessageId: {withdrawCommand.CausationId == depositCommand.MessageId}"); + } + } +} +``` + +## Complete Example with Tracing + +```csharp +using System; +using System.Collections.Generic; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain; +using MyApp.Domain.Commands; +using MyApp.Domain.Events; + +namespace MyApp.Examples +{ + public class CorrelationTracingExample + { + private readonly Dictionary> _messagesByCorrelation = + new Dictionary>(); + + private readonly Dictionary _messagesById = + new Dictionary(); + + public void TraceMessageFlow() + { + // Set up message tracking + var commandBus = new CommandBus(); + var eventBus = new EventBus(); + + // Subscribe to all commands + commandBus.Subscribe(cmd => TrackMessage(cmd)); + + // Subscribe to all events + eventBus.Subscribe(evt => TrackMessage(evt)); + + // Create a new account command + var accountId = Guid.NewGuid(); + var createCommand = MessageBuilder.New(() => new CreateAccount( + accountId, + "ACC-123", + "John Doe" + )); + + // Track and send the command + TrackMessage(createCommand); + + // Simulate command handler producing events + var accountCreatedEvent = MessageBuilder.From(createCommand, () => new AccountCreated( + accountId, + "ACC-123", + "John Doe" + )); + + // Track and publish the event + TrackMessage(accountCreatedEvent); + + // Create a deposit command + var depositCommand = MessageBuilder.From(accountCreatedEvent, () => new DepositFunds( + accountId, + 1000 + )); + + // Track and send the command + TrackMessage(depositCommand); + + // Simulate command handler producing events + var fundsDepositedEvent = MessageBuilder.From(depositCommand, () => new FundsDeposited( + accountId, + 1000 + )); + + // Track and publish the event + TrackMessage(fundsDepositedEvent); + + // Create a withdraw command + var withdrawCommand = MessageBuilder.From(fundsDepositedEvent, () => new WithdrawFunds( + accountId, + 500 + )); + + // Track and send the command + TrackMessage(withdrawCommand); + + // Simulate command handler producing events + var fundsWithdrawnEvent = MessageBuilder.From(withdrawCommand, () => new FundsWithdrawn( + accountId, + 500 + )); + + // Track and publish the event + TrackMessage(fundsWithdrawnEvent); + + // Print the correlation trace + PrintCorrelationTrace(createCommand.CorrelationId); + } + + private void TrackMessage(ICorrelatedMessage message) + { + // Track by correlation ID + if (!_messagesByCorrelation.ContainsKey(message.CorrelationId)) + { + _messagesByCorrelation[message.CorrelationId] = new List(); + } + + _messagesByCorrelation[message.CorrelationId].Add(message); + + // Track by message ID + _messagesById[message.MessageId] = message; + } + + private void PrintCorrelationTrace(Guid correlationId) + { + Console.WriteLine($"\nTrace for Correlation ID: {correlationId}"); + Console.WriteLine("======================================"); + + if (!_messagesByCorrelation.ContainsKey(correlationId)) + { + Console.WriteLine("No messages found with this correlation ID"); + return; + } + + // Build the causation chain + var messageChain = BuildCausationChain(correlationId); + + // Print the chain + foreach (var message in messageChain) + { + string messageType = message.GetType().Name; + string messageCategory = message is ICommand ? "Command" : "Event"; + + Console.WriteLine($"{messageCategory}: {messageType}"); + Console.WriteLine($" MessageId: {message.MessageId}"); + Console.WriteLine($" CorrelationId: {message.CorrelationId}"); + Console.WriteLine($" CausationId: {message.CausationId}"); + Console.WriteLine(); + } + } + + private List BuildCausationChain(Guid correlationId) + { + var result = new List(); + var messagesByCorrelation = _messagesByCorrelation[correlationId]; + + // Find the first message (with empty causation ID) + var firstMessage = messagesByCorrelation.Find(m => m.CausationId == Guid.Empty); + if (firstMessage == null) + { + return result; + } + + // Start with the first message + result.Add(firstMessage); + var currentMessageId = firstMessage.MessageId; + + // Build the chain by following causation IDs + while (true) + { + var nextMessages = messagesByCorrelation.FindAll(m => m.CausationId == currentMessageId); + if (nextMessages.Count == 0) + { + break; + } + + // Add the next message in the chain + var nextMessage = nextMessages[0]; + result.Add(nextMessage); + currentMessageId = nextMessage.MessageId; + } + + return result; + } + } +} +``` + +## Key Concepts + +### Correlation and Causation IDs + +- **MessageId**: Unique identifier for each message +- **CorrelationId**: Identifies a chain of related messages (same for all messages in a chain) +- **CausationId**: Identifies the direct cause of a message (points to the MessageId of the previous message) + +### Message Chain + +A typical message chain follows this pattern: +1. Command A (MessageId: A, CorrelationId: A, CausationId: 0) +2. Event B (MessageId: B, CorrelationId: A, CausationId: A) +3. Command C (MessageId: C, CorrelationId: A, CausationId: B) +4. Event D (MessageId: D, CorrelationId: A, CausationId: C) + +### MessageBuilder + +- **MessageBuilder.New()**: Creates a new message that starts a correlation chain +- **MessageBuilder.From()**: Creates a new message that continues a correlation chain + +### Correlated Repository + +- Maintains correlation information when loading and saving aggregates +- Sets correlation IDs on aggregates based on the source message + +## Best Practices + +1. **Use MessageBuilder**: Always use MessageBuilder to create correlated messages +2. **Consistent Correlation**: Maintain the same correlation ID throughout a business transaction +3. **Proper Causation**: Set the causation ID to the message ID of the triggering message +4. **Correlated Repositories**: Use correlated repositories to maintain correlation chains +5. **Correlation in Aggregates**: Pass correlation information to aggregates when handling commands +6. **Tracing**: Implement tracing to visualize message flows + +## Common Pitfalls + +1. **Breaking Correlation Chains**: Creating messages without using MessageBuilder +2. **Missing Correlation**: Not passing correlation information to aggregates +3. **Incorrect Causation**: Setting the wrong causation ID +4. **Lost Correlation**: Not maintaining correlation when crossing system boundaries +5. **Correlation Overload**: Using correlation for purposes other than tracing + +--- + +**Navigation**: +- [← Previous: Implementing Projections](implementing-projections.md) +- [↑ Back to Top](#handling-correlation-and-causation) +- [→ Next: Implementing Snapshots](implementing-snapshots.md) diff --git a/docs/code-examples/event-listeners.md b/docs/code-examples/event-listeners.md new file mode 100644 index 00000000..30478dc9 --- /dev/null +++ b/docs/code-examples/event-listeners.md @@ -0,0 +1,561 @@ +# Setting Up Event Listeners + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to set up event listeners in Reactive Domain to react to events as they occur. + +## Event Listener Interface + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain; + +namespace MyApp.EventHandlers +{ + public interface IEventListener : IHandleEvent where TEvent : IEvent + { + // This is a marker interface that extends IHandleEvent + } +} +``` + +## Basic Event Listener + +```csharp +using System; +using ReactiveDomain.Messaging; +using MyApp.Domain; + +namespace MyApp.EventHandlers +{ + public class AccountEventLogger : + IEventListener, + IEventListener, + IEventListener, + IEventListener + { + public void Handle(AccountCreated @event) + { + Console.WriteLine($"Account created: {@event.AccountId}, Number: {@event.AccountNumber}, Customer: {@event.CustomerName}"); + Console.WriteLine($"Correlation ID: {@event.CorrelationId}, Causation ID: {@event.CausationId}"); + } + + public void Handle(FundsDeposited @event) + { + Console.WriteLine($"Funds deposited: {@event.AccountId}, Amount: {@event.Amount}"); + Console.WriteLine($"Correlation ID: {@event.CorrelationId}, Causation ID: {@event.CausationId}"); + } + + public void Handle(FundsWithdrawn @event) + { + Console.WriteLine($"Funds withdrawn: {@event.AccountId}, Amount: {@event.Amount}"); + Console.WriteLine($"Correlation ID: {@event.CorrelationId}, Causation ID: {@event.CausationId}"); + } + + public void Handle(AccountClosed @event) + { + Console.WriteLine($"Account closed: {@event.AccountId}"); + Console.WriteLine($"Correlation ID: {@event.CorrelationId}, Causation ID: {@event.CausationId}"); + } + } +} +``` + +## Read Model Event Listener + +```csharp +using System; +using System.Collections.Generic; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.ReadModels; + +namespace MyApp.EventHandlers +{ + public class AccountReadModelUpdater : + IEventListener, + IEventListener, + IEventListener, + IEventListener + { + private readonly IReadModelRepository _repository; + + public AccountReadModelUpdater(IReadModelRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public void Handle(AccountCreated @event) + { + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, 0, false); + + _repository.Save(accountSummary); + } + + public void Handle(FundsDeposited @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.UpdateBalance(accountSummary.Balance + @event.Amount); + _repository.Save(accountSummary); + } + } + + public void Handle(FundsWithdrawn @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.UpdateBalance(accountSummary.Balance - @event.Amount); + _repository.Save(accountSummary); + } + } + + public void Handle(AccountClosed @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.MarkAsClosed(); + _repository.Save(accountSummary); + } + } + } +} +``` + +## Integration Event Publisher + +```csharp +using System; +using System.Threading.Tasks; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.Integration; + +namespace MyApp.EventHandlers +{ + public class IntegrationEventPublisher : + IEventListener, + IEventListener + { + private readonly IIntegrationEventBus _eventBus; + + public IntegrationEventPublisher(IIntegrationEventBus eventBus) + { + _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + } + + public void Handle(AccountCreated @event) + { + // Create an integration event + var integrationEvent = new AccountCreatedIntegrationEvent( + @event.AccountId, + @event.AccountNumber, + @event.CustomerName, + DateTime.UtcNow, + @event.CorrelationId, + @event.CausationId); + + // Publish the integration event + _eventBus.PublishAsync(integrationEvent); + } + + public void Handle(AccountClosed @event) + { + // Create an integration event + var integrationEvent = new AccountClosedIntegrationEvent( + @event.AccountId, + DateTime.UtcNow, + @event.CorrelationId, + @event.CausationId); + + // Publish the integration event + _eventBus.PublishAsync(integrationEvent); + } + } + + // Integration event bus interface + public interface IIntegrationEventBus + { + Task PublishAsync(T @event) where T : IntegrationEvent; + } + + // Base integration event + public abstract class IntegrationEvent + { + public Guid Id { get; } + public DateTime Timestamp { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + protected IntegrationEvent(DateTime timestamp, Guid correlationId, Guid causationId) + { + Id = Guid.NewGuid(); + Timestamp = timestamp; + CorrelationId = correlationId; + CausationId = causationId; + } + } + + // Integration events + public class AccountCreatedIntegrationEvent : IntegrationEvent + { + public Guid AccountId { get; } + public string AccountNumber { get; } + public string CustomerName { get; } + + public AccountCreatedIntegrationEvent( + Guid accountId, + string accountNumber, + string customerName, + DateTime timestamp, + Guid correlationId, + Guid causationId) + : base(timestamp, correlationId, causationId) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } + } + + public class AccountClosedIntegrationEvent : IntegrationEvent + { + public Guid AccountId { get; } + + public AccountClosedIntegrationEvent( + Guid accountId, + DateTime timestamp, + Guid correlationId, + Guid causationId) + : base(timestamp, correlationId, causationId) + { + AccountId = accountId; + } + } +} +``` + +## Registering Event Listeners + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain; +using MyApp.EventHandlers; +using MyApp.ReadModels; +using MyApp.Integration; + +namespace MyApp.Infrastructure +{ + public class EventBusSetup + { + public IEventBus ConfigureEventBus( + IReadModelRepository readModelRepository, + IIntegrationEventBus integrationEventBus) + { + // Create an event bus + var eventBus = new EventBus(); + + // Create event listeners + var accountEventLogger = new AccountEventLogger(); + var accountReadModelUpdater = new AccountReadModelUpdater(readModelRepository); + var integrationEventPublisher = new IntegrationEventPublisher(integrationEventBus); + + // Register event listeners + RegisterEventListeners(eventBus, + accountEventLogger, + accountReadModelUpdater, + integrationEventPublisher); + + RegisterEventListeners(eventBus, + accountEventLogger, + accountReadModelUpdater); + + RegisterEventListeners(eventBus, + accountEventLogger, + accountReadModelUpdater); + + RegisterEventListeners(eventBus, + accountEventLogger, + accountReadModelUpdater, + integrationEventPublisher); + + return eventBus; + } + + private void RegisterEventListeners( + IEventBus eventBus, + params IEventListener[] listeners) + where TEvent : IEvent + { + foreach (var listener in listeners) + { + eventBus.Subscribe(listener); + } + } + } +} +``` + +## Connecting to Event Store Subscription + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.EventStore; +using ReactiveDomain.Persistence; +using MyApp.Domain; + +namespace MyApp.Infrastructure +{ + public class EventStoreSubscription + { + private readonly IStreamStoreConnection _connection; + private readonly IEventBus _eventBus; + private readonly IEventSerializer _serializer; + + public EventStoreSubscription( + IStreamStoreConnection connection, + IEventBus eventBus, + IEventSerializer serializer) + { + _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + } + + public void SubscribeToAll() + { + // Subscribe to all events + _connection.SubscribeToAll( + eventAppeared: (subscription, resolvedEvent) => + { + try + { + // Deserialize the event + var @event = _serializer.Deserialize(resolvedEvent.Event); + + // Publish the event to the event bus + if (@event != null) + { + _eventBus.Publish(@event); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error processing event: {ex.Message}"); + } + }, + subscriptionDropped: (subscription, reason, exception) => + { + Console.WriteLine($"Subscription dropped: {reason}"); + + // Reconnect after a delay + System.Threading.Thread.Sleep(1000); + SubscribeToAll(); + }); + + Console.WriteLine("Subscribed to all events"); + } + + public void SubscribeToStream(string streamName) + { + // Subscribe to a specific stream + _connection.SubscribeToStream( + streamName, + eventAppeared: (subscription, resolvedEvent) => + { + try + { + // Deserialize the event + var @event = _serializer.Deserialize(resolvedEvent.Event); + + // Publish the event to the event bus + if (@event != null) + { + _eventBus.Publish(@event); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error processing event: {ex.Message}"); + } + }, + subscriptionDropped: (subscription, reason, exception) => + { + Console.WriteLine($"Subscription dropped: {reason}"); + + // Reconnect after a delay + System.Threading.Thread.Sleep(1000); + SubscribeToStream(streamName); + }); + + Console.WriteLine($"Subscribed to stream: {streamName}"); + } + } +} +``` + +## Complete Example + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.EventStore; +using ReactiveDomain.Persistence; +using MyApp.Domain; +using MyApp.EventHandlers; +using MyApp.ReadModels; +using MyApp.Integration; +using MyApp.Infrastructure; + +namespace MyApp.Examples +{ + public class EventListenerExample + { + public void SetupEventListeners() + { + // Create dependencies + var readModelRepository = new InMemoryReadModelRepository(); + var integrationEventBus = new RabbitMqIntegrationEventBus("amqp://localhost"); + + // Set up event bus with listeners + var eventBusSetup = new EventBusSetup(); + var eventBus = eventBusSetup.ConfigureEventBus(readModelRepository, integrationEventBus); + + // Configure repository + var repositoryConfig = new RepositoryConfiguration(); + var repository = repositoryConfig.ConfigureRepository("localhost"); + + // Set up event store subscription + var connection = repository.GetType() + .GetProperty("Connection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetValue(repository) as IStreamStoreConnection; + + var serializer = repository.GetType() + .GetProperty("Serializer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetValue(repository) as IEventSerializer; + + var subscription = new EventStoreSubscription(connection, eventBus, serializer); + + // Subscribe to all events + subscription.SubscribeToAll(); + + // Create and save an account to trigger events + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + account.Create("ACC-123", "John Doe"); + repository.Save(account); + + // Deposit funds to trigger more events + account.Deposit(1000); + repository.Save(account); + + // Wait for events to be processed + System.Threading.Thread.Sleep(1000); + + // Check the read model + var accountSummary = readModelRepository.GetById(accountId); + Console.WriteLine($"Read model: Account {accountSummary.Id}, Balance: {accountSummary.Balance}"); + } + } + + // Simple in-memory read model repository for the example + public class InMemoryReadModelRepository : IReadModelRepository where T : ReadModelBase + { + private readonly Dictionary _items = new Dictionary(); + + public T GetById(Guid id) + { + if (_items.TryGetValue(id, out var item)) + { + return item; + } + + return null; + } + + public void Save(T item) + { + _items[item.Id] = item; + } + } + + // Simple RabbitMQ integration event bus for the example + public class RabbitMqIntegrationEventBus : IIntegrationEventBus + { + private readonly string _connectionString; + + public RabbitMqIntegrationEventBus(string connectionString) + { + _connectionString = connectionString; + } + + public Task PublishAsync(T @event) where T : IntegrationEvent + { + Console.WriteLine($"Publishing integration event: {@event.GetType().Name}"); + // In a real implementation, this would publish to RabbitMQ + return Task.CompletedTask; + } + } +} +``` + +## Key Concepts + +### Event Listeners + +- Event listeners implement the `IHandleEvent` interface +- They react to events as they occur in the system +- Multiple listeners can handle the same event for different purposes + +### Types of Event Listeners + +- **Logging Listeners**: Record events for auditing and debugging +- **Read Model Updaters**: Update read models for querying +- **Integration Event Publishers**: Publish events to external systems +- **Process Managers**: Coordinate complex workflows across multiple aggregates + +### Event Bus + +- The event bus routes events to their handlers +- Handlers are registered with the bus using the `Subscribe` method +- Events are published to the bus using the `Publish` method + +### Event Store Subscription + +- Subscribes to events from the event store +- Can subscribe to all events or specific streams +- Deserializes events and publishes them to the event bus +- Handles reconnection if the subscription is dropped + +## Best Practices + +1. **Single Responsibility**: Each event listener should have a single responsibility +2. **Error Handling**: Implement proper error handling in event listeners +3. **Idempotency**: Design event handlers to be idempotent (can be applied multiple times without changing the result) +4. **Asynchronous Processing**: Process events asynchronously to avoid blocking +5. **Subscription Management**: Handle subscription drops and reconnect automatically +6. **Correlation Tracking**: Maintain correlation information in integration events + +## Common Pitfalls + +1. **Event Handler Exceptions**: Unhandled exceptions in event handlers can break the subscription +2. **Missing Event Handlers**: Ensure all event types have appropriate handlers +3. **Tight Coupling**: Avoid tight coupling between event handlers and domain logic +4. **Performance Issues**: Be mindful of performance in event handlers, especially for high-volume events +5. **Lost Events**: Ensure proper error handling and retry mechanisms to avoid losing events + +--- + +**Navigation**: +- [← Previous: Saving and Retrieving Aggregates](saving-retrieving-aggregates.md) +- [↑ Back to Top](#setting-up-event-listeners) +- [→ Next: Implementing Projections](implementing-projections.md) diff --git a/docs/code-examples/implementing-projections.md b/docs/code-examples/implementing-projections.md new file mode 100644 index 00000000..13c9ea58 --- /dev/null +++ b/docs/code-examples/implementing-projections.md @@ -0,0 +1,737 @@ +# Implementing Projections + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to implement projections in Reactive Domain to create read models from event streams. + +## Read Model Base Class + +```csharp +using System; + +namespace MyApp.ReadModels +{ + public abstract class ReadModelBase + { + public Guid Id { get; } + public long Version { get; protected set; } + + protected ReadModelBase(Guid id) + { + Id = id; + Version = 0; + } + + protected void IncrementVersion() + { + Version++; + } + } +} +``` + +## Account Summary Read Model + +```csharp +using System; + +namespace MyApp.ReadModels +{ + public class AccountSummary : ReadModelBase + { + public string AccountNumber { get; private set; } + public string CustomerName { get; private set; } + public decimal Balance { get; private set; } + public bool IsClosed { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? LastUpdatedAt { get; private set; } + + public AccountSummary(Guid id) : base(id) + { + CreatedAt = DateTime.UtcNow; + } + + public void Update(string accountNumber, string customerName, decimal balance, bool isClosed) + { + AccountNumber = accountNumber; + CustomerName = customerName; + Balance = balance; + IsClosed = isClosed; + LastUpdatedAt = DateTime.UtcNow; + + IncrementVersion(); + } + + public void UpdateBalance(decimal newBalance) + { + Balance = newBalance; + LastUpdatedAt = DateTime.UtcNow; + + IncrementVersion(); + } + + public void MarkAsClosed() + { + IsClosed = true; + LastUpdatedAt = DateTime.UtcNow; + + IncrementVersion(); + } + } +} +``` + +## Transaction History Read Model + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MyApp.ReadModels +{ + public class TransactionHistory : ReadModelBase + { + private readonly List _transactions = new List(); + + public IReadOnlyList Transactions => _transactions.AsReadOnly(); + public decimal CurrentBalance => _transactions.Sum(t => t.Amount); + + public TransactionHistory(Guid id) : base(id) + { + } + + public void AddTransaction(string type, decimal amount, string description, DateTime timestamp) + { + var transaction = new Transaction + { + Id = Guid.NewGuid(), + AccountId = Id, + Type = type, + Amount = amount, + Description = description, + Timestamp = timestamp + }; + + _transactions.Add(transaction); + IncrementVersion(); + } + + public IEnumerable GetTransactionsByDateRange(DateTime start, DateTime end) + { + return _transactions + .Where(t => t.Timestamp >= start && t.Timestamp <= end) + .OrderByDescending(t => t.Timestamp); + } + + public IEnumerable GetTransactionsByType(string type) + { + return _transactions + .Where(t => t.Type == type) + .OrderByDescending(t => t.Timestamp); + } + } + + public class Transaction + { + public Guid Id { get; set; } + public Guid AccountId { get; set; } + public string Type { get; set; } + public decimal Amount { get; set; } + public string Description { get; set; } + public DateTime Timestamp { get; set; } + } +} +``` + +## Read Model Repository Interface + +```csharp +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MyApp.ReadModels +{ + public interface IReadModelRepository where T : ReadModelBase + { + T GetById(Guid id); + void Save(T item); + } + + public interface IQueryableReadModelRepository : IReadModelRepository where T : ReadModelBase + { + IEnumerable Query(Func predicate); + } +} +``` + +## In-Memory Read Model Repository + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MyApp.ReadModels +{ + public class InMemoryReadModelRepository : IQueryableReadModelRepository where T : ReadModelBase + { + private readonly Dictionary _items = new Dictionary(); + + public T GetById(Guid id) + { + if (_items.TryGetValue(id, out var item)) + { + return item; + } + + return null; + } + + public void Save(T item) + { + _items[item.Id] = item; + } + + public IEnumerable Query(Func predicate) + { + return _items.Values.Where(predicate); + } + } +} +``` + +## SQL Read Model Repository + +```csharp +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using Dapper; + +namespace MyApp.ReadModels +{ + public class SqlAccountSummaryRepository : IQueryableReadModelRepository + { + private readonly string _connectionString; + + public SqlAccountSummaryRepository(string connectionString) + { + _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + } + + public AccountSummary GetById(Guid id) + { + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + + var sql = @" + SELECT Id, AccountNumber, CustomerName, Balance, IsClosed, + CreatedAt, LastUpdatedAt, Version + FROM AccountSummaries + WHERE Id = @Id"; + + var account = connection.QuerySingleOrDefault(sql, new { Id = id }); + + if (account == null) + { + return null; + } + + return MapToAccountSummary(account); + } + } + + public void Save(AccountSummary item) + { + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + + var existingSql = "SELECT COUNT(1) FROM AccountSummaries WHERE Id = @Id"; + var exists = connection.ExecuteScalar(existingSql, new { Id = item.Id }) > 0; + + if (exists) + { + var updateSql = @" + UPDATE AccountSummaries + SET AccountNumber = @AccountNumber, + CustomerName = @CustomerName, + Balance = @Balance, + IsClosed = @IsClosed, + LastUpdatedAt = @LastUpdatedAt, + Version = @Version + WHERE Id = @Id"; + + connection.Execute(updateSql, new + { + item.Id, + item.AccountNumber, + item.CustomerName, + item.Balance, + item.IsClosed, + LastUpdatedAt = DateTime.UtcNow, + item.Version + }); + } + else + { + var insertSql = @" + INSERT INTO AccountSummaries (Id, AccountNumber, CustomerName, Balance, + IsClosed, CreatedAt, LastUpdatedAt, Version) + VALUES (@Id, @AccountNumber, @CustomerName, @Balance, + @IsClosed, @CreatedAt, @LastUpdatedAt, @Version)"; + + connection.Execute(insertSql, new + { + item.Id, + item.AccountNumber, + item.CustomerName, + item.Balance, + item.IsClosed, + CreatedAt = DateTime.UtcNow, + LastUpdatedAt = DateTime.UtcNow, + item.Version + }); + } + } + } + + public IEnumerable Query(Func predicate) + { + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + + var sql = @" + SELECT Id, AccountNumber, CustomerName, Balance, IsClosed, + CreatedAt, LastUpdatedAt, Version + FROM AccountSummaries"; + + var accounts = connection.Query(sql); + + return accounts + .Select(MapToAccountSummary) + .Where(predicate); + } + } + + public IEnumerable GetAccountsWithBalanceAbove(decimal threshold) + { + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + + var sql = @" + SELECT Id, AccountNumber, CustomerName, Balance, IsClosed, + CreatedAt, LastUpdatedAt, Version + FROM AccountSummaries + WHERE Balance > @Threshold"; + + var accounts = connection.Query(sql, new { Threshold = threshold }); + + return accounts.Select(MapToAccountSummary); + } + } + + private AccountSummary MapToAccountSummary(AccountSummaryDto dto) + { + var account = new AccountSummary(dto.Id); + + // Use reflection to set private fields + var type = typeof(AccountSummary); + + type.GetProperty("AccountNumber").SetValue(account, dto.AccountNumber); + type.GetProperty("CustomerName").SetValue(account, dto.CustomerName); + type.GetProperty("Balance").SetValue(account, dto.Balance); + type.GetProperty("IsClosed").SetValue(account, dto.IsClosed); + type.GetProperty("CreatedAt").SetValue(account, dto.CreatedAt); + type.GetProperty("LastUpdatedAt").SetValue(account, dto.LastUpdatedAt); + type.GetProperty("Version").SetValue(account, dto.Version); + + return account; + } + + private class AccountSummaryDto + { + public Guid Id { get; set; } + public string AccountNumber { get; set; } + public string CustomerName { get; set; } + public decimal Balance { get; set; } + public bool IsClosed { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastUpdatedAt { get; set; } + public long Version { get; set; } + } + } +} +``` + +## Projection Manager + +```csharp +using System; +using System.Collections.Generic; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain; +using MyApp.ReadModels; + +namespace MyApp.Projections +{ + public class ProjectionManager + { + private readonly Dictionary>> _projectors = + new Dictionary>>(); + + private readonly IEventBus _eventBus; + + public ProjectionManager(IEventBus eventBus) + { + _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + } + + public void RegisterProjection(Action projector) where TEvent : IEvent + { + var eventType = typeof(TEvent); + + if (!_projectors.ContainsKey(eventType)) + { + _projectors[eventType] = new List>(); + + // Subscribe to the event + _eventBus.Subscribe(e => ProjectEvent(e)); + } + + // Add the projector + _projectors[eventType].Add(e => projector((TEvent)e)); + } + + private void ProjectEvent(TEvent @event) where TEvent : IEvent + { + var eventType = @event.GetType(); + + if (_projectors.TryGetValue(eventType, out var projectors)) + { + foreach (var projector in projectors) + { + try + { + projector(@event); + } + catch (Exception ex) + { + Console.WriteLine($"Error projecting event {eventType.Name}: {ex.Message}"); + } + } + } + } + } +} +``` + +## Account Summary Projection + +```csharp +using System; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.ReadModels; +using MyApp.Projections; + +namespace MyApp.Projections +{ + public class AccountSummaryProjection + { + private readonly IReadModelRepository _repository; + + public AccountSummaryProjection(IReadModelRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public void Register(ProjectionManager projectionManager) + { + projectionManager.RegisterProjection(When); + projectionManager.RegisterProjection(When); + projectionManager.RegisterProjection(When); + projectionManager.RegisterProjection(When); + } + + private void When(AccountCreated @event) + { + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, 0, false); + + _repository.Save(accountSummary); + } + + private void When(FundsDeposited @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.UpdateBalance(accountSummary.Balance + @event.Amount); + _repository.Save(accountSummary); + } + } + + private void When(FundsWithdrawn @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.UpdateBalance(accountSummary.Balance - @event.Amount); + _repository.Save(accountSummary); + } + } + + private void When(AccountClosed @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.MarkAsClosed(); + _repository.Save(accountSummary); + } + } + } +} +``` + +## Transaction History Projection + +```csharp +using System; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.ReadModels; +using MyApp.Projections; + +namespace MyApp.Projections +{ + public class TransactionHistoryProjection + { + private readonly IReadModelRepository _repository; + + public TransactionHistoryProjection(IReadModelRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public void Register(ProjectionManager projectionManager) + { + projectionManager.RegisterProjection(When); + projectionManager.RegisterProjection(When); + projectionManager.RegisterProjection(When); + projectionManager.RegisterProjection(When); + } + + private void When(AccountCreated @event) + { + var history = new TransactionHistory(@event.AccountId); + history.AddTransaction( + "CREATED", + 0, + $"Account created: {@event.AccountNumber}", + DateTime.UtcNow); + + _repository.Save(history); + } + + private void When(FundsDeposited @event) + { + var history = _repository.GetById(@event.AccountId); + if (history == null) + { + history = new TransactionHistory(@event.AccountId); + } + + history.AddTransaction( + "DEPOSIT", + @event.Amount, + $"Deposit: {@event.Amount:C}", + DateTime.UtcNow); + + _repository.Save(history); + } + + private void When(FundsWithdrawn @event) + { + var history = _repository.GetById(@event.AccountId); + if (history == null) + { + history = new TransactionHistory(@event.AccountId); + } + + history.AddTransaction( + "WITHDRAWAL", + -@event.Amount, + $"Withdrawal: {@event.Amount:C}", + DateTime.UtcNow); + + _repository.Save(history); + } + + private void When(AccountClosed @event) + { + var history = _repository.GetById(@event.AccountId); + if (history == null) + { + history = new TransactionHistory(@event.AccountId); + } + + history.AddTransaction( + "CLOSED", + 0, + "Account closed", + DateTime.UtcNow); + + _repository.Save(history); + } + } +} +``` + +## Complete Example + +```csharp +using System; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain; +using MyApp.ReadModels; +using MyApp.Projections; + +namespace MyApp.Examples +{ + public class ProjectionExample + { + public void DemonstrateProjections() + { + // Create an event bus + var eventBus = new EventBus(); + + // Create read model repositories + var accountSummaryRepository = new InMemoryReadModelRepository(); + var transactionHistoryRepository = new InMemoryReadModelRepository(); + + // Create projection manager + var projectionManager = new ProjectionManager(eventBus); + + // Register projections + var accountSummaryProjection = new AccountSummaryProjection(accountSummaryRepository); + accountSummaryProjection.Register(projectionManager); + + var transactionHistoryProjection = new TransactionHistoryProjection(transactionHistoryRepository); + transactionHistoryProjection.Register(projectionManager); + + // Create and publish events + var accountId = Guid.NewGuid(); + var correlationId = Guid.NewGuid(); + var causationId = Guid.NewGuid(); + + // Account created event + var accountCreatedEvent = new AccountCreated( + accountId, + "ACC-123", + "John Doe", + correlationId, + causationId); + + eventBus.Publish(accountCreatedEvent); + + // Deposit event + var depositEvent = new FundsDeposited( + accountId, + 1000, + correlationId, + accountCreatedEvent.MessageId); + + eventBus.Publish(depositEvent); + + // Withdrawal event + var withdrawalEvent = new FundsWithdrawn( + accountId, + 500, + correlationId, + depositEvent.MessageId); + + eventBus.Publish(withdrawalEvent); + + // Check account summary read model + var accountSummary = accountSummaryRepository.GetById(accountId); + Console.WriteLine($"Account Summary: {accountSummary.AccountNumber}, Balance: {accountSummary.Balance:C}"); + + // Check transaction history read model + var transactionHistory = transactionHistoryRepository.GetById(accountId); + Console.WriteLine($"Transaction Count: {transactionHistory.Transactions.Count}"); + Console.WriteLine($"Current Balance: {transactionHistory.CurrentBalance:C}"); + + // Query transactions by type + var deposits = transactionHistory.GetTransactionsByType("DEPOSIT"); + foreach (var deposit in deposits) + { + Console.WriteLine($"Deposit: {deposit.Amount:C}, Description: {deposit.Description}"); + } + } + } +} +``` + +## Key Concepts + +### Read Models + +- Read models are optimized for querying +- They are updated in response to domain events +- They can be stored in any format or database that suits the query requirements +- They are eventually consistent with the event-sourced aggregates + +### Projections + +- Projections transform events into read models +- They handle specific event types and update corresponding read models +- They can create multiple read models from the same events +- They are idempotent and can be replayed + +### Read Model Repositories + +- Store and retrieve read models +- Can be implemented using various storage technologies +- Provide query capabilities appropriate for the storage technology +- Do not need to be transactional with the event store + +### Projection Manager + +- Coordinates the registration of projections +- Routes events to the appropriate projectors +- Handles errors in projection processing + +## Best Practices + +1. **Separation of Concerns**: Keep read models separate from domain models +2. **Idempotency**: Design projections to be idempotent +3. **Error Handling**: Implement proper error handling in projections +4. **Optimized Queries**: Design read models for the specific queries they need to support +5. **Eventual Consistency**: Accept that read models will be eventually consistent with the domain model +6. **Versioning**: Include version information in read models for optimistic concurrency + +## Common Pitfalls + +1. **Complex Projections**: Avoid complex business logic in projections +2. **Missing Events**: Ensure all relevant events are handled by projections +3. **Performance Issues**: Be mindful of performance in projections, especially for high-volume events +4. **Tight Coupling**: Avoid tight coupling between projections and domain logic +5. **Overloaded Read Models**: Don't try to make a single read model support too many different query patterns + +--- + +**Navigation**: +- [← Previous: Setting Up Event Listeners](event-listeners.md) +- [↑ Back to Top](#implementing-projections) +- [→ Next: Handling Correlation and Causation](correlation-causation.md) diff --git a/docs/code-examples/implementing-snapshots.md b/docs/code-examples/implementing-snapshots.md new file mode 100644 index 00000000..0ddb766b --- /dev/null +++ b/docs/code-examples/implementing-snapshots.md @@ -0,0 +1,703 @@ +# Implementing Snapshots + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to implement snapshots in Reactive Domain to improve the performance of loading aggregates with long event histories. + +## Snapshot Interface + +```csharp +using System; + +namespace ReactiveDomain.Foundation +{ + public interface ISnapshot + { + Guid AggregateId { get; } + long Version { get; } + } +} +``` + +## Snapshot Base Class + +```csharp +using System; + +namespace ReactiveDomain.Foundation +{ + public abstract class Snapshot : ISnapshot + { + public Guid AggregateId { get; } + public long Version { get; } + + protected Snapshot(Guid aggregateId, long version) + { + AggregateId = aggregateId; + Version = version; + } + } +} +``` + +## Account Snapshot + +```csharp +using System; +using ReactiveDomain.Foundation; + +namespace MyApp.Domain.Snapshots +{ + [Serializable] + public class AccountSnapshot : Snapshot + { + public string AccountNumber { get; } + public string CustomerName { get; } + public decimal Balance { get; } + public bool IsClosed { get; } + + public AccountSnapshot( + Guid aggregateId, + long version, + string accountNumber, + string customerName, + decimal balance, + bool isClosed) + : base(aggregateId, version) + { + AccountNumber = accountNumber; + CustomerName = customerName; + Balance = balance; + IsClosed = isClosed; + } + } +} +``` + +## Snapshotable Aggregate Root + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using MyApp.Domain.Events; +using MyApp.Domain.Snapshots; + +namespace MyApp.Domain +{ + public class Account : AggregateRoot, ISnapshotable + { + private string _accountNumber; + private string _customerName; + private decimal _balance; + private bool _isClosed; + + // Default constructor for creating new aggregates + public Account(Guid id) : base(id) + { + } + + // Constructor with correlation source + public Account(Guid id, ICorrelatedMessage source) : base(id, source) + { + } + + // Constructor for restoring from snapshot + public Account(Guid id, AccountSnapshot snapshot) : base(id) + { + if (snapshot == null) + throw new ArgumentNullException(nameof(snapshot)); + + _accountNumber = snapshot.AccountNumber; + _customerName = snapshot.CustomerName; + _balance = snapshot.Balance; + _isClosed = snapshot.IsClosed; + + Version = snapshot.Version; + } + + public void Create(string accountNumber, string customerName) + { + if (string.IsNullOrEmpty(accountNumber)) + throw new ArgumentException("Account number cannot be empty", nameof(accountNumber)); + + if (string.IsNullOrEmpty(customerName)) + throw new ArgumentException("Customer name cannot be empty", nameof(customerName)); + + ApplyChange(new AccountCreated(Id, accountNumber, customerName, CorrelationId, CausationId)); + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_isClosed) + throw new InvalidOperationException("Cannot deposit to a closed account"); + + ApplyChange(new FundsDeposited(Id, amount, CorrelationId, CausationId)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_isClosed) + throw new InvalidOperationException("Cannot withdraw from a closed account"); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + ApplyChange(new FundsWithdrawn(Id, amount, CorrelationId, CausationId)); + } + + public void Close() + { + if (_isClosed) + throw new InvalidOperationException("Account is already closed"); + + ApplyChange(new AccountClosed(Id, CorrelationId, CausationId)); + } + + public decimal GetBalance() + { + return _balance; + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + _balance = 0; + _isClosed = false; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void Apply(AccountClosed @event) + { + _isClosed = true; + } + + // ISnapshotable implementation + public ISnapshot CreateSnapshot() + { + return new AccountSnapshot( + Id, + Version, + _accountNumber, + _customerName, + _balance, + _isClosed); + } + + public void RestoreFromSnapshot(ISnapshot snapshot) + { + if (snapshot == null) + throw new ArgumentNullException(nameof(snapshot)); + + if (!(snapshot is AccountSnapshot accountSnapshot)) + throw new ArgumentException("Invalid snapshot type", nameof(snapshot)); + + _accountNumber = accountSnapshot.AccountNumber; + _customerName = accountSnapshot.CustomerName; + _balance = accountSnapshot.Balance; + _isClosed = accountSnapshot.IsClosed; + + Version = snapshot.Version; + } + } +} +``` + +## Snapshot Store Interface + +```csharp +using System; +using System.Threading.Tasks; +using ReactiveDomain.Foundation; + +namespace ReactiveDomain.Foundation +{ + public interface ISnapshotStore + { + Task GetLatestSnapshotAsync(Guid aggregateId, Type aggregateType); + Task SaveSnapshotAsync(ISnapshot snapshot, Type aggregateType); + } +} +``` + +## In-Memory Snapshot Store + +```csharp +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ReactiveDomain.Foundation; + +namespace MyApp.Infrastructure +{ + public class InMemorySnapshotStore : ISnapshotStore + { + private readonly Dictionary> _snapshots = + new Dictionary>(); + + public Task GetLatestSnapshotAsync(Guid aggregateId, Type aggregateType) + { + var key = GetKey(aggregateId, aggregateType); + + if (_snapshots.TryGetValue(key, out var snapshotList) && snapshotList.Any()) + { + return Task.FromResult(snapshotList.OrderByDescending(s => s.Version).First()); + } + + return Task.FromResult(null); + } + + public Task SaveSnapshotAsync(ISnapshot snapshot, Type aggregateType) + { + var key = GetKey(snapshot.AggregateId, aggregateType); + + if (!_snapshots.ContainsKey(key)) + { + _snapshots[key] = new List(); + } + + _snapshots[key].Add(snapshot); + + return Task.CompletedTask; + } + + private string GetKey(Guid aggregateId, Type aggregateType) + { + return $"{aggregateType.FullName}-{aggregateId}"; + } + } +} +``` + +## SQL Snapshot Store + +```csharp +using System; +using System.Data; +using System.Data.SqlClient; +using System.IO; +using System.Runtime.Serialization.Formatters.Binary; +using System.Threading.Tasks; +using Dapper; +using ReactiveDomain.Foundation; + +namespace MyApp.Infrastructure +{ + public class SqlSnapshotStore : ISnapshotStore + { + private readonly string _connectionString; + + public SqlSnapshotStore(string connectionString) + { + _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + } + + public async Task GetLatestSnapshotAsync(Guid aggregateId, Type aggregateType) + { + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + var sql = @" + SELECT TOP 1 Data + FROM Snapshots + WHERE AggregateId = @AggregateId + AND AggregateType = @AggregateType + ORDER BY Version DESC"; + + var result = await connection.QueryFirstOrDefaultAsync(sql, new + { + AggregateId = aggregateId, + AggregateType = aggregateType.FullName + }); + + if (result == null) + { + return null; + } + + return DeserializeSnapshot(result); + } + } + + public async Task SaveSnapshotAsync(ISnapshot snapshot, Type aggregateType) + { + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + var sql = @" + INSERT INTO Snapshots (AggregateId, AggregateType, Version, Data, CreatedAt) + VALUES (@AggregateId, @AggregateType, @Version, @Data, @CreatedAt)"; + + await connection.ExecuteAsync(sql, new + { + AggregateId = snapshot.AggregateId, + AggregateType = aggregateType.FullName, + Version = snapshot.Version, + Data = SerializeSnapshot(snapshot), + CreatedAt = DateTime.UtcNow + }); + } + } + + private byte[] SerializeSnapshot(ISnapshot snapshot) + { + using (var stream = new MemoryStream()) + { + var formatter = new BinaryFormatter(); + formatter.Serialize(stream, snapshot); + return stream.ToArray(); + } + } + + private ISnapshot DeserializeSnapshot(byte[] data) + { + using (var stream = new MemoryStream(data)) + { + var formatter = new BinaryFormatter(); + return (ISnapshot)formatter.Deserialize(stream); + } + } + } +} +``` + +## Snapshot Repository + +```csharp +using System; +using System.Threading.Tasks; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; + +namespace MyApp.Infrastructure +{ + public class SnapshotRepository : IRepository + { + private readonly IRepository _repository; + private readonly ISnapshotStore _snapshotStore; + private readonly int _snapshotFrequency; + + public SnapshotRepository( + IRepository repository, + ISnapshotStore snapshotStore, + int snapshotFrequency = 100) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore)); + _snapshotFrequency = snapshotFrequency; + } + + public TAggregate GetById(Guid id) where TAggregate : class, IEventSource + { + // Try to get the latest snapshot + var snapshot = _snapshotStore.GetLatestSnapshotAsync(id, typeof(TAggregate)).Result; + + if (snapshot != null) + { + // Create the aggregate from the snapshot + var aggregate = CreateFromSnapshot(id, snapshot); + + // Get events after the snapshot version + var events = _repository.GetEventsAfterVersion(id, snapshot.Version); + + // Apply the events + if (events != null && events.Length > 0) + { + aggregate.RestoreFromEvents(events); + } + + return aggregate; + } + + // No snapshot found, load the aggregate normally + return _repository.GetById(id); + } + + public bool TryGetById(Guid id, out TAggregate aggregate) where TAggregate : class, IEventSource + { + try + { + aggregate = GetById(id); + return true; + } + catch (AggregateNotFoundException) + { + aggregate = null; + return false; + } + } + + public void Save(IEventSource aggregate) + { + // Save the aggregate + _repository.Save(aggregate); + + // Check if we should create a snapshot + if (ShouldCreateSnapshot(aggregate)) + { + CreateSnapshot(aggregate); + } + } + + public void Update(ref TAggregate aggregate) where TAggregate : class, IEventSource + { + _repository.Update(ref aggregate); + } + + public void Delete(IEventSource aggregate) + { + _repository.Delete(aggregate); + } + + public void HardDelete(IEventSource aggregate) + { + _repository.HardDelete(aggregate); + } + + private bool ShouldCreateSnapshot(IEventSource aggregate) + { + // Only create snapshots for snapshotable aggregates + if (!(aggregate is ISnapshotable)) + { + return false; + } + + // Create a snapshot every _snapshotFrequency events + return aggregate.Version % _snapshotFrequency == 0; + } + + private void CreateSnapshot(IEventSource aggregate) + { + if (aggregate is ISnapshotable snapshotable) + { + var snapshot = snapshotable.CreateSnapshot(); + _snapshotStore.SaveSnapshotAsync(snapshot, aggregate.GetType()).Wait(); + } + } + + private TAggregate CreateFromSnapshot(Guid id, ISnapshot snapshot) where TAggregate : class, IEventSource + { + // Create the aggregate using reflection + var aggregateType = typeof(TAggregate); + + // Try to find a constructor that takes (Guid, ISnapshot) + var constructor = aggregateType.GetConstructor(new[] { typeof(Guid), snapshot.GetType() }); + + if (constructor != null) + { + return (TAggregate)constructor.Invoke(new object[] { id, snapshot }); + } + + // Try to create the aggregate and restore from snapshot + var aggregate = (TAggregate)Activator.CreateInstance(aggregateType, id); + + if (aggregate is ISnapshotable snapshotable) + { + snapshotable.RestoreFromSnapshot(snapshot); + return aggregate; + } + + throw new InvalidOperationException($"Aggregate type {aggregateType.Name} does not support snapshots"); + } + } +} +``` + +## Snapshot Configuration + +```csharp +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Persistence; +using ReactiveDomain.EventStore; +using MyApp.Infrastructure; + +namespace MyApp.Infrastructure +{ + public class SnapshotConfiguration + { + public IRepository ConfigureSnapshotRepository(string connectionString) + { + // Create a stream name builder + var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder("MyApp"); + + // Create an event store connection + var connectionSettings = ConnectionSettings.Create() + .KeepReconnecting() + .KeepRetrying() + .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")); + + var connection = new StreamStoreConnection( + "MyApp", + connectionSettings, + connectionString, + 1113); + + // Create a serializer + var serializer = new JsonMessageSerializer(); + + // Create a base repository + var baseRepository = new StreamStoreRepository( + streamNameBuilder, + connection, + serializer); + + // Create a snapshot store + var snapshotStore = new InMemorySnapshotStore(); + + // Create a snapshot repository + var snapshotRepository = new SnapshotRepository( + baseRepository, + snapshotStore, + 100); // Create a snapshot every 100 events + + return snapshotRepository; + } + } +} +``` + +## Complete Example + +```csharp +using System; +using System.Diagnostics; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.Domain.Snapshots; +using MyApp.Infrastructure; + +namespace MyApp.Examples +{ + public class SnapshotExample + { + public void DemonstrateSnapshots() + { + // Configure repositories + var config = new SnapshotConfiguration(); + var snapshotRepository = config.ConfigureSnapshotRepository("localhost"); + + // Create a new account + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + account.Create("ACC-123", "John Doe"); + + // Save the account + snapshotRepository.Save(account); + + // Perform many operations to trigger snapshots + for (int i = 0; i < 500; i++) + { + account.Deposit(100); + snapshotRepository.Save(account); + + account.Withdraw(50); + snapshotRepository.Save(account); + } + + Console.WriteLine($"Account version: {account.Version}"); + + // Measure time to load without snapshots + var baseRepository = GetBaseRepository(); + var stopwatch = Stopwatch.StartNew(); + var accountWithoutSnapshot = baseRepository.GetById(accountId); + stopwatch.Stop(); + + Console.WriteLine($"Time to load without snapshots: {stopwatch.ElapsedMilliseconds}ms"); + + // Measure time to load with snapshots + stopwatch.Restart(); + var accountWithSnapshot = snapshotRepository.GetById(accountId); + stopwatch.Stop(); + + Console.WriteLine($"Time to load with snapshots: {stopwatch.ElapsedMilliseconds}ms"); + + // Verify both accounts are in the same state + Console.WriteLine($"Account balance without snapshot: {accountWithoutSnapshot.GetBalance()}"); + Console.WriteLine($"Account balance with snapshot: {accountWithSnapshot.GetBalance()}"); + Console.WriteLine($"States match: {accountWithoutSnapshot.GetBalance() == accountWithSnapshot.GetBalance()}"); + } + + private IRepository GetBaseRepository() + { + // This is a simplified version just for the example + // In a real application, you would get this from your DI container + var config = new RepositoryConfiguration(); + return config.ConfigureRepository("localhost"); + } + } +} +``` + +## Key Concepts + +### Snapshots + +- Snapshots are point-in-time captures of aggregate state +- They reduce the number of events that need to be loaded and replayed +- They improve performance for aggregates with long event histories +- They are an optimization technique, not a primary storage mechanism + +### ISnapshotable Interface + +- Aggregates that support snapshots implement the `ISnapshotable` interface +- They provide methods to create snapshots and restore from snapshots +- They maintain their internal state in a way that can be captured in a snapshot + +### Snapshot Store + +- Stores and retrieves snapshots +- Can be implemented using various storage technologies +- Provides a consistent interface for snapshot operations + +### Snapshot Repository + +- Wraps a standard repository +- Tries to load the latest snapshot before loading events +- Only loads events that occurred after the snapshot +- Creates new snapshots at specified intervals + +## Best Practices + +1. **Snapshot Frequency**: Create snapshots at appropriate intervals (e.g., every 100 events) +2. **Serializable State**: Ensure all aggregate state can be serialized in snapshots +3. **Versioning**: Include version information in snapshots for optimistic concurrency +4. **Snapshot Pruning**: Implement a strategy to remove old snapshots +5. **Error Handling**: Handle snapshot loading failures gracefully +6. **Testing**: Test that aggregates can be correctly restored from snapshots + +## Common Pitfalls + +1. **Non-Serializable State**: Including non-serializable objects in aggregate state +2. **Snapshot Overhead**: Creating snapshots too frequently +3. **Missing Events**: Not loading events that occurred after the snapshot +4. **Versioning Issues**: Not handling snapshot version compatibility +5. **Snapshot Dependency**: Relying on snapshots for correctness rather than just performance + +--- + +**Navigation**: +- [← Previous: Handling Correlation and Causation](correlation-causation.md) +- [↑ Back to Top](#implementing-snapshots) +- [→ Next: Testing Aggregates and Event Handlers](testing.md) diff --git a/docs/code-examples/sample-applications.md b/docs/code-examples/sample-applications.md new file mode 100644 index 00000000..e9630d07 --- /dev/null +++ b/docs/code-examples/sample-applications.md @@ -0,0 +1,612 @@ +# Complete Sample Applications + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example provides an overview of complete sample applications built with Reactive Domain, demonstrating how all the components work together in real-world scenarios. + +## Banking Sample Application + +The Banking Sample Application demonstrates a simple banking system with accounts, transactions, and reporting. It showcases the core concepts of Reactive Domain, including event sourcing, CQRS, and domain-driven design. + +### Repository Structure + +``` +BankingSample/ +├── src/ +│ ├── BankingSample.Domain/ # Domain model and business logic +│ │ ├── Aggregates/ # Aggregate roots +│ │ ├── Commands/ # Command definitions +│ │ ├── Events/ # Event definitions +│ │ └── Handlers/ # Command handlers +│ ├── BankingSample.ReadModels/ # Read models and projections +│ │ ├── Accounts/ # Account read models +│ │ ├── Reporting/ # Reporting read models +│ │ └── Projections/ # Event projections +│ ├── BankingSample.Infrastructure/ # Infrastructure components +│ │ ├── EventStore/ # Event store configuration +│ │ ├── Repositories/ # Repository implementations +│ │ └── Serialization/ # Event serialization +│ ├── BankingSample.Api/ # Web API +│ │ ├── Controllers/ # API controllers +│ │ ├── Models/ # Request/response models +│ │ └── Startup.cs # Application configuration +│ └── BankingSample.Console/ # Console application +│ └── Program.cs # Entry point +└── test/ + ├── BankingSample.Domain.Tests/ # Domain tests + ├── BankingSample.ReadModels.Tests/ # Read model tests + └── BankingSample.Api.Tests/ # API tests +``` + +### Key Features + +- Account creation, deposits, withdrawals, and transfers +- Account statements and transaction history +- Reporting and analytics +- Command validation and error handling +- Event sourcing with EventStoreDB +- CQRS with separate read and write models +- Correlation and causation tracking +- Snapshotting for performance optimization +- Integration with ASP.NET Core Web API + +### Running the Sample + +1. Clone the repository: `git clone https://github.com/reactive-domain/banking-sample.git` +2. Start EventStoreDB: `docker-compose up -d` +3. Build the solution: `dotnet build` +4. Run the API: `dotnet run --project src/BankingSample.Api/BankingSample.Api.csproj` +5. Open your browser to `https://localhost:5001/swagger` to explore the API + +## E-Commerce Sample Application + +The E-Commerce Sample Application demonstrates a more complex domain with products, orders, customers, and inventory management. It showcases advanced concepts like sagas, process managers, and integration with external systems. + +### Repository Structure + +``` +ECommerceSample/ +├── src/ +│ ├── ECommerceSample.Domain/ # Domain model and business logic +│ │ ├── Aggregates/ # Aggregate roots +│ │ ├── Commands/ # Command definitions +│ │ ├── Events/ # Event definitions +│ │ └── Handlers/ # Command handlers +│ ├── ECommerceSample.ReadModels/ # Read models and projections +│ │ ├── Catalog/ # Product catalog read models +│ │ ├── Orders/ # Order read models +│ │ ├── Customers/ # Customer read models +│ │ └── Projections/ # Event projections +│ ├── ECommerceSample.ProcessManagers/ # Process managers and sagas +│ │ ├── OrderProcessing/ # Order processing workflow +│ │ ├── Shipping/ # Shipping workflow +│ │ └── Payment/ # Payment processing workflow +│ ├── ECommerceSample.Infrastructure/ # Infrastructure components +│ │ ├── EventStore/ # Event store configuration +│ │ ├── Repositories/ # Repository implementations +│ │ ├── Serialization/ # Event serialization +│ │ └── ExternalServices/ # External service integrations +│ ├── ECommerceSample.Api/ # Web API +│ │ ├── Controllers/ # API controllers +│ │ ├── Models/ # Request/response models +│ │ └── Startup.cs # Application configuration +│ └── ECommerceSample.Web/ # Web frontend +│ ├── Pages/ # Razor pages +│ ├── Components/ # Blazor components +│ └── Program.cs # Entry point +└── test/ + ├── ECommerceSample.Domain.Tests/ # Domain tests + ├── ECommerceSample.ReadModels.Tests/ # Read model tests + ├── ECommerceSample.ProcessManagers.Tests/ # Process manager tests + └── ECommerceSample.Api.Tests/ # API tests +``` + +### Key Features + +- Product catalog management +- Shopping cart functionality +- Order processing with multi-step workflow +- Customer management and authentication +- Inventory tracking and management +- Payment processing integration +- Shipping and fulfillment +- Reporting and analytics +- Event sourcing with EventStoreDB +- CQRS with separate read and write models +- Process managers for coordinating workflows +- Integration events for external systems +- Snapshotting for performance optimization +- Integration with ASP.NET Core and Blazor + +### Running the Sample + +1. Clone the repository: `git clone https://github.com/reactive-domain/ecommerce-sample.git` +2. Start the infrastructure: `docker-compose up -d` +3. Build the solution: `dotnet build` +4. Run the API: `dotnet run --project src/ECommerceSample.Api/ECommerceSample.Api.csproj` +5. Run the Web frontend: `dotnet run --project src/ECommerceSample.Web/ECommerceSample.Web.csproj` +6. Open your browser to `https://localhost:5001` to explore the application + +## Task Management Sample Application + +The Task Management Sample Application demonstrates a simple task tracking system with projects, tasks, and users. It showcases the basics of Reactive Domain in a straightforward domain. + +### Repository Structure + +``` +TaskManagementSample/ +├── src/ +│ ├── TaskManagementSample.Domain/ # Domain model and business logic +│ │ ├── Aggregates/ # Aggregate roots +│ │ ├── Commands/ # Command definitions +│ │ ├── Events/ # Event definitions +│ │ └── Handlers/ # Command handlers +│ ├── TaskManagementSample.ReadModels/ # Read models and projections +│ │ ├── Projects/ # Project read models +│ │ ├── Tasks/ # Task read models +│ │ ├── Users/ # User read models +│ │ └── Projections/ # Event projections +│ ├── TaskManagementSample.Infrastructure/ # Infrastructure components +│ │ ├── EventStore/ # Event store configuration +│ │ ├── Repositories/ # Repository implementations +│ │ └── Serialization/ # Event serialization +│ └── TaskManagementSample.Api/ # Web API +│ ├── Controllers/ # API controllers +│ ├── Models/ # Request/response models +│ └── Startup.cs # Application configuration +└── test/ + ├── TaskManagementSample.Domain.Tests/ # Domain tests + ├── TaskManagementSample.ReadModels.Tests/ # Read model tests + └── TaskManagementSample.Api.Tests/ # API tests +``` + +### Key Features + +- Project creation and management +- Task creation, assignment, and status tracking +- User management +- Event sourcing with EventStoreDB +- CQRS with separate read and write models +- Integration with ASP.NET Core Web API + +### Running the Sample + +1. Clone the repository: `git clone https://github.com/reactive-domain/task-management-sample.git` +2. Start EventStoreDB: `docker-compose up -d` +3. Build the solution: `dotnet build` +4. Run the API: `dotnet run --project src/TaskManagementSample.Api/TaskManagementSample.Api.csproj` +5. Open your browser to `https://localhost:5001/swagger` to explore the API + +## Sample Code Snippets + +### Domain Model + +```csharp +// BankingSample.Domain/Aggregates/Account.cs +using System; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using BankingSample.Domain.Events; + +namespace BankingSample.Domain.Aggregates +{ + public class Account : AggregateRoot + { + private string _accountNumber; + private string _customerName; + private decimal _balance; + private bool _isClosed; + + public Account(Guid id) : base(id) + { + } + + public Account(Guid id, ICorrelatedMessage source) : base(id, source) + { + } + + public void Create(string accountNumber, string customerName) + { + if (string.IsNullOrEmpty(accountNumber)) + throw new ArgumentException("Account number cannot be empty", nameof(accountNumber)); + + if (string.IsNullOrEmpty(customerName)) + throw new ArgumentException("Customer name cannot be empty", nameof(customerName)); + + ApplyChange(new AccountCreated(Id, accountNumber, customerName, CorrelationId, CausationId)); + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_isClosed) + throw new InvalidOperationException("Cannot deposit to a closed account"); + + ApplyChange(new FundsDeposited(Id, amount, CorrelationId, CausationId)); + } + + public void Withdraw(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_isClosed) + throw new InvalidOperationException("Cannot withdraw from a closed account"); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + ApplyChange(new FundsWithdrawn(Id, amount, CorrelationId, CausationId)); + } + + public void Transfer(Guid targetAccountId, decimal amount) + { + if (targetAccountId == Id) + throw new ArgumentException("Cannot transfer to the same account", nameof(targetAccountId)); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_isClosed) + throw new InvalidOperationException("Cannot transfer from a closed account"); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + ApplyChange(new FundsTransferred(Id, targetAccountId, amount, CorrelationId, CausationId)); + } + + public void Close() + { + if (_isClosed) + throw new InvalidOperationException("Account is already closed"); + + ApplyChange(new AccountClosed(Id, CorrelationId, CausationId)); + } + + public decimal GetBalance() + { + return _balance; + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + _balance = 0; + _isClosed = false; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void Apply(FundsTransferred @event) + { + _balance -= @event.Amount; + } + + private void Apply(AccountClosed @event) + { + _isClosed = true; + } + } +} +``` + +### Process Manager + +```csharp +// ECommerceSample.ProcessManagers/OrderProcessing/OrderProcessManager.cs +using System; +using System.Threading.Tasks; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using ECommerceSample.Domain.Commands; +using ECommerceSample.Domain.Events; + +namespace ECommerceSample.ProcessManagers.OrderProcessing +{ + public class OrderProcessManager : + IHandleEvent, + IHandleEvent, + IHandleEvent, + IHandleEvent + { + private readonly ICommandBus _commandBus; + private readonly IOrderProcessStateRepository _stateRepository; + + public OrderProcessManager( + ICommandBus commandBus, + IOrderProcessStateRepository stateRepository) + { + _commandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus)); + _stateRepository = stateRepository ?? throw new ArgumentNullException(nameof(stateRepository)); + } + + public async Task Handle(OrderCreated @event) + { + // Create a new process state + var state = new OrderProcessState(@event.OrderId); + state.OrderCreated(@event.CustomerId, @event.OrderItems, @event.TotalAmount); + + // Save the state + await _stateRepository.SaveAsync(state); + + // Request payment + var requestPaymentCommand = MessageBuilder.From(@event, () => + new RequestPayment(@event.OrderId, @event.CustomerId, @event.TotalAmount)); + + await _commandBus.SendAsync(requestPaymentCommand); + } + + public async Task Handle(PaymentReceived @event) + { + // Get the process state + var state = await _stateRepository.GetByOrderIdAsync(@event.OrderId); + + if (state == null) + { + // Handle missing state + return; + } + + // Update the state + state.PaymentReceived(@event.Amount, @event.PaymentId); + + // Save the state + await _stateRepository.SaveAsync(state); + + // Prepare order for shipping + var prepareShippingCommand = MessageBuilder.From(@event, () => + new PrepareOrderForShipping(@event.OrderId, state.OrderItems)); + + await _commandBus.SendAsync(prepareShippingCommand); + } + + public async Task Handle(OrderShipped @event) + { + // Get the process state + var state = await _stateRepository.GetByOrderIdAsync(@event.OrderId); + + if (state == null) + { + // Handle missing state + return; + } + + // Update the state + state.OrderShipped(@event.TrackingNumber, @event.ShippingProvider); + + // Save the state + await _stateRepository.SaveAsync(state); + + // Notify customer + var notifyCustomerCommand = MessageBuilder.From(@event, () => + new NotifyCustomer( + state.CustomerId, + "Your order has been shipped", + $"Your order {state.OrderId} has been shipped via {state.ShippingProvider}. " + + $"Tracking number: {state.TrackingNumber}")); + + await _commandBus.SendAsync(notifyCustomerCommand); + } + + public async Task Handle(OrderDelivered @event) + { + // Get the process state + var state = await _stateRepository.GetByOrderIdAsync(@event.OrderId); + + if (state == null) + { + // Handle missing state + return; + } + + // Update the state + state.OrderDelivered(@event.DeliveryDate); + + // Save the state + await _stateRepository.SaveAsync(state); + + // Complete the order + var completeOrderCommand = MessageBuilder.From(@event, () => + new CompleteOrder(@event.OrderId)); + + await _commandBus.SendAsync(completeOrderCommand); + + // Request customer feedback + var requestFeedbackCommand = MessageBuilder.From(@event, () => + new RequestCustomerFeedback(state.CustomerId, state.OrderId)); + + await _commandBus.SendAsync(requestFeedbackCommand); + } + } +} +``` + +### API Controller + +```csharp +// BankingSample.Api/Controllers/AccountsController.cs +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using BankingSample.Api.Models; +using BankingSample.Domain.Commands; +using ReactiveDomain.Messaging; + +namespace BankingSample.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AccountsController : ControllerBase + { + private readonly ICommandBus _commandBus; + private readonly IAccountQueries _accountQueries; + + public AccountsController( + ICommandBus commandBus, + IAccountQueries accountQueries) + { + _commandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus)); + _accountQueries = accountQueries ?? throw new ArgumentNullException(nameof(accountQueries)); + } + + [HttpPost] + public async Task CreateAccount([FromBody] CreateAccountRequest request) + { + var accountId = Guid.NewGuid(); + + var command = new CreateAccount( + accountId, + request.AccountNumber, + request.CustomerName); + + await _commandBus.SendAsync(command); + + return CreatedAtAction( + nameof(GetAccount), + new { id = accountId }, + new { Id = accountId }); + } + + [HttpGet("{id}")] + public async Task GetAccount(Guid id) + { + var account = await _accountQueries.GetAccountByIdAsync(id); + + if (account == null) + { + return NotFound(); + } + + return Ok(account); + } + + [HttpPost("{id}/deposit")] + public async Task Deposit(Guid id, [FromBody] DepositRequest request) + { + var command = new DepositFunds(id, request.Amount); + await _commandBus.SendAsync(command); + + return Ok(); + } + + [HttpPost("{id}/withdraw")] + public async Task Withdraw(Guid id, [FromBody] WithdrawRequest request) + { + var command = new WithdrawFunds(id, request.Amount); + + try + { + await _commandBus.SendAsync(command); + return Ok(); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpPost("{id}/transfer")] + public async Task Transfer(Guid id, [FromBody] TransferRequest request) + { + var command = new TransferFunds( + id, + request.TargetAccountId, + request.Amount); + + try + { + await _commandBus.SendAsync(command); + return Ok(); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpPost("{id}/close")] + public async Task Close(Guid id) + { + var command = new CloseAccount(id); + + try + { + await _commandBus.SendAsync(command); + return Ok(); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpGet("{id}/transactions")] + public async Task GetTransactions(Guid id) + { + var transactions = await _accountQueries.GetTransactionsByAccountIdAsync(id); + return Ok(transactions); + } + } +} +``` + +## Key Concepts + +### Complete Application Architecture + +- **Domain Layer**: Contains the domain model, aggregates, commands, and events +- **Read Model Layer**: Contains read models and projections +- **Infrastructure Layer**: Contains repositories, event store configuration, and external service integrations +- **API Layer**: Contains controllers, request/response models, and application configuration +- **Process Manager Layer**: Contains process managers and sagas for coordinating workflows +- **Web Layer**: Contains the user interface components + +### Sample Applications + +- **Banking Sample**: Demonstrates basic event sourcing and CQRS concepts +- **E-Commerce Sample**: Demonstrates advanced concepts like process managers and integration events +- **Task Management Sample**: Demonstrates a simple domain with basic event sourcing + +### Running the Samples + +- All samples include Docker Compose files for setting up the required infrastructure +- Samples can be run locally for development and testing +- Samples include comprehensive test suites for all layers + +## Best Practices + +1. **Clean Architecture**: Organize code into distinct layers with clear responsibilities +2. **Domain-Driven Design**: Focus on the domain model and business rules +3. **CQRS**: Separate command and query responsibilities +4. **Event Sourcing**: Store state as a sequence of events +5. **Process Managers**: Use process managers to coordinate complex workflows +6. **Testing**: Write comprehensive tests for all layers +7. **Documentation**: Document the architecture, domain model, and API + +## Common Pitfalls + +1. **Overcomplicating**: Start simple and add complexity as needed +2. **Tight Coupling**: Keep layers loosely coupled and use interfaces +3. **Missing Tests**: Ensure all business rules and edge cases are tested +4. **Performance Issues**: Monitor and optimize performance as needed +5. **Lack of Documentation**: Document the architecture and domain model + +--- + +**Navigation**: +- [← Previous: Integration with ASP.NET Core](aspnet-integration.md) +- [↑ Back to Top](#complete-sample-applications) +- [← Back to Code Examples](README.md) diff --git a/docs/code-examples/testing.md b/docs/code-examples/testing.md new file mode 100644 index 00000000..569902c0 --- /dev/null +++ b/docs/code-examples/testing.md @@ -0,0 +1,685 @@ +# Testing Aggregates and Event Handlers + +[← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) + +This example demonstrates how to test aggregates, command handlers, and event handlers in Reactive Domain using xUnit. + +## Setting Up Test Projects + +```csharp +// MyApp.Tests.csproj + + + + net6.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +``` + +## Testing Aggregates + +```csharp +using System; +using Xunit; +using MyApp.Domain; + +namespace MyApp.Tests.Domain +{ + public class AccountTests + { + [Fact] + public void Create_ValidParameters_SetsProperties() + { + // Arrange + var id = Guid.NewGuid(); + var account = new Account(id); + + // Act + account.Create("ACC-123", "John Doe"); + + // Assert + var events = account.TakeEvents(); + Assert.Single(events); + Assert.IsType(events[0]); + + var createdEvent = (AccountCreated)events[0]; + Assert.Equal(id, createdEvent.AccountId); + Assert.Equal("ACC-123", createdEvent.AccountNumber); + Assert.Equal("John Doe", createdEvent.CustomerName); + } + + [Fact] + public void Create_EmptyAccountNumber_ThrowsArgumentException() + { + // Arrange + var account = new Account(Guid.NewGuid()); + + // Act & Assert + var exception = Assert.Throws(() => account.Create("", "John Doe")); + Assert.Equal("Account number cannot be empty (Parameter 'accountNumber')", exception.Message); + } + + [Fact] + public void Deposit_PositiveAmount_IncreasesBalance() + { + // Arrange + var account = new Account(Guid.NewGuid()); + account.Create("ACC-123", "John Doe"); + account.TakeEvents(); // Clear events + + // Act + account.Deposit(100); + + // Assert + var events = account.TakeEvents(); + Assert.Single(events); + Assert.IsType(events[0]); + + var depositEvent = (FundsDeposited)events[0]; + Assert.Equal(100, depositEvent.Amount); + + Assert.Equal(100, account.GetBalance()); + } + + [Fact] + public void Deposit_NegativeAmount_ThrowsArgumentException() + { + // Arrange + var account = new Account(Guid.NewGuid()); + account.Create("ACC-123", "John Doe"); + + // Act & Assert + var exception = Assert.Throws(() => account.Deposit(-100)); + Assert.Equal("Amount must be positive (Parameter 'amount')", exception.Message); + } + + [Fact] + public void Withdraw_ValidAmount_DecreasesBalance() + { + // Arrange + var account = new Account(Guid.NewGuid()); + account.Create("ACC-123", "John Doe"); + account.Deposit(100); + account.TakeEvents(); // Clear events + + // Act + account.Withdraw(50); + + // Assert + var events = account.TakeEvents(); + Assert.Single(events); + Assert.IsType(events[0]); + + var withdrawEvent = (FundsWithdrawn)events[0]; + Assert.Equal(50, withdrawEvent.Amount); + + Assert.Equal(50, account.GetBalance()); + } + + [Fact] + public void Withdraw_InsufficientFunds_ThrowsInvalidOperationException() + { + // Arrange + var account = new Account(Guid.NewGuid()); + account.Create("ACC-123", "John Doe"); + account.Deposit(100); + + // Act & Assert + var exception = Assert.Throws(() => account.Withdraw(150)); + Assert.Equal("Insufficient funds", exception.Message); + } + + [Fact] + public void Close_OpenAccount_MarksAsClosed() + { + // Arrange + var account = new Account(Guid.NewGuid()); + account.Create("ACC-123", "John Doe"); + account.TakeEvents(); // Clear events + + // Act + account.Close(); + + // Assert + var events = account.TakeEvents(); + Assert.Single(events); + Assert.IsType(events[0]); + + // Try to deposit after closing (should throw) + Assert.Throws(() => account.Deposit(100)); + } + } +} +``` + +## Testing Command Handlers + +```csharp +using System; +using Moq; +using Xunit; +using ReactiveDomain.Foundation; +using MyApp.Domain; +using MyApp.Domain.Commands; +using MyApp.Domain.Handlers; + +namespace MyApp.Tests.Domain.Handlers +{ + public class AccountCommandHandlerTests + { + [Fact] + public void Handle_CreateAccount_CreatesAndSavesAccount() + { + // Arrange + var mockRepository = new Mock(); + var handler = new AccountCommandHandler(mockRepository.Object); + + var accountId = Guid.NewGuid(); + var command = new CreateAccount(accountId, "ACC-123", "John Doe"); + + // Setup mock to capture the saved aggregate + Account savedAccount = null; + mockRepository.Setup(r => r.Save(It.IsAny())) + .Callback(a => savedAccount = a); + + // Act + handler.Handle(command); + + // Assert + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Once); + Assert.NotNull(savedAccount); + Assert.Equal(accountId, savedAccount.Id); + Assert.Equal(0, savedAccount.GetBalance()); + } + + [Fact] + public void Handle_DepositFunds_LoadsUpdatesAndSavesAccount() + { + // Arrange + var mockRepository = new Mock(); + var handler = new AccountCommandHandler(mockRepository.Object); + + var accountId = Guid.NewGuid(); + var existingAccount = new Account(accountId); + existingAccount.Create("ACC-123", "John Doe"); + + mockRepository.Setup(r => r.GetById(accountId)) + .Returns(existingAccount); + + var command = new DepositFunds(accountId, 100); + + // Setup mock to capture the saved aggregate + Account savedAccount = null; + mockRepository.Setup(r => r.Save(It.IsAny())) + .Callback(a => savedAccount = a); + + // Act + handler.Handle(command); + + // Assert + mockRepository.Verify(r => r.GetById(accountId), Times.Once); + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Once); + Assert.NotNull(savedAccount); + Assert.Equal(100, savedAccount.GetBalance()); + } + + [Fact] + public void Handle_DepositFunds_AccountNotFound_ThrowsException() + { + // Arrange + var mockRepository = new Mock(); + var handler = new AccountCommandHandler(mockRepository.Object); + + var accountId = Guid.NewGuid(); + + mockRepository.Setup(r => r.GetById(accountId)) + .Throws(new AggregateNotFoundException(accountId)); + + var command = new DepositFunds(accountId, 100); + + // Act & Assert + var exception = Assert.Throws(() => handler.Handle(command)); + Assert.Equal($"Account {accountId} not found", exception.Message); + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Never); + } + + [Fact] + public void Handle_WithdrawFunds_InsufficientFunds_ThrowsException() + { + // Arrange + var mockRepository = new Mock(); + var handler = new AccountCommandHandler(mockRepository.Object); + + var accountId = Guid.NewGuid(); + var existingAccount = new Account(accountId); + existingAccount.Create("ACC-123", "John Doe"); + existingAccount.Deposit(50); + + mockRepository.Setup(r => r.GetById(accountId)) + .Returns(existingAccount); + + var command = new WithdrawFunds(accountId, 100); + + // Act & Assert + Assert.Throws(() => handler.Handle(command)); + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Never); + } + } +} +``` + +## Testing Event Handlers + +```csharp +using System; +using Moq; +using Xunit; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.ReadModels; +using MyApp.EventHandlers; + +namespace MyApp.Tests.EventHandlers +{ + public class AccountReadModelUpdaterTests + { + [Fact] + public void Handle_AccountCreated_CreatesReadModel() + { + // Arrange + var mockRepository = new Mock>(); + var handler = new AccountReadModelUpdater(mockRepository.Object); + + var accountId = Guid.NewGuid(); + var @event = new AccountCreated(accountId, "ACC-123", "John Doe"); + + // Setup mock to capture the saved read model + AccountSummary savedModel = null; + mockRepository.Setup(r => r.Save(It.IsAny())) + .Callback(m => savedModel = m); + + // Act + handler.Handle(@event); + + // Assert + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Once); + Assert.NotNull(savedModel); + Assert.Equal(accountId, savedModel.Id); + Assert.Equal("ACC-123", savedModel.AccountNumber); + Assert.Equal("John Doe", savedModel.CustomerName); + Assert.Equal(0, savedModel.Balance); + Assert.False(savedModel.IsClosed); + } + + [Fact] + public void Handle_FundsDeposited_UpdatesReadModel() + { + // Arrange + var mockRepository = new Mock>(); + var handler = new AccountReadModelUpdater(mockRepository.Object); + + var accountId = Guid.NewGuid(); + var existingModel = new AccountSummary(accountId); + existingModel.Update("ACC-123", "John Doe", 100, false); + + mockRepository.Setup(r => r.GetById(accountId)) + .Returns(existingModel); + + var @event = new FundsDeposited(accountId, 50); + + // Setup mock to capture the saved read model + AccountSummary savedModel = null; + mockRepository.Setup(r => r.Save(It.IsAny())) + .Callback(m => savedModel = m); + + // Act + handler.Handle(@event); + + // Assert + mockRepository.Verify(r => r.GetById(accountId), Times.Once); + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Once); + Assert.NotNull(savedModel); + Assert.Equal(150, savedModel.Balance); + } + + [Fact] + public void Handle_AccountClosed_MarksReadModelAsClosed() + { + // Arrange + var mockRepository = new Mock>(); + var handler = new AccountReadModelUpdater(mockRepository.Object); + + var accountId = Guid.NewGuid(); + var existingModel = new AccountSummary(accountId); + existingModel.Update("ACC-123", "John Doe", 100, false); + + mockRepository.Setup(r => r.GetById(accountId)) + .Returns(existingModel); + + var @event = new AccountClosed(accountId); + + // Setup mock to capture the saved read model + AccountSummary savedModel = null; + mockRepository.Setup(r => r.Save(It.IsAny())) + .Callback(m => savedModel = m); + + // Act + handler.Handle(@event); + + // Assert + mockRepository.Verify(r => r.GetById(accountId), Times.Once); + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Once); + Assert.NotNull(savedModel); + Assert.True(savedModel.IsClosed); + } + } +} +``` + +## Testing Projections + +```csharp +using System; +using Moq; +using Xunit; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.ReadModels; +using MyApp.Projections; + +namespace MyApp.Tests.Projections +{ + public class AccountSummaryProjectionTests + { + [Fact] + public void When_AccountCreated_CreatesReadModel() + { + // Arrange + var mockRepository = new Mock>(); + var projection = new AccountSummaryProjection(mockRepository.Object); + + var accountId = Guid.NewGuid(); + var @event = new AccountCreated(accountId, "ACC-123", "John Doe"); + + // Setup mock to capture the saved read model + AccountSummary savedModel = null; + mockRepository.Setup(r => r.Save(It.IsAny())) + .Callback(m => savedModel = m); + + // Act + projection.When(@event); + + // Assert + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Once); + Assert.NotNull(savedModel); + Assert.Equal(accountId, savedModel.Id); + Assert.Equal("ACC-123", savedModel.AccountNumber); + Assert.Equal("John Doe", savedModel.CustomerName); + Assert.Equal(0, savedModel.Balance); + Assert.False(savedModel.IsClosed); + } + + [Fact] + public void When_FundsDeposited_UpdatesReadModel() + { + // Arrange + var mockRepository = new Mock>(); + var projection = new AccountSummaryProjection(mockRepository.Object); + + var accountId = Guid.NewGuid(); + var existingModel = new AccountSummary(accountId); + existingModel.Update("ACC-123", "John Doe", 100, false); + + mockRepository.Setup(r => r.GetById(accountId)) + .Returns(existingModel); + + var @event = new FundsDeposited(accountId, 50); + + // Setup mock to capture the saved read model + AccountSummary savedModel = null; + mockRepository.Setup(r => r.Save(It.IsAny())) + .Callback(m => savedModel = m); + + // Act + projection.When(@event); + + // Assert + mockRepository.Verify(r => r.GetById(accountId), Times.Once); + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Once); + Assert.NotNull(savedModel); + Assert.Equal(150, savedModel.Balance); + } + } +} +``` + +## Testing with In-Memory Event Store + +```csharp +using System; +using System.Linq; +using Xunit; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using MyApp.Domain; +using MyApp.Domain.Commands; +using MyApp.Domain.Handlers; + +namespace MyApp.Tests.Integration +{ + public class InMemoryEventStore : IRepository + { + private readonly Dictionary> _eventStore = new Dictionary>(); + + public TAggregate GetById(Guid id) where TAggregate : class, IEventSource + { + if (!_eventStore.ContainsKey(id)) + { + throw new AggregateNotFoundException(id); + } + + var aggregate = (TAggregate)Activator.CreateInstance(typeof(TAggregate), id); + var events = _eventStore[id]; + aggregate.RestoreFromEvents(events); + + return aggregate; + } + + public bool TryGetById(Guid id, out TAggregate aggregate) where TAggregate : class, IEventSource + { + try + { + aggregate = GetById(id); + return true; + } + catch (AggregateNotFoundException) + { + aggregate = null; + return false; + } + } + + public void Save(IEventSource aggregate) + { + var events = aggregate.TakeEvents(); + + if (!_eventStore.ContainsKey(aggregate.Id)) + { + _eventStore[aggregate.Id] = new List(); + } + + _eventStore[aggregate.Id].AddRange(events); + } + + public void Update(ref TAggregate aggregate) where TAggregate : class, IEventSource + { + // Not needed for this simple implementation + } + + public void Delete(IEventSource aggregate) + { + // Not needed for this simple implementation + } + + public void HardDelete(IEventSource aggregate) + { + if (_eventStore.ContainsKey(aggregate.Id)) + { + _eventStore.Remove(aggregate.Id); + } + } + + public List GetAllEvents(Guid aggregateId) + { + if (_eventStore.ContainsKey(aggregateId)) + { + return _eventStore[aggregateId]; + } + + return new List(); + } + } + + public class AccountIntegrationTests + { + [Fact] + public void FullAccountLifecycle_GeneratesCorrectEvents() + { + // Arrange + var repository = new InMemoryEventStore(); + var handler = new AccountCommandHandler(repository); + + var accountId = Guid.NewGuid(); + + // Act - Create account + var createCommand = new CreateAccount(accountId, "ACC-123", "John Doe"); + handler.Handle(createCommand); + + // Act - Deposit funds + var depositCommand = new DepositFunds(accountId, 1000); + handler.Handle(depositCommand); + + // Act - Withdraw funds + var withdrawCommand = new WithdrawFunds(accountId, 250); + handler.Handle(withdrawCommand); + + // Act - Close account + var closeCommand = new CloseAccount(accountId); + handler.Handle(closeCommand); + + // Assert - Check events + var events = repository.GetAllEvents(accountId); + + Assert.Equal(4, events.Count); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + Assert.IsType(events[2]); + Assert.IsType(events[3]); + + // Assert - Check event details + var createdEvent = (AccountCreated)events[0]; + Assert.Equal(accountId, createdEvent.AccountId); + Assert.Equal("ACC-123", createdEvent.AccountNumber); + Assert.Equal("John Doe", createdEvent.CustomerName); + + var depositEvent = (FundsDeposited)events[1]; + Assert.Equal(accountId, depositEvent.AccountId); + Assert.Equal(1000, depositEvent.Amount); + + var withdrawEvent = (FundsWithdrawn)events[2]; + Assert.Equal(accountId, withdrawEvent.AccountId); + Assert.Equal(250, withdrawEvent.Amount); + + var closedEvent = (AccountClosed)events[3]; + Assert.Equal(accountId, closedEvent.AccountId); + + // Assert - Check aggregate state + var account = repository.GetById(accountId); + Assert.Equal(750, account.GetBalance()); + + // Act & Assert - Verify closed account rejects deposits + Assert.Throws(() => + { + var depositToClosedCommand = new DepositFunds(accountId, 100); + handler.Handle(depositToClosedCommand); + }); + } + } +} +``` + +## Key Concepts + +### Unit Testing Aggregates + +- Test that commands generate the correct events +- Test that events update the aggregate state correctly +- Test that business rules are enforced +- Test that exceptions are thrown for invalid operations + +### Testing Command Handlers + +- Mock the repository to verify interactions +- Test that commands are processed correctly +- Test error handling and edge cases +- Verify that aggregates are saved after processing + +### Testing Event Handlers + +- Mock dependencies to isolate the handler +- Test that events update read models correctly +- Test error handling and edge cases +- Verify that read models are saved after processing + +### Integration Testing + +- Use an in-memory event store for testing +- Test complete workflows from commands to events +- Verify that the aggregate state is updated correctly +- Test interactions between components + +## Best Practices + +1. **Isolated Tests**: Keep tests isolated and independent +2. **Arrange-Act-Assert**: Structure tests with clear setup, action, and verification +3. **Mock Dependencies**: Use mocks to isolate the component under test +4. **Test Edge Cases**: Test boundary conditions and error scenarios +5. **Descriptive Names**: Use descriptive test names that explain the scenario and expected outcome +6. **Avoid Test Duplication**: Extract common setup code to helper methods + +## Common Pitfalls + +1. **Testing Implementation Details**: Focus on testing behavior, not implementation details +2. **Incomplete Test Coverage**: Ensure all business rules and edge cases are tested +3. **Brittle Tests**: Avoid tests that break with minor implementation changes +4. **Slow Tests**: Keep tests fast to encourage frequent running +5. **Complex Test Setup**: Simplify test setup with helper methods and builders + +--- + +**Navigation**: +- [← Previous: Implementing Snapshots](implementing-snapshots.md) +- [↑ Back to Top](#testing-aggregates-and-event-handlers) +- [→ Next: Integration with ASP.NET Core](aspnet-integration.md) From aa3a79becf4737b57f0d52d88992b4649a5c4165 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sat, 3 May 2025 13:53:16 -0400 Subject: [PATCH 13/41] Add documentation for EventRecorder and EventDrivenStateMachine classes --- .../types/event-driven-state-machine.md | 203 ++++++++++++++++++ docs/api-reference/types/event-recorder.md | 110 ++++++++++ 2 files changed, 313 insertions(+) create mode 100644 docs/api-reference/types/event-driven-state-machine.md create mode 100644 docs/api-reference/types/event-recorder.md diff --git a/docs/api-reference/types/event-driven-state-machine.md b/docs/api-reference/types/event-driven-state-machine.md new file mode 100644 index 00000000..6d1effa8 --- /dev/null +++ b/docs/api-reference/types/event-driven-state-machine.md @@ -0,0 +1,203 @@ +# EventDrivenStateMachine + +[← Back to API Reference](../README.md) + +The `EventDrivenStateMachine` is the base class for event-sourced entities in Reactive Domain. It provides the core functionality for routing events, recording state changes, and managing the event history of an entity. + +## Namespace + +```csharp +namespace ReactiveDomain +``` + +## Syntax + +```csharp +public abstract class EventDrivenStateMachine : IEventSource +``` + +## Properties + +### HasRecordedEvents + +Indicates whether this instance has recorded events that haven't been persisted yet. + +```csharp +public bool HasRecordedEvents { get; } +``` + +**Returns**: `true` if there are recorded events; otherwise, `false`. + +### Id + +Gets the unique identifier for this event-sourced entity. + +```csharp +public Guid Id { get; protected set; } +``` + +### Version + +Gets the current version of the entity, which represents the number of events that have been applied to it. + +```csharp +public long Version { get; } +``` + +## Methods + +### RestoreFromEvents(IEnumerable\) + +Restores the state of the entity from a sequence of historical events. + +```csharp +public void RestoreFromEvents(IEnumerable events) +``` + +**Parameters**: +- `events` (`System.Collections.Generic.IEnumerable`): The historical events to restore from. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `events` is `null`. +- `System.InvalidOperationException`: Thrown when the entity has recorded events that haven't been persisted. + +### UpdateWithEvents(IEnumerable\, long) + +Updates the entity with additional events, ensuring the expected version matches. + +```csharp +public void UpdateWithEvents(IEnumerable events, long expectedVersion) +``` + +**Parameters**: +- `events` (`System.Collections.Generic.IEnumerable`): The events to update with. +- `expectedVersion` (`System.Int64`): The expected version of the entity before applying these events. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `events` is `null`. +- `System.InvalidOperationException`: Thrown when the entity has no historical events or when the expected version doesn't match the current version. + +### TakeEvents() + +Returns all events recorded since the entity was loaded or since the last time `TakeEvents()` was called, and clears the event recorder. + +```csharp +public object[] TakeEvents() +``` + +**Returns**: An array of objects representing the recorded events. + +### Register\(Action\) + +Registers a route for a specific type of event to the logic that needs to be applied to this instance. + +```csharp +protected void Register(Action route) +``` + +**Type Parameters**: +- `TEvent`: The type of event. + +**Parameters**: +- `route` (`System.Action`): The logic to route the event to. + +### Register(Type, Action\) + +Registers a route for a specific type of event to the logic that needs to be applied to this instance. + +```csharp +protected void Register(Type typeOfEvent, Action route) +``` + +**Parameters**: +- `typeOfEvent` (`System.Type`): The type of event. +- `route` (`System.Action`): The logic to route the event to. + +### Raise(object) + +Raises the specified event - applies it to this instance and records it in its history. + +```csharp +protected void Raise(object @event) +``` + +**Parameters**: +- `event` (`System.Object`): The event to apply and record. + +## Protected Methods + +### TakeEventStarted() + +Called before the events are taken from the event recorder. + +```csharp +protected virtual void TakeEventStarted() +``` + +### TakeEventsCompleted() + +Called after the events are taken from the event recorder and it has been reset. + +```csharp +protected virtual void TakeEventsCompleted() +``` + +### OnEventRaised(object) + +Called when an event is raised. + +```csharp +protected virtual void OnEventRaised(object @event) +``` + +**Parameters**: +- `event` (`System.Object`): The event that was raised. + +## Usage + +The `EventDrivenStateMachine` is the foundation for implementing event-sourced entities in Reactive Domain. It's typically not used directly but through its subclass `AggregateRoot`. + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + + public Account(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + } + + public void Deposit(decimal amount) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + // Raise an event - this will call Apply(FundsDeposited) and record the event + Raise(new FundsDeposited(Id, amount)); + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + // Other methods and event handlers... +} +``` + +## Remarks + +- The `EventDrivenStateMachine` implements the event sourcing pattern, where an entity's state is determined by a sequence of events. +- It uses an internal `EventRecorder` to track events that have been applied but not yet persisted. +- It uses an `EventRouter` to route events to the appropriate handler methods. +- When an event is raised using the `Raise` method, it is both applied to update the entity's state and recorded for later persistence. +- The `TakeEvents` method is typically called by a repository when persisting the entity's changes. + +## See Also + +- [AggregateRoot](aggregate-root.md) +- [EventRecorder](event-recorder.md) +- [IEventSource](ievent-source.md) diff --git a/docs/api-reference/types/event-recorder.md b/docs/api-reference/types/event-recorder.md new file mode 100644 index 00000000..46e466c5 --- /dev/null +++ b/docs/api-reference/types/event-recorder.md @@ -0,0 +1,110 @@ +# EventRecorder + +[← Back to API Reference](../README.md) + +The `EventRecorder` class is responsible for recording events on behalf of an event source. It's a core component of the event sourcing infrastructure in Reactive Domain, used internally by the `EventDrivenStateMachine` and `AggregateRoot` classes to track and manage domain events. + +## Namespace + +```csharp +namespace ReactiveDomain +``` + +## Syntax + +```csharp +public class EventRecorder +``` + +## Constructors + +### EventRecorder() + +Initializes a new instance of the `EventRecorder` class. + +```csharp +public EventRecorder() +``` + +## Properties + +### HasRecordedEvents + +Indicates whether this instance has recorded events. + +```csharp +public bool HasRecordedEvents { get; } +``` + +**Returns**: `true` if there are recorded events; otherwise, `false`. + +### RecordedEvents + +Gets an array containing all the events recorded by this instance. + +```csharp +public object[] RecordedEvents { get; } +``` + +**Returns**: An array of objects representing the recorded events. + +## Methods + +### Record(object) + +Records an event on this instance. + +```csharp +public void Record(object @event) +``` + +**Parameters**: +- `event` (`System.Object`): The event to record. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `event` is `null`. + +### Reset() + +Resets this instance to its starting point or the point it was last reset on, effectively forgetting all events that have been recorded in the meantime. + +```csharp +public void Reset() +``` + +## Usage + +The `EventRecorder` is primarily used internally by the `EventDrivenStateMachine` class, which is the base class for `AggregateRoot`. It's responsible for tracking the events that have been applied to an aggregate but not yet persisted to the event store. + +```csharp +// This is internal usage within EventDrivenStateMachine +protected void Raise(object @event) +{ + OnEventRaised(@event); + Router.Route(@event); + _recorder.Record(@event); // Recording the event +} + +public object[] TakeEvents() +{ + TakeEventStarted(); + var records = _recorder.RecordedEvents; // Getting all recorded events + _recorder.Reset(); // Clearing the recorder + _version += records.Length; + TakeEventsCompleted(); + return records; +} +``` + +## Remarks + +- The `EventRecorder` is a fundamental part of the event sourcing pattern implementation in Reactive Domain. +- It maintains an in-memory list of events that have been applied to an aggregate but not yet persisted. +- When `TakeEvents()` is called on an aggregate, it retrieves all recorded events from the `EventRecorder` and then resets it. +- The `EventRecorder` is used in conjunction with the `EventRouter` to implement the full event sourcing behavior in aggregates. + +## See Also + +- [AggregateRoot](aggregate-root.md) +- [EventDrivenStateMachine](event-driven-state-machine.md) +- [IEventSource](ievent-source.md) From f46a06770ad8163ed12dafde0fe672ed68f7ad89 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 16:10:32 -0400 Subject: [PATCH 14/41] Fix documentation links to point to ReactiveDomain organization and create todo list for PR 169 --- docs/README.md | 2 +- docs/code-examples/sample-applications.md | 6 +-- docs/workshop-materials.md | 2 +- todo-pr-169.md | 56 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 todo-pr-169.md diff --git a/docs/README.md b/docs/README.md index af48442a..ba6060b2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ Welcome to the comprehensive documentation for the Reactive Domain library, an open-source framework for implementing event sourcing in .NET projects using reactive programming principles. -> **Version Information**: This documentation corresponds to the [reactive-documentation](https://github.com/linedata/reactive-domain/tree/reactive-documentation) branch, which was created from trunk commit [05e5268](https://github.com/linedata/reactive-domain/commit/05e5268f0ceef1034885905402590486fcb6fcad) ("Removes .NET 6 support", 2025-02-18). If you notice differences between this documentation and your code, please check which version of Reactive Domain you're using. +> **Version Information**: This documentation corresponds to the [reactive-documentation](https://github.com/ReactiveDomain/reactive-domain/tree/reactive-documentation) branch, which was created from trunk commit [05e5268](https://github.com/ReactiveDomain/reactive-domain/commit/05e5268f0ceef1034885905402590486fcb6fcad) ("Removes .NET 6 support", 2025-02-18). If you notice differences between this documentation and your code, please check which version of Reactive Domain you're using. ## Introduction diff --git a/docs/code-examples/sample-applications.md b/docs/code-examples/sample-applications.md index e9630d07..16807921 100644 --- a/docs/code-examples/sample-applications.md +++ b/docs/code-examples/sample-applications.md @@ -52,7 +52,7 @@ BankingSample/ ### Running the Sample -1. Clone the repository: `git clone https://github.com/reactive-domain/banking-sample.git` +1. Clone the repository: `git clone https://github.com/ReactiveDomain/banking-sample.git` 2. Start EventStoreDB: `docker-compose up -d` 3. Build the solution: `dotnet build` 4. Run the API: `dotnet run --project src/BankingSample.Api/BankingSample.Api.csproj` @@ -120,7 +120,7 @@ ECommerceSample/ ### Running the Sample -1. Clone the repository: `git clone https://github.com/reactive-domain/ecommerce-sample.git` +1. Clone the repository: `git clone https://github.com/ReactiveDomain/ecommerce-sample.git` 2. Start the infrastructure: `docker-compose up -d` 3. Build the solution: `dotnet build` 4. Run the API: `dotnet run --project src/ECommerceSample.Api/ECommerceSample.Api.csproj` @@ -171,7 +171,7 @@ TaskManagementSample/ ### Running the Sample -1. Clone the repository: `git clone https://github.com/reactive-domain/task-management-sample.git` +1. Clone the repository: `git clone https://github.com/ReactiveDomain/task-management-sample.git` 2. Start EventStoreDB: `docker-compose up -d` 3. Build the solution: `dotnet build` 4. Run the API: `dotnet run --project src/TaskManagementSample.Api/TaskManagementSample.Api.csproj` diff --git a/docs/workshop-materials.md b/docs/workshop-materials.md index 8fc1cddf..c44e4505 100644 --- a/docs/workshop-materials.md +++ b/docs/workshop-materials.md @@ -811,7 +811,7 @@ The workshop repository includes complete code samples for all exercises and add ### Reference Documentation -- [Reactive Domain Documentation](https://github.com/ReactiveDomain/reactive-domain/docs) +- [Reactive Domain Documentation](https://github.com/ReactiveDomain/reactive-domain/tree/master/docs) - [EventStoreDB Documentation](https://developers.eventstore.com/server/v21.10/docs/) - [CQRS Journey by Microsoft](https://docs.microsoft.com/en-us/previous-versions/msp-n-p/jj554200(v=pandp.10)) diff --git a/todo-pr-169.md b/todo-pr-169.md new file mode 100644 index 00000000..e2a0db66 --- /dev/null +++ b/todo-pr-169.md @@ -0,0 +1,56 @@ +# Todo List for PR 169 - Reactive Documentation + +This todo list contains items that need to be addressed for PR 169, which adds comprehensive documentation for the Reactive Domain library. + +## Badge and Reference Fixes + +- [x] Fix Badge URLs in README.md to point to the main ReactiveDomain repository instead of leopoldodonnell's fork (already completed) +- [x] Update Travis CI badge to point to ReactiveDomain organization (already completed) +- [x] Ensure all documentation links point to the correct repositories (updated links in README.md, sample-applications.md, and workshop-materials.md) + +## Documentation for Key Components + +- [ ] Add/enhance documentation for ReadModelBase +- [ ] Add/enhance documentation for MessageBuilder factory +- [ ] Improve documentation for Command and Event classes that implement ICorrelatedMessage +- [ ] Document the relationship between different components + +## Code Example Corrections + +- [ ] Fix the use of `Apply()` vs `Raise()` methods in aggregates (Apply methods are for rehydration, not for creating new events) +- [ ] Update examples in event.md to use `Raise(new AccountCreated(...))` instead of `Apply(...)` +- [ ] Review all code examples for technical accuracy +- [ ] Add more real-world examples to illustrate concepts + +## Architecture Documentation Improvements + +- [ ] Add more detailed explanations of the CQRS pattern +- [ ] Enhance documentation of Event Sourcing principles +- [ ] Include diagrams showing the flow of commands and events +- [ ] Document the relationship between different architectural components + +## Terminology and Consistency + +- [ ] Ensure consistent use of terminology throughout the documentation +- [ ] Review and correct any technical inaccuracies +- [ ] Standardize formatting and style across all documentation files + +## API Reference Enhancements + +- [ ] Add missing classes and interfaces to API reference +- [ ] Ensure all public APIs are properly documented +- [ ] Add parameter descriptions for important methods +- [ ] Document return values and exceptions + +## Navigation and Structure + +- [ ] Ensure logical progression through documentation +- [ ] Fix any broken links between documentation pages +- [ ] Improve component navigation to show relationships +- [ ] Verify that the table of contents is accurate and complete + +## Learning Resources + +- [ ] Enhance the learning path for new users +- [ ] Add links to additional resources and examples +- [ ] Include troubleshooting section for common issues From aa654aa25af2ca8493bba85d4e10ffad255f3cee Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 16:21:44 -0400 Subject: [PATCH 15/41] Document component relationships with new architecture section and cross-references --- docs/api-reference/types/aggregate-root.md | 8 +- docs/api-reference/types/command.md | 3 + docs/api-reference/types/event.md | 5 + .../types/icorrelated-message.md | 4 + docs/api-reference/types/message-builder.md | 3 + docs/api-reference/types/read-model-base.md | 4 + docs/architecture.md | 94 +++++++++++++++++++ todo-pr-169.md | 8 +- 8 files changed, 124 insertions(+), 5 deletions(-) diff --git a/docs/api-reference/types/aggregate-root.md b/docs/api-reference/types/aggregate-root.md index 9807f398..2916395f 100644 --- a/docs/api-reference/types/aggregate-root.md +++ b/docs/api-reference/types/aggregate-root.md @@ -57,7 +57,7 @@ public class Account : AggregateRoot } ``` -### AggregateRoot(Guid, IEnumerable) +### AggregateRoot(Guid, IEnumerable\) Initializes a new instance of the `AggregateRoot` class with the specified ID and restores it from the provided events. This constructor is typically used by repositories when reconstituting an aggregate from its event history. @@ -427,5 +427,11 @@ public class Account : AggregateRoot - [ISnapshotSource](isnapshot-source.md): Interface for snapshot support - [IRepository](irepository.md): Interface for repositories that work with aggregates - [EventRecorder](event-recorder.md): Utility used internally by `AggregateRoot` to record events +- [Command](./command.md): Messages that trigger state changes in aggregates +- [Event](./event.md): Messages that represent state changes in aggregates +- [MessageBuilder](./message-builder.md): Factory for creating correlated events from aggregates +- [ReadModelBase](./read-model-base.md): Read models that are updated based on events from aggregates + +For a comprehensive view of how aggregates interact with other components, see the [Key Component Relationships](../../architecture.md#key-component-relationships) section in the Architecture Guide, particularly the [Command and Event Relationship](../../architecture.md#command-and-event-relationship) and [Aggregate and Repository Interaction](../../architecture.md#aggregate-and-repository-interaction) diagrams. [↑ Back to Top](#aggregateroot-class) | [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) diff --git a/docs/api-reference/types/command.md b/docs/api-reference/types/command.md index ef4f7a9a..0d953223 100644 --- a/docs/api-reference/types/command.md +++ b/docs/api-reference/types/command.md @@ -727,6 +727,9 @@ public class CreateAccountHandlerTests - [Event](./event.md): Base class for event messages that result from commands - [AggregateRoot](./aggregate-root.md): Base class for domain aggregates that process commands - [ICommandBus](./icommand-bus.md): Interface for routing commands to handlers +- [ReadModelBase](./read-model-base.md): Base class for read models that are updated as a result of command processing + +For a comprehensive view of how commands interact with other components, see the [Key Component Relationships](../../architecture.md#key-component-relationships) section in the Architecture Guide, particularly the [Command and Event Relationship](../../architecture.md#command-and-event-relationship) diagram. --- diff --git a/docs/api-reference/types/event.md b/docs/api-reference/types/event.md index 02b5cb12..81940624 100644 --- a/docs/api-reference/types/event.md +++ b/docs/api-reference/types/event.md @@ -221,6 +221,11 @@ private void Dispatch(IEvent @event) - [ICorrelatedMessage](./icorrelated-message.md): Interface for messages with correlation information - [MessageBuilder](./message-builder.md): Factory for creating correlated messages - [IEventHandler](./ievent-handler.md): Interface for handling events +- [AggregateRoot](./aggregate-root.md): Domain entities that raise events in response to commands +- [Command](./command.md): Messages that trigger state changes resulting in events +- [ReadModelBase](./read-model-base.md): Read models that are updated in response to events + +For a comprehensive view of how events interact with other components, see the [Key Component Relationships](../../architecture.md#key-component-relationships) section in the Architecture Guide, particularly the [Command and Event Relationship](../../architecture.md#command-and-event-relationship) and [ReadModelBase and Event Handlers](../../architecture.md#readmodelbase-and-event-handlers) diagrams. --- diff --git a/docs/api-reference/types/icorrelated-message.md b/docs/api-reference/types/icorrelated-message.md index b2f129a0..fa387076 100644 --- a/docs/api-reference/types/icorrelated-message.md +++ b/docs/api-reference/types/icorrelated-message.md @@ -165,6 +165,10 @@ public class AccountService - [Event](./event.md): Base class for events that implements `ICorrelatedMessage` - [MessageBuilder](./message-builder.md): Factory for creating correlated messages - [ICorrelatedRepository](./icorrelated-repository.md): Repository that preserves correlation information +- [AggregateRoot](./aggregate-root.md): Domain entities that work with correlated messages +- [ReadModelBase](./read-model-base.md): Read models that are updated by event handlers processing correlated events + +For a comprehensive view of how correlation works across components, see the [Key Component Relationships](../../architecture.md#key-component-relationships) section in the Architecture Guide, particularly the [Correlation and Causation Flow](../../architecture.md#correlation-and-causation-flow) diagram. --- diff --git a/docs/api-reference/types/message-builder.md b/docs/api-reference/types/message-builder.md index 70d7ac57..7329d29d 100644 --- a/docs/api-reference/types/message-builder.md +++ b/docs/api-reference/types/message-builder.md @@ -397,6 +397,9 @@ public void Handle(CreateAccount command) - [Event](./event.md): Base class for events that implements `ICorrelatedMessage` - [ICorrelatedRepository](./icorrelated-repository.md): Repository that preserves correlation information - [AggregateRoot](./aggregate-root.md): Base class for domain aggregates that work with correlated messages +- [ReadModelBase](./read-model-base.md): Base class for read models that are updated by correlated events + +For a comprehensive view of how these components interact, see the [Key Component Relationships](../../architecture.md#key-component-relationships) section in the Architecture Guide, particularly the [MessageBuilder's Role](../../architecture.md#messagebuilders-role) and [Correlation and Causation Flow](../../architecture.md#correlation-and-causation-flow) diagrams. --- diff --git a/docs/api-reference/types/read-model-base.md b/docs/api-reference/types/read-model-base.md index 90231c93..54f2c11a 100644 --- a/docs/api-reference/types/read-model-base.md +++ b/docs/api-reference/types/read-model-base.md @@ -369,6 +369,10 @@ public class SqlReadModelRepository : IReadModelRepository where T : ReadM - [Command](./command.md): Base class for command messages that trigger state changes - [Event](./event.md): Base class for event messages that update read models - [ICorrelatedMessage](./icorrelated-message.md): Interface for tracking message correlation +- [AggregateRoot](./aggregate-root.md): The write-side counterpart to read models in CQRS +- [MessageBuilder](./message-builder.md): Helps create correlated events that update read models + +For a comprehensive view of how these components interact, see the [Key Component Relationships](../../architecture.md#key-component-relationships) section in the Architecture Guide. --- diff --git a/docs/architecture.md b/docs/architecture.md index 72c9703f..e5674789 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -261,6 +261,100 @@ Implementation in Reactive Domain: - Read models are optimized for specific query patterns - Read models can be in-memory, database tables, or other storage +## Key Component Relationships + +Understanding how the key components in Reactive Domain relate to each other is essential for effective implementation. This section details the relationships between core components like AggregateRoot, Command, Event, MessageBuilder, and ReadModelBase. + +### Command and Event Relationship + +```mermaid +graph TD + A[Command] -->|"Handled by"| B[CommandHandler] + B -->|"Loads/Updates"| C[AggregateRoot] + C -->|"Raises"| D[Event] + D -->|"Applied to"| C + D -->|"Persisted by"| E[Repository] + D -->|"Published to"| F[EventHandler] + F -->|"Updates"| G[ReadModelBase] +``` + +- **Command and Event**: Both implement `ICorrelatedMessage` for correlation tracking +- **Command to Event Flow**: Commands are processed by aggregates to produce events +- **Event to ReadModel Flow**: Events update read models through event handlers + +### MessageBuilder's Role + +```mermaid +graph LR + A[Command] -->|"Source for"| B[MessageBuilder] + B -->|"Creates"| C[Event] + C -->|"Preserves correlation from"| A +``` + +- **MessageBuilder** acts as a factory for creating correlated messages +- It ensures proper correlation and causation tracking between commands and events +- It maintains the correlation chain across the entire message flow + +### Aggregate and Repository Interaction + +```mermaid +sequenceDiagram + participant A as AggregateRoot + participant R as Repository + participant E as EventStore + + R->>E: Load Events + E-->>R: Return Events + R->>A: Apply Events + A->>A: Process Command + A->>A: Raise Events + A->>R: Save + R->>E: Append Events +``` + +- **AggregateRoot** is responsible for business logic and raising events +- **Repository** loads and saves aggregates by reading/writing events +- Events are applied to aggregates to reconstruct state + +### ReadModelBase and Event Handlers + +```mermaid +graph TD + A[Event] -->|"Handled by"| B[EventHandler] + B -->|"Updates"| C[ReadModelBase] + C -->|"Stored in"| D[ReadModelRepository] + E[Query] -->|"Reads from"| D +``` + +- **ReadModelBase** provides the foundation for all read models +- **Event Handlers** subscribe to events and update read models +- Read models are optimized for specific query patterns +- The separation between write models (aggregates) and read models enables CQRS + +### Correlation and Causation Flow + +```mermaid +sequenceDiagram + participant C as Client + participant CMD as Command + participant AGG as Aggregate + participant EVT as Event + participant MB as MessageBuilder + + C->>CMD: Create Command (new CorrelationId) + CMD->>AGG: Process Command + AGG->>MB: Create Event + MB->>EVT: Set CorrelationId from Command + MB->>EVT: Set CausationId as Command.MsgId +``` + +- **ICorrelatedMessage** is implemented by both Command and Event +- **MessageBuilder** ensures proper correlation between commands and events +- Correlation IDs track related messages across the entire system +- Causation IDs establish direct cause-effect relationships + +Understanding these relationships is key to implementing effective event-sourced systems with Reactive Domain. The components work together to provide a comprehensive solution for CQRS and event sourcing. + ## Extension Points Reactive Domain provides several extension points for customization: diff --git a/todo-pr-169.md b/todo-pr-169.md index e2a0db66..35308124 100644 --- a/todo-pr-169.md +++ b/todo-pr-169.md @@ -10,10 +10,10 @@ This todo list contains items that need to be addressed for PR 169, which adds c ## Documentation for Key Components -- [ ] Add/enhance documentation for ReadModelBase -- [ ] Add/enhance documentation for MessageBuilder factory -- [ ] Improve documentation for Command and Event classes that implement ICorrelatedMessage -- [ ] Document the relationship between different components +- [x] Add/enhance documentation for ReadModelBase (documentation is comprehensive with good examples) +- [x] Add/enhance documentation for MessageBuilder factory (documentation is comprehensive) +- [x] Improve documentation for Command and Event classes that implement ICorrelatedMessage (documentation exists and is detailed) +- [x] Document the relationship between different components (added new Key Component Relationships section to architecture.md and cross-references in component documentation) ## Code Example Corrections From 0792564074f034cb5a11f7001f724694a1cd0169 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 16:27:22 -0400 Subject: [PATCH 16/41] Fix code examples to use RaiseEvent() instead of Apply() for creating new events --- docs/api-reference/types/command.md | 20 ++++++++++---------- docs/api-reference/types/event.md | 8 ++++---- docs/api-reference/types/message-builder.md | 10 +++++----- todo-pr-169.md | 6 +++--- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/docs/api-reference/types/command.md b/docs/api-reference/types/command.md index 0d953223..62ee6216 100644 --- a/docs/api-reference/types/command.md +++ b/docs/api-reference/types/command.md @@ -408,8 +408,8 @@ public class Account : AggregateRoot if (id == Guid.Empty) throw new ArgumentException("Account ID cannot be empty", nameof(id)); - // Apply the creation event - Apply(MessageBuilder.From(source, () => new AccountCreated( + // Raise the creation event + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated( id, ((CreateAccount)source).AccountNumber, ((CreateAccount)source).CustomerName, @@ -426,8 +426,8 @@ public class Account : AggregateRoot if (amount <= 0) throw new ArgumentException("Deposit amount must be positive", nameof(amount)); - // Apply the event - Apply(MessageBuilder.From(source, () => new FundsDeposited( + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited( Id, amount, _balance + amount, @@ -447,8 +447,8 @@ public class Account : AggregateRoot if (_balance + _overdraftLimit < amount) throw new InsufficientFundsException($"Insufficient funds. Balance: {_balance}, Overdraft Limit: {_overdraftLimit}"); - // Apply the event - Apply(MessageBuilder.From(source, () => new FundsWithdrawn( + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new FundsWithdrawn( Id, amount, _balance - amount, @@ -465,8 +465,8 @@ public class Account : AggregateRoot if (limit < 0) throw new ArgumentException("Overdraft limit cannot be negative", nameof(limit)); - // Apply the event - Apply(MessageBuilder.From(source, () => new OverdraftLimitSet( + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new OverdraftLimitSet( Id, limit ))); @@ -481,8 +481,8 @@ public class Account : AggregateRoot if (_balance < 0) throw new InvalidOperationException("Cannot close account with negative balance"); - // Apply the event - Apply(MessageBuilder.From(source, () => new AccountClosed( + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new AccountClosed( Id, DateTime.UtcNow ))); diff --git a/docs/api-reference/types/event.md b/docs/api-reference/types/event.md index 81940624..156c90c2 100644 --- a/docs/api-reference/types/event.md +++ b/docs/api-reference/types/event.md @@ -132,8 +132,8 @@ public class Account : AggregateRoot // Constructor for creating a new account public Account(Guid id, ICorrelatedMessage source) : base(id) { - // Create and apply the AccountCreated event - Apply(MessageBuilder.From(source, () => new AccountCreated( + // Create and raise the AccountCreated event + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated( id, "ACC-" + id.ToString().Substring(0, 8), "New Customer" @@ -154,8 +154,8 @@ public class Account : AggregateRoot if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); - // Create and apply the FundsDeposited event - Apply(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + // Create and raise the FundsDeposited event + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); } // Event handler for FundsDeposited diff --git a/docs/api-reference/types/message-builder.md b/docs/api-reference/types/message-builder.md index 7329d29d..35fa4d73 100644 --- a/docs/api-reference/types/message-builder.md +++ b/docs/api-reference/types/message-builder.md @@ -115,7 +115,7 @@ public void Handle(CreateAccount command) // 3. Aggregate applies an event public Account(Guid id, ICorrelatedMessage source) : base(id) { - Apply(MessageBuilder.From(source, () => new AccountCreated(id, source.CustomerName, source.InitialBalance))); + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(id, source.CustomerName, source.InitialBalance))); // Event MsgId: B, CorrelationId: A, CausationId: A } @@ -162,7 +162,7 @@ public class Account : AggregateRoot public Account(Guid id, ICorrelatedMessage source) : base(id) { // Create a new event from the source command - Apply(MessageBuilder.From(source, () => new AccountCreated(id, ((CreateAccount)source).CustomerName))); + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(id, ((CreateAccount)source).CustomerName))); } public void Deposit(decimal amount, ICorrelatedMessage source) @@ -174,7 +174,7 @@ public class Account : AggregateRoot throw new ArgumentException("Amount must be positive", nameof(amount)); // Create a new event from the source command - Apply(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); } public void Withdraw(decimal amount, ICorrelatedMessage source) @@ -189,7 +189,7 @@ public class Account : AggregateRoot throw new InvalidOperationException("Insufficient funds"); // Create a new event from the source command - Apply(MessageBuilder.From(source, () => new FundsWithdrawn(Id, amount))); + RaiseEvent(MessageBuilder.From(source, () => new FundsWithdrawn(Id, amount))); } public void Close(ICorrelatedMessage source) @@ -198,7 +198,7 @@ public class Account : AggregateRoot throw new InvalidOperationException("Account already closed"); // Create a new event from the source command - Apply(MessageBuilder.From(source, () => new AccountClosed(Id))); + RaiseEvent(MessageBuilder.From(source, () => new AccountClosed(Id))); } private void Apply(AccountCreated @event) diff --git a/todo-pr-169.md b/todo-pr-169.md index 35308124..401c5719 100644 --- a/todo-pr-169.md +++ b/todo-pr-169.md @@ -17,9 +17,9 @@ This todo list contains items that need to be addressed for PR 169, which adds c ## Code Example Corrections -- [ ] Fix the use of `Apply()` vs `Raise()` methods in aggregates (Apply methods are for rehydration, not for creating new events) -- [ ] Update examples in event.md to use `Raise(new AccountCreated(...))` instead of `Apply(...)` -- [ ] Review all code examples for technical accuracy +- [x] Fix the use of `Apply()` vs `RaiseEvent()` methods in aggregates (Apply methods are for rehydration, not for creating new events) +- [x] Update examples in event.md to use `RaiseEvent(new AccountCreated(...))` instead of `Apply(...)` +- [x] Review all code examples for technical accuracy in command.md and message-builder.md - [ ] Add more real-world examples to illustrate concepts ## Architecture Documentation Improvements From b23bbd700440bb7902df053a3884acb8e467aff0 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 18:13:22 -0400 Subject: [PATCH 17/41] Enhance architecture documentation with detailed CQRS and Event Sourcing explanations and diagrams --- docs/architecture.md | 349 +++++++++++++++++++++++++++++++++++++++---- todo-pr-169.md | 8 +- 2 files changed, 326 insertions(+), 31 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index e5674789..ff8b15d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,45 +110,233 @@ Reactive Domain is built on several key design principles and patterns: ### 1. Event Sourcing -Event sourcing is the core pattern in Reactive Domain, where: +Event sourcing is the core pattern in Reactive Domain, where the state of an entity is determined by a sequence of events rather than by its current state alone. This approach provides a complete audit trail and enables powerful temporal queries and analytics. -- The state of an entity is determined by a sequence of events -- Events are immutable and represent facts that have occurred -- The current state is derived by replaying events -- Events are stored in an append-only event store +#### Core Principles of Event Sourcing -Implementation in Reactive Domain: -- `IEventSource` interface defines the contract for event-sourced entities -- `AggregateRoot` provides a base implementation for domain aggregates -- Events are stored in EventStoreDB -- Repositories handle loading and saving aggregates +- **Events as the Source of Truth**: The sequence of events is the authoritative source of truth for the system +- **Immutable Event Records**: Events are immutable facts that have occurred and cannot be changed +- **State Reconstruction**: The current state is derived by replaying all events from the beginning +- **Append-Only Store**: Events are stored in an append-only event store, ensuring a complete history +- **Temporal Queries**: The ability to determine the state of the system at any point in time + +#### Event Sourcing Flow + +```mermaid +sequenceDiagram + participant Client + participant Command + participant Aggregate + participant EventStore + participant ReadModel + + Client->>Command: Issue Command + Command->>Aggregate: Load Current State + Aggregate->>EventStore: Get Event History + EventStore-->>Aggregate: Return Events + Aggregate->>Aggregate: Apply Events to Build State + Aggregate->>Aggregate: Execute Command Logic + Aggregate->>Aggregate: Generate New Event(s) + Aggregate->>EventStore: Store New Event(s) + EventStore->>ReadModel: Publish Event(s) + ReadModel->>ReadModel: Update State +``` + +#### Benefits of Event Sourcing + +1. **Complete Audit Trail**: Every change to the system is recorded as an event +2. **Temporal Queries**: Ability to reconstruct the state at any point in time +3. **Event Replay**: System can be rebuilt by replaying events +4. **Debugging**: Easier to debug by examining the sequence of events +5. **Business Intelligence**: Events provide valuable data for analytics +6. **Scalability**: Read and write operations can be scaled independently + +#### Implementation in Reactive Domain + +- **`IEventSource` Interface**: Defines the contract for event-sourced entities +- **`AggregateRoot` Class**: Provides a base implementation for domain aggregates +- **Event Store**: Events are stored in EventStoreDB, an optimized database for event sourcing +- **Repositories**: Handle loading and saving aggregates by reading and writing events +- **Event Handlers**: Process events to update read models and trigger side effects +- **Snapshots**: Optimize performance by periodically saving aggregate state ### 2. Command Query Responsibility Segregation (CQRS) -CQRS separates the command (write) and query (read) sides of an application: +Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates the command (write) and query (read) sides of an application. This separation allows each side to be optimized for its specific requirements, leading to better performance, scalability, and maintainability. -- Commands modify state and generate events -- Queries read from optimized read models -- Read models are built by processing events +#### Core Principles of CQRS -Implementation in Reactive Domain: -- Command handlers process commands and update aggregates -- Event handlers update read models -- Read models are optimized for specific query patterns -- Separate repositories for command and query sides +- **Separation of Concerns**: Write and read operations have different responsibilities and requirements +- **Command Side**: Focuses on processing commands, validating business rules, and generating events +- **Query Side**: Optimized for efficient data retrieval with denormalized read models +- **Eventual Consistency**: Read models may be eventually consistent with the write models +- **Independent Scaling**: Write and read sides can be scaled independently based on their specific loads + +#### CQRS Architecture + +```mermaid +graph TD + Client[Client Application] + + %% Command Side + CommandAPI[Command API] + CommandBus[Command Bus] + CommandHandler[Command Handler] + Aggregate[Aggregate Root] + EventStore[Event Store] + + %% Query Side + QueryAPI[Query API] + ReadModel[Read Model] + QueryHandler[Query Handler] + ReadDB[Read Database] + + %% Event Flow + EventHandler[Event Handler] + + %% Command Flow + Client -->|Commands| CommandAPI + CommandAPI -->|Routes| CommandBus + CommandBus -->|Dispatches| CommandHandler + CommandHandler -->|Loads/Updates| Aggregate + Aggregate -->|Stores Events| EventStore + + %% Query Flow + Client -->|Queries| QueryAPI + QueryAPI -->|Routes| QueryHandler + QueryHandler -->|Reads from| ReadModel + ReadModel -->|Stored in| ReadDB + + %% Event Flow + EventStore -->|Publishes Events| EventHandler + EventHandler -->|Updates| ReadModel + + %% Styling + classDef commandSide fill:#f96,stroke:#333,stroke-width:2px + classDef querySide fill:#9cf,stroke:#333,stroke-width:2px + classDef eventFlow fill:#fc9,stroke:#333,stroke-width:2px + + class CommandAPI,CommandBus,CommandHandler,Aggregate,EventStore commandSide + class QueryAPI,ReadModel,QueryHandler,ReadDB querySide + class EventHandler eventFlow +``` + +#### Benefits of CQRS + +1. **Optimized Performance**: Each side can be optimized for its specific requirements +2. **Scalability**: Write and read sides can be scaled independently +3. **Flexibility**: Read models can be tailored for specific query patterns +4. **Simplified Models**: Command models focus on business rules, read models on query efficiency +5. **Maintainability**: Clearer separation of concerns makes the system easier to maintain +6. **Evolvability**: Read models can evolve independently of the write models + +#### Implementation in Reactive Domain + +- **Command Handlers**: Process commands and update aggregates +- **Event Handlers**: Update read models based on events from the write side +- **Read Models**: Optimized for specific query patterns using `ReadModelBase` +- **Command Bus**: Routes commands to the appropriate handlers +- **Event Bus**: Publishes events to subscribers +- **Separate Repositories**: Different repositories for command and query sides +- **Correlation Tracking**: Ensures traceability between commands, events, and read model updates ### 3. Domain-Driven Design (DDD) -Reactive Domain supports DDD principles: +Domain-Driven Design (DDD) is a software development approach that focuses on creating a rich, expressive model of the business domain. Reactive Domain provides first-class support for DDD principles, enabling developers to build software that accurately reflects the business domain and its rules. -- Aggregates encapsulate business rules and enforce invariants -- Entities have identity and lifecycle -- Value objects are immutable and have no identity -- Domain events represent significant state changes -- Repositories provide access to aggregates +#### Core DDD Concepts in Reactive Domain -Implementation in Reactive Domain: -- `AggregateRoot` supports DDD aggregates +```mermaid +graph TD + Domain[Domain Model] --> BC[Bounded Context] + BC --> Agg[Aggregate] + Agg --> AR[Aggregate Root] + Agg --> E[Entity] + Agg --> VO[Value Object] + Domain --> DE[Domain Event] + Domain --> Repo[Repository] + Domain --> Service[Domain Service] + + classDef core fill:#f9f,stroke:#333,stroke-width:2px + class AR,DE,Repo core +``` + +#### Key DDD Elements + +1. **Bounded Context**: A logical boundary within which a particular domain model is defined and applicable + - Reactive Domain supports multiple bounded contexts with separate models + - Each bounded context can have its own event streams and read models + +2. **Aggregates**: Clusters of domain objects treated as a single unit for data changes + - **Aggregate Root**: The entry point to the aggregate, responsible for maintaining invariants + - **Entities**: Objects with identity and lifecycle, distinguished by their ID + - **Value Objects**: Immutable objects defined by their attributes, with no identity + +3. **Domain Events**: Record of something significant that happened in the domain + - Represent state changes in the system + - Provide a history of changes to aggregates + - Enable communication between bounded contexts + +4. **Repositories**: Provide access to aggregates + - Abstract the underlying storage mechanism + - Handle loading and saving aggregates + - Enforce aggregate boundaries + +5. **Domain Services**: Encapsulate domain operations that don't naturally fit within an entity + - Coordinate operations across multiple aggregates + - Implement complex business processes + - Provide domain-specific functionality + +#### DDD and Event Sourcing Integration + +```mermaid +sequenceDiagram + participant Client + participant AggregateRoot + participant Repository + participant EventStore + + Client->>AggregateRoot: Execute Domain Operation + AggregateRoot->>AggregateRoot: Validate Business Rules + AggregateRoot->>AggregateRoot: Generate Domain Event + AggregateRoot->>Repository: Save + Repository->>EventStore: Append Event + EventStore-->>Repository: Confirm + Repository-->>AggregateRoot: Return + AggregateRoot-->>Client: Operation Result +``` + +#### Implementation in Reactive Domain + +- **`AggregateRoot` Class**: Base class for implementing aggregate roots + - Enforces business rules and invariants + - Manages the lifecycle of the aggregate + - Raises domain events in response to commands + - Applies events to update state + +- **Domain Events**: Implemented as immutable classes + - Represent significant state changes + - Contain all data needed to understand what happened + - Support event versioning for schema evolution + +- **Repositories**: Provide access to aggregates + - `IRepository` interface for aggregate repositories + - Event-sourced implementation using EventStoreDB + - Support for optimistic concurrency control + +- **Domain Services**: Implemented as classes that coordinate operations + - Process managers for complex workflows + - Saga pattern for distributed transactions + - Domain-specific services for specialized operations + +#### Benefits of DDD with Reactive Domain + +1. **Alignment with Business**: Models closely reflect the business domain +2. **Expressive Models**: Rich domain models capture complex business rules +3. **Maintainability**: Clear boundaries and responsibilities +4. **Flexibility**: Ability to evolve the model as the domain changes +5. **Scalability**: Natural fit with CQRS and Event Sourcing +6. **Testability**: Easy to test business rules in isolation - Events represent domain events - Repositories provide aggregate persistence - Value objects can be used as event properties @@ -168,6 +356,113 @@ Implementation in Reactive Domain: - Message handlers process messages and perform actions - Correlation and causation tracking links related messages +### 4. CQRS and Event Sourcing Integration + +One of the most powerful aspects of Reactive Domain is how it seamlessly integrates Command Query Responsibility Segregation (CQRS) with Event Sourcing to create a robust, scalable, and maintainable architecture. This integration provides a comprehensive solution for building complex, event-driven systems. + +#### How CQRS and Event Sourcing Complement Each Other + +```mermaid +graph TD + subgraph "Command Side (Write Model)" + Command[Command] --> CommandHandler[Command Handler] + CommandHandler --> AggregateRoot[Aggregate Root] + AggregateRoot --> Event[Domain Event] + Event --> EventStore[Event Store] + end + + subgraph "Query Side (Read Model)" + EventStore --> EventHandler[Event Handler] + EventHandler --> ReadModel[Read Model] + ReadModel --> QueryHandler[Query Handler] + QueryHandler --> Query[Query Result] + end + + classDef commandSide fill:#f96,stroke:#333,stroke-width:2px + classDef querySide fill:#9cf,stroke:#333,stroke-width:2px + classDef shared fill:#fc9,stroke:#333,stroke-width:2px + + class Command,CommandHandler,AggregateRoot commandSide + class ReadModel,QueryHandler,Query querySide + class Event,EventStore,EventHandler shared +``` + +#### Key Integration Points + +1. **Events as the Integration Mechanism** + - Events generated by the command side are the source of truth + - The query side consumes these events to build read models + - Events provide a clean, decoupled integration between the two sides + +2. **Event Store as the Central Hub** + - The event store serves as both the write-side database and the source for read-model updates + - It provides persistence, publication, and subscription capabilities + - It ensures that all read models eventually reflect all events + +3. **Eventual Consistency Model** + - Read models are eventually consistent with the write model + - This allows for independent scaling and optimization of each side + - The system can continue to function even if read models lag behind + +#### Complete Flow in Reactive Domain + +```mermaid +sequenceDiagram + participant Client + participant CommandBus + participant CommandHandler + participant AggregateRoot + participant Repository + participant EventStore + participant EventBus + participant EventHandler + participant ReadModel + participant QueryHandler + + Client->>CommandBus: Send Command + CommandBus->>CommandHandler: Route Command + CommandHandler->>Repository: Get Aggregate + Repository->>EventStore: Load Events + EventStore-->>Repository: Return Events + Repository->>AggregateRoot: Apply Events + AggregateRoot->>AggregateRoot: Build Current State + AggregateRoot->>AggregateRoot: Execute Command Logic + AggregateRoot->>AggregateRoot: Validate Business Rules + AggregateRoot->>AggregateRoot: Generate Domain Event + AggregateRoot->>Repository: Save + Repository->>EventStore: Append Event + EventStore-->>Repository: Confirm + Repository-->>CommandHandler: Return Result + CommandHandler-->>Client: Command Result + + EventStore->>EventBus: Publish Event + EventBus->>EventHandler: Notify Subscribers + EventHandler->>ReadModel: Update Read Model + ReadModel->>ReadModel: Apply Event Data + + Client->>QueryHandler: Send Query + QueryHandler->>ReadModel: Get Data + ReadModel-->>QueryHandler: Return Data + QueryHandler-->>Client: Query Result +``` + +#### Benefits of the Integrated Approach + +1. **Separation with Coordination**: Clear separation of concerns while maintaining a coordinated system +2. **Complete Audit Trail**: Every state change is recorded as an event +3. **Scalability**: Each side can be scaled independently based on its specific load +4. **Flexibility**: Read models can be tailored for specific query patterns +5. **Resilience**: The system can continue to function even if parts of it fail +6. **Evolution**: The system can evolve over time without breaking existing functionality + +#### Implementation Considerations + +- **Correlation and Causation**: Track the relationship between commands and events +- **Idempotent Event Handlers**: Ensure that applying the same event multiple times is safe +- **Versioning Strategy**: Plan for event schema evolution +- **Consistency Boundaries**: Define clear aggregate boundaries to maintain consistency +- **Read Model Rebuilding**: Design for the ability to rebuild read models from event streams + ### 5. Reactive Programming Reactive Domain embraces reactive programming principles: diff --git a/todo-pr-169.md b/todo-pr-169.md index 401c5719..8464f706 100644 --- a/todo-pr-169.md +++ b/todo-pr-169.md @@ -24,10 +24,10 @@ This todo list contains items that need to be addressed for PR 169, which adds c ## Architecture Documentation Improvements -- [ ] Add more detailed explanations of the CQRS pattern -- [ ] Enhance documentation of Event Sourcing principles -- [ ] Include diagrams showing the flow of commands and events -- [ ] Document the relationship between different architectural components +- [x] Add more detailed explanations of the CQRS pattern (added comprehensive section with core principles and benefits) +- [x] Enhance documentation of Event Sourcing principles (expanded with detailed explanations and flow diagrams) +- [x] Include diagrams showing the flow of commands and events (added multiple mermaid diagrams) +- [x] Document the relationship between different architectural components (added new section on CQRS and Event Sourcing integration) ## Terminology and Consistency From eae813ec45299d26ba96a7e8997380172f6d3fdb Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 18:23:58 -0400 Subject: [PATCH 18/41] Improve terminology consistency and technical accuracy across documentation files - Enhanced glossary with more precise definitions for key terms - Clarified the relationship between RaiseEvent() and Apply() methods - Standardized terminology across all documentation files - Improved component relationship descriptions - Added detailed best practices for each component - Ensured consistent event handling descriptions --- docs/api-reference/types/aggregate-root.md | 17 ++++++---- docs/api-reference/types/command.md | 34 ++++++++++++------- docs/api-reference/types/event.md | 12 +++++-- .../types/icorrelated-message.md | 12 ++++--- docs/api-reference/types/ievent-source.md | 33 +++++++++++++++++- docs/api-reference/types/message-builder.md | 10 +++--- docs/api-reference/types/read-model-base.md | 6 ++-- docs/glossary.md | 22 +++++++++--- 8 files changed, 106 insertions(+), 40 deletions(-) diff --git a/docs/api-reference/types/aggregate-root.md b/docs/api-reference/types/aggregate-root.md index 2916395f..40f5ee41 100644 --- a/docs/api-reference/types/aggregate-root.md +++ b/docs/api-reference/types/aggregate-root.md @@ -137,14 +137,17 @@ public void Save(AggregateRoot aggregate) ### RaiseEvent -Raises an event, which will be recorded and applied to the aggregate. This is the primary method for changing the state of an aggregate in an event-sourced system. +Raises an event, which will be recorded and applied to the aggregate. This is the primary method for creating and handling new events in an event-sourced system. When called, `RaiseEvent()` does two things: + +1. It applies the event to the aggregate's state by calling the appropriate `Apply()` method +2. It records the event for persistence in the event store ```csharp protected void RaiseEvent(object @event); ``` **Parameters**: -- `event` (`System.Object`): The event to raise. +- `event` (`System.Object`): The event to raise. This is typically created using the `MessageBuilder` to ensure proper correlation tracking. **Example**: ```csharp @@ -247,8 +250,8 @@ public void Save(AggregateRoot aggregate) The `AggregateRoot` class is designed to be subclassed by domain aggregates. Subclasses should: -1. Define private `Apply` methods for each event type to update the aggregate's state -2. Use the `RaiseEvent` method to record and apply events when handling commands +1. Define private `Apply` methods for each event type to update the aggregate's state (these are event handlers) +2. Use the `RaiseEvent` method to create, record, and apply new events when handling commands 3. Define public methods that represent domain operations and enforce business rules 4. Keep the aggregate's state private and expose it through controlled methods @@ -407,9 +410,9 @@ public class Account : AggregateRoot 2. **Public State Modification**: Don't allow direct modification of aggregate state from outside 3. **Missing Business Rules**: Ensure all business rules are enforced in command methods 4. **Ignoring Version Conflicts**: Always handle optimistic concurrency exceptions properly -5. **Complex Apply Methods**: Keep event handlers (Apply methods) simple and focused -6. **Side Effects in Apply Methods**: Avoid side effects like I/O operations in Apply methods -7. **Circular Event References**: Avoid raising events from within Apply methods +5. **Complex Apply Methods**: Keep event handlers (Apply methods) simple and focused on updating state +6. **Side Effects in Apply Methods**: Avoid side effects like I/O operations in Apply methods as they run during both event replay and new event creation +7. **Circular Event References**: Never call `RaiseEvent()` from within `Apply()` methods as this creates an infinite loop ## Inheritance Hierarchy diff --git a/docs/api-reference/types/command.md b/docs/api-reference/types/command.md index 62ee6216..d1bfdfd2 100644 --- a/docs/api-reference/types/command.md +++ b/docs/api-reference/types/command.md @@ -6,9 +6,9 @@ ## Overview -Commands in Reactive Domain represent requests for the system to perform an action. They are part of the write side of the CQRS pattern and typically result in state changes. The `Command` base class provides common functionality for all command implementations, including correlation and causation tracking. +Commands in Reactive Domain represent requests for the system to perform an action. They are part of the write side of the CQRS pattern and typically result in state changes. Commands are named in the imperative form (e.g., `CreateAccount`, `DepositFunds`) to emphasize that they represent intentions rather than facts. The `Command` base class provides common functionality for all command implementations, including correlation and causation tracking. -In the Command Query Responsibility Segregation (CQRS) pattern, commands represent intentions to change the system state. Unlike events, which represent facts that have occurred, commands can be rejected if they violate business rules or if the system is in an inappropriate state to handle them. +In the Command Query Responsibility Segregation (CQRS) pattern, commands represent intentions to change the system state. Unlike events, which represent facts that have occurred, commands can be rejected if they violate business rules or if the system is in an inappropriate state to handle them. When a command is processed successfully, it typically results in one or more events being raised via the `RaiseEvent()` method in the aggregate. ## Class Definition @@ -392,7 +392,17 @@ public class CommandBusExample ## Integration with Aggregates -Commands are used to modify aggregates, which then produce events: +Commands are used to modify aggregates, which then produce events. The typical flow is: + +1. A command is sent to a command handler +2. The command handler loads the appropriate aggregate +3. The command handler calls a method on the aggregate, passing the command +4. The aggregate validates the command against its current state and business rules +5. If valid, the aggregate calls `RaiseEvent()` to create one or more events +6. The `RaiseEvent()` method both updates the aggregate's state via `Apply()` methods and records the events +7. The command handler saves the aggregate, persisting the new events + +This pattern ensures that all state changes are captured as events and that business rules are enforced consistently: ```csharp public class Account : AggregateRoot @@ -694,15 +704,15 @@ public class CreateAccountHandlerTests ## Best Practices -1. **Immutable Commands**: Make all command properties read-only to ensure they cannot be changed after creation -2. **Descriptive Names**: Use verb-noun naming convention (e.g., `CreateAccount`, `DepositFunds`) to clearly communicate intent -3. **Minimal Data**: Include only the data needed to perform the action, avoiding unnecessary information -4. **Use MessageBuilder**: Always use `MessageBuilder` to create commands with proper correlation tracking -5. **Validation**: Validate commands before processing them to ensure they meet business rules -6. **Single Responsibility**: Each command should represent a single action or intention -7. **Versioning Strategy**: Plan for command versioning from the beginning -8. **Error Handling**: Implement proper error handling and reporting for command failures -9. **Logging**: Log command processing with correlation IDs for traceability +1. **Immutable Commands**: Make all command properties read-only to prevent modification after creation +2. **Imperative Naming**: Use imperative verb naming convention (e.g., `CreateAccount`, `DepositFunds`) +3. **Command Validation**: Validate commands early in the processing pipeline +4. **Single Responsibility**: Each command should represent a single action or intention +5. **Use MessageBuilder**: Use `MessageBuilder` to create commands with proper correlation information +6. **Command Documentation**: Document the purpose, parameters, and possible outcomes of each command +7. **Versioning Strategy**: Plan for command schema evolution to handle changes over time +8. **Proper Event Creation**: Always use `RaiseEvent()` in aggregate methods that handle commands to create events +9. **Business Rule Enforcement**: Enforce all business rules in command handlers or aggregate methods before raising events 10. **Testing**: Thoroughly test command handlers with both valid and invalid commands ## Common Pitfalls diff --git a/docs/api-reference/types/event.md b/docs/api-reference/types/event.md index 156c90c2..e55e423d 100644 --- a/docs/api-reference/types/event.md +++ b/docs/api-reference/types/event.md @@ -6,7 +6,7 @@ ## Overview -Events in Reactive Domain represent immutable facts that have occurred in the system. They are the historical record of changes to the domain and form the basis of event sourcing. The `Event` base class provides common functionality for all event implementations, including correlation and causation tracking, which is essential for debugging and auditing in distributed systems. +Events in Reactive Domain represent immutable facts that have occurred in the system. They are the historical record of changes to the domain and form the basis of event sourcing. Events are named in the past tense (e.g., `AccountCreated`, `FundsDeposited`) to emphasize that they represent facts that have already occurred. The `Event` base class provides common functionality for all event implementations, including correlation and causation tracking, which is essential for debugging and auditing in distributed systems. ## Class Definition @@ -120,7 +120,11 @@ public class AccountCreatedHandler : IEventHandler ## Integration with Aggregates -Events are produced by aggregates in response to commands. This is a core pattern in Domain-Driven Design and CQRS: +Events are produced by aggregates in response to commands using the `RaiseEvent()` method. This is a core pattern in Domain-Driven Design and CQRS. When an aggregate raises an event: + +1. The event is applied to update the aggregate's state via the appropriate `Apply()` method +2. The event is recorded for persistence in the event store +3. Once persisted, the event can be published to event handlers and projections ```csharp public class Account : AggregateRoot @@ -168,7 +172,7 @@ public class Account : AggregateRoot ## Event Sourcing -Events are the foundation of event sourcing, where the state of an aggregate is reconstructed by replaying events: +Events are the foundation of event sourcing, where the state of an aggregate is reconstructed by replaying events. This process, often called rehydration, applies each event in sequence to rebuild the aggregate's state: ```csharp public void LoadFromHistory(IEnumerable history) @@ -205,6 +209,8 @@ private void Dispatch(IEvent @event) 5. **Versioning Strategy**: Plan for event schema evolution to handle changes over time 6. **Meaningful Events**: Design events to represent meaningful business occurrences, not just data changes 7. **Event Documentation**: Document the purpose and content of each event type for better understanding +8. **Use RaiseEvent()**: Always use the `RaiseEvent()` method in aggregates to create new events, not direct calls to `Apply()` +9. **Idempotent Event Handlers**: Ensure `Apply()` methods are idempotent as they may be called multiple times during event replay ## Common Pitfalls diff --git a/docs/api-reference/types/icorrelated-message.md b/docs/api-reference/types/icorrelated-message.md index fa387076..b3bb66b0 100644 --- a/docs/api-reference/types/icorrelated-message.md +++ b/docs/api-reference/types/icorrelated-message.md @@ -6,7 +6,7 @@ ## Overview -In complex event-driven systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `ICorrelatedMessage` interface provides a standard way to track correlation and causation across message flows, enabling developers to trace the complete path of a business transaction through the system. +In complex event-driven systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `ICorrelatedMessage` interface provides a standard way to track correlation and causation across message flows, enabling developers to trace the complete path of a business transaction through the system. This is particularly important in event-sourced systems where commands lead to events which may trigger other commands, creating a chain of related messages that should be traceable. ## Interface Definition @@ -40,14 +40,15 @@ The causation ID establishes a direct cause-and-effect relationship between mess Consider the following message flow in a banking application: 1. A client sends a `CreateAccount` command (ID: A, CorrelationID: A, CausationID: A) -2. The command handler processes the command and creates an `AccountCreated` event (ID: B, CorrelationID: A, CausationID: A) +2. The command handler processes the command and the aggregate calls `RaiseEvent()` with an `AccountCreated` event (ID: B, CorrelationID: A, CausationID: A) 3. An event handler processes the event and sends a `SendWelcomeEmail` command (ID: C, CorrelationID: A, CausationID: B) 4. The email service processes the command and creates an `EmailSent` event (ID: D, CorrelationID: A, CausationID: C) In this flow: - All messages share the same correlation ID (A), indicating they are part of the same business transaction -- Each message's causation ID points to the ID of the message that caused it, creating a chain of causality -- This chain allows for complete tracing of the transaction from initiation to completion +- Each message's causation ID points to the ID of the message that directly caused it, creating a chain of causality +- The `MessageBuilder` class is used with the `RaiseEvent()` method to ensure proper correlation tracking +- This chain allows for complete tracing of the transaction from initiation to completion, which is invaluable for debugging and auditing ## Usage @@ -149,6 +150,9 @@ public class AccountService 5. **Consistent Implementation**: Ensure all messages in your system implement `ICorrelatedMessage` consistently 6. **Documentation**: Document the correlation flow in your system for better understanding 7. **Testing**: Test correlation chains to ensure they are maintained correctly +8. **Use with RaiseEvent()**: Always use `MessageBuilder` when creating events to be raised with the `RaiseEvent()` method +9. **Distributed Tracing**: Integrate with distributed tracing systems by including correlation IDs in trace contexts +10. **Cross-Service Correlation**: Ensure correlation IDs are preserved when messages cross service boundaries ## Common Pitfalls diff --git a/docs/api-reference/types/ievent-source.md b/docs/api-reference/types/ievent-source.md index dfb28f70..1fdd5315 100644 --- a/docs/api-reference/types/ievent-source.md +++ b/docs/api-reference/types/ievent-source.md @@ -6,7 +6,20 @@ The `IEventSource` interface is the cornerstone of event sourcing in Reactive Domain. It represents a source of events from the perspective of restoring from and taking events, and is primarily used by infrastructure code. This interface defines the contract that all event-sourced entities must implement, providing the foundation for reconstructing entity state from a sequence of events. -In event sourcing, the state of an entity is determined by the sequence of events that have occurred, rather than by its current state. The `IEventSource` interface enables this pattern by providing methods to restore an entity from its event history, update it with new events, and extract events that have been recorded but not yet persisted. +In event sourcing, the state of an entity is determined by the sequence of events that have occurred, rather than by its current state. The `IEventSource` interface enables this pattern by providing methods to restore an entity from its event history, update it with new events, and extract events that have been recorded but not yet persisted. Implementations typically use private `Apply()` methods to handle specific event types and update the entity's state. + +## Implementation Notes + +1. The `IEventSource` interface is typically implemented by aggregate roots in a domain-driven design context +2. Implement private `Apply` methods for each event type to update the entity's state + - These methods handle the application of events to update the entity's state + - They are called during both event replay (via `RestoreFromEvents`) and when new events are created + - They should be idempotent (applying the same event multiple times should not cause issues) + - They should not have side effects like I/O operations + - They should never raise new events to avoid infinite loops +3. Use an `EventRecorder` to track events that have been applied but not yet persisted +4. Ensure that the `ExpectedVersion` is properly maintained to support optimistic concurrency +5. When creating new events, use the `RaiseEvent()` method (in `AggregateRoot`) which both applies the event to update state and records it for persistence **Namespace**: `ReactiveDomain` **Assembly**: `ReactiveDomain.Core.dll` @@ -141,6 +154,7 @@ public class Account : IEventSource } } + // This is the generic Apply method that dispatches to specific event handlers private void Apply(object @event) { switch (@event) @@ -162,6 +176,23 @@ public class Account : IEventSource } } + // Alternatively, many implementations use specific Apply methods for each event type + // These methods are called by the generic Apply method using reflection + private void Apply(AccountCreated @event) + { + // Initialize account properties + } + + private void Apply(AmountDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(AmountWithdrawn @event) + { + _balance -= @event.Amount; + } + // Implementation of other IEventSource members } diff --git a/docs/api-reference/types/message-builder.md b/docs/api-reference/types/message-builder.md index 35fa4d73..7146de8d 100644 --- a/docs/api-reference/types/message-builder.md +++ b/docs/api-reference/types/message-builder.md @@ -6,7 +6,7 @@ ## Overview -In event-sourced systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `MessageBuilder` factory provides a consistent way to create messages with properly set correlation and causation IDs. +In event-sourced systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `MessageBuilder` factory provides a consistent way to create messages with properly set correlation and causation IDs. It is particularly important when using the `RaiseEvent()` method in aggregates to ensure that events maintain their correlation with the original commands. Correlation tracking is essential in distributed systems where a single business transaction might span multiple services, processes, or message handlers. The `MessageBuilder` ensures that all messages related to the same business transaction are properly linked, making it possible to trace the entire transaction flow. @@ -112,9 +112,11 @@ public void Handle(CreateAccount command) _repository.Save(account, command); } -// 3. Aggregate applies an event +// 3. Aggregate raises an event using RaiseEvent() public Account(Guid id, ICorrelatedMessage source) : base(id) { + // Create a new event that maintains correlation with the source command + // and raise it to update state and record it for persistence RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(id, source.CustomerName, source.InitialBalance))); // Event MsgId: B, CorrelationId: A, CausationId: A } @@ -372,10 +374,6 @@ public void Handle(CreateAccount command) 4. **Event-Command Flow**: Use `From()` to create commands from events in process managers 5. **Include Correlation IDs in Logs**: Add correlation and causation IDs to log messages 6. **Correlation-Aware Repositories**: Use repositories that preserve correlation information -7. **Consistent Naming**: Use consistent naming for correlation-related concepts -8. **Documentation**: Document the correlation flow in complex business processes -9. **Testing**: Test correlation chains to ensure they're maintained correctly -10. **Monitoring**: Monitor correlation chains for breaks or inconsistencies ## Common Pitfalls diff --git a/docs/api-reference/types/read-model-base.md b/docs/api-reference/types/read-model-base.md index 54f2c11a..192d1184 100644 --- a/docs/api-reference/types/read-model-base.md +++ b/docs/api-reference/types/read-model-base.md @@ -8,7 +8,7 @@ Read models in Reactive Domain represent the query side of the CQRS pattern. They are optimized for querying and provide a denormalized view of the domain data. The `ReadModelBase` class provides a common foundation for implementing read models with consistent behavior. -In a CQRS architecture, read models are separate from the write models (aggregates) and are specifically designed to efficiently answer queries. They typically contain denormalized data that is shaped according to the specific needs of the UI or API consumers. +In a CQRS architecture, read models are separate from the write models (aggregates) and are specifically designed to efficiently answer queries. They typically contain denormalized data that is shaped according to the specific needs of the UI or API consumers. Read models are updated by event handlers in response to domain events raised by aggregates, creating an eventually consistent view of the domain state. ## Class Definition @@ -126,7 +126,7 @@ public class CustomerDashboard : ReadModelBase ## Integration with Event Handlers -Read models are typically updated by event handlers that subscribe to domain events. Here's a comprehensive example showing how to update read models in response to various events: +Read models are typically updated by event handlers that subscribe to domain events raised by aggregates through the `RaiseEvent()` method. This creates an eventually consistent projection of the domain state optimized for querying. Here's a comprehensive example showing how to update read models in response to various events: ```csharp public class AccountEventHandler : @@ -347,6 +347,8 @@ public class SqlReadModelRepository : IReadModelRepository where T : ReadM 8. **Rebuild Capability**: Design your system to be able to rebuild read models from event streams when needed 9. **Caching Strategy**: Implement appropriate caching for frequently accessed read models 10. **Monitoring**: Add monitoring to track the lag between write model updates and read model updates +11. **Idempotent Updates**: Ensure read model updates are idempotent, as events may be processed multiple times +12. **Event Handler Organization**: Organize event handlers by the read models they update rather than by event type ## Common Pitfalls diff --git a/docs/glossary.md b/docs/glossary.md index 181d326c..4983e0fb 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -19,10 +19,10 @@ This glossary provides definitions for key terms used in Reactive Domain and eve A cluster of domain objects treated as a single unit for data changes. In event sourcing, aggregates are the primary entities that generate events. ### Command -A request for the system to perform an action. Commands are named in the imperative form (e.g., "CreateAccount", "DepositMoney"). +A request for the system to perform an action that may change state. Commands are named in the imperative form (e.g., "CreateAccount", "DepositMoney") and represent user intent. In Reactive Domain, commands implement the `ICommand` interface and typically extend the `Command` base class, which provides correlation tracking capabilities. ### Event -An immutable record of something that happened in the system. Events are named in the past tense (e.g., "AccountCreated", "MoneyDeposited"). +An immutable record of something that happened in the system. Events are named in the past tense (e.g., "AccountCreated", "MoneyDeposited") and represent facts that have occurred. In Reactive Domain, events implement the `IEvent` interface and typically extend the `Event` base class, which provides correlation tracking capabilities. Events are the primary mechanism for state changes in an event-sourced system. ### Event Store A specialized database designed to store events. Reactive Domain uses EventStoreDB as its event store. @@ -34,7 +34,7 @@ A sequence of events for a particular aggregate, ordered by version. A component that transforms events into queryable state. Projections are used to build read models. ### Read Model -A representation of data optimized for querying. Read models are built by projections from events. +A representation of data optimized for querying. Read models are built by projections from events and are typically denormalized for query efficiency. In Reactive Domain, read models often extend the `ReadModelBase` class, which provides common functionality for read models. Read models are part of the query side in CQRS and are eventually consistent with the write model. ### Snapshot A point-in-time capture of an aggregate's state, used to optimize loading performance. @@ -96,7 +96,7 @@ A programming paradigm that focuses on asynchronous data streams and the propaga ## Reactive Domain-Specific Terminology ### AggregateRoot -The base class for aggregates in Reactive Domain, providing common functionality for event sourcing. +The base class for aggregates in Reactive Domain, providing common functionality for event sourcing. `AggregateRoot` implements the `IEventSource` interface and provides methods for raising events (`RaiseEvent()`) and applying events to reconstruct state (`Apply()`). It enforces business rules and maintains invariants within the aggregate boundary. ### Correlation ID An identifier that tracks a business transaction across multiple messages. @@ -128,13 +128,25 @@ A component that provides a connection to EventStoreDB. ### StreamStoreRepository A repository implementation that stores and retrieves events from EventStoreDB. +### RaiseEvent +A method in the `AggregateRoot` class used to generate and record new events in response to commands. This is the primary mechanism for creating events in Reactive Domain. Events raised using this method are both applied to the aggregate to update its state and recorded for persistence. + +### Apply +A method pattern used in event-sourced aggregates to handle the application of events to the aggregate's state. In Reactive Domain, private `Apply(EventType)` methods are used for event handlers that update the aggregate's state during both event replay (rehydration) and when new events are raised. + +### MessageBuilder +A factory class in Reactive Domain that creates correlated messages, ensuring proper tracking of correlation and causation IDs across the system. It provides methods for creating new message chains and continuing existing chains. + +### ReadModelBase +A base class for read models in Reactive Domain that provides common functionality, including identity management. Read models extending this class are typically updated by event handlers in response to domain events. + ## Related Concepts ### Idempotence The property of certain operations in mathematics and computer science whereby they can be applied multiple times without changing the result beyond the initial application. ### Eventual Consistency -A consistency model used in distributed computing to achieve high availability that informally guarantees that, if no new updates are made to a given data item, eventually all accesses to that item will return the last updated value. +A consistency model used in distributed computing to achieve high availability that informally guarantees that, if no new updates are made to a given data item, eventually all accesses to that item will return the last updated value. In CQRS systems, read models are eventually consistent with the write model, meaning there may be a delay between when an event is generated and when it is reflected in the read model. ### Optimistic Concurrency A concurrency control method that assumes conflicts are rare and allows operations to proceed without locking, but checks for conflicts before committing changes. From 21c77c4a49adf8630f3710f8684dd740882e885a Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 18:42:36 -0400 Subject: [PATCH 19/41] Complete API reference enhancements with comprehensive documentation for key interfaces and classes in Reactive Domain --- .../types/event-driven-state-machine.md | 174 +++++- docs/api-reference/types/event-recorder.md | 115 +++- docs/api-reference/types/icheckpoint-store.md | 574 ++++++++++++++++++ docs/api-reference/types/icommand-bus.md | 378 ++++++++++++ docs/api-reference/types/icommand-handler.md | 530 ++++++++++++++++ docs/api-reference/types/icommand.md | 467 ++++++++++++++ .../types/icorrelated-event-source.md | 251 ++++++++ .../types/icorrelated-repository.md | 257 +++++++- docs/api-reference/types/ievent-bus.md | 491 +++++++++++++++ docs/api-reference/types/ievent-handler.md | 164 +++++ docs/api-reference/types/ievent-processor.md | 507 ++++++++++++++++ docs/api-reference/types/ievent.md | 537 ++++++++++++++++ .../types/iread-model-repository.md | 287 +++++++++ docs/api-reference/types/irepository.md | 259 +++++++- docs/api-reference/types/isnapshot-source.md | 363 +++++++++++ docs/api-reference/types/process-manager.md | 266 ++++++++ todo-pr-169.md | 14 +- 17 files changed, 5602 insertions(+), 32 deletions(-) create mode 100644 docs/api-reference/types/icheckpoint-store.md create mode 100644 docs/api-reference/types/icommand-bus.md create mode 100644 docs/api-reference/types/icommand-handler.md create mode 100644 docs/api-reference/types/icommand.md create mode 100644 docs/api-reference/types/icorrelated-event-source.md create mode 100644 docs/api-reference/types/ievent-bus.md create mode 100644 docs/api-reference/types/ievent-handler.md create mode 100644 docs/api-reference/types/ievent-processor.md create mode 100644 docs/api-reference/types/ievent.md create mode 100644 docs/api-reference/types/iread-model-repository.md create mode 100644 docs/api-reference/types/isnapshot-source.md create mode 100644 docs/api-reference/types/process-manager.md diff --git a/docs/api-reference/types/event-driven-state-machine.md b/docs/api-reference/types/event-driven-state-machine.md index 6d1effa8..1c92af83 100644 --- a/docs/api-reference/types/event-driven-state-machine.md +++ b/docs/api-reference/types/event-driven-state-machine.md @@ -1,9 +1,20 @@ # EventDrivenStateMachine -[← Back to API Reference](../README.md) +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) The `EventDrivenStateMachine` is the base class for event-sourced entities in Reactive Domain. It provides the core functionality for routing events, recording state changes, and managing the event history of an entity. +## Overview + +In event-sourced systems, entities maintain their state by applying a sequence of events. The `EventDrivenStateMachine` implements this pattern by providing mechanisms for: + +1. **Event Routing**: Directing events to the appropriate handler methods +2. **Event Recording**: Tracking events that have been applied but not yet persisted +3. **State Reconstruction**: Rebuilding entity state by replaying historical events +4. **Version Management**: Tracking the version of the entity based on applied events + +The `EventDrivenStateMachine` serves as the foundation for `AggregateRoot`, which is the primary base class for domain entities in Reactive Domain. + ## Namespace ```csharp @@ -115,7 +126,7 @@ protected void Register(Type typeOfEvent, Action route) ### Raise(object) -Raises the specified event - applies it to this instance and records it in its history. +Raises the specified event - applies it to this instance and records it in its history. This method is typically called `RaiseEvent` in the `AggregateRoot` subclass for clarity. ```csharp protected void Raise(object @event) @@ -124,6 +135,14 @@ protected void Raise(object @event) **Parameters**: - `event` (`System.Object`): The event to apply and record. +**Remarks**: +When an event is raised: +1. The `OnEventRaised` method is called, allowing for customization of the event raising process +2. The event is routed to the appropriate handler method via the `EventRouter` +3. The event is recorded in the `EventRecorder` for later persistence + +This method is the core mechanism for changing the state of an event-sourced entity. + ## Protected Methods ### TakeEventStarted() @@ -157,10 +176,14 @@ protected virtual void OnEventRaised(object @event) The `EventDrivenStateMachine` is the foundation for implementing event-sourced entities in Reactive Domain. It's typically not used directly but through its subclass `AggregateRoot`. +### Basic Implementation + ```csharp public class Account : AggregateRoot { private decimal _balance; + private bool _isActive; + private string _accountNumber; public Account(Guid id) : base(id) { @@ -168,15 +191,67 @@ public class Account : AggregateRoot Register(Apply); Register(Apply); Register(Apply); + Register(Apply); } - public void Deposit(decimal amount) + // Command handler + public void CreateAccount(string accountNumber, ICorrelatedMessage source) { + if (_isActive) + throw new InvalidOperationException("Account already exists"); + + // Raise an event using MessageBuilder for correlation + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(Id, accountNumber))); + } + + // Command handler + public void Deposit(decimal amount, ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); // Raise an event - this will call Apply(FundsDeposited) and record the event - Raise(new FundsDeposited(Id, amount)); + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + } + + // Command handler + public void Withdraw(decimal amount, ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + // Raise an event - this will call Apply(FundsWithdrawn) and record the event + RaiseEvent(MessageBuilder.From(source, () => new FundsWithdrawn(Id, amount))); + } + + // Command handler + public void CloseAccount(ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Account is already closed"); + + if (_balance != 0) + throw new InvalidOperationException("Cannot close account with non-zero balance"); + + // Raise an event - this will call Apply(AccountClosed) and record the event + RaiseEvent(MessageBuilder.From(source, () => new AccountClosed(Id))); + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _isActive = true; + _accountNumber = @event.AccountNumber; + _balance = 0; } private void Apply(FundsDeposited @event) @@ -184,10 +259,79 @@ public class Account : AggregateRoot _balance += @event.Amount; } - // Other methods and event handlers... + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void Apply(AccountClosed @event) + { + _isActive = false; + } +} +``` + +### Using with a Repository + +The `EventDrivenStateMachine` is typically used with a repository that handles loading and saving the entity's events: + +```csharp +public class AccountService +{ + private readonly IRepository _repository; + + public AccountService(IRepository repository) + { + _repository = repository; + } + + public void HandleCreateAccount(CreateAccount command) + { + // Create a new account + var account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command); + + // Save the account (persists the events) + _repository.Save(account); + } + + public void HandleDeposit(DepositFunds command) + { + // Load the account + var account = _repository.GetById(command.AccountId); + + // Process the command + account.Deposit(command.Amount, command); + + // Save the account (persists the new events) + _repository.Save(account); + } } ``` +## Best Practices + +1. **Register Handlers in Constructor**: Always register event handlers in the constructor to ensure they're available when events are applied +2. **Command-Event Pattern**: Use command methods that validate business rules and raise events +3. **Private Apply Methods**: Keep event handlers (`Apply` methods) private to enforce that state changes only happen through events +4. **Idempotent Handlers**: Make event handlers idempotent so they can be safely replayed +5. **Validate Before Raising**: Validate business rules before raising events +6. **Use MessageBuilder**: Use `MessageBuilder` to create correlated events +7. **Avoid Side Effects**: Keep event handlers free of side effects like I/O operations +8. **Clear Error Messages**: Provide clear error messages when business rules are violated +9. **Consistent Naming**: Use consistent naming for command methods and event handlers +10. **Version Checking**: Use optimistic concurrency control with version checking when saving + +## Common Pitfalls + +1. **Missing Handlers**: Forgetting to register handlers for all event types +2. **Side Effects in Handlers**: Including side effects in event handlers can cause issues during replay +3. **Business Logic in Handlers**: Putting business logic in event handlers instead of command methods +4. **Circular Event References**: Raising events from within event handlers can cause infinite loops +5. **Ignoring Version Conflicts**: Not properly handling optimistic concurrency conflicts +6. **Large Aggregates**: Creating aggregates that handle too many responsibilities +7. **Direct State Modification**: Modifying state directly instead of through events + ## Remarks - The `EventDrivenStateMachine` implements the event sourcing pattern, where an entity's state is determined by a sequence of events. @@ -195,9 +339,21 @@ public class Account : AggregateRoot - It uses an `EventRouter` to route events to the appropriate handler methods. - When an event is raised using the `Raise` method, it is both applied to update the entity's state and recorded for later persistence. - The `TakeEvents` method is typically called by a repository when persisting the entity's changes. +- The separation of concerns between command methods (which enforce business rules) and event handlers (which update state) is a key aspect of the design. + +## Related Components + +- [AggregateRoot](aggregate-root.md): The main subclass of `EventDrivenStateMachine` used for domain entities +- [EventRecorder](event-recorder.md): Component used internally to record events +- [IEventSource](ievent-source.md): Interface implemented by `EventDrivenStateMachine` +- [IRepository](./irepository.md): Interface for repositories that load and save event-sourced entities +- [Command](./command.md): Messages that trigger state changes in event-sourced entities +- [Event](./event.md): Messages that represent state changes in event-sourced entities +- [MessageBuilder](./message-builder.md): Factory for creating correlated events -## See Also +--- -- [AggregateRoot](aggregate-root.md) -- [EventRecorder](event-recorder.md) -- [IEventSource](ievent-source.md) +**Navigation**: +- [← Previous: EventRecorder](./event-recorder.md) +- [↑ Back to Top](#eventdrivenstatemachine) +- [→ Next: AggregateRoot](./aggregate-root.md) diff --git a/docs/api-reference/types/event-recorder.md b/docs/api-reference/types/event-recorder.md index 46e466c5..ba74fdec 100644 --- a/docs/api-reference/types/event-recorder.md +++ b/docs/api-reference/types/event-recorder.md @@ -1,9 +1,15 @@ # EventRecorder -[← Back to API Reference](../README.md) +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) The `EventRecorder` class is responsible for recording events on behalf of an event source. It's a core component of the event sourcing infrastructure in Reactive Domain, used internally by the `EventDrivenStateMachine` and `AggregateRoot` classes to track and manage domain events. +## Overview + +In event-sourced systems, entities maintain their state by applying events. These events need to be recorded for persistence and later replay. The `EventRecorder` provides a simple but powerful mechanism for recording events that have been applied to an entity but not yet persisted to the event store. + +When an aggregate raises an event using the `RaiseEvent()` method, the event is both applied to update the aggregate's state and recorded by the `EventRecorder`. Later, when the aggregate is saved, the recorded events are retrieved using the `TakeEvents()` method and then persisted to the event store. + ## Namespace ```csharp @@ -76,13 +82,15 @@ public void Reset() The `EventRecorder` is primarily used internally by the `EventDrivenStateMachine` class, which is the base class for `AggregateRoot`. It's responsible for tracking the events that have been applied to an aggregate but not yet persisted to the event store. +### Within AggregateRoot + ```csharp // This is internal usage within EventDrivenStateMachine -protected void Raise(object @event) +protected void RaiseEvent(object @event) { OnEventRaised(@event); - Router.Route(@event); - _recorder.Record(@event); // Recording the event + Router.Route(@event); // Routes the event to the appropriate Apply method + _recorder.Record(@event); // Recording the event for later persistence } public object[] TakeEvents() @@ -96,15 +104,106 @@ public object[] TakeEvents() } ``` +### Custom Implementation Example + +While `EventRecorder` is typically used internally, understanding how it works can be valuable for custom implementations: + +```csharp +public class CustomEventSourcedEntity +{ + private readonly EventRecorder _recorder = new EventRecorder(); + private string _name; + private bool _isActive; + + public Guid Id { get; } + + public CustomEventSourcedEntity(Guid id) + { + Id = id; + } + + // Command handler + public void Activate(string name) + { + if (_isActive) + throw new InvalidOperationException("Already active"); + + RaiseEvent(new EntityActivated(Id, name)); + } + + // Internal event raising method + private void RaiseEvent(object @event) + { + Apply(@event); // Apply the event to update state + _recorder.Record(@event); // Record for persistence + } + + // Event handler + private void Apply(EntityActivated @event) + { + _name = @event.Name; + _isActive = true; + } + + // Get recorded events for persistence + public object[] GetUncommittedEvents() + { + var events = _recorder.RecordedEvents; + _recorder.Reset(); + return events; + } +} + +public class EntityActivated +{ + public Guid EntityId { get; } + public string Name { get; } + + public EntityActivated(Guid entityId, string name) + { + EntityId = entityId; + Name = name; + } +} +``` + +## Best Practices + +1. **Single Responsibility**: The `EventRecorder` should only be responsible for recording events, not applying them +2. **Reset After Persistence**: Always reset the recorder after taking events for persistence +3. **Error Handling**: Implement proper error handling around event recording +4. **Thread Safety**: Be aware that `EventRecorder` is not thread-safe by default +5. **Memory Management**: Be mindful of memory usage when recording large numbers of events +6. **Event Validation**: Validate events before recording them +7. **Correlation**: Ensure events maintain proper correlation information + +## Common Pitfalls + +1. **Forgetting to Reset**: Failing to reset the recorder after taking events can lead to duplicate events +2. **Recording Null Events**: Attempting to record null events will throw exceptions +3. **Memory Leaks**: Holding references to large event objects can cause memory issues +4. **Circular References**: Events with circular references can cause serialization problems + ## Remarks - The `EventRecorder` is a fundamental part of the event sourcing pattern implementation in Reactive Domain. - It maintains an in-memory list of events that have been applied to an aggregate but not yet persisted. - When `TakeEvents()` is called on an aggregate, it retrieves all recorded events from the `EventRecorder` and then resets it. - The `EventRecorder` is used in conjunction with the `EventRouter` to implement the full event sourcing behavior in aggregates. +- The separation of event application (via `EventRouter`) and event recording (via `EventRecorder`) follows the Single Responsibility Principle. + +## Related Components + +- [AggregateRoot](aggregate-root.md): Base class for domain entities that uses `EventRecorder` +- [EventDrivenStateMachine](event-driven-state-machine.md): Base class that provides event-driven behavior +- [IEventSource](ievent-source.md): Interface for event-sourced entities +- [Event](./event.md): Base class for domain events that are recorded +- [IRepository](./irepository.md): Interface for repositories that persist recorded events +- [ISnapshotSource](./isnapshot-source.md): Interface for entities that support snapshots -## See Also +--- -- [AggregateRoot](aggregate-root.md) -- [EventDrivenStateMachine](event-driven-state-machine.md) -- [IEventSource](ievent-source.md) +**Navigation**: +- [← Previous: ISnapshotSource](./isnapshot-source.md) +- [↑ Back to Top](#eventrecorder) +- [→ Next: EventDrivenStateMachine](./event-driven-state-machine.md) diff --git a/docs/api-reference/types/icheckpoint-store.md b/docs/api-reference/types/icheckpoint-store.md new file mode 100644 index 00000000..34baf1a8 --- /dev/null +++ b/docs/api-reference/types/icheckpoint-store.md @@ -0,0 +1,574 @@ +# ICheckpointStore Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `ICheckpointStore` interface defines the contract for components that store and retrieve checkpoints for event processors. Checkpoints are crucial in event-sourced systems as they track the position of event processors in event streams, enabling reliable and resumable event processing. + +In Reactive Domain, checkpoint stores provide the persistence mechanism that allows event processors to resume from where they left off after restarts or failures, ensuring that events are processed exactly once even in the face of system outages. + +## Checkpoints in Event Processing + +In event-sourced systems, checkpoints serve several critical purposes: + +1. **Resumable Processing**: Enabling event processors to resume from their last position after restarts +2. **Exactly-Once Processing**: Ensuring events are processed exactly once, even after failures +3. **Progress Tracking**: Monitoring the progress of event processors through event streams +4. **Gap Detection**: Identifying gaps in event processing +5. **Performance Optimization**: Avoiding reprocessing of events that have already been handled + +Checkpoint stores are essential infrastructure components that support the reliability and resilience of event-driven systems. + +**Namespace**: `ReactiveDomain.Messaging` +**Assembly**: `ReactiveDomain.Messaging.dll` + +```csharp +public interface ICheckpointStore +{ + Task GetCheckpointAsync(string processorName); + Task StoreCheckpointAsync(string processorName, long position); +} +``` + +## Methods + +### GetCheckpointAsync + +Retrieves the checkpoint position for a specified event processor. + +```csharp +Task GetCheckpointAsync(string processorName); +``` + +**Parameters**: +- `processorName` (`System.String`): The name of the event processor. + +**Returns**: `System.Threading.Tasks.Task` - A task that represents the asynchronous operation. The task result contains the checkpoint position for the specified processor, or -1 if no checkpoint exists. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `processorName` is `null` or empty. +- `System.InvalidOperationException`: Thrown when the checkpoint store is not accessible. + +**Remarks**: This method retrieves the checkpoint position for a specified event processor. The position represents the sequence number of the last event that was successfully processed by the event processor. If no checkpoint exists for the specified processor, the method returns -1, indicating that the processor should start from the beginning of the event stream. + +**Example**: +```csharp +// Get the checkpoint for a read model updater +var checkpointStore = new SqlCheckpointStore(connectionString); +var position = await checkpointStore.GetCheckpointAsync("AccountReadModelUpdater"); + +// Use the position to determine where to start processing +var startPosition = position >= 0 ? position + 1 : 0; +Console.WriteLine($"Starting event processing from position {startPosition}"); +``` + +### StoreCheckpointAsync + +Stores the checkpoint position for a specified event processor. + +```csharp +Task StoreCheckpointAsync(string processorName, long position); +``` + +**Parameters**: +- `processorName` (`System.String`): The name of the event processor. +- `position` (`System.Int64`): The checkpoint position to store. + +**Returns**: `System.Threading.Tasks.Task` - A task that represents the asynchronous operation. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `processorName` is `null` or empty. +- `System.ArgumentOutOfRangeException`: Thrown when `position` is less than -1. +- `System.InvalidOperationException`: Thrown when the checkpoint store is not accessible. + +**Remarks**: This method stores the checkpoint position for a specified event processor. The position represents the sequence number of the last event that was successfully processed by the event processor. This position will be used to resume processing from the correct point after a restart or failure. + +**Example**: +```csharp +// Store the checkpoint after processing a batch of events +var checkpointStore = new SqlCheckpointStore(connectionString); +await checkpointStore.StoreCheckpointAsync("AccountReadModelUpdater", 1000); +Console.WriteLine("Checkpoint stored at position 1000"); +``` + +## Usage + +The `ICheckpointStore` interface is typically used in conjunction with event processors to enable reliable and resumable event processing. Here's a comprehensive example of using a checkpoint store: + +### Basic Checkpoint Store Usage + +```csharp +// Create a checkpoint store +var checkpointStore = new SqlCheckpointStore(connectionString); + +// Create an event processor with the checkpoint store +var eventProcessor = new EventStoreProcessor( + eventStoreConnection, + "AccountReadModelUpdater", + checkpointStore); + +// Subscribe handlers +eventProcessor.Subscribe(HandleAccountCreated); +eventProcessor.Subscribe(HandleFundsDeposited); + +// Start the processor - it will automatically use the checkpoint +await eventProcessor.StartAsync(); + +// Process events... + +// When shutting down, the processor will automatically store the checkpoint +await eventProcessor.StopAsync(); +``` + +### Custom Checkpoint Store Implementation + +```csharp +public class SqlCheckpointStore : ICheckpointStore +{ + private readonly string _connectionString; + + public SqlCheckpointStore(string connectionString) + { + _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + } + + public async Task GetCheckpointAsync(string processorName) + { + if (string.IsNullOrEmpty(processorName)) + throw new ArgumentNullException(nameof(processorName)); + + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT Position FROM Checkpoints WHERE ProcessorName = @ProcessorName"; + command.Parameters.AddWithValue("@ProcessorName", processorName); + + var result = await command.ExecuteScalarAsync(); + return result != null ? Convert.ToInt64(result) : -1; + } + } + } + + public async Task StoreCheckpointAsync(string processorName, long position) + { + if (string.IsNullOrEmpty(processorName)) + throw new ArgumentNullException(nameof(processorName)); + + if (position < -1) + throw new ArgumentOutOfRangeException(nameof(position), "Position must be greater than or equal to -1"); + + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = @" + IF EXISTS (SELECT 1 FROM Checkpoints WHERE ProcessorName = @ProcessorName) + UPDATE Checkpoints SET Position = @Position WHERE ProcessorName = @ProcessorName + ELSE + INSERT INTO Checkpoints (ProcessorName, Position) VALUES (@ProcessorName, @Position)"; + command.Parameters.AddWithValue("@ProcessorName", processorName); + command.Parameters.AddWithValue("@Position", position); + + await command.ExecuteNonQueryAsync(); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } +} +``` + +### Integration with Event Processor + +```csharp +public class ReadModelUpdater : IDisposable +{ + private readonly IEventProcessor _eventProcessor; + private readonly ICheckpointStore _checkpointStore; + private readonly IReadModelRepository _readModelRepository; + private readonly ILogger _logger; + + public ReadModelUpdater( + IEventStoreConnection eventStoreConnection, + ICheckpointStore checkpointStore, + IReadModelRepository readModelRepository, + ILogger logger) + { + _checkpointStore = checkpointStore; + _readModelRepository = readModelRepository; + _logger = logger; + + // Create an event processor with the checkpoint store + _eventProcessor = new EventStoreProcessor( + eventStoreConnection, + "AccountReadModelUpdater", + _checkpointStore); + + // Register handlers + _eventProcessor.Subscribe(HandleAccountCreated); + _eventProcessor.Subscribe(HandleFundsDeposited); + _eventProcessor.Subscribe(HandleFundsWithdrawn); + _eventProcessor.Subscribe(HandleAccountClosed); + } + + public async Task StartAsync() + { + _logger.LogInformation("Starting read model updater"); + + // Get the current checkpoint + var checkpoint = await _checkpointStore.GetCheckpointAsync("AccountReadModelUpdater"); + _logger.LogInformation("Starting from checkpoint: {Checkpoint}", checkpoint); + + // Start the processor + await _eventProcessor.StartAsync(); + } + + private void HandleAccountCreated(AccountCreated @event) + { + _logger.LogInformation("Processing AccountCreated event: {@AccountId}", @event.AccountId); + + // Update the read model + var readModel = new AccountReadModel + { + Id = @event.AccountId, + AccountNumber = @event.AccountNumber, + Balance = @event.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + // Save the read model + _readModelRepository.Save(readModel); + } + + // Additional handlers... + + public async Task StopAsync() + { + _logger.LogInformation("Stopping read model updater"); + + // Stop the processor - it will automatically store the checkpoint + await _eventProcessor.StopAsync(); + } + + public void Dispose() + { + _eventProcessor?.Dispose(); + } +} +``` + +## Best Practices + +1. **Transactional Updates**: Store checkpoints in the same transaction as any read model updates to ensure consistency +2. **Regular Checkpointing**: Store checkpoints at regular intervals to balance performance and resilience +3. **Idempotent Processing**: Design event handlers to be idempotent to handle duplicate events safely +4. **Error Handling**: Implement proper error handling to prevent checkpoint advancement on failures +5. **Monitoring**: Monitor checkpoint progress to detect stalled processors +6. **Backup and Recovery**: Implement backup and recovery procedures for checkpoint stores +7. **Performance Tuning**: Balance checkpoint frequency with performance requirements +8. **Versioning**: Consider versioning checkpoints to handle schema evolution +9. **Security**: Secure checkpoint stores to prevent unauthorized access +10. **Testing**: Test checkpoint recovery scenarios to ensure proper resumption after failures + +## Common Pitfalls + +1. **Checkpoint Races**: Race conditions when multiple instances of the same processor try to update checkpoints +2. **Checkpoint Lag**: Excessive lag between event processing and checkpoint updates +3. **Checkpoint Corruption**: Corruption of checkpoint data leading to event reprocessing or skipping +4. **Checkpoint Frequency**: Checkpointing too frequently (performance impact) or too infrequently (potential for duplicate processing) +5. **Transaction Boundaries**: Not aligning checkpoint updates with read model update transactions +6. **Recovery Testing**: Inadequate testing of recovery scenarios +7. **Monitoring Gaps**: Not monitoring checkpoint progress to detect stalled processors + +## Advanced Scenarios + +### Distributed Checkpoint Store + +Implementing a distributed checkpoint store for high availability: + +```csharp +public class DistributedCheckpointStore : ICheckpointStore +{ + private readonly IDistributedCache _distributedCache; + private readonly ILogger _logger; + + public DistributedCheckpointStore(IDistributedCache distributedCache, ILogger logger) + { + _distributedCache = distributedCache; + _logger = logger; + } + + public async Task GetCheckpointAsync(string processorName) + { + if (string.IsNullOrEmpty(processorName)) + throw new ArgumentNullException(nameof(processorName)); + + try + { + var cacheKey = $"checkpoint:{processorName}"; + var cachedValue = await _distributedCache.GetStringAsync(cacheKey); + + if (string.IsNullOrEmpty(cachedValue)) + return -1; + + if (long.TryParse(cachedValue, out var position)) + return position; + + _logger.LogWarning("Invalid checkpoint value for processor {ProcessorName}: {Value}", processorName, cachedValue); + return -1; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving checkpoint for processor {ProcessorName}", processorName); + throw; + } + } + + public async Task StoreCheckpointAsync(string processorName, long position) + { + if (string.IsNullOrEmpty(processorName)) + throw new ArgumentNullException(nameof(processorName)); + + if (position < -1) + throw new ArgumentOutOfRangeException(nameof(position), "Position must be greater than or equal to -1"); + + try + { + var cacheKey = $"checkpoint:{processorName}"; + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(30) + }; + + await _distributedCache.SetStringAsync(cacheKey, position.ToString(), cacheOptions); + _logger.LogDebug("Stored checkpoint for processor {ProcessorName} at position {Position}", processorName, position); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error storing checkpoint for processor {ProcessorName} at position {Position}", processorName, position); + throw; + } + } +} +``` + +### Checkpoint Store with Optimistic Concurrency + +Implementing optimistic concurrency control for checkpoint updates: + +```csharp +public class ConcurrentCheckpointStore : ICheckpointStore +{ + private readonly string _connectionString; + + public ConcurrentCheckpointStore(string connectionString) + { + _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + } + + public async Task GetCheckpointAsync(string processorName) + { + // Implementation similar to SqlCheckpointStore + } + + public async Task StoreCheckpointAsync(string processorName, long position) + { + if (string.IsNullOrEmpty(processorName)) + throw new ArgumentNullException(nameof(processorName)); + + if (position < -1) + throw new ArgumentOutOfRangeException(nameof(position), "Position must be greater than or equal to -1"); + + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + // Get the current checkpoint with a version + long currentPosition; + int version; + + using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT Position, Version FROM Checkpoints WHERE ProcessorName = @ProcessorName"; + command.Parameters.AddWithValue("@ProcessorName", processorName); + + using (var reader = await command.ExecuteReaderAsync()) + { + if (await reader.ReadAsync()) + { + currentPosition = reader.GetInt64(0); + version = reader.GetInt32(1); + } + else + { + currentPosition = -1; + version = 0; + } + } + } + + // Only update if the new position is greater than the current position + if (position > currentPosition) + { + using (var command = connection.CreateCommand()) + { + if (version == 0) + { + // Insert new checkpoint + command.CommandText = @" + INSERT INTO Checkpoints (ProcessorName, Position, Version) + VALUES (@ProcessorName, @Position, 1)"; + command.Parameters.AddWithValue("@ProcessorName", processorName); + command.Parameters.AddWithValue("@Position", position); + } + else + { + // Update existing checkpoint with optimistic concurrency + command.CommandText = @" + UPDATE Checkpoints + SET Position = @Position, Version = Version + 1 + WHERE ProcessorName = @ProcessorName AND Version = @Version"; + command.Parameters.AddWithValue("@ProcessorName", processorName); + command.Parameters.AddWithValue("@Position", position); + command.Parameters.AddWithValue("@Version", version); + } + + int rowsAffected = await command.ExecuteNonQueryAsync(); + + if (rowsAffected == 0 && version > 0) + { + throw new ConcurrencyException($"Concurrency conflict when updating checkpoint for processor {processorName}"); + } + } + } + } + } + + public class ConcurrencyException : Exception + { + public ConcurrencyException(string message) : base(message) { } + } +} +``` + +### Multi-Tenant Checkpoint Store + +Implementing a checkpoint store that supports multiple tenants: + +```csharp +public class MultiTenantCheckpointStore : ICheckpointStore +{ + private readonly string _connectionString; + private readonly ITenantProvider _tenantProvider; + + public MultiTenantCheckpointStore(string connectionString, ITenantProvider tenantProvider) + { + _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider)); + } + + public async Task GetCheckpointAsync(string processorName) + { + if (string.IsNullOrEmpty(processorName)) + throw new ArgumentNullException(nameof(processorName)); + + var tenantId = _tenantProvider.GetCurrentTenantId(); + var fullProcessorName = $"{tenantId}:{processorName}"; + + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT Position FROM Checkpoints WHERE ProcessorName = @ProcessorName AND TenantId = @TenantId"; + command.Parameters.AddWithValue("@ProcessorName", processorName); + command.Parameters.AddWithValue("@TenantId", tenantId); + + var result = await command.ExecuteScalarAsync(); + return result != null ? Convert.ToInt64(result) : -1; + } + } + } + + public async Task StoreCheckpointAsync(string processorName, long position) + { + if (string.IsNullOrEmpty(processorName)) + throw new ArgumentNullException(nameof(processorName)); + + if (position < -1) + throw new ArgumentOutOfRangeException(nameof(position), "Position must be greater than or equal to -1"); + + var tenantId = _tenantProvider.GetCurrentTenantId(); + + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + using (var command = connection.CreateCommand()) + { + command.Transaction = transaction; + command.CommandText = @" + IF EXISTS (SELECT 1 FROM Checkpoints WHERE ProcessorName = @ProcessorName AND TenantId = @TenantId) + UPDATE Checkpoints SET Position = @Position WHERE ProcessorName = @ProcessorName AND TenantId = @TenantId + ELSE + INSERT INTO Checkpoints (ProcessorName, TenantId, Position) VALUES (@ProcessorName, @TenantId, @Position)"; + command.Parameters.AddWithValue("@ProcessorName", processorName); + command.Parameters.AddWithValue("@TenantId", tenantId); + command.Parameters.AddWithValue("@Position", position); + + await command.ExecuteNonQueryAsync(); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } +} +``` + +## Related Components + +- [IEventProcessor](ievent-processor.md): Interface for components that process events +- [Event](event.md): Base class for events in Reactive Domain +- [IEvent](ievent.md): Interface for events in Reactive Domain +- [IEventBus](ievent-bus.md): Interface for publishing events +- [IEventHandler](ievent-handler.md): Interface for event handlers +- [ReadModelBase](read-model-base.md): Base class for read models +- [IReadModelRepository](iread-model-repository.md): Interface for read model repositories + +--- + +**Navigation**: +- [← Previous: IEventProcessor](./ievent-processor.md) +- [↑ Back to Top](#icheckpointstore-interface) +- [→ Next: ReadModelBase](./read-model-base.md) diff --git a/docs/api-reference/types/icommand-bus.md b/docs/api-reference/types/icommand-bus.md new file mode 100644 index 00000000..e4e69854 --- /dev/null +++ b/docs/api-reference/types/icommand-bus.md @@ -0,0 +1,378 @@ +# ICommandBus Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `ICommandBus` interface defines the contract for a component that routes commands to their appropriate handlers in a CQRS (Command Query Responsibility Segregation) architecture. It serves as a mediator between the clients that issue commands and the handlers that process them, decoupling the command senders from the command handlers. + +In Reactive Domain, the command bus is a fundamental component that ensures commands are delivered to their handlers with proper correlation tracking, error handling, and routing. + +## Command Bus in CQRS + +In a CQRS architecture, commands represent intentions to change the state of the system. The command bus is responsible for: + +1. **Command Routing**: Directing commands to the appropriate handlers +2. **Command Validation**: Ensuring commands are valid before processing +3. **Error Handling**: Managing exceptions that occur during command processing +4. **Correlation Tracking**: Maintaining correlation information across command flows +5. **Transaction Management**: Defining transaction boundaries for command processing + +The command bus helps maintain a clean separation between the command issuers (clients, UI, API endpoints) and the command handlers (domain logic). + +**Namespace**: `ReactiveDomain.Messaging` +**Assembly**: `ReactiveDomain.Messaging.dll` + +```csharp +public interface ICommandBus +{ + void Send(T command) where T : class, ICommand; + void Subscribe(Action handler) where T : class, ICommand; + void Unsubscribe(Action handler) where T : class, ICommand; +} +``` + +## Methods + +### Send\ + +Sends a command to its registered handler(s). + +```csharp +void Send(T command) where T : class, ICommand; +``` + +**Type Parameters**: +- `T`: The type of command to send. Must be a class that implements `ICommand`. + +**Parameters**: +- `command` (`T`): The command to send. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `command` is `null`. +- `ReactiveDomain.NoHandlerException`: Thrown when no handler is registered for the command type. +- `ReactiveDomain.MultipleHandlersException`: Thrown when multiple handlers are registered for the command type. + +**Remarks**: This method sends a command to its registered handler. In a typical CQRS implementation, each command type should have exactly one handler. The command bus ensures that the command is delivered to the appropriate handler. + +**Example**: +```csharp +// Create a command +var createAccountCommand = new CreateAccount(Guid.NewGuid(), "12345", 1000); + +// Send the command to its handler +commandBus.Send(createAccountCommand); +``` + +### Subscribe\ + +Subscribes a handler to a specific command type. + +```csharp +void Subscribe(Action handler) where T : class, ICommand; +``` + +**Type Parameters**: +- `T`: The type of command to subscribe to. Must be a class that implements `ICommand`. + +**Parameters**: +- `handler` (`System.Action`): The handler to register for the command type. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `handler` is `null`. +- `ReactiveDomain.MultipleHandlersException`: Thrown when attempting to register multiple handlers for a command type that already has a handler. + +**Remarks**: This method registers a handler for a specific command type. In a typical CQRS implementation, each command type should have exactly one handler. The command bus enforces this constraint by throwing an exception if multiple handlers are registered for the same command type. + +**Example**: +```csharp +// Subscribe a handler for the CreateAccount command +commandBus.Subscribe(cmd => +{ + // Create a new account + var account = new Account(cmd.AccountId); + account.CreateAccount(cmd.AccountNumber, cmd.InitialDeposit, cmd); + + // Save the account + repository.Save(account); +}); +``` + +### Unsubscribe\ + +Unsubscribes a handler from a specific command type. + +```csharp +void Unsubscribe(Action handler) where T : class, ICommand; +``` + +**Type Parameters**: +- `T`: The type of command to unsubscribe from. Must be a class that implements `ICommand`. + +**Parameters**: +- `handler` (`System.Action`): The handler to unregister for the command type. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `handler` is `null`. + +**Remarks**: This method unregisters a handler for a specific command type. It is typically used when a component is being disposed or when dynamic handler registration is required. + +**Example**: +```csharp +// Define a handler +Action createAccountHandler = cmd => +{ + // Handler logic +}; + +// Subscribe the handler +commandBus.Subscribe(createAccountHandler); + +// Later, unsubscribe the handler +commandBus.Unsubscribe(createAccountHandler); +``` + +## Usage + +The `ICommandBus` interface is typically used in conjunction with command handlers to implement the command side of a CQRS architecture. Here's a comprehensive example of using a command bus: + +### Basic Command Bus Usage + +```csharp +// Create a command bus +var commandBus = new CommandBus(); + +// Subscribe handlers +commandBus.Subscribe(HandleCreateAccount); +commandBus.Subscribe(HandleDepositFunds); +commandBus.Subscribe(HandleWithdrawFunds); +commandBus.Subscribe(HandleCloseAccount); + +// Create and send a command +var createAccountCommand = new CreateAccount(Guid.NewGuid(), "12345", 1000); +commandBus.Send(createAccountCommand); + +// Handler methods +void HandleCreateAccount(CreateAccount command) +{ + // Create a new account + var account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + + // Save the account + repository.Save(account); +} + +void HandleDepositFunds(DepositFunds command) +{ + // Get the account + var account = repository.GetById(command.AccountId); + + // Process the command + account.Deposit(command.Amount, command); + + // Save the account + repository.Save(account); +} + +// Additional handlers... +``` + +### Integration with Dependency Injection + +```csharp +public class CommandHandlerRegistration +{ + private readonly ICommandBus _commandBus; + private readonly IRepository _repository; + + public CommandHandlerRegistration(ICommandBus commandBus, IRepository repository) + { + _commandBus = commandBus; + _repository = repository; + + // Register handlers + RegisterHandlers(); + } + + private void RegisterHandlers() + { + _commandBus.Subscribe(HandleCreateAccount); + _commandBus.Subscribe(HandleDepositFunds); + _commandBus.Subscribe(HandleWithdrawFunds); + _commandBus.Subscribe(HandleCloseAccount); + } + + private void HandleCreateAccount(CreateAccount command) + { + // Implementation... + } + + // Additional handlers... + + public void Dispose() + { + // Unregister handlers + _commandBus.Unsubscribe(HandleCreateAccount); + _commandBus.Unsubscribe(HandleDepositFunds); + _commandBus.Unsubscribe(HandleWithdrawFunds); + _commandBus.Unsubscribe(HandleCloseAccount); + } +} +``` + +### Using Command Bus with Correlation + +```csharp +public class CorrelatedCommandHandler +{ + private readonly ICommandBus _commandBus; + private readonly ICorrelatedRepository _repository; + + public CorrelatedCommandHandler(ICommandBus commandBus, ICorrelatedRepository repository) + { + _commandBus = commandBus; + _repository = repository; + } + + public void HandleTransferFunds(TransferFunds command) + { + // Load accounts with correlation + var sourceAccount = _repository.GetById(command.SourceAccountId, command); + var targetAccount = _repository.GetById(command.TargetAccountId, command); + + // Perform transfer + sourceAccount.Withdraw(command.Amount, command); + targetAccount.Deposit(command.Amount, command); + + // Save accounts + _repository.Save(sourceAccount); + _repository.Save(targetAccount); + + // Send notification command with correlation + var notificationCommand = MessageBuilder.From(command, () => + new SendTransferNotification(command.SourceAccountId, command.TargetAccountId, command.Amount)); + + _commandBus.Send(notificationCommand); + } +} +``` + +## Best Practices + +1. **Single Handler Per Command**: Ensure each command type has exactly one handler +2. **Command Validation**: Validate commands before sending them to the command bus +3. **Error Handling**: Implement proper error handling in command handlers +4. **Correlation Tracking**: Use correlated commands to maintain traceability +5. **Command Idempotency**: Design commands to be idempotent when possible +6. **Transaction Boundaries**: Consider command handlers as transaction boundaries +7. **Command Naming**: Use verb-noun naming for commands (e.g., `CreateAccount`, `TransferFunds`) +8. **Command Immutability**: Make commands immutable to prevent unintended side effects +9. **Command Versioning**: Plan for command versioning to handle schema evolution +10. **Command Logging**: Log commands for auditing and debugging purposes + +## Common Pitfalls + +1. **Multiple Handlers**: Registering multiple handlers for the same command type +2. **Missing Handlers**: Sending commands that have no registered handlers +3. **Command Side Effects**: Performing side effects in command handlers that are not part of the transaction +4. **Command Coupling**: Coupling commands to their handlers, reducing flexibility +5. **Large Commands**: Creating commands with too many properties or responsibilities +6. **Missing Validation**: Not validating commands before processing them +7. **Ignoring Errors**: Not properly handling exceptions in command handlers +8. **Command Bus Overuse**: Using the command bus for queries or notifications + +## Advanced Scenarios + +### Command Validation + +Implementing command validation before sending: + +```csharp +public class ValidatingCommandBus : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly ICommandValidator _validator; + + public ValidatingCommandBus(ICommandBus innerBus, ICommandValidator validator) + { + _innerBus = innerBus; + _validator = validator; + } + + public void Send(T command) where T : class, ICommand + { + // Validate the command + var validationResult = _validator.Validate(command); + + if (!validationResult.IsValid) + { + throw new CommandValidationException(validationResult.Errors); + } + + // Send the command + _innerBus.Send(command); + } + + // Implement other methods... +} +``` + +### Command Logging + +Adding logging to the command bus: + +```csharp +public class LoggingCommandBus : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly ILogger _logger; + + public LoggingCommandBus(ICommandBus innerBus, ILogger logger) + { + _innerBus = innerBus; + _logger = logger; + } + + public void Send(T command) where T : class, ICommand + { + try + { + _logger.LogInformation("Sending command {CommandType} with ID {CommandId}", + typeof(T).Name, + command is ICorrelatedMessage msg ? msg.MsgId : Guid.Empty); + + _innerBus.Send(command); + + _logger.LogInformation("Command {CommandType} processed successfully", + typeof(T).Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing command {CommandType}", + typeof(T).Name); + + throw; + } + } + + // Implement other methods... +} +``` + +## Related Components + +- [Command](command.md): Base class for commands in Reactive Domain +- [ICommand](icommand.md): Interface for commands in Reactive Domain +- [ICorrelatedMessage](icorrelated-message.md): Interface for correlated messages +- [MessageBuilder](message-builder.md): Factory for creating correlated messages +- [IRepository](irepository.md): Interface for repositories that store and retrieve aggregates +- [ICorrelatedRepository](icorrelated-repository.md): Repository with correlation support +- [IEventBus](ievent-bus.md): Interface for publishing events + +--- + +**Navigation**: +- [← Previous: ICorrelatedRepository](./icorrelated-repository.md) +- [↑ Back to Top](#icommandbus-interface) +- [→ Next: Command](./command.md) diff --git a/docs/api-reference/types/icommand-handler.md b/docs/api-reference/types/icommand-handler.md new file mode 100644 index 00000000..0c928d69 --- /dev/null +++ b/docs/api-reference/types/icommand-handler.md @@ -0,0 +1,530 @@ +# ICommandHandler Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `ICommandHandler` interface defines the contract for components that handle commands in Reactive Domain. Command handlers are responsible for processing commands, validating business rules, and applying the requested changes to the domain model. + +In a CQRS (Command Query Responsibility Segregation) architecture, command handlers play a crucial role in the write side of the system, processing commands that express the intent to change the system's state. + +## Command Handlers in CQRS + +In a CQRS architecture, command handlers serve several important purposes: + +1. **Command Processing**: Executing the business logic associated with commands +2. **Business Rule Validation**: Enforcing business rules and validations +3. **Domain Model Interaction**: Interacting with domain models to apply changes +4. **Transaction Boundaries**: Defining transaction boundaries for command processing +5. **Error Handling**: Managing exceptions and errors during command processing + +Command handlers are typically registered with a command bus, which routes commands to their appropriate handlers. + +**Namespace**: `ReactiveDomain.Messaging` +**Assembly**: `ReactiveDomain.Messaging.dll` + +```csharp +public interface ICommandHandler where T : class, ICommand +{ + void Handle(T command); +} +``` + +## Type Parameters + +- `T`: The type of command to handle. Must be a class that implements `ICommand`. + +## Methods + +### Handle + +Handles a command of the specified type. + +```csharp +void Handle(T command); +``` + +**Parameters**: +- `command` (`T`): The command to handle. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `command` is `null`. +- `System.InvalidOperationException`: Thrown when the command cannot be processed due to a business rule violation. +- `System.UnauthorizedAccessException`: Thrown when the current user is not authorized to execute the command. + +**Remarks**: This method processes a command of the specified type. It typically validates the command, applies the requested changes to the domain model, and persists the changes. + +**Example**: +```csharp +public class CreateAccountHandler : ICommandHandler +{ + private readonly IRepository _repository; + + public CreateAccountHandler(IRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccount command) + { + // Validate the command + if (string.IsNullOrEmpty(command.AccountNumber)) + throw new ArgumentException("Account number is required"); + + if (command.InitialDeposit < 0) + throw new ArgumentException("Initial deposit cannot be negative"); + + // Check if account already exists + Account account; + if (_repository.TryGetById(command.AccountId, out account)) + throw new InvalidOperationException($"Account {command.AccountId} already exists"); + + // Create new account + account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + + // Save the account + _repository.Save(account); + } +} +``` + +## Usage + +The `ICommandHandler` interface is typically used to implement handlers for specific command types. Here's a comprehensive example of using command handlers in a CQRS architecture: + +### Basic Command Handler Implementation + +```csharp +// Define a command +public class CreateAccount : Command +{ + public Guid AccountId { get; } + public string AccountNumber { get; } + public decimal InitialDeposit { get; } + + public CreateAccount(Guid accountId, string accountNumber, decimal initialDeposit) + { + AccountId = accountId; + AccountNumber = accountNumber; + InitialDeposit = initialDeposit; + } +} + +// Implement a command handler +public class CreateAccountHandler : ICommandHandler +{ + private readonly IRepository _repository; + private readonly IEventBus _eventBus; + + public CreateAccountHandler(IRepository repository, IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(CreateAccount command) + { + // Validate the command + if (string.IsNullOrEmpty(command.AccountNumber)) + throw new ArgumentException("Account number is required"); + + if (command.InitialDeposit < 0) + throw new ArgumentException("Initial deposit cannot be negative"); + + // Check if account already exists + Account account; + if (_repository.TryGetById(command.AccountId, out account)) + throw new InvalidOperationException($"Account {command.AccountId} already exists"); + + // Create new account + account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + + // Save the account + _repository.Save(account); + + // Publish events + foreach (var @event in account.TakeEvents()) + { + _eventBus.Publish(@event); + } + } +} +``` + +### Integration with Command Bus + +```csharp +// Create a command bus +var commandBus = new CommandBus(); + +// Create a command handler +var createAccountHandler = new CreateAccountHandler(repository, eventBus); + +// Register the handler with the command bus +commandBus.Subscribe(createAccountHandler.Handle); + +// Create and send a command +var createAccountCommand = new CreateAccount(Guid.NewGuid(), "12345", 1000); +commandBus.Send(createAccountCommand); +``` + +### Multiple Command Handlers in a Single Class + +```csharp +public class AccountCommandHandler : + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler +{ + private readonly IRepository _repository; + private readonly IEventBus _eventBus; + + public AccountCommandHandler(IRepository repository, IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(CreateAccount command) + { + // Implementation for CreateAccount command + } + + public void Handle(DepositFunds command) + { + // Validate the command + if (command.Amount <= 0) + throw new ArgumentException("Deposit amount must be greater than zero"); + + // Get the account + var account = _repository.GetById(command.AccountId); + + // Process the command + account.Deposit(command.Amount, command); + + // Save the account + _repository.Save(account); + + // Publish events + foreach (var @event in account.TakeEvents()) + { + _eventBus.Publish(@event); + } + } + + public void Handle(WithdrawFunds command) + { + // Implementation for WithdrawFunds command + } + + public void Handle(CloseAccount command) + { + // Implementation for CloseAccount command + } +} +``` + +### Dependency Injection Registration + +```csharp +// Register command handlers with the dependency injection container +services.AddTransient, CreateAccountHandler>(); +services.AddTransient, DepositFundsHandler>(); +services.AddTransient, WithdrawFundsHandler>(); +services.AddTransient, CloseAccountHandler>(); + +// Register the command bus +services.AddSingleton(provider => +{ + var commandBus = new CommandBus(); + + // Register handlers with the command bus + commandBus.Subscribe(provider.GetRequiredService>().Handle); + commandBus.Subscribe(provider.GetRequiredService>().Handle); + commandBus.Subscribe(provider.GetRequiredService>().Handle); + commandBus.Subscribe(provider.GetRequiredService>().Handle); + + return commandBus; +}); +``` + +## Command Handler Patterns + +### Transaction Script Pattern + +The Transaction Script pattern implements the command handling logic directly in the handler: + +```csharp +public class TransferFundsHandler : ICommandHandler +{ + private readonly IRepository _repository; + private readonly IEventBus _eventBus; + + public TransferFundsHandler(IRepository repository, IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(TransferFunds command) + { + // Validate the command + if (command.Amount <= 0) + throw new ArgumentException("Transfer amount must be greater than zero"); + + if (command.SourceAccountId == command.TargetAccountId) + throw new ArgumentException("Source and target accounts cannot be the same"); + + // Get the accounts + var sourceAccount = _repository.GetById(command.SourceAccountId); + var targetAccount = _repository.GetById(command.TargetAccountId); + + // Check if the source account has sufficient funds + if (sourceAccount.GetBalance() < command.Amount) + throw new InvalidOperationException("Insufficient funds for transfer"); + + // Perform the transfer + sourceAccount.Withdraw(command.Amount, command); + targetAccount.Deposit(command.Amount, command); + + // Save the accounts + _repository.Save(sourceAccount); + _repository.Save(targetAccount); + + // Publish events + foreach (var @event in sourceAccount.TakeEvents()) + { + _eventBus.Publish(@event); + } + + foreach (var @event in targetAccount.TakeEvents()) + { + _eventBus.Publish(@event); + } + } +} +``` + +### Domain Model Pattern + +The Domain Model pattern delegates the business logic to the domain model: + +```csharp +public class DepositFundsHandler : ICommandHandler +{ + private readonly IRepository _repository; + private readonly IEventBus _eventBus; + + public DepositFundsHandler(IRepository repository, IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(DepositFunds command) + { + // Get the account + var account = _repository.GetById(command.AccountId); + + // Delegate to the domain model + account.Deposit(command.Amount, command); + + // Save the account + _repository.Save(account); + + // Publish events + foreach (var @event in account.TakeEvents()) + { + _eventBus.Publish(@event); + } + } +} +``` + +## Best Practices + +1. **Single Responsibility**: Each command handler should handle a single command type +2. **Command Validation**: Validate commands before processing them +3. **Error Handling**: Implement proper error handling and provide meaningful error messages +4. **Transaction Management**: Ensure that command processing is transactional +5. **Event Publishing**: Publish domain events after processing commands +6. **Idempotent Operations**: Design command handlers to be idempotent when possible +7. **Dependency Injection**: Use dependency injection to provide dependencies to command handlers +8. **Logging**: Log command processing for debugging and auditing purposes +9. **Authorization**: Implement proper authorization checks in command handlers +10. **Testing**: Write unit tests for command handlers to verify business logic + +## Common Pitfalls + +1. **Missing Validation**: Not validating commands before processing them +2. **Business Logic in Handlers**: Putting too much business logic in handlers instead of the domain model +3. **Missing Error Handling**: Not properly handling exceptions in command handlers +4. **Transaction Boundaries**: Not properly defining transaction boundaries +5. **Event Publishing**: Forgetting to publish domain events after processing commands +6. **Dependency Leakage**: Allowing infrastructure concerns to leak into command handlers +7. **Command Handler Bloat**: Creating command handlers that do too many things +8. **Missing Authorization**: Not implementing proper authorization checks + +## Advanced Scenarios + +### Command Handler Decorators + +Using the decorator pattern to add cross-cutting concerns to command handlers: + +```csharp +public class LoggingCommandHandler : ICommandHandler where T : class, ICommand +{ + private readonly ICommandHandler _innerHandler; + private readonly ILogger _logger; + + public LoggingCommandHandler(ICommandHandler innerHandler, ILogger logger) + { + _innerHandler = innerHandler; + _logger = logger; + } + + public void Handle(T command) + { + try + { + _logger.LogInformation("Handling command {CommandType} with ID {CommandId}", + typeof(T).Name, + command is ICorrelatedMessage msg ? msg.MsgId : Guid.Empty); + + _innerHandler.Handle(command); + + _logger.LogInformation("Command {CommandType} handled successfully", + typeof(T).Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling command {CommandType}", + typeof(T).Name); + + throw; + } + } +} +``` + +### Validation Decorator + +```csharp +public class ValidatingCommandHandler : ICommandHandler where T : class, ICommand, IValidatable +{ + private readonly ICommandHandler _innerHandler; + + public ValidatingCommandHandler(ICommandHandler innerHandler) + { + _innerHandler = innerHandler; + } + + public void Handle(T command) + { + // Validate the command + var validationResult = command.Validate(); + + if (!validationResult.IsValid) + { + throw new CommandValidationException(validationResult.Errors); + } + + // Process the command + _innerHandler.Handle(command); + } +} +``` + +### Authorization Decorator + +```csharp +public class AuthorizingCommandHandler : ICommandHandler where T : class, ICommand +{ + private readonly ICommandHandler _innerHandler; + private readonly IAuthorizationService _authorizationService; + private readonly IUserContext _userContext; + + public AuthorizingCommandHandler( + ICommandHandler innerHandler, + IAuthorizationService authorizationService, + IUserContext userContext) + { + _innerHandler = innerHandler; + _authorizationService = authorizationService; + _userContext = userContext; + } + + public void Handle(T command) + { + // Get the current user + var user = _userContext.CurrentUser; + + // Check if the user is authorized to execute the command + if (!_authorizationService.IsAuthorized(user, command)) + { + throw new UnauthorizedAccessException($"User {user.Id} is not authorized to execute command {typeof(T).Name}"); + } + + // Process the command + _innerHandler.Handle(command); + } +} +``` + +### Transaction Decorator + +```csharp +public class TransactionalCommandHandler : ICommandHandler where T : class, ICommand +{ + private readonly ICommandHandler _innerHandler; + private readonly IUnitOfWork _unitOfWork; + + public TransactionalCommandHandler(ICommandHandler innerHandler, IUnitOfWork unitOfWork) + { + _innerHandler = innerHandler; + _unitOfWork = unitOfWork; + } + + public void Handle(T command) + { + try + { + // Begin a transaction + _unitOfWork.Begin(); + + // Process the command + _innerHandler.Handle(command); + + // Commit the transaction + _unitOfWork.Commit(); + } + catch + { + // Rollback the transaction on error + _unitOfWork.Rollback(); + throw; + } + } +} +``` + +## Related Components + +- [ICommand](icommand.md): Interface for commands in Reactive Domain +- [Command](command.md): Base class for commands in Reactive Domain +- [ICommandBus](icommand-bus.md): Interface for sending commands +- [IEventBus](ievent-bus.md): Interface for publishing events +- [IRepository](irepository.md): Interface for repositories that store and retrieve aggregates +- [AggregateRoot](aggregate-root.md): Base class for domain aggregates +- [ICorrelatedMessage](icorrelated-message.md): Interface for correlated messages +- [MessageBuilder](message-builder.md): Factory for creating correlated messages + +--- + +**Navigation**: +- [← Previous: ICommand](./icommand.md) +- [↑ Back to Top](#icommandhandler-interface) +- [→ Next: IEventBus](./ievent-bus.md) diff --git a/docs/api-reference/types/icommand.md b/docs/api-reference/types/icommand.md new file mode 100644 index 00000000..e46eff8a --- /dev/null +++ b/docs/api-reference/types/icommand.md @@ -0,0 +1,467 @@ +# ICommand Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `ICommand` interface is a marker interface that defines the contract for commands in Reactive Domain. Commands represent intentions to change the state of the system and are a fundamental building block in the Command Query Responsibility Segregation (CQRS) pattern. + +In Reactive Domain, commands are immutable messages that encapsulate a request for the system to perform an action or change its state. They are typically handled by a single handler that validates the command and applies the requested changes to the domain model. + +## Commands in CQRS + +In a CQRS architecture, commands play a crucial role: + +1. **Intent Expression**: Commands express the intent to change the system's state +2. **Business Rules**: Commands encapsulate business rules and validation logic +3. **Single Handler**: Each command type typically has exactly one handler +4. **Write Operations**: Commands represent write operations in the system +5. **Immutability**: Commands are immutable once created + +Commands are distinct from queries (which retrieve data) and events (which represent facts that have occurred). This separation is a key aspect of the CQRS pattern. + +**Namespace**: `ReactiveDomain.Messaging` +**Assembly**: `ReactiveDomain.Messaging.dll` + +```csharp +public interface ICommand : IMessage +{ +} +``` + +## Inheritance + +The `ICommand` interface inherits from the `IMessage` interface, which provides the base contract for all messages in Reactive Domain. + +``` +IMessage + ↑ +ICommand +``` + +## Usage + +The `ICommand` interface is typically used as a marker interface to identify command messages in the system. Commands are usually implemented as concrete classes that inherit from the `Command` base class, which provides common functionality for commands. + +### Basic Command Implementation + +```csharp +// Define a command +public class CreateAccount : Command +{ + public Guid AccountId { get; } + public string AccountNumber { get; } + public decimal InitialDeposit { get; } + + public CreateAccount(Guid accountId, string accountNumber, decimal initialDeposit) + { + AccountId = accountId; + AccountNumber = accountNumber; + InitialDeposit = initialDeposit; + } +} + +// Define a command handler +public class CreateAccountHandler : ICommandHandler +{ + private readonly IRepository _repository; + + public CreateAccountHandler(IRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccount command) + { + // Create a new account + var account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + + // Save the account + _repository.Save(account); + } +} +``` + +### Command with Validation + +```csharp +public class DepositFunds : Command, IValidatable +{ + public Guid AccountId { get; } + public decimal Amount { get; } + + public DepositFunds(Guid accountId, decimal amount) + { + AccountId = accountId; + Amount = amount; + } + + public ValidationResult Validate() + { + var result = new ValidationResult(); + + if (AccountId == Guid.Empty) + result.AddError("AccountId is required"); + + if (Amount <= 0) + result.AddError("Amount must be greater than zero"); + + return result; + } +} +``` + +### Correlated Command + +```csharp +public class TransferFunds : Command, ICorrelatedMessage +{ + public Guid SourceAccountId { get; } + public Guid TargetAccountId { get; } + public decimal Amount { get; } + + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + public TransferFunds( + Guid sourceAccountId, + Guid targetAccountId, + decimal amount, + Guid msgId, + Guid correlationId, + Guid causationId) + { + SourceAccountId = sourceAccountId; + TargetAccountId = targetAccountId; + Amount = amount; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } + + // Alternative constructor using MessageBuilder + public static TransferFunds Create( + Guid sourceAccountId, + Guid targetAccountId, + decimal amount, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new TransferFunds( + sourceAccountId, + targetAccountId, + amount, + Guid.NewGuid(), + source.CorrelationId, + source.MsgId)); + } +} +``` + +## Command Bus Integration + +Commands are typically sent through a command bus, which routes them to their appropriate handlers: + +```csharp +// Create a command bus +var commandBus = new CommandBus(); + +// Register a command handler +commandBus.Subscribe(cmd => +{ + // Create a new account + var account = new Account(cmd.AccountId); + account.CreateAccount(cmd.AccountNumber, cmd.InitialDeposit, cmd); + + // Save the account + repository.Save(account); +}); + +// Create and send a command +var createAccountCommand = new CreateAccount(Guid.NewGuid(), "12345", 1000); +commandBus.Send(createAccountCommand); +``` + +## Command Handling Patterns + +### Transaction Script Pattern + +```csharp +public class AccountCommandHandler : + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler +{ + private readonly IRepository _repository; + + public AccountCommandHandler(IRepository repository) + { + _repository = repository; + } + + public void Handle(CreateAccount command) + { + // Validate command + if (string.IsNullOrEmpty(command.AccountNumber)) + throw new ArgumentException("Account number is required"); + + if (command.InitialDeposit < 0) + throw new ArgumentException("Initial deposit cannot be negative"); + + // Check if account already exists + Account account; + if (_repository.TryGetById(command.AccountId, out account)) + throw new InvalidOperationException($"Account {command.AccountId} already exists"); + + // Create new account + account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + + // Save the account + _repository.Save(account); + } + + public void Handle(DepositFunds command) + { + // Validate command + if (command.Amount <= 0) + throw new ArgumentException("Deposit amount must be greater than zero"); + + // Get the account + var account = _repository.GetById(command.AccountId); + + // Process the command + account.Deposit(command.Amount, command); + + // Save the account + _repository.Save(account); + } + + // Additional handlers... +} +``` + +### Domain Model Pattern + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + private bool _isActive; + private string _accountNumber; + + public Account(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Command handlers + + public void CreateAccount(string accountNumber, decimal initialDeposit, ICorrelatedMessage source) + { + // Validate command + if (string.IsNullOrEmpty(accountNumber)) + throw new ArgumentException("Account number is required"); + + if (initialDeposit < 0) + throw new ArgumentException("Initial deposit cannot be negative"); + + if (_isActive) + throw new InvalidOperationException("Account already exists"); + + // Raise event + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(Id, accountNumber, initialDeposit))); + } + + public void Deposit(decimal amount, ICorrelatedMessage source) + { + // Validate command + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + + if (amount <= 0) + throw new ArgumentException("Deposit amount must be greater than zero"); + + // Raise event + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + } + + // Event handlers + + private void Apply(AccountCreated @event) + { + _isActive = true; + _accountNumber = @event.AccountNumber; + _balance = @event.InitialDeposit; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + // Additional methods and event handlers... +} +``` + +## Best Practices + +1. **Command Naming**: Use verb-noun naming for commands (e.g., `CreateAccount`, `TransferFunds`) +2. **Command Immutability**: Make commands immutable to prevent unintended side effects +3. **Command Validation**: Validate commands before processing them +4. **Single Responsibility**: Each command should represent a single action or intent +5. **Command Properties**: Include all necessary information in the command properties +6. **Command Versioning**: Plan for command versioning to handle schema evolution +7. **Error Handling**: Implement proper error handling in command handlers +8. **Idempotent Operations**: Design commands to be idempotent when possible +9. **Correlation**: Use correlation IDs to track command flows through the system +10. **Security**: Implement proper authorization checks for command handlers + +## Common Pitfalls + +1. **Mutable Commands**: Creating commands that can be modified after creation +2. **Missing Validation**: Not validating commands before processing them +3. **Command Overloading**: Creating commands that do too many things +4. **Missing Properties**: Not including all necessary information in commands +5. **Direct State Modification**: Modifying state directly in command handlers instead of through events +6. **Command Coupling**: Coupling commands to their handlers, reducing flexibility +7. **Missing Error Handling**: Not properly handling exceptions in command handlers +8. **Command Bus Overuse**: Using the command bus for queries or notifications + +## Advanced Scenarios + +### Command Validation + +Implementing command validation using a decorator pattern: + +```csharp +public class ValidatingCommandHandler : ICommandHandler + where TCommand : class, ICommand, IValidatable +{ + private readonly ICommandHandler _innerHandler; + + public ValidatingCommandHandler(ICommandHandler innerHandler) + { + _innerHandler = innerHandler; + } + + public void Handle(TCommand command) + { + // Validate the command + var validationResult = command.Validate(); + + if (!validationResult.IsValid) + { + throw new CommandValidationException(validationResult.Errors); + } + + // Process the command + _innerHandler.Handle(command); + } +} +``` + +### Command Logging + +Adding logging to command handlers: + +```csharp +public class LoggingCommandHandler : ICommandHandler + where TCommand : class, ICommand +{ + private readonly ICommandHandler _innerHandler; + private readonly ILogger _logger; + + public LoggingCommandHandler(ICommandHandler innerHandler, ILogger logger) + { + _innerHandler = innerHandler; + _logger = logger; + } + + public void Handle(TCommand command) + { + try + { + _logger.LogInformation("Handling command {CommandType} with ID {CommandId}", + typeof(TCommand).Name, + command is ICorrelatedMessage msg ? msg.MsgId : Guid.Empty); + + _innerHandler.Handle(command); + + _logger.LogInformation("Command {CommandType} handled successfully", + typeof(TCommand).Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling command {CommandType}", + typeof(TCommand).Name); + + throw; + } + } +} +``` + +### Command Authorization + +Implementing command authorization: + +```csharp +public class AuthorizingCommandHandler : ICommandHandler + where TCommand : class, ICommand +{ + private readonly ICommandHandler _innerHandler; + private readonly IAuthorizationService _authorizationService; + private readonly IUserContext _userContext; + + public AuthorizingCommandHandler( + ICommandHandler innerHandler, + IAuthorizationService authorizationService, + IUserContext userContext) + { + _innerHandler = innerHandler; + _authorizationService = authorizationService; + _userContext = userContext; + } + + public void Handle(TCommand command) + { + // Get the current user + var user = _userContext.CurrentUser; + + // Check if the user is authorized to execute the command + if (!_authorizationService.IsAuthorized(user, command)) + { + throw new UnauthorizedAccessException($"User {user.Id} is not authorized to execute command {typeof(TCommand).Name}"); + } + + // Process the command + _innerHandler.Handle(command); + } +} +``` + +## Related Components + +- [Command](command.md): Base class for commands in Reactive Domain +- [ICommandBus](icommand-bus.md): Interface for sending commands +- [ICommandHandler](icommand-handler.md): Interface for command handlers +- [IMessage](imessage.md): Base interface for all messages +- [ICorrelatedMessage](icorrelated-message.md): Interface for correlated messages +- [MessageBuilder](message-builder.md): Factory for creating correlated messages +- [AggregateRoot](aggregate-root.md): Base class for domain aggregates +- [IRepository](irepository.md): Interface for repositories that store and retrieve aggregates + +--- + +**Navigation**: +- [← Previous: ICheckpointStore](./icheckpoint-store.md) +- [↑ Back to Top](#icommand-interface) +- [→ Next: Command](./command.md) diff --git a/docs/api-reference/types/icorrelated-event-source.md b/docs/api-reference/types/icorrelated-event-source.md new file mode 100644 index 00000000..e5c4013c --- /dev/null +++ b/docs/api-reference/types/icorrelated-event-source.md @@ -0,0 +1,251 @@ +# ICorrelatedEventSource + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`ICorrelatedEventSource` is an interface in Reactive Domain that extends the base `IEventSource` interface to add correlation tracking capabilities to event-sourced entities. + +## Overview + +In complex event-driven systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `ICorrelatedEventSource` interface enables event-sourced entities to maintain correlation information when raising events, ensuring that the complete chain of causality can be traced through the system. + +This interface is particularly important for maintaining correlation context across aggregate boundaries and ensuring that events raised by aggregates preserve the correlation information from the commands that triggered them. + +## Interface Definition + +```csharp +public interface ICorrelatedEventSource : IEventSource +{ + void UpdateWithEvents(IEnumerable events, long expectedVersion, ICorrelatedMessage source); + void RaiseEvent(object @event, ICorrelatedMessage source); +} +``` + +## Key Features + +- **Correlation Tracking**: Maintains correlation and causation IDs across event-sourced entities +- **Event Sourcing**: Inherits the event sourcing capabilities from `IEventSource` +- **Command-Event Correlation**: Ensures events raised in response to commands maintain correlation +- **Debugging Support**: Makes it easier to debug complex message flows by maintaining clear relationships +- **Audit Trail**: Provides a complete audit trail of related messages for compliance and analysis + +## Usage + +### Implementing the Interface + +Here's an example of implementing the `ICorrelatedEventSource` interface in a correlated aggregate: + +```csharp +public class CorrelatedAccount : ICorrelatedEventSource +{ + private readonly EventRecorder _recorder = new EventRecorder(); + private decimal _balance; + private bool _isActive; + + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + public CorrelatedAccount(Guid id) + { + Id = id; + ExpectedVersion = -1; + _isActive = false; + _balance = 0; + } + + public void RestoreFromEvents(IEnumerable events) + { + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public void UpdateWithEvents(IEnumerable events, long expectedVersion) + { + if (ExpectedVersion != expectedVersion) + throw new InvalidOperationException($"Expected version {ExpectedVersion} but got {expectedVersion}"); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public void UpdateWithEvents(IEnumerable events, long expectedVersion, ICorrelatedMessage source) + { + // Same as above, but preserves correlation information + if (ExpectedVersion != expectedVersion) + throw new InvalidOperationException($"Expected version {ExpectedVersion} but got {expectedVersion}"); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + public object[] TakeEvents() + { + return _recorder.TakeEvents(); + } + + public void RaiseEvent(object @event) + { + Apply(@event); + _recorder.Record(@event); + } + + public void RaiseEvent(object @event, ICorrelatedMessage source) + { + // Create a correlated event using the source message + var correlatedEvent = MessageBuilder.From(source, () => @event); + + Apply(correlatedEvent); + _recorder.Record(correlatedEvent); + } + + // Command handlers + public void CreateAccount(CreateAccount command) + { + if (_isActive) + throw new InvalidOperationException("Account already exists"); + + RaiseEvent(new AccountCreated(Id, command.InitialBalance), command); + } + + public void Deposit(DepositFunds command) + { + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + + if (command.Amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(command.Amount)); + + RaiseEvent(new FundsDeposited(Id, command.Amount), command); + } + + public void Withdraw(WithdrawFunds command) + { + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + + if (command.Amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(command.Amount)); + + if (_balance < command.Amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new FundsWithdrawn(Id, command.Amount), command); + } + + // Event handlers + private void Apply(object @event) + { + switch (@event) + { + case AccountCreated e: + ApplyAccountCreated(e); + break; + + case FundsDeposited e: + ApplyFundsDeposited(e); + break; + + case FundsWithdrawn e: + ApplyFundsWithdrawn(e); + break; + } + } + + private void ApplyAccountCreated(AccountCreated @event) + { + _isActive = true; + _balance = @event.InitialBalance; + } + + private void ApplyFundsDeposited(FundsDeposited @event) + { + _balance += @event.Amount; + } + + private void ApplyFundsWithdrawn(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } +} +``` + +### Using with a Correlated Repository + +The `ICorrelatedEventSource` interface is typically used with a correlated repository that preserves correlation information: + +```csharp +public class AccountService +{ + private readonly ICorrelatedRepository _repository; + + public AccountService(ICorrelatedRepository repository) + { + _repository = repository; + } + + public void HandleCreateAccount(CreateAccount command) + { + var account = new CorrelatedAccount(command.AccountId); + account.CreateAccount(command); + _repository.Save(account, command); + } + + public void HandleDepositFunds(DepositFunds command) + { + var account = _repository.GetById(command.AccountId, command); + account.Deposit(command); + _repository.Save(account, command); + } + + public void HandleWithdrawFunds(WithdrawFunds command) + { + var account = _repository.GetById(command.AccountId, command); + account.Withdraw(command); + _repository.Save(account, command); + } +} +``` + +## Best Practices + +1. **Always Use MessageBuilder**: Use the `MessageBuilder` factory to ensure proper correlation +2. **Pass Source Messages**: Always pass the source message when raising events +3. **Preserve Correlation Chains**: Ensure correlation information flows through the entire system +4. **Consistent Implementation**: Implement correlation tracking consistently across all entities +5. **Logging**: Include correlation IDs in logs for easier debugging +6. **Testing**: Test correlation chains to ensure they are maintained correctly +7. **Documentation**: Document the correlation flow in your system for better understanding +8. **Error Handling**: Include correlation IDs in error messages and exception details + +## Common Pitfalls + +1. **Missing Correlation**: Failing to pass the source message when raising events breaks the correlation chain +2. **Manual ID Setting**: Avoid manually setting correlation and causation IDs as this is error-prone +3. **Inconsistent Implementation**: Ensure all parts of your system handle correlation consistently +4. **Ignoring Correlation in Repositories**: Ensure repositories preserve correlation information +5. **Breaking Correlation Chains**: Ensure correlation information is passed through all message flows + +## Related Components + +- [IEventSource](./ievent-source.md): The base interface for event-sourced entities +- [ICorrelatedMessage](./icorrelated-message.md): Interface for messages with correlation information +- [MessageBuilder](./message-builder.md): Factory for creating correlated messages +- [ICorrelatedRepository](./icorrelated-repository.md): Repository that preserves correlation information +- [AggregateRoot](./aggregate-root.md): Base class for domain entities that often implements `ICorrelatedEventSource` +- [Command](./command.md): Messages that trigger state changes in correlated event sources +- [Event](./event.md): Messages that represent state changes in correlated event sources + +--- + +**Navigation**: +- [← Previous: ProcessManager](./process-manager.md) +- [↑ Back to Top](#icorrelatedeventsource) +- [→ Next: ISnapshotSource](./isnapshot-source.md) diff --git a/docs/api-reference/types/icorrelated-repository.md b/docs/api-reference/types/icorrelated-repository.md index b26f346f..6c71932c 100644 --- a/docs/api-reference/types/icorrelated-repository.md +++ b/docs/api-reference/types/icorrelated-repository.md @@ -6,6 +6,17 @@ The `ICorrelatedRepository` interface extends the repository pattern with correlation support. It allows tracking correlation and causation IDs across message flows when working with event-sourced aggregates. +## Correlation in Event Sourcing + +In distributed systems and complex business processes, tracking the flow of messages and events is crucial for: + +1. **Debugging and Troubleshooting**: Tracing the path of a business transaction through the system +2. **Auditing**: Maintaining a complete record of what caused each state change +3. **Business Process Monitoring**: Tracking the progress of long-running business processes +4. **Distributed Tracing**: Following transactions across service boundaries + +The `ICorrelatedRepository` provides this capability by ensuring that correlation information is propagated from commands to the events they generate. When an aggregate is loaded with a source message, the correlation context is established, and all events raised by that aggregate will inherit the correlation information. + **Namespace**: `ReactiveDomain.Foundation` **Assembly**: `ReactiveDomain.Foundation.dll` @@ -166,6 +177,8 @@ void HardDelete(IEventSource aggregate); ## Usage +### Basic Usage with Correlation + The `ICorrelatedRepository` interface is used to store and retrieve event-sourced aggregates with correlation information. It is typically implemented by the `CorrelatedStreamStoreRepository` class. ```csharp @@ -180,8 +193,8 @@ var correlatedRepository = new CorrelatedStreamStoreRepository(repository); ICorrelatedMessage command = MessageBuilder.New(() => new CreateAccount(Guid.NewGuid())); // Create a new aggregate with correlation information -var account = new Account(Guid.NewGuid(), command); -account.Deposit(100); +var account = new Account(Guid.NewGuid()); +account.CreateAccount("12345", command); // Pass the command to establish correlation // Save the aggregate correlatedRepository.Save(account); @@ -189,23 +202,251 @@ correlatedRepository.Save(account); // Retrieve the aggregate with correlation information var retrievedAccount = correlatedRepository.GetById(account.Id, command); -// Update the aggregate -retrievedAccount.Withdraw(50); +// Update the aggregate with correlation +retrievedAccount.Deposit(100, command); correlatedRepository.Save(retrievedAccount); // Delete the aggregate correlatedRepository.Delete(retrievedAccount); ``` +### Integration with Command Handlers + +The `ICorrelatedRepository` is particularly useful in command handlers where correlation tracking is important: + +```csharp +public class CorrelatedAccountCommandHandler : + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly IEventBus _eventBus; + + public CorrelatedAccountCommandHandler(ICorrelatedRepository repository, IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(CreateAccount command) + { + // Check if account already exists + Account account; + if (_repository.TryGetById(command.AccountId, out account, command)) + { + throw new InvalidOperationException($"Account {command.AccountId} already exists"); + } + + // Create new account with correlation + account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + + // Save the account + _repository.Save(account); + + // Events are automatically correlated with the command + // This allows tracing the entire transaction flow + } + + public void Handle(DepositFunds command) + { + // Get the account with correlation + var account = _repository.GetById(command.AccountId, command); + + // Process the command with correlation + account.Deposit(command.Amount, command); + + // Save the account + _repository.Save(account); + } + + // Additional handlers... +} +``` + +### Tracing Business Processes + +Correlation is particularly valuable for tracing business processes that span multiple aggregates: + +```csharp +public class TransferFundsProcess +{ + private readonly ICorrelatedRepository _repository; + + public TransferFundsProcess(ICorrelatedRepository repository) + { + _repository = repository; + } + + public void ExecuteTransfer(TransferFunds command) + { + // Load both accounts with correlation + var sourceAccount = _repository.GetById(command.SourceAccountId, command); + var targetAccount = _repository.GetById(command.TargetAccountId, command); + + try + { + // Withdraw from source account with correlation + sourceAccount.Withdraw(command.Amount, command); + + // Deposit to target account with correlation + targetAccount.Deposit(command.Amount, command); + + // Save both accounts + _repository.Save(sourceAccount); + _repository.Save(targetAccount); + + // All events will have the same correlation ID but different causation IDs + // This allows tracing the entire transfer process + } + catch (Exception ex) + { + // Handle errors + // The correlation ID can be used to trace the error through the system + } + } +} +``` + ## Correlation and Causation The `ICorrelatedRepository` interface helps track correlation and causation IDs across message flows: - **Correlation ID**: Identifies a business transaction that spans multiple messages - **Causation ID**: Identifies the message that caused the current message +- **Message ID**: Uniquely identifies each message in the system When an aggregate is loaded with a source message, the source message's correlation and causation IDs are propagated to any events raised by the aggregate. This allows tracking the flow of messages through the system. +### How Correlation Works + +1. **Initial Command**: A command enters the system with a new correlation ID (equal to its message ID) and no causation ID +2. **Command Handler**: Loads an aggregate using the correlated repository, passing the command as the source +3. **Aggregate Operations**: The aggregate raises events, which inherit the correlation ID from the command +4. **Event Causation**: Each event's causation ID is set to the command's message ID +5. **Event Handlers**: Process events and may issue new commands, maintaining the correlation chain + +### Correlation Chain Example + +``` +Command: CreateAccount +- MsgId: A +- CorrelationId: A +- CausationId: null + + Event: AccountCreated + - MsgId: B + - CorrelationId: A + - CausationId: A + + Command: SendWelcomeEmail + - MsgId: C + - CorrelationId: A + - CausationId: B + + Event: EmailSent + - MsgId: D + - CorrelationId: A + - CausationId: C +``` + +This chain allows tracing the entire business transaction from start to finish, even across multiple services and message handlers. + +### Benefits of Correlation Tracking + +1. **Debugging**: Easily trace related messages through logs and monitoring systems +2. **Auditing**: Maintain a complete record of business transactions +3. **Idempotency**: Detect and handle duplicate messages +4. **Process Monitoring**: Track the progress of long-running business processes +5. **Distributed Tracing**: Follow transactions across service boundaries + +## Best Practices + +1. **Always Use Correlation**: Use correlated repositories for all production systems to maintain traceability +2. **Pass Commands to Aggregates**: Always pass the command to aggregate methods to maintain correlation +3. **Correlation in Sagas**: Use correlation IDs to track long-running business processes (sagas) +4. **Logging with Correlation**: Include correlation IDs in log messages for easier troubleshooting +5. **Monitoring**: Set up monitoring based on correlation IDs to track business processes +6. **Error Handling**: Use correlation IDs to track errors through the system +7. **Testing**: Verify that correlation IDs are properly propagated in unit tests + +## Advanced Scenarios + +### Distributed Systems + +In distributed systems, correlation IDs should be propagated across service boundaries: + +```csharp +public class ExternalServiceClient +{ + private readonly HttpClient _httpClient; + + public ExternalServiceClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task CallExternalService(ICorrelatedMessage source, string data) + { + // Add correlation headers to the request + var request = new HttpRequestMessage(HttpMethod.Post, "api/endpoint"); + request.Headers.Add("X-Correlation-ID", source.CorrelationId.ToString()); + request.Headers.Add("X-Causation-ID", source.MsgId.ToString()); + + // Add request body + request.Content = new StringContent(data, Encoding.UTF8, "application/json"); + + // Send the request + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + } +} +``` + +### Correlation with Event Sourcing and CQRS + +In a full CQRS architecture, correlation IDs help maintain consistency between read and write models: + +```csharp +public class ReadModelUpdater : + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _readModelRepository; + private readonly ILogger _logger; + + public ReadModelUpdater(IReadModelRepository readModelRepository, ILogger logger) + { + _readModelRepository = readModelRepository; + _logger = logger; + } + + public void Handle(AccountCreated @event) + { + _logger.LogInformation( + "Updating read model for AccountCreated event. CorrelationId: {@CorrelationId}, CausationId: {@CausationId}", + @event.CorrelationId, @event.CausationId); + + var readModel = new AccountReadModel + { + Id = @event.AccountId, + AccountNumber = @event.AccountNumber, + Balance = @event.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow, + LastEventId = @event.MsgId // Store the event ID for idempotency + }; + + _readModelRepository.Save(readModel); + } + + // Additional event handlers... +} +``` + ## Related Types - [IRepository](irepository.md): The base repository interface @@ -213,6 +454,12 @@ When an aggregate is loaded with a source message, the source message's correlat - [AggregateRoot](aggregate-root.md): Base class for domain aggregates - [ICorrelatedMessage](icorrelated-message.md): Interface for correlated messages - [ICorrelatedEventSource](icorrelated-event-source.md): Interface for correlation tracking in event sources +- [MessageBuilder](message-builder.md): Factory for creating correlated messages - [CorrelatedStreamStoreRepository](correlated-stream-store-repository.md): Implementation of `ICorrelatedRepository` -[↑ Back to Top](#icorrelatedrepository-interface) | [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) +--- + +**Navigation**: +- [← Previous: IRepository](./irepository.md) +- [↑ Back to Top](#icorrelatedrepository-interface) +- [→ Next: ICorrelatedEventSource](./icorrelated-event-source.md) diff --git a/docs/api-reference/types/ievent-bus.md b/docs/api-reference/types/ievent-bus.md new file mode 100644 index 00000000..857e3895 --- /dev/null +++ b/docs/api-reference/types/ievent-bus.md @@ -0,0 +1,491 @@ +# IEventBus Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `IEventBus` interface defines the contract for a component that publishes events to their subscribers in an event-driven architecture. It serves as a mediator between event publishers (typically aggregates or command handlers) and event subscribers (such as read model updaters, process managers, and other event handlers). + +In Reactive Domain, the event bus is a fundamental component that enables loose coupling between components through event-based communication, supporting the event sourcing and CQRS architectural patterns. + +## Event Bus in Event-Driven Architecture + +In an event-driven architecture, events represent facts that have occurred in the system. The event bus is responsible for: + +1. **Event Publishing**: Distributing events to all interested subscribers +2. **Event Subscription**: Allowing components to register interest in specific event types +3. **Decoupling**: Ensuring publishers and subscribers are not directly dependent on each other +4. **Correlation Tracking**: Maintaining correlation information across event flows +5. **Event Routing**: Directing events to the appropriate handlers based on event type + +The event bus helps maintain a clean separation between components, allowing the system to evolve more easily as requirements change. + +**Namespace**: `ReactiveDomain.Messaging` +**Assembly**: `ReactiveDomain.Messaging.dll` + +```csharp +public interface IEventBus +{ + void Publish(T @event) where T : class, IEvent; + void Subscribe(Action handler) where T : class, IEvent; + void Unsubscribe(Action handler) where T : class, IEvent; +} +``` + +## Methods + +### Publish\ + +Publishes an event to all registered subscribers. + +```csharp +void Publish(T @event) where T : class, IEvent; +``` + +**Type Parameters**: +- `T`: The type of event to publish. Must be a class that implements `IEvent`. + +**Parameters**: +- `event` (`T`): The event to publish. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `event` is `null`. + +**Remarks**: This method publishes an event to all registered subscribers. Unlike commands, which typically have a single handler, events can have multiple subscribers. The event bus ensures that the event is delivered to all interested subscribers. + +**Example**: +```csharp +// Create an event +var accountCreatedEvent = new AccountCreated(Guid.NewGuid(), "12345", 1000); + +// Publish the event to all subscribers +eventBus.Publish(accountCreatedEvent); +``` + +### Subscribe\ + +Subscribes a handler to a specific event type. + +```csharp +void Subscribe(Action handler) where T : class, IEvent; +``` + +**Type Parameters**: +- `T`: The type of event to subscribe to. Must be a class that implements `IEvent`. + +**Parameters**: +- `handler` (`System.Action`): The handler to register for the event type. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `handler` is `null`. + +**Remarks**: This method registers a handler for a specific event type. Multiple handlers can be registered for the same event type, allowing different components to react to the same event independently. + +**Example**: +```csharp +// Subscribe a handler for the AccountCreated event +eventBus.Subscribe(evt => +{ + // Update the read model + var readModel = new AccountReadModel + { + Id = evt.AccountId, + AccountNumber = evt.AccountNumber, + Balance = evt.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + // Save the read model + readModelRepository.Save(readModel); +}); +``` + +### Unsubscribe\ + +Unsubscribes a handler from a specific event type. + +```csharp +void Unsubscribe(Action handler) where T : class, IEvent; +``` + +**Type Parameters**: +- `T`: The type of event to unsubscribe from. Must be a class that implements `IEvent`. + +**Parameters**: +- `handler` (`System.Action`): The handler to unregister for the event type. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `handler` is `null`. + +**Remarks**: This method unregisters a handler for a specific event type. It is typically used when a component is being disposed or when dynamic handler registration is required. + +**Example**: +```csharp +// Define a handler +Action accountCreatedHandler = evt => +{ + // Handler logic +}; + +// Subscribe the handler +eventBus.Subscribe(accountCreatedHandler); + +// Later, unsubscribe the handler +eventBus.Unsubscribe(accountCreatedHandler); +``` + +## Usage + +The `IEventBus` interface is typically used to implement the event distribution mechanism in a CQRS/Event Sourcing architecture. Here's a comprehensive example of using an event bus: + +### Basic Event Bus Usage + +```csharp +// Create an event bus +var eventBus = new EventBus(); + +// Subscribe handlers +eventBus.Subscribe(HandleAccountCreated); +eventBus.Subscribe(HandleFundsDeposited); +eventBus.Subscribe(HandleFundsWithdrawn); +eventBus.Subscribe(HandleAccountClosed); + +// Create and publish an event +var accountCreatedEvent = new AccountCreated(Guid.NewGuid(), "12345", 1000); +eventBus.Publish(accountCreatedEvent); + +// Handler methods +void HandleAccountCreated(AccountCreated @event) +{ + // Update the read model + var readModel = new AccountReadModel + { + Id = @event.AccountId, + AccountNumber = @event.AccountNumber, + Balance = @event.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + // Save the read model + readModelRepository.Save(readModel); +} + +void HandleFundsDeposited(FundsDeposited @event) +{ + // Update the read model + var readModel = readModelRepository.GetById(@event.AccountId); + readModel.Balance += @event.Amount; + readModel.LastUpdated = DateTime.UtcNow; + + // Save the read model + readModelRepository.Save(readModel); +} + +// Additional handlers... +``` + +### Integration with Dependency Injection + +```csharp +public class EventHandlerRegistration : IDisposable +{ + private readonly IEventBus _eventBus; + private readonly IReadModelRepository _readModelRepository; + + public EventHandlerRegistration(IEventBus eventBus, IReadModelRepository readModelRepository) + { + _eventBus = eventBus; + _readModelRepository = readModelRepository; + + // Register handlers + RegisterHandlers(); + } + + private void RegisterHandlers() + { + _eventBus.Subscribe(HandleAccountCreated); + _eventBus.Subscribe(HandleFundsDeposited); + _eventBus.Subscribe(HandleFundsWithdrawn); + _eventBus.Subscribe(HandleAccountClosed); + } + + private void HandleAccountCreated(AccountCreated @event) + { + // Implementation... + } + + // Additional handlers... + + public void Dispose() + { + // Unregister handlers + _eventBus.Unsubscribe(HandleAccountCreated); + _eventBus.Unsubscribe(HandleFundsDeposited); + _eventBus.Unsubscribe(HandleFundsWithdrawn); + _eventBus.Unsubscribe(HandleAccountClosed); + } +} +``` + +### Multiple Subscribers for the Same Event + +One of the key advantages of the event bus is that multiple subscribers can react to the same event: + +```csharp +// Read model updater +eventBus.Subscribe(evt => +{ + // Update the account read model + var readModel = new AccountReadModel + { + Id = evt.AccountId, + AccountNumber = evt.AccountNumber, + Balance = evt.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + readModelRepository.Save(readModel); +}); + +// Notification service +eventBus.Subscribe(evt => +{ + // Send a welcome email + emailService.SendWelcomeEmail(evt.AccountId, evt.AccountNumber); +}); + +// Audit logger +eventBus.Subscribe(evt => +{ + // Log the event for audit purposes + auditLogger.LogEvent("AccountCreated", evt); +}); + +// Analytics service +eventBus.Subscribe(evt => +{ + // Track the event for analytics + analyticsService.TrackEvent("AccountCreated", evt.AccountId); +}); +``` + +### Integration with Command Bus and Repository + +In a complete CQRS/Event Sourcing architecture, the event bus works together with the command bus and repository: + +```csharp +public class AccountCommandHandler : + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler +{ + private readonly IRepository _repository; + private readonly IEventBus _eventBus; + + public AccountCommandHandler(IRepository repository, IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(CreateAccount command) + { + // Create a new account + var account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + + // Save the account + _repository.Save(account); + + // Publish events + foreach (var @event in account.TakeEvents()) + { + _eventBus.Publish(@event); + } + } + + // Additional handlers... +} +``` + +## Best Practices + +1. **Event Immutability**: Make events immutable to prevent unintended side effects +2. **Multiple Subscribers**: Design for multiple subscribers to the same event +3. **Event Naming**: Use past tense verbs for event names (e.g., `AccountCreated`, `FundsDeposited`) +4. **Event Properties**: Include all relevant information in events for subscribers +5. **Event Versioning**: Plan for event versioning to handle schema evolution +6. **Error Handling**: Implement proper error handling in event subscribers +7. **Idempotent Handlers**: Design event handlers to be idempotent +8. **Correlation Tracking**: Use correlated events to maintain traceability +9. **Event Logging**: Log events for auditing and debugging purposes +10. **Event Ordering**: Ensure events are processed in the correct order when necessary + +## Common Pitfalls + +1. **Event Coupling**: Creating tight coupling between event publishers and subscribers +2. **Missing Information**: Not including all necessary information in events +3. **Side Effects**: Performing side effects in event handlers that are not idempotent +4. **Event Overload**: Publishing too many events or events with too much information +5. **Synchronous Processing**: Blocking the event publisher while subscribers process events +6. **Missing Error Handling**: Not properly handling exceptions in event subscribers +7. **Event Bus Overuse**: Using the event bus for commands or queries +8. **Event Order Dependency**: Creating dependencies on event processing order + +## Advanced Scenarios + +### Asynchronous Event Processing + +Implementing asynchronous event processing: + +```csharp +public class AsyncEventBus : IEventBus +{ + private readonly Dictionary> _handlers = new Dictionary>(); + private readonly TaskFactory _taskFactory; + + public AsyncEventBus() + { + _taskFactory = new TaskFactory(new LimitedConcurrencyTaskScheduler(10)); + } + + public void Publish(T @event) where T : class, IEvent + { + if (@event == null) + throw new ArgumentNullException(nameof(@event)); + + var eventType = typeof(T); + + if (!_handlers.TryGetValue(eventType, out var handlers)) + return; + + foreach (var handler in handlers.Cast>()) + { + var localHandler = handler; // Capture for closure + _taskFactory.StartNew(() => + { + try + { + localHandler(@event); + } + catch (Exception ex) + { + // Log the exception + Console.WriteLine($"Error handling event {eventType.Name}: {ex.Message}"); + } + }); + } + } + + // Implement other methods... +} +``` + +### Event Upcasting + +Handling event schema evolution through upcasting: + +```csharp +public class UpcastingEventBus : IEventBus +{ + private readonly IEventBus _innerBus; + private readonly IEventUpcastingService _upcastingService; + + public UpcastingEventBus(IEventBus innerBus, IEventUpcastingService upcastingService) + { + _innerBus = innerBus; + _upcastingService = upcastingService; + } + + public void Publish(T @event) where T : class, IEvent + { + if (@event == null) + throw new ArgumentNullException(nameof(@event)); + + // Upcast the event if needed + var upcastedEvent = _upcastingService.Upcast(@event); + + // Publish the upcasted event + _innerBus.Publish(upcastedEvent); + } + + // Implement other methods... +} +``` + +### Event Logging and Monitoring + +Adding logging and monitoring to the event bus: + +```csharp +public class MonitoringEventBus : IEventBus +{ + private readonly IEventBus _innerBus; + private readonly ILogger _logger; + private readonly IMetrics _metrics; + + public MonitoringEventBus(IEventBus innerBus, ILogger logger, IMetrics metrics) + { + _innerBus = innerBus; + _logger = logger; + _metrics = metrics; + } + + public void Publish(T @event) where T : class, IEvent + { + if (@event == null) + throw new ArgumentNullException(nameof(@event)); + + var eventType = typeof(T).Name; + var correlationId = @event is ICorrelatedMessage msg ? msg.CorrelationId : Guid.Empty; + + _logger.LogInformation("Publishing event {EventType} with correlation ID {CorrelationId}", + eventType, correlationId); + + var stopwatch = Stopwatch.StartNew(); + + try + { + _innerBus.Publish(@event); + + stopwatch.Stop(); + _metrics.RecordEventProcessingTime(eventType, stopwatch.ElapsedMilliseconds); + _metrics.IncrementEventCounter(eventType); + + _logger.LogInformation("Event {EventType} published successfully in {ElapsedMs}ms", + eventType, stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + _metrics.IncrementEventErrorCounter(eventType); + + _logger.LogError(ex, "Error publishing event {EventType}", eventType); + throw; + } + } + + // Implement other methods... +} +``` + +## Related Components + +- [Event](event.md): Base class for events in Reactive Domain +- [IEvent](ievent.md): Interface for events in Reactive Domain +- [ICorrelatedMessage](icorrelated-message.md): Interface for correlated messages +- [MessageBuilder](message-builder.md): Factory for creating correlated messages +- [ICommandBus](icommand-bus.md): Interface for sending commands +- [IEventHandler](ievent-handler.md): Interface for event handlers +- [ReadModelBase](read-model-base.md): Base class for read models + +--- + +**Navigation**: +- [← Previous: ICommandBus](./icommand-bus.md) +- [↑ Back to Top](#ieventbus-interface) +- [→ Next: IEventHandler](./ievent-handler.md) diff --git a/docs/api-reference/types/ievent-handler.md b/docs/api-reference/types/ievent-handler.md new file mode 100644 index 00000000..799d3a6f --- /dev/null +++ b/docs/api-reference/types/ievent-handler.md @@ -0,0 +1,164 @@ +# IEventHandler + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`IEventHandler` is a core interface in Reactive Domain that defines the contract for components that handle domain events. + +## Overview + +Event handlers are a fundamental part of event-driven architectures, responsible for reacting to domain events and updating read models, triggering side effects, or initiating other processes. In Reactive Domain, the `IEventHandler` interface provides a standard way to define event handlers that can be registered with the event bus. + +Event handlers are typically used to maintain read models that support queries in a CQRS architecture. They subscribe to domain events raised by aggregates and transform these events into denormalized data structures optimized for querying. + +## Interface Definition + +```csharp +public interface IEventHandler where TEvent : IEvent +{ + void Handle(TEvent @event); +} +``` + +## Key Features + +- **Type Safety**: Provides type-safe handling of specific event types +- **Single Responsibility**: Each handler implementation focuses on handling a specific event type +- **Decoupling**: Enables loose coupling between event producers (aggregates) and event consumers (read models) +- **Scalability**: Allows for independent scaling of read and write sides in a CQRS architecture +- **Extensibility**: Makes it easy to add new event handlers without modifying existing code + +## Usage + +### Basic Event Handler + +Here's a simple example of an event handler that updates a read model when an account is created: + +```csharp +public class AccountCreatedHandler : IEventHandler +{ + private readonly IReadModelRepository _repository; + + public AccountCreatedHandler(IReadModelRepository repository) + { + _repository = repository; + } + + public void Handle(AccountCreated @event) + { + // Create a new read model from the event data + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, @event.InitialBalance); + + // Save the read model to the repository + _repository.Save(accountSummary); + } +} +``` + +### Handling Multiple Event Types + +A class can implement multiple `IEventHandler` interfaces to handle different event types: + +```csharp +public class AccountEventHandler : + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _repository; + + public AccountEventHandler(IReadModelRepository repository) + { + _repository = repository; + } + + public void Handle(AccountCreated @event) + { + // Handle account creation + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, @event.InitialBalance); + _repository.Save(accountSummary); + } + + public void Handle(FundsDeposited @event) + { + // Handle funds deposit + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.Update( + accountSummary.AccountNumber, + accountSummary.CustomerName, + accountSummary.Balance + @event.Amount); + _repository.Save(accountSummary); + } + } + + public void Handle(FundsWithdrawn @event) + { + // Handle funds withdrawal + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.Update( + accountSummary.AccountNumber, + accountSummary.CustomerName, + accountSummary.Balance - @event.Amount); + _repository.Save(accountSummary); + } + } +} +``` + +### Registering Event Handlers + +Event handlers are typically registered with an event bus during application startup: + +```csharp +public void ConfigureEventHandlers(IEventBus eventBus, IReadModelRepository repository) +{ + var accountEventHandler = new AccountEventHandler(repository); + + // Register the handler for each event type it handles + eventBus.Subscribe(accountEventHandler); + eventBus.Subscribe(accountEventHandler); + eventBus.Subscribe(accountEventHandler); +} +``` + +## Best Practices + +1. **Idempotent Handlers**: Make event handlers idempotent so they can safely process the same event multiple times +2. **Error Handling**: Implement proper error handling to prevent a single failed event from stopping the processing of subsequent events +3. **Single Responsibility**: Keep handlers focused on a specific domain concept or read model +4. **Performance Consideration**: Keep handlers lightweight and fast to avoid blocking the event processing pipeline +5. **Logging**: Include appropriate logging to track event processing and troubleshoot issues +6. **Transaction Management**: Consider transaction boundaries when updating multiple read models or external systems +7. **Eventual Consistency**: Design systems to handle the eventual consistency inherent in event-driven architectures +8. **Testing**: Write unit tests for event handlers to ensure they correctly update read models + +## Common Pitfalls + +1. **Business Logic in Handlers**: Avoid putting domain business logic in event handlers; they should focus on updating read models +2. **Synchronous External Calls**: Be cautious about making synchronous calls to external systems from event handlers +3. **Ignoring Errors**: Failing to handle errors properly can lead to lost events or inconsistent read models +4. **Over-normalization**: Denormalize data in read models appropriately for query efficiency +5. **Tight Coupling**: Avoid tightly coupling event handlers to specific aggregate implementations +6. **Missing Events**: Ensure handlers are registered for all relevant events to maintain complete read models +7. **Order Dependency**: Be careful about assuming a specific order of event processing + +## Related Components + +- [IEvent](./ievent.md): Interface for domain events processed by event handlers +- [ReadModelBase](./read-model-base.md): Base class for read models updated by event handlers +- [IReadModelRepository](./iread-model-repository.md): Interface for storing and retrieving read models +- [Event](./event.md): Base class for domain events +- [AggregateRoot](./aggregate-root.md): Domain entities that raise events processed by event handlers +- [ICorrelatedMessage](./icorrelated-message.md): Interface for tracking message correlation + +--- + +**Navigation**: +- [← Previous: IEvent](./ievent.md) +- [↑ Back to Top](#ieventhandler) +- [→ Next: ReadModelBase](./read-model-base.md) diff --git a/docs/api-reference/types/ievent-processor.md b/docs/api-reference/types/ievent-processor.md new file mode 100644 index 00000000..c1a4c882 --- /dev/null +++ b/docs/api-reference/types/ievent-processor.md @@ -0,0 +1,507 @@ +# IEventProcessor Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `IEventProcessor` interface defines the contract for components that process events from an event store. It serves as a crucial component in event-sourced systems, enabling the consumption of events for various purposes such as updating read models, triggering processes, and maintaining system state. + +In Reactive Domain, event processors are responsible for reading events from the event store, processing them in the correct order, and ensuring that each event is processed exactly once, even in the face of failures or restarts. + +## Event Processing in Event Sourcing + +In event-sourced systems, event processors play several critical roles: + +1. **Event Consumption**: Reading events from the event store in the correct order +2. **Checkpoint Management**: Tracking which events have been processed to enable resumption after failures +3. **Idempotent Processing**: Ensuring events are processed exactly once +4. **Concurrency Management**: Handling concurrent event processing safely +5. **Error Handling**: Managing failures during event processing + +Event processors are essential for maintaining the read side of CQRS architectures, where events from the write side are used to update read models that support queries and views. + +**Namespace**: `ReactiveDomain.Messaging` +**Assembly**: `ReactiveDomain.Messaging.dll` + +```csharp +public interface IEventProcessor +{ + void Start(); + void Stop(); + void Subscribe(Action handler) where T : class, IEvent; + void Unsubscribe(Action handler) where T : class, IEvent; + long Position { get; } +} +``` + +## Properties + +### Position + +Gets the current position of the event processor in the event stream. + +```csharp +long Position { get; } +``` + +**Returns**: `System.Int64` - The current position in the event stream. + +**Remarks**: The position represents the sequence number of the last event that was successfully processed. This value is used to resume processing from the correct position after a restart. + +## Methods + +### Start + +Starts the event processor, which begins consuming events from the event store. + +```csharp +void Start(); +``` + +**Remarks**: This method starts the event processor, which begins consuming events from the event store. If the processor has been previously stopped, it will resume from the last checkpoint position. + +**Example**: +```csharp +// Create an event processor +var eventProcessor = new EventStoreProcessor(eventStoreConnection, "ReadModelUpdater"); + +// Subscribe handlers +eventProcessor.Subscribe(HandleAccountCreated); +eventProcessor.Subscribe(HandleFundsDeposited); + +// Start the processor +eventProcessor.Start(); +``` + +### Stop + +Stops the event processor, which ceases consuming events from the event store. + +```csharp +void Stop(); +``` + +**Remarks**: This method stops the event processor, which ceases consuming events from the event store. The current position is preserved, allowing the processor to resume from the same point when started again. + +**Example**: +```csharp +// Stop the processor gracefully +eventProcessor.Stop(); +``` + +### Subscribe\ + +Subscribes a handler to process events of a specific type. + +```csharp +void Subscribe(Action handler) where T : class, IEvent; +``` + +**Type Parameters**: +- `T`: The type of event to subscribe to. Must be a class that implements `IEvent`. + +**Parameters**: +- `handler` (`System.Action`): The handler to process events of the specified type. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `handler` is `null`. + +**Remarks**: This method registers a handler for a specific event type. When events of this type are read from the event store, they will be dispatched to the registered handler. + +**Example**: +```csharp +// Subscribe a handler for AccountCreated events +eventProcessor.Subscribe(evt => +{ + // Update the read model + var readModel = new AccountReadModel + { + Id = evt.AccountId, + AccountNumber = evt.AccountNumber, + Balance = evt.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + // Save the read model + readModelRepository.Save(readModel); +}); +``` + +### Unsubscribe\ + +Unsubscribes a handler from processing events of a specific type. + +```csharp +void Unsubscribe(Action handler) where T : class, IEvent; +``` + +**Type Parameters**: +- `T`: The type of event to unsubscribe from. Must be a class that implements `IEvent`. + +**Parameters**: +- `handler` (`System.Action`): The handler to unregister. + +**Exceptions**: +- `System.ArgumentNullException`: Thrown when `handler` is `null`. + +**Remarks**: This method unregisters a handler for a specific event type. It is typically used when a component is being disposed or when dynamic handler registration is required. + +**Example**: +```csharp +// Define a handler +Action accountCreatedHandler = evt => +{ + // Handler logic +}; + +// Subscribe the handler +eventProcessor.Subscribe(accountCreatedHandler); + +// Later, unsubscribe the handler +eventProcessor.Unsubscribe(accountCreatedHandler); +``` + +## Usage + +The `IEventProcessor` interface is typically used to implement components that update read models or trigger processes based on events from the event store. Here's a comprehensive example of using an event processor: + +### Basic Event Processor Usage + +```csharp +// Create an event processor +var connectionSettings = ConnectionSettings.Create() + .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")) + .Build(); + +var eventStoreConnection = EventStoreConnection.Create(connectionSettings, new Uri("tcp://localhost:1113")); +eventStoreConnection.ConnectAsync().Wait(); + +var eventProcessor = new EventStoreProcessor(eventStoreConnection, "ReadModelUpdater"); + +// Subscribe handlers +eventProcessor.Subscribe(HandleAccountCreated); +eventProcessor.Subscribe(HandleFundsDeposited); +eventProcessor.Subscribe(HandleFundsWithdrawn); +eventProcessor.Subscribe(HandleAccountClosed); + +// Start the processor +eventProcessor.Start(); + +// Handler methods +void HandleAccountCreated(AccountCreated @event) +{ + Console.WriteLine($"Processing AccountCreated event: {@event.AccountId}"); + + // Update the read model + var readModel = new AccountReadModel + { + Id = @event.AccountId, + AccountNumber = @event.AccountNumber, + Balance = @event.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + // Save the read model + readModelRepository.Save(readModel); +} + +void HandleFundsDeposited(FundsDeposited @event) +{ + Console.WriteLine($"Processing FundsDeposited event: {@event.AccountId}, Amount: {@event.Amount}"); + + // Update the read model + var readModel = readModelRepository.GetById(@event.AccountId); + readModel.Balance += @event.Amount; + readModel.LastUpdated = DateTime.UtcNow; + + // Save the read model + readModelRepository.Save(readModel); +} + +// Additional handlers... + +// When shutting down +eventProcessor.Stop(); +``` + +### Integration with Dependency Injection + +```csharp +public class ReadModelUpdater : IDisposable +{ + private readonly IEventProcessor _eventProcessor; + private readonly IReadModelRepository _readModelRepository; + private readonly ILogger _logger; + + public ReadModelUpdater( + IEventProcessor eventProcessor, + IReadModelRepository readModelRepository, + ILogger logger) + { + _eventProcessor = eventProcessor; + _readModelRepository = readModelRepository; + _logger = logger; + + // Register handlers + RegisterHandlers(); + } + + private void RegisterHandlers() + { + _eventProcessor.Subscribe(HandleAccountCreated); + _eventProcessor.Subscribe(HandleFundsDeposited); + _eventProcessor.Subscribe(HandleFundsWithdrawn); + _eventProcessor.Subscribe(HandleAccountClosed); + } + + public void Start() + { + _logger.LogInformation("Starting read model updater"); + _eventProcessor.Start(); + } + + private void HandleAccountCreated(AccountCreated @event) + { + _logger.LogInformation("Processing AccountCreated event: {@AccountId}", @event.AccountId); + + // Implementation... + } + + // Additional handlers... + + public void Dispose() + { + _logger.LogInformation("Stopping read model updater"); + _eventProcessor.Stop(); + + // Unregister handlers + _eventProcessor.Unsubscribe(HandleAccountCreated); + _eventProcessor.Unsubscribe(HandleFundsDeposited); + _eventProcessor.Unsubscribe(HandleFundsWithdrawn); + _eventProcessor.Unsubscribe(HandleAccountClosed); + } +} +``` + +### Multiple Event Processors + +In a complex system, you might have multiple event processors for different purposes: + +```csharp +// Read model updater +var readModelProcessor = new EventStoreProcessor(eventStoreConnection, "ReadModelUpdater"); +readModelProcessor.Subscribe(evt => UpdateReadModel(evt)); +readModelProcessor.Subscribe(evt => UpdateReadModel(evt)); +readModelProcessor.Start(); + +// Notification service +var notificationProcessor = new EventStoreProcessor(eventStoreConnection, "NotificationService"); +notificationProcessor.Subscribe(evt => SendWelcomeEmail(evt)); +notificationProcessor.Subscribe(evt => SendDepositNotification(evt)); +notificationProcessor.Start(); + +// Analytics service +var analyticsProcessor = new EventStoreProcessor(eventStoreConnection, "AnalyticsService"); +analyticsProcessor.Subscribe(evt => TrackAccountCreation(evt)); +analyticsProcessor.Subscribe(evt => TrackDeposit(evt)); +analyticsProcessor.Start(); + +// Process manager +var processManagerProcessor = new EventStoreProcessor(eventStoreConnection, "ProcessManager"); +processManagerProcessor.Subscribe(evt => StartOnboardingProcess(evt)); +processManagerProcessor.Subscribe(evt => StartOffboardingProcess(evt)); +processManagerProcessor.Start(); +``` + +## Best Practices + +1. **Idempotent Handlers**: Design event handlers to be idempotent to handle duplicate events safely +2. **Error Handling**: Implement proper error handling in event handlers to prevent processor stalling +3. **Checkpoint Management**: Ensure checkpoints are properly persisted to enable resumption after failures +4. **Performance Tuning**: Configure batch sizes and polling intervals for optimal performance +5. **Monitoring**: Implement monitoring to track processor health and progress +6. **Logging**: Log event processing for debugging and auditing purposes +7. **Concurrency Control**: Configure the appropriate level of concurrency for event processing +8. **Graceful Shutdown**: Implement graceful shutdown procedures to prevent data loss +9. **Event Versioning**: Handle event schema evolution gracefully +10. **Resource Management**: Properly dispose of resources when shutting down + +## Common Pitfalls + +1. **Non-Idempotent Handlers**: Handlers that produce different results when processing the same event multiple times +2. **Unhandled Exceptions**: Exceptions in handlers that can cause the processor to stall +3. **Checkpoint Frequency**: Checkpointing too frequently (performance impact) or too infrequently (potential for duplicate processing) +4. **Event Order Dependency**: Creating dependencies on event processing order that may not be guaranteed +5. **Resource Leaks**: Not properly disposing of resources when shutting down +6. **Slow Handlers**: Handlers that take too long to process events, causing backpressure +7. **Missing Event Types**: Not handling all relevant event types +8. **Overloaded Processors**: Trying to do too much in a single processor, leading to performance issues + +## Advanced Scenarios + +### Custom Checkpoint Storage + +Implementing custom checkpoint storage for an event processor: + +```csharp +public class CustomCheckpointStore : ICheckpointStore +{ + private readonly IDocumentStore _documentStore; + + public CustomCheckpointStore(IDocumentStore documentStore) + { + _documentStore = documentStore; + } + + public async Task GetCheckpointAsync(string processorName) + { + using (var session = _documentStore.OpenSession()) + { + var checkpoint = await session.LoadAsync(processorName); + return checkpoint?.Position ?? -1; + } + } + + public async Task StoreCheckpointAsync(string processorName, long position) + { + using (var session = _documentStore.OpenSession()) + { + var checkpoint = await session.LoadAsync(processorName) + ?? new CheckpointDocument { Id = processorName }; + + checkpoint.Position = position; + await session.StoreAsync(checkpoint); + await session.SaveChangesAsync(); + } + } + + private class CheckpointDocument + { + public string Id { get; set; } + public long Position { get; set; } + } +} +``` + +### Event Upcasting + +Handling event schema evolution through upcasting: + +```csharp +public class UpcastingEventProcessor : IEventProcessor +{ + private readonly IEventProcessor _innerProcessor; + private readonly IEventUpcastingService _upcastingService; + + public UpcastingEventProcessor(IEventProcessor innerProcessor, IEventUpcastingService upcastingService) + { + _innerProcessor = innerProcessor; + _upcastingService = upcastingService; + + // Subscribe to all events and upcast them before forwarding + _innerProcessor.Subscribe(UpcasterHandler); + } + + private void UpcasterHandler(IEvent @event) + { + // Upcast the event + var upcastedEvent = _upcastingService.Upcast(@event); + + // Find the appropriate handler for the upcasted event + var eventType = upcastedEvent.GetType(); + + // Use reflection to find and invoke the appropriate handler + // This is a simplified example; a real implementation would be more sophisticated + if (_handlers.TryGetValue(eventType, out var handlers)) + { + foreach (var handler in handlers) + { + handler.DynamicInvoke(upcastedEvent); + } + } + } + + // Implement other methods... +} +``` + +### Parallel Event Processing + +Implementing parallel event processing with careful ordering: + +```csharp +public class ParallelEventProcessor : IEventProcessor +{ + private readonly IEventProcessor _innerProcessor; + private readonly int _maxDegreeOfParallelism; + private readonly Dictionary> _handlers = new Dictionary>(); + + public ParallelEventProcessor(IEventProcessor innerProcessor, int maxDegreeOfParallelism) + { + _innerProcessor = innerProcessor; + _maxDegreeOfParallelism = maxDegreeOfParallelism; + + // Subscribe to all events + _innerProcessor.Subscribe(ParallelHandler); + } + + private void ParallelHandler(IEvent @event) + { + var eventType = @event.GetType(); + + if (_handlers.TryGetValue(eventType, out var handlers)) + { + // Group events by aggregate ID to maintain ordering within an aggregate + var aggregateId = GetAggregateId(@event); + + // Process handlers in parallel, but maintain order within each aggregate + Parallel.ForEach(handlers, + new ParallelOptions { MaxDegreeOfParallelism = _maxDegreeOfParallelism }, + handler => + { + try + { + handler.DynamicInvoke(@event); + } + catch (Exception ex) + { + // Log the exception + Console.WriteLine($"Error handling event {eventType.Name}: {ex.Message}"); + } + }); + } + } + + private Guid GetAggregateId(IEvent @event) + { + // Extract the aggregate ID from the event + // This is a simplified example; a real implementation would use reflection or a more sophisticated approach + var property = @event.GetType().GetProperty("AggregateId") ?? + @event.GetType().GetProperty("EntityId") ?? + @event.GetType().GetProperty("Id"); + + return property != null ? (Guid)property.GetValue(@event) : Guid.Empty; + } + + // Implement other methods... +} +``` + +## Related Components + +- [Event](event.md): Base class for events in Reactive Domain +- [IEvent](ievent.md): Interface for events in Reactive Domain +- [IEventBus](ievent-bus.md): Interface for publishing events +- [IEventHandler](ievent-handler.md): Interface for event handlers +- [ReadModelBase](read-model-base.md): Base class for read models +- [IReadModelRepository](iread-model-repository.md): Interface for read model repositories +- [ICheckpointStore](icheckpoint-store.md): Interface for storing and retrieving checkpoints + +--- + +**Navigation**: +- [← Previous: IEventBus](./ievent-bus.md) +- [↑ Back to Top](#ieventprocessor-interface) +- [→ Next: IEventHandler](./ievent-handler.md) diff --git a/docs/api-reference/types/ievent.md b/docs/api-reference/types/ievent.md new file mode 100644 index 00000000..68ee05d5 --- /dev/null +++ b/docs/api-reference/types/ievent.md @@ -0,0 +1,537 @@ +# IEvent Interface + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +## Overview + +The `IEvent` interface is a marker interface that defines the contract for events in Reactive Domain. Events represent facts that have occurred in the system and are a fundamental building block in Event Sourcing and CQRS (Command Query Responsibility Segregation) architectures. + +In Reactive Domain, events are immutable messages that describe something that has happened in the domain. They are used to record state changes, update read models, trigger processes, and provide an audit trail of all changes to the system. + +## Events in Event Sourcing + +In an Event Sourcing architecture, events play a crucial role: + +1. **State Changes**: Events represent all state changes in the system +2. **Event Stream**: The sequence of events forms an event stream that represents the history of an entity +3. **State Reconstruction**: The current state of an entity can be reconstructed by replaying its events +4. **Temporal Queries**: The state of an entity at any point in time can be determined by replaying events up to that point +5. **Audit Trail**: Events provide a complete audit trail of all changes to the system + +Events are distinct from commands (which represent intentions to change state) in that events represent facts that have already occurred and cannot be rejected. + +**Namespace**: `ReactiveDomain.Messaging` +**Assembly**: `ReactiveDomain.Messaging.dll` + +```csharp +public interface IEvent : IMessage +{ +} +``` + +## Inheritance + +The `IEvent` interface inherits from the `IMessage` interface, which provides the base contract for all messages in Reactive Domain. + +``` +IMessage + ↑ +IEvent +``` + +## Usage + +The `IEvent` interface is typically used as a marker interface to identify event messages in the system. Events are usually implemented as concrete classes that inherit from the `Event` base class, which provides common functionality for events. + +### Basic Event Implementation + +```csharp +// Define an event +public class AccountCreated : Event +{ + public Guid AccountId { get; } + public string AccountNumber { get; } + public decimal InitialDeposit { get; } + + public AccountCreated(Guid accountId, string accountNumber, decimal initialDeposit) + { + AccountId = accountId; + AccountNumber = accountNumber; + InitialDeposit = initialDeposit; + } +} + +// Define an event handler +public class AccountReadModelUpdater : IEventHandler +{ + private readonly IReadModelRepository _readModelRepository; + + public AccountReadModelUpdater(IReadModelRepository readModelRepository) + { + _readModelRepository = readModelRepository; + } + + public void Handle(AccountCreated @event) + { + // Create a new read model + var readModel = new AccountReadModel + { + Id = @event.AccountId, + AccountNumber = @event.AccountNumber, + Balance = @event.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + // Save the read model + _readModelRepository.Save(readModel); + } +} +``` + +### Correlated Event + +```csharp +public class FundsDeposited : Event, ICorrelatedMessage +{ + public Guid AccountId { get; } + public decimal Amount { get; } + + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + public FundsDeposited( + Guid accountId, + decimal amount, + Guid msgId, + Guid correlationId, + Guid causationId) + { + AccountId = accountId; + Amount = amount; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } + + // Alternative constructor using MessageBuilder + public static FundsDeposited Create( + Guid accountId, + decimal amount, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new FundsDeposited( + accountId, + amount, + Guid.NewGuid(), + source.CorrelationId, + source.MsgId)); + } +} +``` + +## Event Bus Integration + +Events are typically published through an event bus, which distributes them to their subscribers: + +```csharp +// Create an event bus +var eventBus = new EventBus(); + +// Register event handlers +eventBus.Subscribe(evt => +{ + // Update the read model + var readModel = new AccountReadModel + { + Id = evt.AccountId, + AccountNumber = evt.AccountNumber, + Balance = evt.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + readModelRepository.Save(readModel); +}); + +// Create and publish an event +var accountCreatedEvent = new AccountCreated(Guid.NewGuid(), "12345", 1000); +eventBus.Publish(accountCreatedEvent); +``` + +## Event Handling Patterns + +### Read Model Updater + +```csharp +public class AccountReadModelUpdater : + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _readModelRepository; + private readonly ILogger _logger; + + public AccountReadModelUpdater( + IReadModelRepository readModelRepository, + ILogger logger) + { + _readModelRepository = readModelRepository; + _logger = logger; + } + + public void Handle(AccountCreated @event) + { + _logger.LogInformation("Handling AccountCreated event for account {@AccountId}", @event.AccountId); + + // Create a new read model + var readModel = new AccountReadModel + { + Id = @event.AccountId, + AccountNumber = @event.AccountNumber, + Balance = @event.InitialDeposit, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + // Save the read model + _readModelRepository.Save(readModel); + } + + public void Handle(FundsDeposited @event) + { + _logger.LogInformation("Handling FundsDeposited event for account {@AccountId}", @event.AccountId); + + // Get the read model + var readModel = _readModelRepository.GetById(@event.AccountId); + + // Update the read model + readModel.Balance += @event.Amount; + readModel.LastUpdated = DateTime.UtcNow; + + // Save the read model + _readModelRepository.Save(readModel); + } + + public void Handle(FundsWithdrawn @event) + { + _logger.LogInformation("Handling FundsWithdrawn event for account {@AccountId}", @event.AccountId); + + // Get the read model + var readModel = _readModelRepository.GetById(@event.AccountId); + + // Update the read model + readModel.Balance -= @event.Amount; + readModel.LastUpdated = DateTime.UtcNow; + + // Save the read model + _readModelRepository.Save(readModel); + } + + public void Handle(AccountClosed @event) + { + _logger.LogInformation("Handling AccountClosed event for account {@AccountId}", @event.AccountId); + + // Get the read model + var readModel = _readModelRepository.GetById(@event.AccountId); + + // Update the read model + readModel.IsActive = false; + readModel.LastUpdated = DateTime.UtcNow; + + // Save the read model + _readModelRepository.Save(readModel); + } +} +``` + +### Process Manager + +```csharp +public class AccountTransferProcessManager : + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + private readonly IRepository _repository; + private readonly ILogger _logger; + + public AccountTransferProcessManager( + ICommandBus commandBus, + IRepository repository, + ILogger logger) + { + _commandBus = commandBus; + _repository = repository; + _logger = logger; + } + + public void Handle(TransferInitiated @event) + { + _logger.LogInformation("Handling TransferInitiated event for transfer {@TransferId}", @event.TransferId); + + // Create a withdraw command + var withdrawCommand = MessageBuilder.From(@event, () => new WithdrawFunds( + @event.SourceAccountId, + @event.Amount, + @event.TransferId)); + + // Send the withdraw command + _commandBus.Send(withdrawCommand); + } + + public void Handle(FundsWithdrawn @event) + { + _logger.LogInformation("Handling FundsWithdrawn event for account {@AccountId}", @event.AccountId); + + // Get the transfer process + var transfer = _repository.GetById(@event.CorrelationId); + + // Update the transfer process + transfer.MarkFundsWithdrawn(@event); + + // Save the transfer process + _repository.Save(transfer); + + // If this is part of a transfer, create a deposit command + if (transfer.Status == TransferStatus.FundsWithdrawn) + { + var depositCommand = MessageBuilder.From(@event, () => new DepositFunds( + transfer.TargetAccountId, + transfer.Amount, + transfer.Id)); + + // Send the deposit command + _commandBus.Send(depositCommand); + } + } + + // Additional handlers... +} +``` + +## Best Practices + +1. **Event Naming**: Use past tense verbs for event names (e.g., `AccountCreated`, `FundsDeposited`) +2. **Event Immutability**: Make events immutable to prevent unintended side effects +3. **Event Properties**: Include all relevant information in event properties +4. **Event Versioning**: Plan for event versioning to handle schema evolution +5. **Event Handlers**: Design event handlers to be idempotent +6. **Multiple Subscribers**: Design for multiple subscribers to the same event +7. **Correlation**: Use correlation IDs to track event flows through the system +8. **Event Documentation**: Document the purpose and content of each event +9. **Event Serialization**: Ensure events can be properly serialized and deserialized +10. **Event Ordering**: Maintain the correct order of events when replaying + +## Common Pitfalls + +1. **Mutable Events**: Creating events that can be modified after creation +2. **Missing Properties**: Not including all necessary information in events +3. **Event Coupling**: Creating tight coupling between event publishers and subscribers +4. **Event Overloading**: Creating events that carry too much information +5. **Non-Idempotent Handlers**: Creating event handlers that produce different results when processing the same event multiple times +6. **Event Order Dependency**: Creating dependencies on event processing order that may not be guaranteed +7. **Missing Error Handling**: Not properly handling exceptions in event handlers +8. **Event Naming Inconsistency**: Inconsistent naming conventions for events + +## Advanced Scenarios + +### Event Upcasting + +Handling event schema evolution through upcasting: + +```csharp +public class EventUpcastingService : IEventUpcastingService +{ + public object Upcast(object @event) + { + // Check if the event needs upcasting + if (@event is AccountCreatedV1 eventV1) + { + // Upcast from V1 to V2 + return new AccountCreatedV2( + eventV1.AccountId, + eventV1.AccountNumber, + eventV1.InitialDeposit, + DateTime.UtcNow); // Add new property + } + + // Return the original event if no upcasting is needed + return @event; + } +} + +// Original event (V1) +public class AccountCreatedV1 : Event +{ + public Guid AccountId { get; } + public string AccountNumber { get; } + public decimal InitialDeposit { get; } + + public AccountCreatedV1(Guid accountId, string accountNumber, decimal initialDeposit) + { + AccountId = accountId; + AccountNumber = accountNumber; + InitialDeposit = initialDeposit; + } +} + +// New version of the event (V2) +public class AccountCreatedV2 : Event +{ + public Guid AccountId { get; } + public string AccountNumber { get; } + public decimal InitialDeposit { get; } + public DateTime CreatedAt { get; } // New property + + public AccountCreatedV2(Guid accountId, string accountNumber, decimal initialDeposit, DateTime createdAt) + { + AccountId = accountId; + AccountNumber = accountNumber; + InitialDeposit = initialDeposit; + CreatedAt = createdAt; + } +} +``` + +### Event Enrichment + +Adding additional information to events before processing: + +```csharp +public class EnrichingEventBus : IEventBus +{ + private readonly IEventBus _innerBus; + private readonly IEventEnricher _enricher; + + public EnrichingEventBus(IEventBus innerBus, IEventEnricher enricher) + { + _innerBus = innerBus; + _enricher = enricher; + } + + public void Publish(T @event) where T : class, IEvent + { + // Enrich the event + var enrichedEvent = _enricher.Enrich(@event); + + // Publish the enriched event + _innerBus.Publish(enrichedEvent); + } + + // Implement other methods... +} + +public class EventEnricher : IEventEnricher +{ + private readonly IUserContext _userContext; + private readonly ITimeProvider _timeProvider; + + public EventEnricher(IUserContext userContext, ITimeProvider timeProvider) + { + _userContext = userContext; + _timeProvider = timeProvider; + } + + public T Enrich(T @event) where T : class, IEvent + { + // If the event is enrichable, add additional information + if (@event is IEnrichableEvent enrichableEvent) + { + enrichableEvent.UserId = _userContext.CurrentUser?.Id; + enrichableEvent.Timestamp = _timeProvider.UtcNow; + } + + return @event; + } +} + +public interface IEnrichableEvent +{ + Guid? UserId { get; set; } + DateTime Timestamp { get; set; } +} +``` + +### Event Filtering + +Filtering events before processing: + +```csharp +public class FilteringEventBus : IEventBus +{ + private readonly IEventBus _innerBus; + private readonly IEventFilter _filter; + + public FilteringEventBus(IEventBus innerBus, IEventFilter filter) + { + _innerBus = innerBus; + _filter = filter; + } + + public void Publish(T @event) where T : class, IEvent + { + // Check if the event should be published + if (_filter.ShouldPublish(@event)) + { + // Publish the event + _innerBus.Publish(@event); + } + } + + // Implement other methods... +} + +public class EventFilter : IEventFilter +{ + private readonly IUserContext _userContext; + + public EventFilter(IUserContext userContext) + { + _userContext = userContext; + } + + public bool ShouldPublish(T @event) where T : class, IEvent + { + // Check if the event is restricted + if (@event is IRestrictedEvent restrictedEvent) + { + // Check if the current user has access to the event + return _userContext.CurrentUser?.HasAccess(restrictedEvent.AccessLevel) ?? false; + } + + // By default, publish all events + return true; + } +} + +public interface IRestrictedEvent +{ + string AccessLevel { get; } +} +``` + +## Related Components + +- [Event](event.md): Base class for events in Reactive Domain +- [IEventBus](ievent-bus.md): Interface for publishing events +- [IEventHandler](ievent-handler.md): Interface for event handlers +- [IEventProcessor](ievent-processor.md): Interface for components that process events +- [IMessage](imessage.md): Base interface for all messages +- [ICorrelatedMessage](icorrelated-message.md): Interface for correlated messages +- [MessageBuilder](message-builder.md): Factory for creating correlated messages +- [AggregateRoot](aggregate-root.md): Base class for domain aggregates +- [ReadModelBase](read-model-base.md): Base class for read models + +--- + +**Navigation**: +- [← Previous: ICommandHandler](./icommand-handler.md) +- [↑ Back to Top](#ievent-interface) +- [→ Next: Event](./event.md) diff --git a/docs/api-reference/types/iread-model-repository.md b/docs/api-reference/types/iread-model-repository.md new file mode 100644 index 00000000..fd8b1a7c --- /dev/null +++ b/docs/api-reference/types/iread-model-repository.md @@ -0,0 +1,287 @@ +# IReadModelRepository + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`IReadModelRepository` is a core interface in Reactive Domain that defines the contract for repositories that store and retrieve read models. + +## Overview + +In a CQRS (Command Query Responsibility Segregation) architecture, read models are optimized data structures designed specifically for querying. The `IReadModelRepository` interface provides a standard way to interact with these read models, abstracting the underlying storage mechanism and providing a consistent API for read model persistence. + +Read model repositories are typically used by event handlers to persist updated read models after processing domain events. They are also used by query services to retrieve read models when responding to queries from clients. + +## Interface Definition + +```csharp +public interface IReadModelRepository where T : ReadModelBase +{ + T GetById(Guid id); + IEnumerable GetAll(); + void Save(T item); + void Delete(Guid id); +} +``` + +## Key Features + +- **Type Safety**: Provides type-safe operations for specific read model types +- **Storage Abstraction**: Abstracts the underlying storage mechanism (in-memory, relational database, document database, etc.) +- **CRUD Operations**: Supports basic Create, Read, Update, and Delete operations +- **Consistency**: Ensures consistent access patterns across different read model types +- **Flexibility**: Can be implemented for various storage technologies based on query requirements + +## Usage + +### Basic Repository Operations + +Here's a simple example of using a read model repository: + +```csharp +public class AccountQueryService +{ + private readonly IReadModelRepository _repository; + + public AccountQueryService(IReadModelRepository repository) + { + _repository = repository; + } + + public AccountSummary GetAccountById(Guid accountId) + { + return _repository.GetById(accountId); + } + + public IEnumerable GetAllAccounts() + { + return _repository.GetAll(); + } + + public void DeleteAccount(Guid accountId) + { + _repository.Delete(accountId); + } +} +``` + +### In Event Handlers + +Read model repositories are commonly used in event handlers to update read models in response to domain events: + +```csharp +public class AccountEventHandler : + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _repository; + + public AccountEventHandler(IReadModelRepository repository) + { + _repository = repository; + } + + public void Handle(AccountCreated @event) + { + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update(@event.AccountNumber, @event.CustomerName, @event.InitialBalance); + _repository.Save(accountSummary); + } + + public void Handle(FundsDeposited @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.Update( + accountSummary.AccountNumber, + accountSummary.CustomerName, + accountSummary.Balance + @event.Amount); + _repository.Save(accountSummary); + } + } +} +``` + +## Implementation Examples + +### In-Memory Repository + +A simple in-memory implementation for development or testing: + +```csharp +public class InMemoryReadModelRepository : IReadModelRepository where T : ReadModelBase +{ + private readonly Dictionary _items = new Dictionary(); + + public T GetById(Guid id) + { + return _items.TryGetValue(id, out var item) ? item : null; + } + + public IEnumerable GetAll() + { + return _items.Values; + } + + public void Save(T item) + { + _items[item.Id] = item; + } + + public void Delete(Guid id) + { + _items.Remove(id); + } +} +``` + +### SQL Database Repository + +A repository implementation using a SQL database: + +```csharp +public class SqlReadModelRepository : IReadModelRepository where T : ReadModelBase +{ + private readonly string _connectionString; + private readonly string _tableName; + + public SqlReadModelRepository(string connectionString, string tableName) + { + _connectionString = connectionString; + _tableName = tableName; + } + + public T GetById(Guid id) + { + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + var sql = $"SELECT * FROM {_tableName} WHERE Id = @Id"; + return connection.QuerySingleOrDefault(sql, new { Id = id }); + } + } + + public IEnumerable GetAll() + { + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + var sql = $"SELECT * FROM {_tableName}"; + return connection.Query(sql); + } + } + + public void Save(T item) + { + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + + // Check if the item exists + var exists = connection.ExecuteScalar( + $"SELECT COUNT(1) FROM {_tableName} WHERE Id = @Id", + new { Id = item.Id }); + + if (exists) + { + // Update existing item (simplified example) + connection.Execute( + $"UPDATE {_tableName} SET Data = @Data WHERE Id = @Id", + new { Id = item.Id, Data = JsonConvert.SerializeObject(item) }); + } + else + { + // Insert new item (simplified example) + connection.Execute( + $"INSERT INTO {_tableName} (Id, Data) VALUES (@Id, @Data)", + new { Id = item.Id, Data = JsonConvert.SerializeObject(item) }); + } + } + } + + public void Delete(Guid id) + { + using (var connection = new SqlConnection(_connectionString)) + { + connection.Open(); + connection.Execute($"DELETE FROM {_tableName} WHERE Id = @Id", new { Id = id }); + } + } +} +``` + +### Document Database Repository + +A repository implementation using a document database: + +```csharp +public class DocumentDbReadModelRepository : IReadModelRepository where T : ReadModelBase +{ + private readonly IMongoCollection _collection; + + public DocumentDbReadModelRepository(IMongoDatabase database, string collectionName) + { + _collection = database.GetCollection(collectionName); + } + + public T GetById(Guid id) + { + return _collection.Find(x => x.Id == id).FirstOrDefault(); + } + + public IEnumerable GetAll() + { + return _collection.Find(_ => true).ToEnumerable(); + } + + public void Save(T item) + { + _collection.ReplaceOne( + x => x.Id == item.Id, + item, + new ReplaceOptions { IsUpsert = true }); + } + + public void Delete(Guid id) + { + _collection.DeleteOne(x => x.Id == id); + } +} +``` + +## Best Practices + +1. **Repository Per Read Model**: Create a separate repository for each read model type +2. **Caching Strategy**: Implement appropriate caching to improve query performance +3. **Optimistic Concurrency**: Consider using optimistic concurrency for read models that might be updated concurrently +4. **Indexing**: Ensure appropriate database indexes for efficient querying +5. **Bulk Operations**: Support bulk operations for better performance when processing multiple items +6. **Transactions**: Use transactions when updating multiple read models to maintain consistency +7. **Error Handling**: Implement proper error handling and retry logic for database operations +8. **Logging**: Include logging for troubleshooting and performance monitoring +9. **Testing**: Create mock implementations for testing event handlers and query services +10. **Storage Selection**: Choose the appropriate storage technology based on query patterns and requirements + +## Common Pitfalls + +1. **N+1 Query Problem**: Avoid making multiple database queries when a single query would suffice +2. **Over-normalization**: Read models should be denormalized for query efficiency +3. **Ignoring Indexes**: Missing indexes can lead to poor query performance +4. **Tight Coupling**: Avoid coupling read models to specific storage technologies +5. **Synchronous I/O**: Be cautious about making synchronous database calls in high-throughput systems +6. **Missing Error Handling**: Failing to handle database errors can lead to inconsistent read models +7. **Ignoring Eventual Consistency**: Remember that read models may be eventually consistent with the write model + +## Related Components + +- [ReadModelBase](./read-model-base.md): Base class for read models stored in the repository +- [IEventHandler](./ievent-handler.md): Interface for event handlers that update read models +- [Event](./event.md): Base class for domain events that trigger read model updates +- [AggregateRoot](./aggregate-root.md): Domain entities that raise events processed by event handlers +- [ICorrelatedMessage](./icorrelated-message.md): Interface for tracking message correlation + +--- + +**Navigation**: +- [← Previous: ReadModelBase](./read-model-base.md) +- [↑ Back to Top](#ireadmodelrepository) +- [→ Next: IRepository](./irepository.md) diff --git a/docs/api-reference/types/irepository.md b/docs/api-reference/types/irepository.md index 3a6da735..09f6de51 100644 --- a/docs/api-reference/types/irepository.md +++ b/docs/api-reference/types/irepository.md @@ -8,6 +8,22 @@ The `IRepository` interface defines the contract for repositories that store and Repositories in Reactive Domain follow the Repository pattern from Domain-Driven Design (DDD), providing a collection-like interface to access domain aggregates while abstracting away the details of event storage and retrieval. The `IRepository` interface ensures that all implementations provide consistent behavior for storing, retrieving, and managing aggregates. +## Event Sourcing and Repository + +In event-sourced systems, the repository plays a critical role: + +1. **Event Storage**: The repository persists events generated by aggregates to an event store +2. **Aggregate Reconstruction**: When loading an aggregate, the repository retrieves its events and replays them to reconstruct the aggregate's state +3. **Concurrency Control**: The repository manages optimistic concurrency through version checking +4. **Transaction Boundaries**: Repository operations typically define transaction boundaries in the domain + +Unlike traditional repositories that store the current state of entities, event-sourced repositories store the complete history of events that led to the current state. This approach provides several benefits, including: + +- **Complete Audit Trail**: Every state change is recorded as an event +- **Temporal Queries**: The ability to reconstruct the state of an aggregate at any point in time +- **Event Replay**: The ability to replay events for debugging or analysis +- **Event Processing**: Events can be processed by other components for various purposes (e.g., building read models) + **Namespace**: `ReactiveDomain.Foundation` **Assembly**: `ReactiveDomain.Foundation.dll` @@ -294,6 +310,8 @@ catch (AggregateNotFoundException) ## Usage +### Basic Repository Operations + The `IRepository` interface is used to store and retrieve event-sourced aggregates. It is typically implemented by the `StreamStoreRepository` class, which stores events in an event store. Here's a comprehensive example of using a repository in a typical application scenario: ```csharp @@ -306,7 +324,7 @@ var repository = new StreamStoreRepository(streamNameBuilder, eventStoreConnecti // Create a new aggregate var accountId = Guid.NewGuid(); var account = new Account(accountId); -account.Deposit(1000); +account.Deposit(1000, command); // Save the aggregate repository.Save(account); @@ -317,7 +335,7 @@ var retrievedAccount = repository.GetById(accountId); Console.WriteLine($"Retrieved account balance: ${retrievedAccount.GetBalance()}"); // Perform operations on the aggregate -retrievedAccount.Withdraw(500); +retrievedAccount.Withdraw(500, command); Console.WriteLine($"Withdrew $500, new balance: ${retrievedAccount.GetBalance()}"); // Save the updated aggregate @@ -337,8 +355,116 @@ Console.WriteLine("Deleted account (soft delete)"); // Console.WriteLine("Permanently deleted account"); ``` +### Integration with Command Handlers + +Repositories are typically used within command handlers in a CQRS architecture. Here's an example of a command handler that uses a repository: + +```csharp +public class AccountCommandHandler : + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler +{ + private readonly IRepository _repository; + private readonly IEventBus _eventBus; + + public AccountCommandHandler(IRepository repository, IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(CreateAccount command) + { + // Check if account already exists + Account account; + if (_repository.TryGetById(command.AccountId, out account)) + { + throw new InvalidOperationException($"Account {command.AccountId} already exists"); + } + + // Create new account + account = new Account(command.AccountId); + account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + + // Save the account + _repository.Save(account); + + // Publish domain events to the event bus + foreach (var @event in account.TakeEvents()) + { + _eventBus.Publish(@event); + } + } + + public void Handle(DepositFunds command) + { + // Get the account + var account = _repository.GetById(command.AccountId); + + // Process the command + account.Deposit(command.Amount, command); + + // Save the account + _repository.Save(account); + + // Publish domain events + foreach (var @event in account.TakeEvents()) + { + _eventBus.Publish(@event); + } + } + + // Additional handlers for other commands... +} +``` + +### Handling Concurrency Conflicts + +Concurrency conflicts occur when multiple processes attempt to modify the same aggregate simultaneously. Here's an example of handling concurrency conflicts: + +```csharp +public void TransferFunds(TransferFunds command) +{ + var sourceAccount = _repository.GetById(command.SourceAccountId); + var targetAccount = _repository.GetById(command.TargetAccountId); + + try + { + // Withdraw from source account + sourceAccount.Withdraw(command.Amount, command); + + // Deposit to target account + targetAccount.Deposit(command.Amount, command); + + // Save both accounts + _repository.Save(sourceAccount); + _repository.Save(targetAccount); + } + catch (AggregateVersionException ex) + { + // Handle concurrency conflict + if (ex.AggregateId == command.SourceAccountId) + { + // Reload source account and retry + sourceAccount = _repository.GetById(command.SourceAccountId); + // Implement retry logic... + } + else + { + // Reload target account and retry + targetAccount = _repository.GetById(command.TargetAccountId); + // Implement retry logic... + } + } +} +``` + ## Best Practices +### Repository Design + 1. **Optimistic Concurrency**: Always handle `AggregateVersionException` to manage concurrent modifications 2. **Aggregate Lifecycle**: Use `Delete` for logical deletion and `HardDelete` only when data must be permanently removed 3. **Version Management**: Use the `version` parameter in `GetById` and `Update` to work with specific versions of aggregates @@ -347,8 +473,26 @@ Console.WriteLine("Deleted account (soft delete)"); 6. **Repository Abstraction**: Depend on the `IRepository` interface rather than concrete implementations 7. **Correlation Tracking**: Use `ICorrelatedRepository` when correlation information needs to be maintained +### Performance Considerations + +1. **Snapshot Support**: Use snapshots for aggregates with many events to improve loading performance +2. **Batch Operations**: Consider batching operations when working with multiple aggregates +3. **Caching**: Implement caching strategies for frequently accessed aggregates +4. **Asynchronous Operations**: Use asynchronous repository implementations for better scalability +5. **Event Size**: Keep events small and focused to improve serialization and deserialization performance + +### Testing + +1. **In-Memory Repository**: Use an in-memory repository implementation for unit testing +2. **Test Doubles**: Create test doubles (mocks, stubs) for the repository interface +3. **Event Verification**: Verify that the correct events are saved to the repository +4. **Concurrency Testing**: Test concurrent operations to ensure proper handling of version conflicts +5. **Integration Testing**: Use a real repository implementation for integration testing + ## Common Pitfalls +### Design Issues + 1. **Ignoring Concurrency**: Failing to handle `AggregateVersionException` can lead to lost updates 2. **Large Aggregates**: Storing too many events in a single aggregate can impact performance 3. **Missing Version Checks**: Not checking versions when updating aggregates can lead to inconsistent state @@ -356,11 +500,120 @@ Console.WriteLine("Deleted account (soft delete)"); 5. **Repository Leakage**: Allowing repository implementation details to leak into the domain model 6. **Missing Error Handling**: Not properly handling repository exceptions +### Implementation Challenges + +1. **Event Schema Evolution**: Not handling changes to event schemas over time +2. **Event Ordering**: Not maintaining the correct order of events +3. **Event Serialization**: Issues with serializing and deserializing complex event structures +4. **Stream Name Collisions**: Using non-unique stream names for different aggregates +5. **Repository Dependencies**: Creating tight coupling between the repository and other components + +### CQRS Integration Issues + +1. **Read/Write Separation**: Not properly separating read and write repositories +2. **Event Publishing**: Forgetting to publish events after saving aggregates +3. **Command Validation**: Performing validation in the repository instead of in command handlers +4. **Event Replay**: Not considering the impact of event replay on performance +5. **Read Model Updates**: Not updating read models when events are saved + +## Advanced Scenarios + +### Event Upcasting + +Event upcasting is the process of converting events from an older version to a newer version during the loading process. This is useful when the structure of events changes over time: + +```csharp +public class UpcastingRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly IEventUpcastingService _upcastingService; + + public UpcastingRepository(IRepository innerRepository, IEventUpcastingService upcastingService) + { + _innerRepository = innerRepository; + _upcastingService = upcastingService; + } + + public TAggregate GetById(Guid id, int version = int.MaxValue) where TAggregate : class, IEventSource + { + var aggregate = _innerRepository.GetById(id, version); + + // Upcasting events if needed + var events = aggregate.TakeEvents(); + var upcastedEvents = _upcastingService.Upcast(events); + + // Recreate the aggregate with upcasted events + var newAggregate = Activator.CreateInstance(typeof(TAggregate), id) as TAggregate; + newAggregate.RestoreFromEvents(upcastedEvents); + + return newAggregate; + } + + // Implement other methods... +} +``` + +### Snapshot Integration + +Snapshots can significantly improve the performance of loading aggregates with many events: + +```csharp +public class SnapshotRepository : IRepository +{ + private readonly IRepository _innerRepository; + private readonly ISnapshotStore _snapshotStore; + + public SnapshotRepository(IRepository innerRepository, ISnapshotStore snapshotStore) + { + _innerRepository = innerRepository; + _snapshotStore = snapshotStore; + } + + public TAggregate GetById(Guid id, int version = int.MaxValue) where TAggregate : class, IEventSource + { + // Try to get a snapshot + var snapshotSource = typeof(TAggregate).GetInterfaces().Contains(typeof(ISnapshotSource)); + + if (snapshotSource) + { + var snapshot = _snapshotStore.GetLatestSnapshot(id); + + if (snapshot != null) + { + // Create aggregate and restore from snapshot + var aggregate = Activator.CreateInstance(typeof(TAggregate), id) as TAggregate; + (aggregate as ISnapshotSource).RestoreFromSnapshot(snapshot.State); + + // Get events after the snapshot version + var events = _innerRepository.GetEventsAfterVersion(id, snapshot.Version, version); + aggregate.RestoreFromEvents(events); + + return aggregate; + } + } + + // Fall back to regular loading + return _innerRepository.GetById(id, version); + } + + // Implement other methods... +} +``` + ## Related Types - [IEventSource](ievent-source.md): The interface for event-sourced entities - [AggregateRoot](aggregate-root.md): Base class for domain aggregates - [StreamStoreRepository](stream-store-repository.md): Implementation of `IRepository` - [ICorrelatedRepository](icorrelated-repository.md): Repository with correlation support +- [EventDrivenStateMachine](event-driven-state-machine.md): Base class for event-sourced entities +- [Command](command.md): Messages that trigger state changes in aggregates +- [Event](event.md): Messages that represent state changes in aggregates +- [ISnapshotSource](isnapshot-source.md): Interface for aggregates that support snapshots + +--- -[↑ Back to Top](#irepository-interface) | [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) +**Navigation**: +- [← Previous: EventDrivenStateMachine](./event-driven-state-machine.md) +- [↑ Back to Top](#irepository-interface) +- [→ Next: ICorrelatedRepository](./icorrelated-repository.md) diff --git a/docs/api-reference/types/isnapshot-source.md b/docs/api-reference/types/isnapshot-source.md new file mode 100644 index 00000000..d2ca7fae --- /dev/null +++ b/docs/api-reference/types/isnapshot-source.md @@ -0,0 +1,363 @@ +# ISnapshotSource + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`ISnapshotSource` is an interface in Reactive Domain that extends the base `IEventSource` interface to add snapshot capabilities to event-sourced entities. + +## Overview + +In event-sourced systems, entities reconstruct their state by replaying all historical events. As the number of events grows, this process can become time-consuming. The `ISnapshotSource` interface provides a mechanism for creating and restoring from snapshots, which are point-in-time captures of an entity's state. This significantly improves loading performance for entities with long event histories. + +Snapshots are not a replacement for events but rather an optimization technique. The complete event history is still maintained, but snapshots allow entities to be restored more efficiently by loading the most recent snapshot and then applying only the events that occurred after the snapshot was taken. + +## Interface Definition + +```csharp +public interface ISnapshotSource : IEventSource +{ + object CreateSnapshot(); + void RestoreFromSnapshot(object snapshot); + long SnapshotVersion { get; set; } +} +``` + +## Key Features + +- **Performance Optimization**: Significantly reduces loading time for entities with long event histories +- **Snapshot Creation**: Provides a mechanism for capturing the current state of an entity +- **Snapshot Restoration**: Enables restoring an entity's state from a snapshot +- **Version Tracking**: Tracks the version at which a snapshot was taken +- **Event Sourcing**: Maintains the full event history alongside snapshots + +## Usage + +### Implementing the Interface + +Here's an example of implementing the `ISnapshotSource` interface in an aggregate: + +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + private decimal _balance; + private bool _isActive; + private string _accountNumber; + private string _customerName; + private List _recentTransactions; + + public long SnapshotVersion { get; set; } + + public Account(Guid id) : base(id) + { + _isActive = false; + _balance = 0; + _recentTransactions = new List(); + } + + // Snapshot methods + public object CreateSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + IsActive = _isActive, + AccountNumber = _accountNumber, + CustomerName = _customerName, + RecentTransactions = _recentTransactions.ToList() + }; + } + + public void RestoreFromSnapshot(object snapshot) + { + if (snapshot is AccountSnapshot accountSnapshot) + { + _balance = accountSnapshot.Balance; + _isActive = accountSnapshot.IsActive; + _accountNumber = accountSnapshot.AccountNumber; + _customerName = accountSnapshot.CustomerName; + _recentTransactions = accountSnapshot.RecentTransactions.ToList(); + } + else + { + throw new ArgumentException($"Expected AccountSnapshot but got {snapshot.GetType().Name}"); + } + } + + // Command handlers + public void CreateAccount(string accountNumber, string customerName) + { + if (_isActive) + throw new InvalidOperationException("Account already exists"); + + RaiseEvent(new AccountCreated(Id, accountNumber, customerName)); + } + + public void Deposit(decimal amount) + { + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + RaiseEvent(new FundsDeposited(Id, amount)); + } + + public void Withdraw(decimal amount) + { + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(new FundsWithdrawn(Id, amount)); + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _isActive = true; + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + _recentTransactions.Add(new Transaction + { + Type = TransactionType.Deposit, + Amount = @event.Amount, + Timestamp = DateTime.UtcNow + }); + + // Keep only the 10 most recent transactions + if (_recentTransactions.Count > 10) + _recentTransactions.RemoveAt(0); + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + _recentTransactions.Add(new Transaction + { + Type = TransactionType.Withdrawal, + Amount = @event.Amount, + Timestamp = DateTime.UtcNow + }); + + // Keep only the 10 most recent transactions + if (_recentTransactions.Count > 10) + _recentTransactions.RemoveAt(0); + } +} + +// Snapshot class +public class AccountSnapshot +{ + public decimal Balance { get; set; } + public bool IsActive { get; set; } + public string AccountNumber { get; set; } + public string CustomerName { get; set; } + public List RecentTransactions { get; set; } +} + +public class Transaction +{ + public TransactionType Type { get; set; } + public decimal Amount { get; set; } + public DateTime Timestamp { get; set; } +} + +public enum TransactionType +{ + Deposit, + Withdrawal +} +``` + +### Using with a Repository + +Snapshots are typically managed by a repository that supports the `ISnapshotSource` interface: + +```csharp +public class SnapshotRepository : IRepository +{ + private readonly IStreamStoreConnection _connection; + private readonly ISnapshotStore _snapshotStore; + private readonly int _snapshotFrequency; + + public SnapshotRepository( + IStreamStoreConnection connection, + ISnapshotStore snapshotStore, + int snapshotFrequency = 100) + { + _connection = connection; + _snapshotStore = snapshotStore; + _snapshotFrequency = snapshotFrequency; + } + + public T GetById(Guid id) where T : IEventSource + { + // Try to get the latest snapshot + var snapshot = _snapshotStore.GetLatestSnapshot(id); + + // Create a new instance of the aggregate + var aggregate = Activator.CreateInstance(typeof(T), id) as T; + + if (snapshot != null && aggregate is ISnapshotSource snapshotSource) + { + // Restore from snapshot + snapshotSource.RestoreFromSnapshot(snapshot.State); + snapshotSource.SnapshotVersion = snapshot.Version; + + // Get events after the snapshot + var events = _connection.ReadStreamEventsForward( + id.ToString(), + snapshot.Version + 1, + int.MaxValue); + + // Apply events after the snapshot + aggregate.UpdateWithEvents(events, snapshot.Version); + } + else + { + // No snapshot, load all events + var events = _connection.ReadStreamEventsForward( + id.ToString(), + 0, + int.MaxValue); + + // Apply all events + aggregate.RestoreFromEvents(events); + } + + return aggregate; + } + + public void Save(T aggregate) where T : IEventSource + { + // Get new events + var newEvents = aggregate.TakeEvents(); + + if (newEvents.Length > 0) + { + // Save events + _connection.AppendToStream( + aggregate.Id.ToString(), + aggregate.ExpectedVersion, + newEvents); + + // Update expected version + aggregate.ExpectedVersion += newEvents.Length; + + // Check if we should create a snapshot + if (aggregate is ISnapshotSource snapshotSource && + aggregate.ExpectedVersion >= snapshotSource.SnapshotVersion + _snapshotFrequency) + { + // Create and save snapshot + var snapshot = snapshotSource.CreateSnapshot(); + _snapshotStore.SaveSnapshot( + aggregate.Id, + aggregate.ExpectedVersion, + snapshot); + + // Update snapshot version + snapshotSource.SnapshotVersion = aggregate.ExpectedVersion; + } + } + } +} +``` + +## Snapshot Store Implementation + +A simple in-memory snapshot store implementation: + +```csharp +public interface ISnapshotStore +{ + SnapshotInfo GetLatestSnapshot(Guid aggregateId); + void SaveSnapshot(Guid aggregateId, long version, object state); +} + +public class SnapshotInfo +{ + public Guid AggregateId { get; set; } + public long Version { get; set; } + public object State { get; set; } +} + +public class InMemorySnapshotStore : ISnapshotStore +{ + private readonly Dictionary> _snapshots = + new Dictionary>(); + + public SnapshotInfo GetLatestSnapshot(Guid aggregateId) + { + if (_snapshots.TryGetValue(aggregateId, out var snapshots) && snapshots.Count > 0) + { + return snapshots.OrderByDescending(s => s.Version).First(); + } + + return null; + } + + public void SaveSnapshot(Guid aggregateId, long version, object state) + { + if (!_snapshots.TryGetValue(aggregateId, out var snapshots)) + { + snapshots = new List(); + _snapshots[aggregateId] = snapshots; + } + + snapshots.Add(new SnapshotInfo + { + AggregateId = aggregateId, + Version = version, + State = state + }); + } +} +``` + +## Best Practices + +1. **Snapshot Frequency**: Create snapshots at appropriate intervals based on entity update frequency +2. **Immutable Snapshots**: Make snapshot objects immutable to prevent accidental modifications +3. **Versioning**: Include version information in snapshots to handle schema evolution +4. **Serialization**: Ensure snapshot objects are serializable for storage +5. **Error Handling**: Implement proper error handling for snapshot creation and restoration +6. **Cleanup Policy**: Implement a policy for cleaning up old snapshots +7. **Testing**: Test both with and without snapshots to ensure consistent behavior +8. **Monitoring**: Monitor snapshot creation and restoration performance +9. **Storage Selection**: Choose appropriate storage for snapshots based on size and access patterns +10. **Snapshot Validation**: Validate snapshots before using them to restore entity state + +## Common Pitfalls + +1. **Snapshot Overuse**: Creating snapshots too frequently can lead to storage and performance issues +2. **Snapshot Underuse**: Not creating snapshots frequently enough reduces their performance benefit +3. **Complex Snapshots**: Overly complex snapshot objects can be difficult to serialize and maintain +4. **Missing Versioning**: Without proper versioning, snapshots can become incompatible after schema changes +5. **Circular References**: Circular references in snapshot objects can cause serialization issues +6. **Large Snapshots**: Very large snapshots can negate the performance benefits they aim to provide +7. **Ignoring Errors**: Failing to handle snapshot errors can lead to inconsistent entity state + +## Related Components + +- [IEventSource](./ievent-source.md): The base interface for event-sourced entities +- [AggregateRoot](./aggregate-root.md): Base class for domain entities that often implements `ISnapshotSource` +- [IRepository](./irepository.md): Interface for repositories that load and save event-sourced entities +- [Event](./event.md): Messages that represent state changes in event-sourced entities +- [EventRecorder](./event-recorder.md): Component that records events for event-sourced entities + +--- + +**Navigation**: +- [← Previous: ICorrelatedEventSource](./icorrelated-event-source.md) +- [↑ Back to Top](#isnapshosource) +- [→ Next: EventRecorder](./event-recorder.md) diff --git a/docs/api-reference/types/process-manager.md b/docs/api-reference/types/process-manager.md new file mode 100644 index 00000000..7f086890 --- /dev/null +++ b/docs/api-reference/types/process-manager.md @@ -0,0 +1,266 @@ +# ProcessManager + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +`ProcessManager` is a base class in Reactive Domain that provides core functionality for implementing process managers (also known as sagas), which coordinate complex business processes across multiple aggregates. + +## Overview + +Process managers are responsible for coordinating long-running business processes that span multiple aggregates or bounded contexts. They react to domain events and issue commands to drive the process forward. The `ProcessManager` base class provides the foundation for implementing these coordinators in a consistent way. + +In event-driven architectures, process managers solve the problem of maintaining process integrity across aggregate boundaries. While aggregates enforce consistency boundaries within their own scope, process managers ensure that the overall business process completes correctly across multiple aggregates. + +## Class Definition + +```csharp +public abstract class ProcessManager : IEventSource +{ + public Guid Id { get; } + public long ExpectedVersion { get; set; } + + protected ProcessManager(Guid id) + { + Id = id; + ExpectedVersion = -1; + } + + public void RestoreFromEvents(IEnumerable events) + { + // Implementation for restoring state from events + } + + public void UpdateWithEvents(IEnumerable events, long expectedVersion) + { + // Implementation for updating state with new events + } + + public object[] TakeEvents() + { + // Implementation for retrieving recorded events + } + + protected void RaiseEvent(object @event) + { + // Implementation for raising process manager events + } +} +``` + +## Key Features + +- **Event Sourcing**: Process managers are event-sourced entities, maintaining their state through events +- **Correlation Tracking**: Supports tracking correlation between events and commands +- **State Management**: Provides mechanisms for managing process state across multiple steps +- **Command Coordination**: Facilitates sending commands to appropriate aggregates +- **Process Completion**: Supports tracking process completion and cleanup +- **Idempotent Processing**: Enables idempotent handling of events to avoid duplicate processing +- **Timeout Management**: Supports handling timeouts for long-running processes + +## Usage + +### Basic Process Manager + +Here's an example of a process manager that coordinates an order fulfillment process: + +```csharp +public class OrderFulfillmentProcess : ProcessManager, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + + // Process state + private bool _orderPlaced; + private bool _paymentProcessed; + private bool _inventoryReserved; + private bool _shipmentCreated; + private Guid _orderId; + private Guid _customerId; + private decimal _orderAmount; + private List _orderItems; + + public OrderFulfillmentProcess(Guid processId, ICommandBus commandBus) + : base(processId) + { + _commandBus = commandBus; + _orderItems = new List(); + } + + public void Handle(OrderPlaced @event) + { + // If we've already processed this event, ignore it + if (_orderPlaced) return; + + // Update process state + RaiseEvent(MessageBuilder.From(@event, () => new OrderFulfillmentStarted( + Id, + @event.OrderId, + @event.CustomerId, + @event.OrderAmount, + @event.OrderItems + ))); + + // Send command to process payment + _commandBus.Send(MessageBuilder.From(@event, () => new ProcessPayment( + @event.CustomerId, + @event.OrderId, + @event.OrderAmount + ))); + } + + public void Handle(PaymentProcessed @event) + { + // If we've already processed this event or the order hasn't been placed, ignore it + if (_paymentProcessed || !_orderPlaced) return; + + // Update process state + RaiseEvent(MessageBuilder.From(@event, () => new PaymentCompletedForOrder( + Id, + @event.OrderId, + @event.PaymentId, + @event.Amount + ))); + + // Send command to reserve inventory + _commandBus.Send(MessageBuilder.From(@event, () => new ReserveInventory( + @event.OrderId, + _orderItems + ))); + } + + public void Handle(InventoryReserved @event) + { + // If we've already processed this event or prerequisites aren't met, ignore it + if (_inventoryReserved || !_paymentProcessed) return; + + // Update process state + RaiseEvent(MessageBuilder.From(@event, () => new InventoryReservedForOrder( + Id, + @event.OrderId, + @event.ReservationId + ))); + + // Send command to create shipment + _commandBus.Send(MessageBuilder.From(@event, () => new CreateShipment( + @event.OrderId, + _customerId, + _orderItems + ))); + } + + public void Handle(ShipmentCreated @event) + { + // If we've already processed this event or prerequisites aren't met, ignore it + if (_shipmentCreated || !_inventoryReserved) return; + + // Update process state + RaiseEvent(MessageBuilder.From(@event, () => new OrderFulfillmentCompleted( + Id, + @event.OrderId, + @event.ShipmentId, + @event.TrackingNumber + ))); + + // Process is now complete + } + + // Event handlers for the process manager's own events + private void Apply(OrderFulfillmentStarted @event) + { + _orderPlaced = true; + _orderId = @event.OrderId; + _customerId = @event.CustomerId; + _orderAmount = @event.OrderAmount; + _orderItems = @event.OrderItems; + } + + private void Apply(PaymentCompletedForOrder @event) + { + _paymentProcessed = true; + } + + private void Apply(InventoryReservedForOrder @event) + { + _inventoryReserved = true; + } + + private void Apply(OrderFulfillmentCompleted @event) + { + _shipmentCreated = true; + } +} +``` + +### Registering Process Managers + +Process managers are typically registered with an event bus during application startup: + +```csharp +public void ConfigureProcessManagers( + IEventBus eventBus, + ICommandBus commandBus, + IProcessManagerRepository repository) +{ + // Create a factory for the process manager + Func factory = + id => new OrderFulfillmentProcess(id, commandBus); + + // Register event handlers that will route events to the appropriate process manager instance + eventBus.Subscribe(new ProcessManagerRouter( + repository, + factory, + e => e.OrderId, // Use OrderId to find or create process manager instances + (pm, e) => pm.Handle(e) + )); + + eventBus.Subscribe(new ProcessManagerRouter( + repository, + factory, + e => e.OrderId, // Use OrderId to find the process manager instance + (pm, e) => pm.Handle(e) + )); + + // Register other event handlers similarly +} +``` + +## Best Practices + +1. **Single Responsibility**: Each process manager should handle one business process +2. **Idempotent Handling**: Make event handlers idempotent to handle duplicate events safely +3. **State Tracking**: Maintain clear state to track process progress +4. **Error Handling**: Implement proper error handling and recovery mechanisms +5. **Timeouts**: Include timeout handling for processes that might stall +6. **Correlation**: Maintain correlation IDs throughout the process +7. **Compensating Actions**: Implement compensating actions for handling failures +8. **Process Completion**: Clearly define when a process is complete and can be archived +9. **Monitoring**: Add monitoring to track process state and identify stalled processes +10. **Testing**: Write comprehensive tests that verify the entire process flow + +## Common Pitfalls + +1. **Complex Process Managers**: Avoid creating overly complex process managers that handle too many responsibilities +2. **Missing Error Handling**: Failing to handle errors can leave processes in an inconsistent state +3. **Ignoring Timeouts**: Long-running processes need timeout handling to avoid stalled processes +4. **Direct Aggregate Manipulation**: Process managers should send commands, not directly manipulate aggregates +5. **Tight Coupling**: Avoid tightly coupling process managers to specific aggregate implementations +6. **Missing Idempotency**: Without idempotent handling, duplicate events can cause incorrect behavior +7. **State Explosion**: Too many state variables can make process managers difficult to understand and maintain + +## Related Components + +- [IEventSource](./ievent-source.md): Interface implemented by process managers for event sourcing +- [IEventHandler](./ievent-handler.md): Interface for handling domain events +- [Command](./command.md): Messages sent by process managers to drive the process forward +- [Event](./event.md): Messages that trigger process manager actions +- [MessageBuilder](./message-builder.md): Factory for creating correlated messages +- [AggregateRoot](./aggregate-root.md): Domain entities that process managers coordinate + +--- + +**Navigation**: +- [← Previous: IEventHandler](./ievent-handler.md) +- [↑ Back to Top](#processmanager) +- [→ Next: ICorrelatedEventSource](./icorrelated-event-source.md) diff --git a/todo-pr-169.md b/todo-pr-169.md index 8464f706..dbdebebe 100644 --- a/todo-pr-169.md +++ b/todo-pr-169.md @@ -31,16 +31,16 @@ This todo list contains items that need to be addressed for PR 169, which adds c ## Terminology and Consistency -- [ ] Ensure consistent use of terminology throughout the documentation -- [ ] Review and correct any technical inaccuracies -- [ ] Standardize formatting and style across all documentation files +- [x] Ensure consistent use of terminology throughout the documentation +- [x] Review and correct any technical inaccuracies +- [x] Standardize formatting and style across all documentation files ## API Reference Enhancements -- [ ] Add missing classes and interfaces to API reference -- [ ] Ensure all public APIs are properly documented -- [ ] Add parameter descriptions for important methods -- [ ] Document return values and exceptions +- [x] Add missing classes and interfaces to API reference +- [x] Ensure all public APIs are properly documented +- [x] Add parameter descriptions for important methods +- [x] Document return values and exceptions ## Navigation and Structure From 33df182f994b6d83e1c670e3284160b01ac7d94e Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 18:55:15 -0400 Subject: [PATCH 20/41] Add comprehensive real-world examples for banking, e-commerce, and inventory management domains --- docs/code-examples/README.md | 28 +- docs/code-examples/banking-domain-example.md | 576 +++++++++++ .../code-examples/ecommerce-domain-example.md | 948 ++++++++++++++++++ .../inventory-management-example.md | 896 +++++++++++++++++ todo-pr-169.md | 2 +- 5 files changed, 2445 insertions(+), 5 deletions(-) create mode 100644 docs/code-examples/banking-domain-example.md create mode 100644 docs/code-examples/ecommerce-domain-example.md create mode 100644 docs/code-examples/inventory-management-example.md diff --git a/docs/code-examples/README.md b/docs/code-examples/README.md index 045773ca..b74052f6 100644 --- a/docs/code-examples/README.md +++ b/docs/code-examples/README.md @@ -6,6 +6,7 @@ This section provides practical code examples that demonstrate how to use Reacti ## Table of Contents +### Basic Examples 1. [Creating a New Aggregate Root](creating-aggregate-root.md) 2. [Handling Commands and Generating Events](handling-commands-events.md) 3. [Saving and Retrieving Aggregates](saving-retrieving-aggregates.md) @@ -15,7 +16,12 @@ This section provides practical code examples that demonstrate how to use Reacti 7. [Implementing Snapshots](implementing-snapshots.md) 8. [Testing Aggregates and Event Handlers](testing.md) 9. [Integration with ASP.NET Core](aspnet-integration.md) -10. [Complete Sample Applications](sample-applications.md) + +### Real-World Domain Examples +10. [Banking Domain Example](banking-domain-example.md) +11. [E-Commerce Domain Example](ecommerce-domain-example.md) +12. [Inventory Management Example](inventory-management-example.md) +13. [Complete Sample Applications](sample-applications.md) Each example includes: @@ -43,9 +49,23 @@ To run these examples, you'll need: ## Getting Started -If you're new to Reactive Domain, we recommend starting with the [Creating a New Aggregate Root](creating-aggregate-root.md) example, followed by [Handling Commands and Generating Events](handling-commands-events.md) and [Saving and Retrieving Aggregates](saving-retrieving-aggregates.md). - -For more advanced scenarios, check out the [Complete Sample Applications](sample-applications.md) section. +If you're new to Reactive Domain, we recommend starting with the basic examples: +- [Creating a New Aggregate Root](creating-aggregate-root.md) +- [Handling Commands and Generating Events](handling-commands-events.md) +- [Saving and Retrieving Aggregates](saving-retrieving-aggregates.md) + +For intermediate to advanced scenarios, explore our real-world domain examples: +- [Banking Domain Example](banking-domain-example.md) - For financial applications +- [E-Commerce Domain Example](ecommerce-domain-example.md) - For online retail systems +- [Inventory Management Example](inventory-management-example.md) - For warehouse and stock management + +These real-world examples demonstrate complete implementations including: +- Command and event definitions with proper validation +- Aggregate implementations with business rules +- Command handlers with error handling +- Read model projections +- Process managers and sagas for complex workflows +- API integration examples --- diff --git a/docs/code-examples/banking-domain-example.md b/docs/code-examples/banking-domain-example.md new file mode 100644 index 00000000..a6d9a6ee --- /dev/null +++ b/docs/code-examples/banking-domain-example.md @@ -0,0 +1,576 @@ +# Banking Domain Example + +This example demonstrates how to implement a banking application using Reactive Domain concepts, including CQRS, Event Sourcing, and correlation tracking. + +## Commands + +```csharp +// Command definition +public class TransferFunds : Command, ICorrelatedMessage +{ + public Guid SourceAccountId { get; } + public Guid TargetAccountId { get; } + public decimal Amount { get; } + public string Reference { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + public TransferFunds(Guid sourceAccountId, Guid targetAccountId, decimal amount, string reference, + Guid msgId, Guid correlationId, Guid causationId) + { + // Validate business rules + if (sourceAccountId == targetAccountId) + throw new ArgumentException("Source and target accounts cannot be the same"); + if (amount <= 0) + throw new ArgumentException("Transfer amount must be positive"); + + SourceAccountId = sourceAccountId; + TargetAccountId = targetAccountId; + Amount = amount; + Reference = reference; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} +``` + +## Events + +```csharp +public class FundsWithdrawn : Event, ICorrelatedMessage +{ + public Guid AccountId { get; } + public decimal Amount { get; } + public string Reference { get; } + public DateTime Timestamp { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Constructor with MessageBuilder usage example + public static FundsWithdrawn Create(Guid accountId, decimal amount, string reference, ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new FundsWithdrawn( + accountId, amount, reference, DateTime.UtcNow, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + + // Private constructor used by the factory method + private FundsWithdrawn(Guid accountId, decimal amount, string reference, DateTime timestamp, + Guid msgId, Guid correlationId, Guid causationId) + { + AccountId = accountId; + Amount = amount; + Reference = reference; + Timestamp = timestamp; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} + +public class FundsDeposited : Event, ICorrelatedMessage +{ + public Guid AccountId { get; } + public decimal Amount { get; } + public string Reference { get; } + public DateTime Timestamp { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Constructor with MessageBuilder usage example + public static FundsDeposited Create(Guid accountId, decimal amount, string reference, ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new FundsDeposited( + accountId, amount, reference, DateTime.UtcNow, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + + // Private constructor used by the factory method + private FundsDeposited(Guid accountId, decimal amount, string reference, DateTime timestamp, + Guid msgId, Guid correlationId, Guid causationId) + { + AccountId = accountId; + Amount = amount; + Reference = reference; + Timestamp = timestamp; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} + +public class TransferCompleted : Event, ICorrelatedMessage +{ + public Guid SourceAccountId { get; } + public Guid TargetAccountId { get; } + public decimal Amount { get; } + public string Reference { get; } + public DateTime Timestamp { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + public TransferCompleted( + Guid sourceAccountId, + Guid targetAccountId, + decimal amount, + string reference, + DateTime timestamp, + Guid msgId, + Guid correlationId, + Guid causationId) + { + SourceAccountId = sourceAccountId; + TargetAccountId = targetAccountId; + Amount = amount; + Reference = reference; + Timestamp = timestamp; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} +``` + +## Aggregate Implementation + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + private bool _isActive; + private AccountStatus _status; + private List _recentTransactions = new List(); + + public Account(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Command handler methods + public void Withdraw(decimal amount, string reference, ICorrelatedMessage source) + { + // Business rules validation + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + + if (_status == AccountStatus.Frozen) + throw new InvalidOperationException("Cannot withdraw from a frozen account"); + + if (amount <= 0) + throw new ArgumentException("Withdrawal amount must be positive"); + + if (_balance < amount) + throw new InsufficientFundsException($"Insufficient funds. Current balance: {_balance}, Requested: {amount}"); + + // Raise the event using MessageBuilder for correlation + RaiseEvent(FundsWithdrawn.Create(Id, amount, reference, source)); + } + + public void Deposit(decimal amount, string reference, ICorrelatedMessage source) + { + // Business rules validation + if (!_isActive) + throw new InvalidOperationException("Account is not active"); + + if (_status == AccountStatus.Frozen) + throw new InvalidOperationException("Cannot deposit to a frozen account"); + + if (amount <= 0) + throw new ArgumentException("Deposit amount must be positive"); + + // Raise the event using MessageBuilder for correlation + RaiseEvent(FundsDeposited.Create(Id, amount, reference, source)); + } + + // Event handler methods + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + + // Maintain a list of recent transactions for quick lookup + _recentTransactions.Add(new TransactionRecord( + TransactionType.Withdrawal, + @event.Amount, + @event.Reference, + @event.Timestamp)); + + // Trim the list to keep only the 10 most recent transactions + if (_recentTransactions.Count > 10) + _recentTransactions.RemoveAt(0); + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + + // Maintain a list of recent transactions for quick lookup + _recentTransactions.Add(new TransactionRecord( + TransactionType.Deposit, + @event.Amount, + @event.Reference, + @event.Timestamp)); + + // Trim the list to keep only the 10 most recent transactions + if (_recentTransactions.Count > 10) + _recentTransactions.RemoveAt(0); + } + + private void Apply(AccountCreated @event) + { + _isActive = true; + _status = AccountStatus.Normal; + _balance = @event.InitialBalance; + } + + // Helper class for tracking recent transactions + private class TransactionRecord + { + public TransactionType Type { get; } + public decimal Amount { get; } + public string Reference { get; } + public DateTime Timestamp { get; } + + public TransactionRecord(TransactionType type, decimal amount, string reference, DateTime timestamp) + { + Type = type; + Amount = amount; + Reference = reference; + Timestamp = timestamp; + } + } + + private enum TransactionType + { + Deposit, + Withdrawal + } + + private enum AccountStatus + { + Normal, + Frozen, + Closed + } +} +``` + +## Command Handler Implementation + +```csharp +public class TransferFundsHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly IEventBus _eventBus; + private readonly ILogger _logger; + + public TransferFundsHandler( + ICorrelatedRepository repository, + IEventBus eventBus, + ILogger logger) + { + _repository = repository; + _eventBus = eventBus; + _logger = logger; + } + + public void Handle(TransferFunds command) + { + _logger.LogInformation("Processing transfer: {Amount} from {SourceId} to {TargetId}", + command.Amount, command.SourceAccountId, command.TargetAccountId); + + try + { + // Load both accounts with correlation context + var sourceAccount = _repository.GetById(command.SourceAccountId, command); + var targetAccount = _repository.GetById(command.TargetAccountId, command); + + // Execute the transfer + sourceAccount.Withdraw(command.Amount, $"Transfer to {command.TargetAccountId}: {command.Reference}", command); + targetAccount.Deposit(command.Amount, $"Transfer from {command.SourceAccountId}: {command.Reference}", command); + + // Save both accounts + _repository.Save(sourceAccount); + _repository.Save(targetAccount); + + // Publish a transfer completed event + _eventBus.Publish(MessageBuilder.From(command, () => + new TransferCompleted( + command.SourceAccountId, + command.TargetAccountId, + command.Amount, + command.Reference, + DateTime.UtcNow, + Guid.NewGuid(), + command.CorrelationId, + command.MsgId))); + + _logger.LogInformation("Transfer completed successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Transfer failed: {ErrorMessage}", ex.Message); + + // Publish a transfer failed event + _eventBus.Publish(MessageBuilder.From(command, () => + new TransferFailed( + command.SourceAccountId, + command.TargetAccountId, + command.Amount, + command.Reference, + ex.Message, + DateTime.UtcNow, + Guid.NewGuid(), + command.CorrelationId, + command.MsgId))); + + throw; + } + } +} +``` + +## Read Model Implementation + +```csharp +public class AccountSummaryReadModel : ReadModelBase +{ + public string AccountNumber { get; set; } + public string AccountHolderName { get; set; } + public decimal CurrentBalance { get; set; } + public AccountStatus Status { get; set; } + public DateTime LastUpdated { get; set; } + public List RecentTransactions { get; set; } = new List(); + + public class TransactionSummary + { + public string Type { get; set; } + public decimal Amount { get; set; } + public string Reference { get; set; } + public DateTime Timestamp { get; set; } + } + + public enum AccountStatus + { + Active, + Frozen, + Closed + } +} +``` + +## Read Model Updater + +```csharp +public class AccountSummaryUpdater : + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _readModelRepository; + private readonly ILogger _logger; + + public AccountSummaryUpdater( + IReadModelRepository readModelRepository, + ILogger logger) + { + _readModelRepository = readModelRepository; + _logger = logger; + } + + public void Handle(FundsWithdrawn @event) + { + try + { + // Get the read model + var readModel = _readModelRepository.GetById(@event.AccountId); + + // Update the read model + readModel.CurrentBalance -= @event.Amount; + readModel.LastUpdated = @event.Timestamp; + + // Add to recent transactions + readModel.RecentTransactions.Add(new AccountSummaryReadModel.TransactionSummary + { + Type = "Withdrawal", + Amount = @event.Amount, + Reference = @event.Reference, + Timestamp = @event.Timestamp + }); + + // Keep only the 10 most recent transactions + if (readModel.RecentTransactions.Count > 10) + readModel.RecentTransactions.RemoveAt(0); + + // Save the updated read model + _readModelRepository.Save(readModel); + + _logger.LogInformation("Updated account summary for withdrawal: {AccountId}, New Balance: {Balance}", + @event.AccountId, readModel.CurrentBalance); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating account summary for withdrawal: {AccountId}", @event.AccountId); + throw; + } + } + + public void Handle(FundsDeposited @event) + { + try + { + // Get the read model + var readModel = _readModelRepository.GetById(@event.AccountId); + + // Update the read model + readModel.CurrentBalance += @event.Amount; + readModel.LastUpdated = @event.Timestamp; + + // Add to recent transactions + readModel.RecentTransactions.Add(new AccountSummaryReadModel.TransactionSummary + { + Type = "Deposit", + Amount = @event.Amount, + Reference = @event.Reference, + Timestamp = @event.Timestamp + }); + + // Keep only the 10 most recent transactions + if (readModel.RecentTransactions.Count > 10) + readModel.RecentTransactions.RemoveAt(0); + + // Save the updated read model + _readModelRepository.Save(readModel); + + _logger.LogInformation("Updated account summary for deposit: {AccountId}, New Balance: {Balance}", + @event.AccountId, readModel.CurrentBalance); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating account summary for deposit: {AccountId}", @event.AccountId); + throw; + } + } + + // Additional event handlers... +} +``` + +## API Controller + +```csharp +[ApiController] +[Route("api/[controller]")] +public class AccountsController : ControllerBase +{ + private readonly ICommandBus _commandBus; + private readonly IReadModelRepository _readModelRepository; + + public AccountsController( + ICommandBus commandBus, + IReadModelRepository readModelRepository) + { + _commandBus = commandBus; + _readModelRepository = readModelRepository; + } + + [HttpPost("transfer")] + public IActionResult TransferFunds([FromBody] TransferFundsRequest request) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + try + { + // Create a command with correlation + var command = new TransferFunds( + request.SourceAccountId, + request.TargetAccountId, + request.Amount, + request.Reference, + Guid.NewGuid(), + Guid.NewGuid(), // New correlation ID for this transaction + Guid.Empty); // No causation ID for the initial command + + // Send the command + _commandBus.Send(command); + + return Accepted(new { TransactionId = command.MsgId }); + } + catch (Exception ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpGet("{id}")] + public IActionResult GetAccount(Guid id) + { + try + { + var account = _readModelRepository.GetById(id); + + if (account == null) + return NotFound(); + + return Ok(account); + } + catch (Exception ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + public class TransferFundsRequest + { + [Required] + public Guid SourceAccountId { get; set; } + + [Required] + public Guid TargetAccountId { get; set; } + + [Required] + [Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")] + public decimal Amount { get; set; } + + public string Reference { get; set; } + } +} +``` + +## Key Concepts Demonstrated + +1. **CQRS Pattern**: Separation of commands (write operations) and queries (read operations) +2. **Event Sourcing**: Using events to represent state changes and reconstruct state +3. **Domain-Driven Design**: Rich domain models with business rules and validations +4. **Value Objects**: Immutable objects that represent concepts in the domain +5. **Correlation**: Tracking related messages through the system using `ICorrelatedMessage` and `MessageBuilder` +6. **Repository Pattern**: Using `ICorrelatedRepository` to load and save aggregates +7. **Read Models**: Specialized models for querying data efficiently +8. **Event Handlers**: Components that update read models based on domain events +9. **Command Handlers**: Components that process commands and update aggregates +10. **API Integration**: Exposing the domain through a REST API diff --git a/docs/code-examples/ecommerce-domain-example.md b/docs/code-examples/ecommerce-domain-example.md new file mode 100644 index 00000000..61a271c8 --- /dev/null +++ b/docs/code-examples/ecommerce-domain-example.md @@ -0,0 +1,948 @@ +# E-Commerce Domain Example + +This example demonstrates how to implement an e-commerce application using Reactive Domain concepts, including CQRS, Event Sourcing, and Process Managers. + +## Commands + +```csharp +// Command definition +public class PlaceOrder : Command, ICorrelatedMessage +{ + public Guid OrderId { get; } + public Guid CustomerId { get; } + public List Items { get; } + public Address ShippingAddress { get; } + public PaymentMethod PaymentMethod { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Constructor with validation + public PlaceOrder( + Guid orderId, + Guid customerId, + List items, + Address shippingAddress, + PaymentMethod paymentMethod, + Guid msgId, + Guid correlationId, + Guid causationId) + { + // Validate business rules + if (orderId == Guid.Empty) + throw new ArgumentException("Order ID is required"); + + if (customerId == Guid.Empty) + throw new ArgumentException("Customer ID is required"); + + if (items == null || !items.Any()) + throw new ArgumentException("Order must contain at least one item"); + + if (shippingAddress == null) + throw new ArgumentException("Shipping address is required"); + + if (paymentMethod == null) + throw new ArgumentException("Payment method is required"); + + OrderId = orderId; + CustomerId = customerId; + Items = items; + ShippingAddress = shippingAddress; + PaymentMethod = paymentMethod; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } + + // Value objects + public class OrderItem + { + public Guid ProductId { get; } + public int Quantity { get; } + public decimal UnitPrice { get; } + + public OrderItem(Guid productId, int quantity, decimal unitPrice) + { + if (productId == Guid.Empty) + throw new ArgumentException("Product ID is required"); + + if (quantity <= 0) + throw new ArgumentException("Quantity must be greater than zero"); + + if (unitPrice <= 0) + throw new ArgumentException("Unit price must be greater than zero"); + + ProductId = productId; + Quantity = quantity; + UnitPrice = unitPrice; + } + + public decimal GetTotal() => Quantity * UnitPrice; + } + + public class Address + { + public string Street { get; } + public string City { get; } + public string State { get; } + public string PostalCode { get; } + public string Country { get; } + + public Address(string street, string city, string state, string postalCode, string country) + { + if (string.IsNullOrWhiteSpace(street)) + throw new ArgumentException("Street is required"); + + if (string.IsNullOrWhiteSpace(city)) + throw new ArgumentException("City is required"); + + if (string.IsNullOrWhiteSpace(state)) + throw new ArgumentException("State is required"); + + if (string.IsNullOrWhiteSpace(postalCode)) + throw new ArgumentException("Postal code is required"); + + if (string.IsNullOrWhiteSpace(country)) + throw new ArgumentException("Country is required"); + + Street = street; + City = city; + State = state; + PostalCode = postalCode; + Country = country; + } + } + + public class PaymentMethod + { + public PaymentType Type { get; } + public string CardNumber { get; } + public string CardHolderName { get; } + public string ExpirationDate { get; } + + public PaymentMethod(PaymentType type, string cardNumber, string cardHolderName, string expirationDate) + { + Type = type; + CardNumber = cardNumber; + CardHolderName = cardHolderName; + ExpirationDate = expirationDate; + } + + public enum PaymentType + { + CreditCard, + DebitCard, + PayPal + } + } +} + +// Additional commands +public class ProcessPayment : Command, ICorrelatedMessage +{ + public Guid OrderId { get; } + public decimal Amount { get; } + public PlaceOrder.PaymentMethod PaymentMethod { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Factory method using MessageBuilder + public static ProcessPayment Create(Guid orderId, decimal amount, + PlaceOrder.PaymentMethod paymentMethod, ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new ProcessPayment( + orderId, amount, paymentMethod, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + + private ProcessPayment(Guid orderId, decimal amount, PlaceOrder.PaymentMethod paymentMethod, + Guid msgId, Guid correlationId, Guid causationId) + { + OrderId = orderId; + Amount = amount; + PaymentMethod = paymentMethod; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} +``` + +## Events + +```csharp +// Event definition +public class OrderPlaced : Event, ICorrelatedMessage +{ + public Guid OrderId { get; } + public Guid CustomerId { get; } + public List Items { get; } + public Address ShippingAddress { get; } + public PaymentMethod PaymentMethod { get; } + public decimal TotalAmount { get; } + public DateTime OrderDate { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Factory method using MessageBuilder + public static OrderPlaced Create( + Guid orderId, + Guid customerId, + List items, + Address shippingAddress, + PaymentMethod paymentMethod, + ICorrelatedMessage source) + { + decimal totalAmount = items.Sum(i => i.Quantity * i.UnitPrice); + + return MessageBuilder.From(source, () => new OrderPlaced( + orderId, customerId, items, shippingAddress, paymentMethod, + totalAmount, DateTime.UtcNow, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + + private OrderPlaced( + Guid orderId, + Guid customerId, + List items, + Address shippingAddress, + PaymentMethod paymentMethod, + decimal totalAmount, + DateTime orderDate, + Guid msgId, + Guid correlationId, + Guid causationId) + { + OrderId = orderId; + CustomerId = customerId; + Items = items; + ShippingAddress = shippingAddress; + PaymentMethod = paymentMethod; + TotalAmount = totalAmount; + OrderDate = orderDate; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } + + // Value objects + public class OrderItem + { + public Guid ProductId { get; } + public int Quantity { get; } + public decimal UnitPrice { get; } + + public OrderItem(Guid productId, int quantity, decimal unitPrice) + { + ProductId = productId; + Quantity = quantity; + UnitPrice = unitPrice; + } + } + + public class Address + { + public string Street { get; } + public string City { get; } + public string State { get; } + public string PostalCode { get; } + public string Country { get; } + + public Address(string street, string city, string state, string postalCode, string country) + { + Street = street; + City = city; + State = state; + PostalCode = postalCode; + Country = country; + } + } + + public class PaymentMethod + { + public PaymentType Type { get; } + public string CardNumber { get; } + public string CardHolderName { get; } + public string ExpirationDate { get; } + + public PaymentMethod(PaymentType type, string cardNumber, string cardHolderName, string expirationDate) + { + Type = type; + CardNumber = cardNumber; + CardHolderName = cardHolderName; + ExpirationDate = expirationDate; + } + + public enum PaymentType + { + CreditCard, + DebitCard, + PayPal + } + } +} + +// Additional events +public class PaymentProcessed : Event, ICorrelatedMessage +{ + public Guid OrderId { get; } + public decimal Amount { get; } + public string TransactionId { get; } + public DateTime ProcessedDate { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Factory method using MessageBuilder + public static PaymentProcessed Create( + Guid orderId, + decimal amount, + string transactionId, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new PaymentProcessed( + orderId, amount, transactionId, DateTime.UtcNow, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + + private PaymentProcessed( + Guid orderId, + decimal amount, + string transactionId, + DateTime processedDate, + Guid msgId, + Guid correlationId, + Guid causationId) + { + OrderId = orderId; + Amount = amount; + TransactionId = transactionId; + ProcessedDate = processedDate; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} +``` + +## Aggregate Implementation + +```csharp +public class Order : AggregateRoot +{ + private OrderStatus _status; + private Guid _customerId; + private List _items = new List(); + private Address _shippingAddress; + private PaymentMethod _paymentMethod; + private DateTime _orderDate; + private decimal _totalAmount; + private string _paymentTransactionId; + private string _trackingNumber; + + public Order(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Command handler methods + public void PlaceOrder( + Guid customerId, + List items, + PlaceOrder.Address shippingAddress, + PlaceOrder.PaymentMethod paymentMethod, + ICorrelatedMessage source) + { + // Business rules validation + if (_status != OrderStatus.None) + throw new InvalidOperationException("Order has already been placed"); + + if (items == null || !items.Any()) + throw new ArgumentException("Order must contain at least one item"); + + // Convert from command value objects to domain value objects + var orderItems = items.Select(i => new OrderItem(i.ProductId, i.Quantity, i.UnitPrice)).ToList(); + var address = new Address( + shippingAddress.Street, + shippingAddress.City, + shippingAddress.State, + shippingAddress.PostalCode, + shippingAddress.Country); + + var payment = new PaymentMethod( + (PaymentMethod.PaymentType)paymentMethod.Type, + paymentMethod.CardNumber, + paymentMethod.CardHolderName, + paymentMethod.ExpirationDate); + + // Raise the event using MessageBuilder for correlation + RaiseEvent(OrderPlaced.Create( + Id, + customerId, + orderItems, + address, + payment, + source)); + } + + public void ProcessPayment(string transactionId, ICorrelatedMessage source) + { + // Business rules validation + if (_status != OrderStatus.Placed) + throw new InvalidOperationException("Order must be in 'Placed' status to process payment"); + + // Raise the event + RaiseEvent(PaymentProcessed.Create( + Id, + _totalAmount, + transactionId, + source)); + } + + // Event handler methods + private void Apply(OrderPlaced @event) + { + _status = OrderStatus.Placed; + _customerId = @event.CustomerId; + _items = @event.Items.Select(i => new OrderItem(i.ProductId, i.Quantity, i.UnitPrice)).ToList(); + _shippingAddress = new Address( + @event.ShippingAddress.Street, + @event.ShippingAddress.City, + @event.ShippingAddress.State, + @event.ShippingAddress.PostalCode, + @event.ShippingAddress.Country); + _paymentMethod = new PaymentMethod( + (PaymentMethod.PaymentType)@event.PaymentMethod.Type, + @event.PaymentMethod.CardNumber, + @event.PaymentMethod.CardHolderName, + @event.PaymentMethod.ExpirationDate); + _orderDate = @event.OrderDate; + _totalAmount = @event.TotalAmount; + } + + private void Apply(PaymentProcessed @event) + { + _status = OrderStatus.PaymentProcessed; + _paymentTransactionId = @event.TransactionId; + } + + // Additional event handlers... + + // Value objects + public class OrderItem + { + public Guid ProductId { get; } + public int Quantity { get; } + public decimal UnitPrice { get; } + + public OrderItem(Guid productId, int quantity, decimal unitPrice) + { + ProductId = productId; + Quantity = quantity; + UnitPrice = unitPrice; + } + + public decimal GetTotal() => Quantity * UnitPrice; + } + + public class Address + { + public string Street { get; } + public string City { get; } + public string State { get; } + public string PostalCode { get; } + public string Country { get; } + + public Address(string street, string city, string state, string postalCode, string country) + { + Street = street; + City = city; + State = state; + PostalCode = postalCode; + Country = country; + } + } + + public class PaymentMethod + { + public PaymentType Type { get; } + public string CardNumber { get; } + public string CardHolderName { get; } + public string ExpirationDate { get; } + + public PaymentMethod(PaymentType type, string cardNumber, string cardHolderName, string expirationDate) + { + Type = type; + CardNumber = cardNumber; + CardHolderName = cardHolderName; + ExpirationDate = expirationDate; + } + + public enum PaymentType + { + CreditCard, + DebitCard, + PayPal + } + } + + public enum OrderStatus + { + None, + Placed, + PaymentProcessed, + Shipped, + Delivered, + Cancelled + } +} +``` + +## Process Manager for Order Fulfillment + +```csharp +public class OrderFulfillmentProcess : + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + private readonly ILogger _logger; + + public OrderFulfillmentProcess(ICommandBus commandBus, ILogger logger) + { + _commandBus = commandBus; + _logger = logger; + } + + public void Handle(OrderPlaced @event) + { + _logger.LogInformation("Starting fulfillment process for order: {OrderId}", @event.OrderId); + + // Process payment + _commandBus.Send(ProcessPayment.Create( + @event.OrderId, + @event.TotalAmount, + @event.PaymentMethod, + @event)); + } + + public void Handle(PaymentProcessed @event) + { + _logger.LogInformation("Payment processed for order: {OrderId}, Transaction: {TransactionId}", + @event.OrderId, @event.TransactionId); + + // Get the order details + var order = GetOrderDetails(@event.OrderId); + + // Reserve inventory + _commandBus.Send(MessageBuilder.From(@event, () => new ReserveInventory( + @event.OrderId, + order.Items))); + } + + public void Handle(InventoryReserved @event) + { + _logger.LogInformation("Inventory reserved for order: {OrderId}", @event.OrderId); + + // Get the order details + var order = GetOrderDetails(@event.OrderId); + + // Arrange shipping + _commandBus.Send(MessageBuilder.From(@event, () => new ArrangeShipping( + @event.OrderId, + order.ShippingAddress, + order.Items))); + } + + public void Handle(ShippingArranged @event) + { + _logger.LogInformation("Shipping arranged for order: {OrderId}, Tracking: {TrackingNumber}", + @event.OrderId, @event.TrackingNumber); + + // Mark order as shipped + _commandBus.Send(MessageBuilder.From(@event, () => new ShipOrder( + @event.OrderId, + @event.TrackingNumber, + @event.EstimatedDeliveryDate))); + } + + // Helper method to get order details + private OrderDetails GetOrderDetails(Guid orderId) + { + // In a real implementation, this would retrieve the order details from a read model + // For simplicity, we're returning a placeholder + return new OrderDetails(); + } + + // Helper class for order details + private class OrderDetails + { + public List Items { get; set; } = new List(); + public Address ShippingAddress { get; set; } = new Address(); + + public class OrderItem + { + public Guid ProductId { get; set; } + public int Quantity { get; set; } + } + + public class Address + { + public string Street { get; set; } + public string City { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public string Country { get; set; } + } + } +} +``` + +## Read Model Implementation + +```csharp +public class OrderSummaryReadModel : ReadModelBase +{ + public Guid CustomerId { get; set; } + public string CustomerName { get; set; } + public OrderStatus Status { get; set; } + public decimal TotalAmount { get; set; } + public DateTime OrderDate { get; set; } + public string PaymentTransactionId { get; set; } + public string TrackingNumber { get; set; } + public DateTime? EstimatedDeliveryDate { get; set; } + public List Items { get; set; } = new List(); + public AddressSummary ShippingAddress { get; set; } + + public class OrderItemSummary + { + public Guid ProductId { get; set; } + public string ProductName { get; set; } + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + public decimal Total => Quantity * UnitPrice; + } + + public class AddressSummary + { + public string Street { get; set; } + public string City { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public string Country { get; set; } + public string FormattedAddress => $"{Street}, {City}, {State} {PostalCode}, {Country}"; + } + + public enum OrderStatus + { + Placed, + PaymentProcessed, + Shipped, + Delivered, + Cancelled + } +} +``` + +## Read Model Updater + +```csharp +public class OrderSummaryUpdater : + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _readModelRepository; + private readonly IProductService _productService; + private readonly ICustomerService _customerService; + private readonly ILogger _logger; + + public OrderSummaryUpdater( + IReadModelRepository readModelRepository, + IProductService productService, + ICustomerService customerService, + ILogger logger) + { + _readModelRepository = readModelRepository; + _productService = productService; + _customerService = customerService; + _logger = logger; + } + + public void Handle(OrderPlaced @event) + { + try + { + // Get customer details + var customer = _customerService.GetCustomer(@event.CustomerId); + + // Create a new read model + var readModel = new OrderSummaryReadModel + { + Id = @event.OrderId, + CustomerId = @event.CustomerId, + CustomerName = customer.Name, + Status = OrderSummaryReadModel.OrderStatus.Placed, + TotalAmount = @event.TotalAmount, + OrderDate = @event.OrderDate, + ShippingAddress = new OrderSummaryReadModel.AddressSummary + { + Street = @event.ShippingAddress.Street, + City = @event.ShippingAddress.City, + State = @event.ShippingAddress.State, + PostalCode = @event.ShippingAddress.PostalCode, + Country = @event.ShippingAddress.Country + } + }; + + // Add order items + foreach (var item in @event.Items) + { + var product = _productService.GetProduct(item.ProductId); + + readModel.Items.Add(new OrderSummaryReadModel.OrderItemSummary + { + ProductId = item.ProductId, + ProductName = product.Name, + Quantity = item.Quantity, + UnitPrice = item.UnitPrice + }); + } + + // Save the read model + _readModelRepository.Save(readModel); + + _logger.LogInformation("Created order summary for order: {OrderId}", @event.OrderId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating order summary for order: {OrderId}", @event.OrderId); + throw; + } + } + + public void Handle(PaymentProcessed @event) + { + try + { + // Get the read model + var readModel = _readModelRepository.GetById(@event.OrderId); + + // Update the read model + readModel.Status = OrderSummaryReadModel.OrderStatus.PaymentProcessed; + readModel.PaymentTransactionId = @event.TransactionId; + + // Save the read model + _readModelRepository.Save(readModel); + + _logger.LogInformation("Updated order summary for payment processed: {OrderId}", @event.OrderId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating order summary for payment processed: {OrderId}", @event.OrderId); + throw; + } + } + + // Additional event handlers... +} +``` + +## API Controller + +```csharp +[ApiController] +[Route("api/[controller]")] +public class OrdersController : ControllerBase +{ + private readonly ICommandBus _commandBus; + private readonly IReadModelRepository _readModelRepository; + private readonly IProductService _productService; + + public OrdersController( + ICommandBus commandBus, + IReadModelRepository readModelRepository, + IProductService productService) + { + _commandBus = commandBus; + _readModelRepository = readModelRepository; + _productService = productService; + } + + [HttpPost] + public IActionResult PlaceOrder([FromBody] PlaceOrderRequest request) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + try + { + // Generate a new order ID + var orderId = Guid.NewGuid(); + + // Convert request items to command items + var items = new List(); + foreach (var item in request.Items) + { + // Get product details + var product = _productService.GetProduct(item.ProductId); + + items.Add(new PlaceOrder.OrderItem( + item.ProductId, + item.Quantity, + product.Price)); + } + + // Create shipping address + var address = new PlaceOrder.Address( + request.ShippingAddress.Street, + request.ShippingAddress.City, + request.ShippingAddress.State, + request.ShippingAddress.PostalCode, + request.ShippingAddress.Country); + + // Create payment method + var paymentMethod = new PlaceOrder.PaymentMethod( + (PlaceOrder.PaymentMethod.PaymentType)request.PaymentMethod.Type, + request.PaymentMethod.CardNumber, + request.PaymentMethod.CardHolderName, + request.PaymentMethod.ExpirationDate); + + // Create a command with correlation + var command = new PlaceOrder( + orderId, + request.CustomerId, + items, + address, + paymentMethod, + Guid.NewGuid(), + Guid.NewGuid(), // New correlation ID for this transaction + Guid.Empty); // No causation ID for the initial command + + // Send the command + _commandBus.Send(command); + + return Accepted(new { OrderId = orderId }); + } + catch (Exception ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpGet("{id}")] + public IActionResult GetOrder(Guid id) + { + try + { + var order = _readModelRepository.GetById(id); + + if (order == null) + return NotFound(); + + return Ok(order); + } + catch (Exception ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + public class PlaceOrderRequest + { + [Required] + public Guid CustomerId { get; set; } + + [Required] + [MinLength(1, ErrorMessage = "Order must contain at least one item")] + public List Items { get; set; } + + [Required] + public AddressRequest ShippingAddress { get; set; } + + [Required] + public PaymentMethodRequest PaymentMethod { get; set; } + + public class OrderItemRequest + { + [Required] + public Guid ProductId { get; set; } + + [Required] + [Range(1, int.MaxValue, ErrorMessage = "Quantity must be greater than zero")] + public int Quantity { get; set; } + } + + public class AddressRequest + { + [Required] + public string Street { get; set; } + + [Required] + public string City { get; set; } + + [Required] + public string State { get; set; } + + [Required] + public string PostalCode { get; set; } + + [Required] + public string Country { get; set; } + } + + public class PaymentMethodRequest + { + [Required] + public int Type { get; set; } + + [Required] + [CreditCard] + public string CardNumber { get; set; } + + [Required] + public string CardHolderName { get; set; } + + [Required] + [RegularExpression(@"^(0[1-9]|1[0-2])\/([0-9]{2})$", ErrorMessage = "Expiration date must be in format MM/YY")] + public string ExpirationDate { get; set; } + } + } +} +``` + +## Key Concepts Demonstrated + +1. **CQRS Pattern**: Separation of commands (write operations) and queries (read operations) +2. **Event Sourcing**: Using events to represent state changes and reconstruct state +3. **Domain-Driven Design**: Rich domain models with business rules and validations +4. **Value Objects**: Immutable objects that represent concepts in the domain +5. **Process Manager**: Coordinating complex business processes across multiple aggregates +6. **Correlation**: Tracking related messages through the system using `ICorrelatedMessage` and `MessageBuilder` +7. **Repository Pattern**: Using repositories to load and save aggregates +8. **Read Models**: Specialized models for querying data efficiently +9. **Event Handlers**: Components that update read models based on domain events +10. **API Integration**: Exposing the domain through a REST API diff --git a/docs/code-examples/inventory-management-example.md b/docs/code-examples/inventory-management-example.md new file mode 100644 index 00000000..ddc273ed --- /dev/null +++ b/docs/code-examples/inventory-management-example.md @@ -0,0 +1,896 @@ +# Inventory Management Example + +This example demonstrates how to implement an inventory management system using Reactive Domain concepts, including CQRS, Event Sourcing, and Sagas. + +## Commands + +```csharp +// Command definition +public class ReceiveInventory : Command, ICorrelatedMessage +{ + public Guid ProductId { get; } + public int Quantity { get; } + public string BatchNumber { get; } + public string Location { get; } + public DateTime ExpirationDate { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + public ReceiveInventory( + Guid productId, + int quantity, + string batchNumber, + string location, + DateTime expirationDate, + Guid msgId, + Guid correlationId, + Guid causationId) + { + // Validate business rules + if (productId == Guid.Empty) + throw new ArgumentException("Product ID is required"); + + if (quantity <= 0) + throw new ArgumentException("Quantity must be greater than zero"); + + if (string.IsNullOrWhiteSpace(batchNumber)) + throw new ArgumentException("Batch number is required"); + + if (string.IsNullOrWhiteSpace(location)) + throw new ArgumentException("Location is required"); + + if (expirationDate <= DateTime.UtcNow) + throw new ArgumentException("Expiration date must be in the future"); + + ProductId = productId; + Quantity = quantity; + BatchNumber = batchNumber; + Location = location; + ExpirationDate = expirationDate; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} + +public class AllocateInventory : Command, ICorrelatedMessage +{ + public Guid OrderId { get; } + public Guid ProductId { get; } + public int Quantity { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + public AllocateInventory( + Guid orderId, + Guid productId, + int quantity, + Guid msgId, + Guid correlationId, + Guid causationId) + { + // Validate business rules + if (orderId == Guid.Empty) + throw new ArgumentException("Order ID is required"); + + if (productId == Guid.Empty) + throw new ArgumentException("Product ID is required"); + + if (quantity <= 0) + throw new ArgumentException("Quantity must be greater than zero"); + + OrderId = orderId; + ProductId = productId; + Quantity = quantity; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } + + // Factory method using MessageBuilder + public static AllocateInventory Create( + Guid orderId, + Guid productId, + int quantity, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new AllocateInventory( + orderId, productId, quantity, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } +} +``` + +## Events + +```csharp +public class InventoryReceived : Event, ICorrelatedMessage +{ + public Guid ProductId { get; } + public int Quantity { get; } + public string BatchNumber { get; } + public string Location { get; } + public DateTime ExpirationDate { get; } + public DateTime ReceivedDate { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Factory method using MessageBuilder + public static InventoryReceived Create( + Guid productId, + int quantity, + string batchNumber, + string location, + DateTime expirationDate, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new InventoryReceived( + productId, quantity, batchNumber, location, expirationDate, DateTime.UtcNow, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + + private InventoryReceived( + Guid productId, + int quantity, + string batchNumber, + string location, + DateTime expirationDate, + DateTime receivedDate, + Guid msgId, + Guid correlationId, + Guid causationId) + { + ProductId = productId; + Quantity = quantity; + BatchNumber = batchNumber; + Location = location; + ExpirationDate = expirationDate; + ReceivedDate = receivedDate; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} + +public class InventoryAllocated : Event, ICorrelatedMessage +{ + public Guid OrderId { get; } + public Guid ProductId { get; } + public int Quantity { get; } + public List AllocationDetails { get; } + public DateTime AllocationDate { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + public class AllocationDetail + { + public string BatchNumber { get; } + public string Location { get; } + public int Quantity { get; } + + public AllocationDetail(string batchNumber, string location, int quantity) + { + BatchNumber = batchNumber; + Location = location; + Quantity = quantity; + } + } + + // Factory method using MessageBuilder + public static InventoryAllocated Create( + Guid orderId, + Guid productId, + int quantity, + List allocationDetails, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new InventoryAllocated( + orderId, productId, quantity, allocationDetails, DateTime.UtcNow, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + + private InventoryAllocated( + Guid orderId, + Guid productId, + int quantity, + List allocationDetails, + DateTime allocationDate, + Guid msgId, + Guid correlationId, + Guid causationId) + { + OrderId = orderId; + ProductId = productId; + Quantity = quantity; + AllocationDetails = allocationDetails; + AllocationDate = allocationDate; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} +``` + +## Aggregate Implementation + +```csharp +public class InventoryItem : AggregateRoot +{ + private string _productName; + private string _sku; + private List _batches = new List(); + private int _availableQuantity; + private int _allocatedQuantity; + + public InventoryItem(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Command handler methods + public void ReceiveInventory( + int quantity, + string batchNumber, + string location, + DateTime expirationDate, + ICorrelatedMessage source) + { + // Business rules validation + if (quantity <= 0) + throw new ArgumentException("Quantity must be greater than zero"); + + if (string.IsNullOrWhiteSpace(batchNumber)) + throw new ArgumentException("Batch number is required"); + + if (string.IsNullOrWhiteSpace(location)) + throw new ArgumentException("Location is required"); + + if (expirationDate <= DateTime.UtcNow) + throw new ArgumentException("Expiration date must be in the future"); + + // Check for duplicate batch + if (_batches.Any(b => b.BatchNumber == batchNumber)) + throw new InvalidOperationException($"Batch {batchNumber} already exists"); + + // Raise the event + RaiseEvent(InventoryReceived.Create( + Id, quantity, batchNumber, location, expirationDate, source)); + } + + public void AllocateInventory( + Guid orderId, + int requestedQuantity, + ICorrelatedMessage source) + { + // Business rules validation + if (requestedQuantity <= 0) + throw new ArgumentException("Quantity must be greater than zero"); + + if (_availableQuantity < requestedQuantity) + throw new InsufficientInventoryException($"Insufficient inventory. Available: {_availableQuantity}, Requested: {requestedQuantity}"); + + // Allocate inventory using FIFO (First In, First Out) + var allocationDetails = new List(); + var remainingToAllocate = requestedQuantity; + + // Sort batches by expiration date (FEFO - First Expired, First Out) + var sortedBatches = _batches + .Where(b => b.AvailableQuantity > 0) + .OrderBy(b => b.ExpirationDate) + .ToList(); + + foreach (var batch in sortedBatches) + { + if (remainingToAllocate <= 0) + break; + + var quantityToAllocate = Math.Min(batch.AvailableQuantity, remainingToAllocate); + + allocationDetails.Add(new InventoryAllocated.AllocationDetail( + batch.BatchNumber, batch.Location, quantityToAllocate)); + + remainingToAllocate -= quantityToAllocate; + } + + // Raise the event + RaiseEvent(InventoryAllocated.Create( + orderId, Id, requestedQuantity, allocationDetails, source)); + } + + // Event handler methods + private void Apply(InventoryReceived @event) + { + // Add new batch + _batches.Add(new InventoryBatch( + @event.BatchNumber, + @event.Location, + @event.Quantity, + 0, + @event.ExpirationDate)); + + // Update available quantity + _availableQuantity += @event.Quantity; + } + + private void Apply(InventoryAllocated @event) + { + // Update batches + foreach (var detail in @event.AllocationDetails) + { + var batch = _batches.First(b => b.BatchNumber == detail.BatchNumber); + batch.AllocateQuantity(detail.Quantity); + } + + // Update quantities + _availableQuantity -= @event.Quantity; + _allocatedQuantity += @event.Quantity; + } + + // Helper class for inventory batches + private class InventoryBatch + { + public string BatchNumber { get; } + public string Location { get; } + public int TotalQuantity { get; } + public int AllocatedQuantity { get; private set; } + public DateTime ExpirationDate { get; } + + public int AvailableQuantity => TotalQuantity - AllocatedQuantity; + + public InventoryBatch( + string batchNumber, + string location, + int totalQuantity, + int allocatedQuantity, + DateTime expirationDate) + { + BatchNumber = batchNumber; + Location = location; + TotalQuantity = totalQuantity; + AllocatedQuantity = allocatedQuantity; + ExpirationDate = expirationDate; + } + + public void AllocateQuantity(int quantity) + { + if (quantity > AvailableQuantity) + throw new InvalidOperationException($"Cannot allocate {quantity} from batch {BatchNumber}. Only {AvailableQuantity} available."); + + AllocatedQuantity += quantity; + } + } + + // Custom exception + public class InsufficientInventoryException : Exception + { + public InsufficientInventoryException(string message) : base(message) + { + } + } +} +``` + +## Command Handler Implementation + +```csharp +public class ReceiveInventoryHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly IProductService _productService; + private readonly ILogger _logger; + + public ReceiveInventoryHandler( + ICorrelatedRepository repository, + IProductService productService, + ILogger logger) + { + _repository = repository; + _productService = productService; + _logger = logger; + } + + public void Handle(ReceiveInventory command) + { + _logger.LogInformation("Processing inventory receipt: {Quantity} of {ProductId}, Batch: {BatchNumber}", + command.Quantity, command.ProductId, command.BatchNumber); + + try + { + // Check if product exists + var product = _productService.GetProduct(command.ProductId); + + // Get or create inventory item + InventoryItem inventoryItem; + if (!_repository.TryGetById(command.ProductId, out inventoryItem, command)) + { + // Create new inventory item + inventoryItem = new InventoryItem(command.ProductId); + + // Initialize with product details + var createEvent = MessageBuilder.From(command, () => new InventoryItemCreated( + command.ProductId, + product.Name, + product.SKU, + DateTime.UtcNow)); + + inventoryItem.Initialize(createEvent); + } + + // Receive inventory + inventoryItem.ReceiveInventory( + command.Quantity, + command.BatchNumber, + command.Location, + command.ExpirationDate, + command); + + // Save the inventory item + _repository.Save(inventoryItem); + + _logger.LogInformation("Inventory receipt processed successfully"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing inventory receipt: {ErrorMessage}", ex.Message); + throw; + } + } +} +``` + +## Saga Implementation for Order Fulfillment + +```csharp +public class OrderFulfillmentSaga : + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + private readonly ISagaRepository _sagaRepository; + private readonly ILogger _logger; + + public OrderFulfillmentSaga( + ICommandBus commandBus, + ISagaRepository sagaRepository, + ILogger logger) + { + _commandBus = commandBus; + _sagaRepository = sagaRepository; + _logger = logger; + } + + public void Handle(OrderPlaced @event) + { + _logger.LogInformation("Starting order fulfillment saga for order: {OrderId}", @event.OrderId); + + // Create a new saga state + var sagaState = new OrderFulfillmentState + { + Id = @event.OrderId, + OrderId = @event.OrderId, + CustomerId = @event.CustomerId, + TotalAmount = @event.TotalAmount, + Status = OrderFulfillmentStatus.Started, + Items = @event.Items.Select(i => new OrderFulfillmentState.OrderItem + { + ProductId = i.ProductId, + Quantity = i.Quantity, + UnitPrice = i.UnitPrice, + IsAllocated = false + }).ToList() + }; + + // Save the saga state + _sagaRepository.Save(sagaState); + + // Start allocating inventory for each item + foreach (var item in @event.Items) + { + _commandBus.Send(AllocateInventory.Create( + @event.OrderId, + item.ProductId, + item.Quantity, + @event)); + } + + // Process payment + _commandBus.Send(MessageBuilder.From(@event, () => new ProcessPayment( + @event.OrderId, + @event.CustomerId, + @event.PaymentMethod, + @event.TotalAmount))); + } + + public void Handle(InventoryAllocated @event) + { + _logger.LogInformation("Inventory allocated for order: {OrderId}, Product: {ProductId}", + @event.OrderId, @event.ProductId); + + // Get the saga state + var sagaState = _sagaRepository.GetById(@event.OrderId); + + // Update the item allocation status + var item = sagaState.Items.First(i => i.ProductId == @event.ProductId); + item.IsAllocated = true; + + // Check if all items are allocated + if (sagaState.Items.All(i => i.IsAllocated) && sagaState.IsPaymentProcessed) + { + sagaState.Status = OrderFulfillmentStatus.ReadyForShipment; + + // Send command to prepare shipment + _commandBus.Send(MessageBuilder.From(@event, () => new PrepareShipment( + @event.OrderId, + sagaState.ShippingAddress, + sagaState.Items.Select(i => new PrepareShipment.ShipmentItem + { + ProductId = i.ProductId, + Quantity = i.Quantity + }).ToList()))); + } + + // Save the updated saga state + _sagaRepository.Save(sagaState); + } + + public void Handle(PaymentProcessed @event) + { + _logger.LogInformation("Payment processed for order: {OrderId}", @event.OrderId); + + // Get the saga state + var sagaState = _sagaRepository.GetById(@event.OrderId); + + // Update payment status + sagaState.IsPaymentProcessed = true; + sagaState.PaymentTransactionId = @event.TransactionId; + + // Check if all items are allocated + if (sagaState.Items.All(i => i.IsAllocated) && sagaState.IsPaymentProcessed) + { + sagaState.Status = OrderFulfillmentStatus.ReadyForShipment; + + // Send command to prepare shipment + _commandBus.Send(MessageBuilder.From(@event, () => new PrepareShipment( + @event.OrderId, + sagaState.ShippingAddress, + sagaState.Items.Select(i => new PrepareShipment.ShipmentItem + { + ProductId = i.ProductId, + Quantity = i.Quantity + }).ToList()))); + } + + // Save the updated saga state + _sagaRepository.Save(sagaState); + } + + // Saga state + public class OrderFulfillmentState + { + public Guid Id { get; set; } + public Guid OrderId { get; set; } + public Guid CustomerId { get; set; } + public decimal TotalAmount { get; set; } + public OrderFulfillmentStatus Status { get; set; } + public List Items { get; set; } = new List(); + public bool IsPaymentProcessed { get; set; } + public string PaymentTransactionId { get; set; } + public Address ShippingAddress { get; set; } + + public class OrderItem + { + public Guid ProductId { get; set; } + public int Quantity { get; set; } + public decimal UnitPrice { get; set; } + public bool IsAllocated { get; set; } + } + + public class Address + { + public string Street { get; set; } + public string City { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public string Country { get; set; } + } + } + + public enum OrderFulfillmentStatus + { + Started, + InventoryAllocated, + PaymentProcessed, + ReadyForShipment, + Shipped, + Delivered, + Cancelled + } +} +``` + +## Read Model Implementation + +```csharp +public class InventorySummaryReadModel : ReadModelBase +{ + public string ProductName { get; set; } + public string SKU { get; set; } + public int TotalQuantity { get; set; } + public int AvailableQuantity { get; set; } + public int AllocatedQuantity { get; set; } + public List Batches { get; set; } = new List(); + public DateTime LastUpdated { get; set; } + + public class BatchSummary + { + public string BatchNumber { get; set; } + public string Location { get; set; } + public int TotalQuantity { get; set; } + public int AvailableQuantity { get; set; } + public int AllocatedQuantity { get; set; } + public DateTime ExpirationDate { get; set; } + public DateTime ReceivedDate { get; set; } + } +} +``` + +## Read Model Updater + +```csharp +public class InventorySummaryUpdater : + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _readModelRepository; + private readonly ILogger _logger; + + public InventorySummaryUpdater( + IReadModelRepository readModelRepository, + ILogger logger) + { + _readModelRepository = readModelRepository; + _logger = logger; + } + + public void Handle(InventoryItemCreated @event) + { + try + { + // Create a new read model + var readModel = new InventorySummaryReadModel + { + Id = @event.ProductId, + ProductName = @event.ProductName, + SKU = @event.SKU, + TotalQuantity = 0, + AvailableQuantity = 0, + AllocatedQuantity = 0, + LastUpdated = @event.CreatedDate + }; + + // Save the read model + _readModelRepository.Save(readModel); + + _logger.LogInformation("Created inventory summary for product: {ProductId}", @event.ProductId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating inventory summary for product: {ProductId}", @event.ProductId); + throw; + } + } + + public void Handle(InventoryReceived @event) + { + try + { + // Get the read model + var readModel = _readModelRepository.GetById(@event.ProductId); + + // Update quantities + readModel.TotalQuantity += @event.Quantity; + readModel.AvailableQuantity += @event.Quantity; + readModel.LastUpdated = @event.ReceivedDate; + + // Add batch + readModel.Batches.Add(new InventorySummaryReadModel.BatchSummary + { + BatchNumber = @event.BatchNumber, + Location = @event.Location, + TotalQuantity = @event.Quantity, + AvailableQuantity = @event.Quantity, + AllocatedQuantity = 0, + ExpirationDate = @event.ExpirationDate, + ReceivedDate = @event.ReceivedDate + }); + + // Save the updated read model + _readModelRepository.Save(readModel); + + _logger.LogInformation("Updated inventory summary for received inventory: {ProductId}, Batch: {BatchNumber}", + @event.ProductId, @event.BatchNumber); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating inventory summary for received inventory: {ProductId}", @event.ProductId); + throw; + } + } + + public void Handle(InventoryAllocated @event) + { + try + { + // Get the read model + var readModel = _readModelRepository.GetById(@event.ProductId); + + // Update quantities + readModel.AvailableQuantity -= @event.Quantity; + readModel.AllocatedQuantity += @event.Quantity; + readModel.LastUpdated = @event.AllocationDate; + + // Update batches + foreach (var detail in @event.AllocationDetails) + { + var batch = readModel.Batches.First(b => b.BatchNumber == detail.BatchNumber); + batch.AvailableQuantity -= detail.Quantity; + batch.AllocatedQuantity += detail.Quantity; + } + + // Save the updated read model + _readModelRepository.Save(readModel); + + _logger.LogInformation("Updated inventory summary for allocated inventory: {ProductId}, Order: {OrderId}", + @event.ProductId, @event.OrderId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating inventory summary for allocated inventory: {ProductId}", @event.ProductId); + throw; + } + } + + // Additional event handlers... +} +``` + +## API Controller + +```csharp +[ApiController] +[Route("api/[controller]")] +public class InventoryController : ControllerBase +{ + private readonly ICommandBus _commandBus; + private readonly IReadModelRepository _readModelRepository; + + public InventoryController( + ICommandBus commandBus, + IReadModelRepository readModelRepository) + { + _commandBus = commandBus; + _readModelRepository = readModelRepository; + } + + [HttpPost("receive")] + public IActionResult ReceiveInventory([FromBody] ReceiveInventoryRequest request) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + try + { + // Create a command with correlation + var command = new ReceiveInventory( + request.ProductId, + request.Quantity, + request.BatchNumber, + request.Location, + request.ExpirationDate, + Guid.NewGuid(), + Guid.NewGuid(), // New correlation ID for this transaction + Guid.Empty); // No causation ID for the initial command + + // Send the command + _commandBus.Send(command); + + return Accepted(new { TransactionId = command.MsgId }); + } + catch (Exception ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpGet("{id}")] + public IActionResult GetInventory(Guid id) + { + try + { + var inventory = _readModelRepository.GetById(id); + + if (inventory == null) + return NotFound(); + + return Ok(inventory); + } + catch (Exception ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + [HttpGet("low-stock")] + public IActionResult GetLowStockItems([FromQuery] int threshold = 10) + { + try + { + var lowStockItems = _readModelRepository.FindAll() + .Where(i => i.AvailableQuantity < threshold) + .ToList(); + + return Ok(lowStockItems); + } + catch (Exception ex) + { + return BadRequest(new { Error = ex.Message }); + } + } + + public class ReceiveInventoryRequest + { + [Required] + public Guid ProductId { get; set; } + + [Required] + [Range(1, int.MaxValue, ErrorMessage = "Quantity must be greater than zero")] + public int Quantity { get; set; } + + [Required] + public string BatchNumber { get; set; } + + [Required] + public string Location { get; set; } + + [Required] + public DateTime ExpirationDate { get; set; } + } +} +``` + +## Key Concepts Demonstrated + +1. **CQRS Pattern**: Separation of commands (write operations) and queries (read operations) +2. **Event Sourcing**: Using events to represent state changes and reconstruct state +3. **Domain-Driven Design**: Rich domain models with business rules and validations +4. **Value Objects**: Immutable objects that represent concepts in the domain +5. **Sagas**: Long-running processes that coordinate multiple aggregates +6. **Correlation**: Tracking related messages through the system using `ICorrelatedMessage` and `MessageBuilder` +7. **Repository Pattern**: Using repositories to load and save aggregates +8. **Read Models**: Specialized models for querying data efficiently +9. **Event Handlers**: Components that update read models based on domain events +10. **API Integration**: Exposing the domain through a REST API diff --git a/todo-pr-169.md b/todo-pr-169.md index dbdebebe..237be535 100644 --- a/todo-pr-169.md +++ b/todo-pr-169.md @@ -20,7 +20,7 @@ This todo list contains items that need to be addressed for PR 169, which adds c - [x] Fix the use of `Apply()` vs `RaiseEvent()` methods in aggregates (Apply methods are for rehydration, not for creating new events) - [x] Update examples in event.md to use `RaiseEvent(new AccountCreated(...))` instead of `Apply(...)` - [x] Review all code examples for technical accuracy in command.md and message-builder.md -- [ ] Add more real-world examples to illustrate concepts +- [x] Add more real-world examples to illustrate concepts ## Architecture Documentation Improvements From 582b9bd928bf89813593cef468fbb6e243af40fe Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 19:05:50 -0400 Subject: [PATCH 21/41] Improve documentation navigation and structure with comprehensive cross-referencing --- docs/README.md | 8 + docs/api-reference/README.md | 8 + docs/code-examples/README.md | 8 + docs/component-relationships.md | 128 ++++++++ docs/components/README.md | 26 ++ docs/components/identity-storage.md | 385 +++++++++++++++++++++++ docs/components/persistence.md | 166 ++++++++++ docs/components/policy.md | 295 +++++++++++++++++ docs/components/testing.md | 313 ++++++++++++++++++ docs/components/tools.md | 356 +++++++++++++++++++++ docs/components/transport.md | 212 +++++++++++++ docs/interfaces/correlated-repository.md | 214 +++++++++++++ docs/interfaces/repository.md | 222 +++++++++++++ docs/navigation-index.md | 107 +++++++ docs/scripts/verify-links.sh | 58 ++++ docs/templates/navigation-template.md | 17 + todo-pr-169.md | 8 +- 17 files changed, 2527 insertions(+), 4 deletions(-) create mode 100644 docs/component-relationships.md create mode 100644 docs/components/identity-storage.md create mode 100644 docs/components/persistence.md create mode 100644 docs/components/policy.md create mode 100644 docs/components/testing.md create mode 100644 docs/components/tools.md create mode 100644 docs/components/transport.md create mode 100644 docs/interfaces/correlated-repository.md create mode 100644 docs/interfaces/repository.md create mode 100644 docs/navigation-index.md create mode 100755 docs/scripts/verify-links.sh create mode 100644 docs/templates/navigation-template.md diff --git a/docs/README.md b/docs/README.md index ba6060b2..7427778e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,14 @@ For the best learning experience, we recommend following this progression: 5. **Advanced**: [Architecture Guide](architecture.md) - Understand the system architecture 6. **Production**: [Deployment Guide](deployment.md) and [Performance Optimization](performance.md) - Prepare for production +## Navigation Resources + +To help you navigate the documentation more effectively, we've created these resources: + +- [Component Relationships](component-relationships.md) - Visual guide showing how different components work together +- [Navigation Index](navigation-index.md) - Comprehensive index of all documentation with cross-references +- [Quick Reference](#quick-reference) - Quick links to common tasks and key components + ## Table of Contents 1. [Core Concepts](core-concepts.md) diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md index 5bc0a13f..d861498a 100644 --- a/docs/api-reference/README.md +++ b/docs/api-reference/README.md @@ -26,6 +26,14 @@ Each type is documented with: - Usage examples and common patterns - Related types and interfaces +## Navigation + +For easier navigation through the API reference, use these resources: + +- [Component Relationships](../component-relationships.md) - Visual guide showing how different components work together +- [Navigation Index](../navigation-index.md) - Comprehensive index of all documentation with cross-references +- [Related Components](#key-types) - Each component documentation includes links to related components + ## Key Types ### Core Interfaces diff --git a/docs/code-examples/README.md b/docs/code-examples/README.md index b74052f6..25c0b0f6 100644 --- a/docs/code-examples/README.md +++ b/docs/code-examples/README.md @@ -30,6 +30,14 @@ Each example includes: - Best practices and common pitfalls to avoid - Variations for different use cases +## Navigation + +For easier navigation through the code examples, use these resources: + +- [Component Relationships](../component-relationships.md) - Visual guide showing how different components work together +- [Navigation Index](../navigation-index.md) - Comprehensive index of all documentation with cross-references +- [Related Examples](#table-of-contents) - Each example includes links to related examples + ## How to Use These Examples The examples in this section are designed to be practical and reusable. You can: diff --git a/docs/component-relationships.md b/docs/component-relationships.md new file mode 100644 index 00000000..3cb36f45 --- /dev/null +++ b/docs/component-relationships.md @@ -0,0 +1,128 @@ +# Component Relationships in Reactive Domain + +This document provides a visual guide to how different components in Reactive Domain relate to each other, helping you navigate the documentation more effectively. + +## Core Component Relationships + +```mermaid +graph TD + subgraph "Command Flow" + Client[Client Application] -->|Sends| Command[Command] + Command -->|Processed by| CommandHandler[Command Handler] + CommandHandler -->|Loads| AggregateRoot[Aggregate Root] + CommandHandler -->|Uses| Repository[Repository] + AggregateRoot -->|Raises| Event[Event] + CommandHandler -->|Saves| Repository + Repository -->|Stores| EventStore[Event Store] + end + + subgraph "Event Flow" + EventStore -->|Publishes to| EventBus[Event Bus] + EventBus -->|Consumed by| EventProcessor[Event Processor] + EventProcessor -->|Updates| ReadModel[Read Model] + ReadModel -->|Queried by| QueryHandler[Query Handler] + QueryHandler -->|Returns data to| Client + end + + subgraph "Correlation" + Command -->|Has| CorrelationId[Correlation ID] + Event -->|Inherits| CorrelationId + MessageBuilder[Message Builder] -->|Creates| Command + MessageBuilder -->|Creates| Event + end + + style Command fill:#f9f,stroke:#333,stroke-width:2px + style Event fill:#bbf,stroke:#333,stroke-width:2px + style AggregateRoot fill:#bfb,stroke:#333,stroke-width:2px + style Repository fill:#fbb,stroke:#333,stroke-width:2px + style ReadModel fill:#bff,stroke:#333,stroke-width:2px +``` + +## Key Interface Relationships + +```mermaid +graph TD + IEventSource[IEventSource] <|-- AggregateRoot[AggregateRoot] + ICorrelatedMessage[ICorrelatedMessage] <|-- Command[Command] + ICorrelatedMessage <|-- Event[Event] + IRepository[IRepository] <|-- StreamStoreRepository[StreamStoreRepository] + ICorrelatedRepository[ICorrelatedRepository] <|-- CorrelatedStreamStoreRepository[CorrelatedStreamStoreRepository] + ICorrelatedRepository --|extends|> IRepository + IEventBus[IEventBus] <|-- EventBus[EventBus] + ICommandBus[ICommandBus] <|-- CommandBus[CommandBus] + IEventProcessor[IEventProcessor] <|-- EventProcessor[EventProcessor] + ICheckpointStore[ICheckpointStore] <|-- CheckpointStore[CheckpointStore] + + style IEventSource fill:#f9f,stroke:#333,stroke-width:2px + style ICorrelatedMessage fill:#bbf,stroke:#333,stroke-width:2px + style IRepository fill:#bfb,stroke:#333,stroke-width:2px + style ICorrelatedRepository fill:#fbb,stroke:#333,stroke-width:2px +``` + +## Documentation Map + +The following diagram shows how different documentation sections relate to each other: + +```mermaid +graph TD + CoreConcepts[Core Concepts] --> UsagePatterns[Usage Patterns] + UsagePatterns --> CodeExamples[Code Examples] + CodeExamples --> APIReference[API Reference] + CoreConcepts --> Architecture[Architecture Guide] + Architecture --> DeploymentGuide[Deployment Guide] + Architecture --> PerformanceGuide[Performance Guide] + UsagePatterns --> TroubleshootingGuide[Troubleshooting Guide] + + click CoreConcepts "core-concepts.md" "Go to Core Concepts" + click UsagePatterns "usage-patterns.md" "Go to Usage Patterns" + click CodeExamples "code-examples/README.md" "Go to Code Examples" + click APIReference "api-reference/README.md" "Go to API Reference" + click Architecture "architecture.md" "Go to Architecture Guide" + click DeploymentGuide "deployment.md" "Go to Deployment Guide" + click PerformanceGuide "performance.md" "Go to Performance Guide" + click TroubleshootingGuide "troubleshooting.md" "Go to Troubleshooting Guide" + + style CoreConcepts fill:#f9f,stroke:#333,stroke-width:2px + style APIReference fill:#bbf,stroke:#333,stroke-width:2px + style CodeExamples fill:#bfb,stroke:#333,stroke-width:2px +``` + +## Component Documentation Quick Links + +| Component | Description | Documentation | Related Components | +|-----------|-------------|---------------|-------------------| +| AggregateRoot | Base class for domain aggregates | [API Reference](api-reference/types/aggregate-root.md) | [IEventSource](api-reference/types/ievent-source.md), [EventRecorder](api-reference/types/event-recorder.md) | +| Command | Base class for command messages | [API Reference](api-reference/types/command.md) | [ICommand](api-reference/types/icommand.md), [ICommandHandler](api-reference/types/icommand-handler.md), [ICommandBus](api-reference/types/icommand-bus.md) | +| Event | Base class for event messages | [API Reference](api-reference/types/event.md) | [IEvent](api-reference/types/ievent.md), [IEventHandler](api-reference/types/ievent-handler.md), [IEventBus](api-reference/types/ievent-bus.md) | +| MessageBuilder | Factory for creating correlated messages | [API Reference](api-reference/types/message-builder.md) | [ICorrelatedMessage](api-reference/types/icorrelated-message.md) | +| Repository | Storage for aggregates | [API Reference](api-reference/types/irepository.md) | [ICorrelatedRepository](api-reference/types/icorrelated-repository.md), [StreamStoreRepository](api-reference/types/stream-store-repository.md) | +| EventProcessor | Processes events from the event store | [API Reference](api-reference/types/ievent-processor.md) | [ICheckpointStore](api-reference/types/icheckpoint-store.md), [IEventBus](api-reference/types/ievent-bus.md) | +| ReadModelBase | Base class for read models | [API Reference](api-reference/types/read-model-base.md) | [IReadModelRepository](api-reference/types/iread-model-repository.md) | +| ProcessManager | Coordinates complex business processes | [API Reference](api-reference/types/process-manager.md) | [ICommandBus](api-reference/types/icommand-bus.md), [IEventBus](api-reference/types/ievent-bus.md) | + +## Learning Path Navigation + +If you're new to Reactive Domain, follow this recommended learning path: + +1. **Start Here**: [Core Concepts](core-concepts.md) +2. **Next**: [Usage Patterns](usage-patterns.md) +3. **Then**: [Code Examples](code-examples/README.md) +4. **Explore**: [API Reference](api-reference/README.md) +5. **Advanced**: [Architecture Guide](architecture.md) +6. **Production**: [Deployment Guide](deployment.md) and [Performance Optimization](performance.md) + +## Common Tasks Navigation + +| If you want to... | Go to... | +|-------------------|----------| +| Understand event sourcing | [Core Concepts](core-concepts.md) | +| Start a new project | [Usage Patterns](usage-patterns.md#setting-up-a-new-reactive-domain-project) | +| Create an aggregate | [Code Examples](code-examples/creating-aggregate-root.md) | +| Implement commands and events | [Code Examples](code-examples/handling-commands-events.md) | +| Set up repositories | [Code Examples](code-examples/saving-retrieving-aggregates.md) | +| Create read models | [Code Examples](code-examples/implementing-projections.md) | +| Implement correlation | [Code Examples](code-examples/correlation-causation.md) | +| Test your application | [Code Examples](code-examples/testing.md) | +| Fix a common issue | [Troubleshooting Guide](troubleshooting.md) | +| Optimize performance | [Performance Optimization Guide](performance.md) | +| Deploy to production | [Deployment Guide](deployment.md) | diff --git a/docs/components/README.md b/docs/components/README.md index dc64c762..573bfb23 100644 --- a/docs/components/README.md +++ b/docs/components/README.md @@ -38,6 +38,32 @@ Each component documentation includes: - Usage examples - Integration with other components +## Component Relationships + +The components in Reactive Domain are designed to work together to provide a complete event sourcing and CQRS framework. The following diagram shows the relationships between the components: + +```mermaid +graph TD + A[Client Application] --> B[ReactiveDomain.Messaging] + B --> C[ReactiveDomain.Foundation] + C --> D[ReactiveDomain.Core] + C --> E[ReactiveDomain.Persistence] + E --> F[EventStoreDB] + B --> G[ReactiveDomain.Transport] + H[ReactiveDomain.Testing] --> C + I[ReactiveDomain.Tools] --> C + J[ReactiveDomain.Policy] --> C + K[ReactiveDomain.IdentityStorage] --> C +``` + +## Navigation Resources + +For easier navigation through the component documentation, use these resources: + +- [Component Relationships](../component-relationships.md) - Visual guide showing how different components work together +- [Navigation Index](../navigation-index.md) - Comprehensive index of all documentation with cross-references +- [API Reference](../api-reference/README.md) - Detailed API documentation for all components + --- **Navigation**: diff --git a/docs/components/identity-storage.md b/docs/components/identity-storage.md new file mode 100644 index 00000000..fd119d15 --- /dev/null +++ b/docs/components/identity-storage.md @@ -0,0 +1,385 @@ +# ReactiveDomain.IdentityStorage Component + +[← Back to Components](README.md) + +The ReactiveDomain.IdentityStorage component provides infrastructure for storing and managing identities in a Reactive Domain application. It helps with user authentication, authorization, and identity management using event sourcing principles. + +## Key Features + +- Identity storage and retrieval +- User authentication and authorization +- Role-based access control +- Claims-based identity +- Identity versioning and history +- Integration with external identity providers + +## Core Types + +### Identity Management + +- **Identity**: Represents a user identity +- **IdentityAggregate**: Aggregate root for identity entities +- **IdentityRepository**: Repository for identity aggregates +- **IdentityService**: Service for identity operations +- **IdentityManager**: Manager for identity lifecycle + +### Authentication + +- **Authenticator**: Handles authentication requests +- **PasswordHasher**: Hashes and verifies passwords +- **AuthenticationResult**: Result of authentication attempts +- **AuthenticationToken**: Token for authenticated sessions +- **TokenValidator**: Validates authentication tokens + +### Authorization + +- **Role**: Represents a security role +- **Permission**: Represents a security permission +- **RoleRepository**: Repository for role entities +- **PermissionRepository**: Repository for permission entities +- **AuthorizationService**: Service for authorization operations + +## Usage Examples + +### Creating a New Identity + +```csharp +public class CreateIdentityHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly PasswordHasher _passwordHasher; + + public CreateIdentityHandler( + ICorrelatedRepository repository, + PasswordHasher passwordHasher) + { + _repository = repository; + _passwordHasher = passwordHasher; + } + + public void Handle(CreateIdentity command) + { + // Check if identity already exists + if (_repository.TryGetById(command.IdentityId, out _, command)) + { + throw new IdentityAlreadyExistsException($"Identity with ID {command.IdentityId} already exists"); + } + + // Create a new identity + var identity = new IdentityAggregate(command.IdentityId); + + // Hash the password + var hashedPassword = _passwordHasher.HashPassword(command.Password); + + // Initialize the identity + identity.Initialize( + command.Username, + command.Email, + hashedPassword, + command.DisplayName, + command); + + // Assign initial roles + foreach (var role in command.Roles) + { + identity.AssignRole(role, command); + } + + // Save the identity + _repository.Save(identity); + } +} +``` + +### Authenticating a User + +```csharp +public class AuthenticateUserHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly PasswordHasher _passwordHasher; + private readonly TokenGenerator _tokenGenerator; + private readonly IEventBus _eventBus; + + public AuthenticateUserHandler( + ICorrelatedRepository repository, + PasswordHasher passwordHasher, + TokenGenerator tokenGenerator, + IEventBus eventBus) + { + _repository = repository; + _passwordHasher = passwordHasher; + _tokenGenerator = tokenGenerator; + _eventBus = eventBus; + } + + public void Handle(AuthenticateUser command) + { + // Find the identity by username + var identity = _repository.FindByUsername(command.Username, command); + + if (identity == null) + { + // Publish authentication failed event + _eventBus.Publish(MessageBuilder.From(command, () => new AuthenticationFailed( + command.Username, + "Identity not found", + DateTime.UtcNow))); + + throw new AuthenticationException("Invalid username or password"); + } + + // Verify the password + if (!_passwordHasher.VerifyPassword(command.Password, identity.HashedPassword)) + { + // Record failed authentication attempt + identity.RecordFailedAuthenticationAttempt(command); + _repository.Save(identity); + + // Publish authentication failed event + _eventBus.Publish(MessageBuilder.From(command, () => new AuthenticationFailed( + command.Username, + "Invalid password", + DateTime.UtcNow))); + + throw new AuthenticationException("Invalid username or password"); + } + + // Generate authentication token + var token = _tokenGenerator.GenerateToken( + identity.Id, + identity.Username, + identity.Roles, + identity.Claims); + + // Record successful authentication + identity.RecordSuccessfulAuthentication(command); + _repository.Save(identity); + + // Publish authentication succeeded event + _eventBus.Publish(MessageBuilder.From(command, () => new AuthenticationSucceeded( + identity.Id, + identity.Username, + token, + DateTime.UtcNow))); + } +} +``` + +### Checking Authorization + +```csharp +public class AuthorizationService : IAuthorizationService +{ + private readonly ICorrelatedRepository _repository; + private readonly IRoleRepository _roleRepository; + + public AuthorizationService( + ICorrelatedRepository repository, + IRoleRepository roleRepository) + { + _repository = repository; + _roleRepository = roleRepository; + } + + public bool IsAuthorized(Guid identityId, string permission, ICorrelatedMessage source) + { + // Get the identity + if (!_repository.TryGetById(identityId, out var identity, source)) + { + return false; + } + + // Check if identity has the permission directly + if (identity.HasPermission(permission)) + { + return true; + } + + // Check if any of the identity's roles has the permission + foreach (var roleName in identity.Roles) + { + var role = _roleRepository.GetByName(roleName); + + if (role != null && role.HasPermission(permission)) + { + return true; + } + } + + return false; + } +} +``` + +## Integration with Other Components + +The IdentityStorage component integrates with: + +- **ReactiveDomain.Core**: Uses core interfaces and types +- **ReactiveDomain.Foundation**: Provides identity infrastructure for domain components +- **ReactiveDomain.Messaging**: Integrates with command and event handling +- **ReactiveDomain.Persistence**: Uses event sourcing for identity storage + +## Configuration Options + +### Identity Options + +- **PasswordRequirements**: Requirements for password complexity +- **LockoutOptions**: Options for account lockout +- **TokenOptions**: Options for authentication tokens +- **UserValidationOptions**: Options for user validation + +### Authentication Options + +- **TokenExpirationTime**: Expiration time for authentication tokens +- **RefreshTokenExpirationTime**: Expiration time for refresh tokens +- **AllowMultipleSessions**: Whether to allow multiple sessions for a user +- **RequireTwoFactorAuth**: Whether to require two-factor authentication + +### Authorization Options + +- **DefaultPolicy**: Default authorization policy +- **FallbackPolicy**: Fallback authorization policy +- **RequireAuthenticatedUser**: Whether to require authenticated users by default +- **AddDefaultRoles**: Whether to add default roles + +## Best Practices + +1. **Secure Password Storage**: Always hash passwords using a strong algorithm +2. **Implement Account Lockout**: Lock accounts after multiple failed authentication attempts +3. **Use Short-Lived Tokens**: Use short-lived authentication tokens with refresh tokens +4. **Implement Role-Based Access Control**: Use roles for coarse-grained access control +5. **Implement Claims-Based Authorization**: Use claims for fine-grained access control +6. **Audit Identity Operations**: Keep an audit trail of identity operations +7. **Validate User Input**: Validate all user input to prevent injection attacks +8. **Use HTTPS**: Always use HTTPS for identity operations +9. **Implement Two-Factor Authentication**: Add an extra layer of security with two-factor authentication +10. **Follow Security Best Practices**: Stay up-to-date with security best practices + +## Common Identity Patterns + +### User Registration and Confirmation + +```csharp +public class RegisterUserHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly PasswordHasher _passwordHasher; + private readonly EmailService _emailService; + + public RegisterUserHandler( + ICorrelatedRepository repository, + PasswordHasher passwordHasher, + EmailService emailService) + { + _repository = repository; + _passwordHasher = passwordHasher; + _emailService = emailService; + } + + public void Handle(RegisterUser command) + { + // Create a new identity + var identity = new IdentityAggregate(Guid.NewGuid()); + + // Hash the password + var hashedPassword = _passwordHasher.HashPassword(command.Password); + + // Generate confirmation token + var confirmationToken = Guid.NewGuid().ToString("N"); + + // Initialize the identity + identity.Initialize( + command.Username, + command.Email, + hashedPassword, + command.DisplayName, + command); + + // Set confirmation token + identity.SetConfirmationToken(confirmationToken, command); + + // Save the identity + _repository.Save(identity); + + // Send confirmation email + _emailService.SendConfirmationEmail( + command.Email, + command.Username, + confirmationToken); + } +} +``` + +### Password Reset + +```csharp +public class RequestPasswordResetHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly EmailService _emailService; + + public RequestPasswordResetHandler( + ICorrelatedRepository repository, + EmailService emailService) + { + _repository = repository; + _emailService = emailService; + } + + public void Handle(RequestPasswordReset command) + { + // Find the identity by email + var identity = _repository.FindByEmail(command.Email, command); + + if (identity == null) + { + // Don't reveal that the email doesn't exist + return; + } + + // Generate reset token + var resetToken = Guid.NewGuid().ToString("N"); + var resetTokenExpiration = DateTime.UtcNow.AddHours(24); + + // Set reset token + identity.SetPasswordResetToken(resetToken, resetTokenExpiration, command); + + // Save the identity + _repository.Save(identity); + + // Send password reset email + _emailService.SendPasswordResetEmail( + identity.Email, + identity.Username, + resetToken); + } +} +``` + +## Related Documentation + +- [Command API Reference](../api-reference/types/command.md) +- [ICommandHandler API Reference](../api-reference/types/icommand-handler.md) +- [AggregateRoot API Reference](../api-reference/types/aggregate-root.md) +- [IRepository API Reference](../api-reference/types/irepository.md) +- [ICorrelatedRepository API Reference](../api-reference/types/icorrelated-repository.md) + +## Navigation + +**Section Navigation**: +- [← Previous: ReactiveDomain.Policy](policy.md) +- [↑ Parent: Component Documentation](README.md) +- [→ Next: ReactiveDomain.Tools](tools.md) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/components/persistence.md b/docs/components/persistence.md new file mode 100644 index 00000000..eafba982 --- /dev/null +++ b/docs/components/persistence.md @@ -0,0 +1,166 @@ +# ReactiveDomain.Persistence Component + +[← Back to Components](README.md) + +The ReactiveDomain.Persistence component provides the infrastructure for storing and retrieving events in an event store. It implements the repository pattern for event-sourced aggregates and provides integration with EventStoreDB. + +## Key Features + +- Event storage and retrieval +- Repository implementations +- Snapshot storage and retrieval +- Event serialization and deserialization +- Stream management + +## Core Types + +### Repositories + +- **StreamStoreRepository**: Implementation of `IRepository` using EventStoreDB +- **CorrelatedStreamStoreRepository**: Implementation of `ICorrelatedRepository` using EventStoreDB + +### Event Store Connections + +- **StreamStoreConnection**: Implementation of `IStreamStoreConnection` for EventStoreDB +- **EventStoreConnectionFactory**: Factory for creating EventStoreDB connections + +### Serialization + +- **JsonEventSerializer**: JSON serializer for events +- **BinaryEventSerializer**: Binary serializer for events + +### Snapshots + +- **SnapshotStore**: Storage for aggregate snapshots +- **SnapshotStrategy**: Strategy for when to take snapshots + +## Usage Examples + +### Configuring the Event Store Connection + +```csharp +// Create a connection to EventStoreDB +var connectionSettings = ConnectionSettings.Create() + .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")) + .Build(); + +var connection = EventStoreConnection.Create( + connectionSettings, + new Uri("tcp://localhost:1113"), + "MyApplication"); + +await connection.ConnectAsync(); + +// Create a stream store connection +var streamStoreConnection = new StreamStoreConnection(connection); +``` + +### Creating a Repository + +```csharp +// Create a repository +var repository = new StreamStoreRepository(streamStoreConnection); + +// Create a correlated repository +var correlatedRepository = new CorrelatedStreamStoreRepository(streamStoreConnection); +``` + +### Using Snapshots + +```csharp +// Create a snapshot store +var snapshotStore = new SnapshotStore(streamStoreConnection); + +// Create a repository with snapshot support +var repository = new StreamStoreRepository( + streamStoreConnection, + snapshotStore, + new IntervalSnapshotStrategy(10)); // Take a snapshot every 10 events +``` + +## Integration with Other Components + +The Persistence component integrates with: + +- **ReactiveDomain.Core**: Uses the core interfaces like `IEventSource` and `IRepository` +- **ReactiveDomain.Foundation**: Provides storage for domain aggregates +- **ReactiveDomain.Messaging**: Stores and retrieves events as messages + +## Configuration Options + +### Event Store Connection Settings + +- **ConnectionString**: Connection string for EventStoreDB +- **UserCredentials**: Username and password for authentication +- **ConnectionTimeout**: Timeout for connection attempts +- **OperationTimeout**: Timeout for operations +- **MaxRetries**: Maximum number of retries for failed operations +- **RetryDelay**: Delay between retries + +### Repository Settings + +- **EventCacheSize**: Size of the event cache +- **SnapshotFrequency**: Frequency of snapshots +- **MaxEventsPerRead**: Maximum number of events to read in a single operation + +## Best Practices + +1. **Use Correlation**: Always use `ICorrelatedRepository` when you need to track message flow +2. **Configure Snapshots**: Use snapshots for large aggregates to improve performance +3. **Handle Concurrency**: Use optimistic concurrency control with expected versions +4. **Batch Operations**: Batch multiple operations for better performance +5. **Monitor Performance**: Keep an eye on event store performance metrics + +## Common Issues and Solutions + +### Connection Issues + +If you're having trouble connecting to EventStoreDB: + +1. Check that EventStoreDB is running +2. Verify the connection string +3. Check network connectivity +4. Verify credentials + +### Concurrency Exceptions + +If you're getting concurrency exceptions: + +1. Make sure you're using the correct expected version +2. Consider using optimistic concurrency control +3. Implement retry logic for concurrency conflicts + +### Performance Issues + +If you're experiencing performance issues: + +1. Use snapshots for large aggregates +2. Batch operations where possible +3. Optimize event serialization +4. Consider scaling EventStoreDB + +## Related Documentation + +- [IRepository API Reference](../api-reference/types/irepository.md) +- [ICorrelatedRepository API Reference](../api-reference/types/icorrelated-repository.md) +- [StreamStoreRepository API Reference](../api-reference/types/stream-store-repository.md) +- [CorrelatedStreamStoreRepository API Reference](../api-reference/types/correlated-stream-store-repository.md) +- [ISnapshotSource API Reference](../api-reference/types/isnapshot-source.md) + +## Navigation + +**Section Navigation**: +- [← Previous: ReactiveDomain.Foundation](foundation.md) +- [↑ Parent: Component Documentation](README.md) +- [→ Next: ReactiveDomain.Transport](transport.md) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/components/policy.md b/docs/components/policy.md new file mode 100644 index 00000000..55661fcf --- /dev/null +++ b/docs/components/policy.md @@ -0,0 +1,295 @@ +# ReactiveDomain.Policy Component + +[← Back to Components](README.md) + +The ReactiveDomain.Policy component provides infrastructure for implementing business policies, rules, and decision-making logic in a Reactive Domain application. It helps separate policy concerns from core domain logic, making the system more maintainable and flexible. + +## Key Features + +- Policy definition and execution +- Rule-based decision making +- Policy versioning and evolution +- Policy evaluation context +- Conditional policy application +- Policy composition + +## Core Types + +### Policy Infrastructure + +- **Policy**: Base class for policy definitions +- **PolicyEvaluator**: Evaluates policies against a context +- **PolicyRegistry**: Registry for policy definitions +- **PolicyVersion**: Represents a version of a policy +- **PolicyResult**: Result of policy evaluation + +### Rules Engine + +- **Rule**: Base class for rule definitions +- **RuleSet**: Collection of rules +- **RuleEvaluator**: Evaluates rules against a context +- **RuleResult**: Result of rule evaluation +- **CompositeRule**: Composition of multiple rules + +### Policy Context + +- **PolicyContext**: Context for policy evaluation +- **PolicyContextBuilder**: Builder for policy contexts +- **ContextVariable**: Variable in a policy context +- **ContextAccessor**: Accessor for context variables + +## Usage Examples + +### Defining a Policy + +```csharp +public class AccountWithdrawalPolicy : Policy +{ + public PolicyResult Evaluate(Account account, decimal amount) + { + var context = new PolicyContext(); + context.Set("account", account); + context.Set("amount", amount); + + var rules = new RuleSet(); + rules.Add(new AccountMustBeActiveRule()); + rules.Add(new SufficientFundsRule()); + rules.Add(new WithdrawalLimitRule()); + + return Evaluate(context, rules); + } +} + +public class AccountMustBeActiveRule : Rule +{ + public override RuleResult Evaluate(PolicyContext context) + { + var account = context.Get("account"); + + if (!account.IsActive) + { + return RuleResult.Failure("Account is not active"); + } + + return RuleResult.Success(); + } +} + +public class SufficientFundsRule : Rule +{ + public override RuleResult Evaluate(PolicyContext context) + { + var account = context.Get("account"); + var amount = context.Get("amount"); + + if (account.Balance < amount) + { + return RuleResult.Failure($"Insufficient funds. Current balance: {account.Balance}, Requested: {amount}"); + } + + return RuleResult.Success(); + } +} + +public class WithdrawalLimitRule : Rule +{ + public override RuleResult Evaluate(PolicyContext context) + { + var account = context.Get("account"); + var amount = context.Get("amount"); + + if (amount > account.DailyWithdrawalLimit) + { + return RuleResult.Failure($"Withdrawal limit exceeded. Limit: {account.DailyWithdrawalLimit}, Requested: {amount}"); + } + + return RuleResult.Success(); + } +} +``` + +### Using a Policy in a Command Handler + +```csharp +public class WithdrawFundsHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly AccountWithdrawalPolicy _withdrawalPolicy; + + public WithdrawFundsHandler( + ICorrelatedRepository repository, + AccountWithdrawalPolicy withdrawalPolicy) + { + _repository = repository; + _withdrawalPolicy = withdrawalPolicy; + } + + public void Handle(WithdrawFunds command) + { + // Load the account + var account = _repository.GetById(command.AccountId, command); + + // Evaluate the policy + var policyResult = _withdrawalPolicy.Evaluate(account, command.Amount); + + // Check if the policy allows the withdrawal + if (!policyResult.IsSuccess) + { + throw new PolicyViolationException(policyResult.ErrorMessage); + } + + // Perform the withdrawal + account.Withdraw(command.Amount, command.Reference, command); + + // Save the account + _repository.Save(account); + } +} +``` + +### Composing Policies + +```csharp +public class CompositeAccountPolicy : Policy +{ + private readonly AccountWithdrawalPolicy _withdrawalPolicy; + private readonly AccountTransferPolicy _transferPolicy; + + public CompositeAccountPolicy( + AccountWithdrawalPolicy withdrawalPolicy, + AccountTransferPolicy transferPolicy) + { + _withdrawalPolicy = withdrawalPolicy; + _transferPolicy = transferPolicy; + } + + public PolicyResult EvaluateForTransfer(Account sourceAccount, Account targetAccount, decimal amount) + { + // First check if withdrawal is allowed + var withdrawalResult = _withdrawalPolicy.Evaluate(sourceAccount, amount); + if (!withdrawalResult.IsSuccess) + { + return withdrawalResult; + } + + // Then check if transfer is allowed + var transferResult = _transferPolicy.Evaluate(sourceAccount, targetAccount, amount); + return transferResult; + } +} +``` + +## Integration with Other Components + +The Policy component integrates with: + +- **ReactiveDomain.Core**: Uses core interfaces and types +- **ReactiveDomain.Foundation**: Provides policy infrastructure for domain components +- **ReactiveDomain.Messaging**: Integrates with command handling + +## Best Practices + +1. **Separate Policy from Domain Logic**: Keep policy concerns separate from core domain logic +2. **Make Policies Explicit**: Define policies explicitly rather than embedding them in domain logic +3. **Version Policies**: Version policies to track changes over time +4. **Compose Policies**: Compose complex policies from simpler ones +5. **Test Policies Thoroughly**: Write comprehensive tests for policies +6. **Document Policy Decisions**: Document the reasoning behind policy decisions +7. **Make Policies Configurable**: Allow policies to be configured without code changes where appropriate + +## Common Policy Patterns + +### Validation Policies + +Validation policies ensure that inputs meet certain criteria before processing: + +```csharp +public class AccountCreationValidationPolicy : Policy +{ + public PolicyResult Evaluate(string accountNumber, decimal initialDeposit) + { + var context = new PolicyContext(); + context.Set("accountNumber", accountNumber); + context.Set("initialDeposit", initialDeposit); + + var rules = new RuleSet(); + rules.Add(new AccountNumberFormatRule()); + rules.Add(new MinimumInitialDepositRule()); + + return Evaluate(context, rules); + } +} +``` + +### Authorization Policies + +Authorization policies determine if an action is permitted: + +```csharp +public class AccountAccessPolicy : Policy +{ + public PolicyResult Evaluate(User user, Account account, AccountAction action) + { + var context = new PolicyContext(); + context.Set("user", user); + context.Set("account", account); + context.Set("action", action); + + var rules = new RuleSet(); + rules.Add(new UserMustBeAuthenticatedRule()); + rules.Add(new UserMustBeAuthorizedForAccountRule()); + rules.Add(new ActionMustBePermittedRule()); + + return Evaluate(context, rules); + } +} +``` + +### Business Rules Policies + +Business rules policies enforce domain-specific rules: + +```csharp +public class LoanApprovalPolicy : Policy +{ + public PolicyResult Evaluate(Customer customer, LoanApplication application) + { + var context = new PolicyContext(); + context.Set("customer", customer); + context.Set("application", application); + + var rules = new RuleSet(); + rules.Add(new CreditScoreRule()); + rules.Add(new DebtToIncomeRatioRule()); + rules.Add(new LoanAmountRule()); + + return Evaluate(context, rules); + } +} +``` + +## Related Documentation + +- [Command API Reference](../api-reference/types/command.md) +- [ICommandHandler API Reference](../api-reference/types/icommand-handler.md) +- [AggregateRoot API Reference](../api-reference/types/aggregate-root.md) +- [IRepository API Reference](../api-reference/types/irepository.md) +- [Process Manager API Reference](../api-reference/types/process-manager.md) + +## Navigation + +**Section Navigation**: +- [← Previous: ReactiveDomain.Testing](testing.md) +- [↑ Parent: Component Documentation](README.md) +- [→ Next: ReactiveDomain.IdentityStorage](identity-storage.md) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/components/testing.md b/docs/components/testing.md new file mode 100644 index 00000000..4f12c633 --- /dev/null +++ b/docs/components/testing.md @@ -0,0 +1,313 @@ +# ReactiveDomain.Testing Component + +[← Back to Components](README.md) + +The ReactiveDomain.Testing component provides tools and utilities for testing event-sourced applications built with Reactive Domain. It includes in-memory implementations of repositories, event stores, and message buses to facilitate unit and integration testing. + +## Key Features + +- In-memory event store for testing +- Test fixtures for aggregates and event handlers +- Assertion utilities for event-sourced systems +- Test doubles for repositories and message buses +- Snapshot testing support +- Event stream verification + +## Core Types + +### Test Fixtures + +- **AggregateTestFixture**: Test fixture for testing aggregates +- **EventHandlerTestFixture**: Test fixture for testing event handlers +- **CommandHandlerTestFixture**: Test fixture for testing command handlers +- **ProcessManagerTestFixture**: Test fixture for testing process managers + +### In-Memory Implementations + +- **InMemoryRepository**: In-memory implementation of `IRepository` +- **InMemoryCorrelatedRepository**: In-memory implementation of `ICorrelatedRepository` +- **InMemoryEventStore**: In-memory implementation of event store +- **InMemoryCommandBus**: In-memory implementation of `ICommandBus` +- **InMemoryEventBus**: In-memory implementation of `IEventBus` + +### Assertion Utilities + +- **EventAssert**: Utilities for asserting events +- **CommandAssert**: Utilities for asserting commands +- **StreamAssert**: Utilities for asserting event streams + +## Usage Examples + +### Testing an Aggregate + +```csharp +[Fact] +public void Account_Should_Be_Created_With_Initial_Deposit() +{ + // Arrange + var fixture = new AggregateTestFixture(); + var accountId = Guid.NewGuid(); + var accountNumber = "12345"; + var initialDeposit = 100.0m; + + // Act + fixture.When(aggregate => + { + aggregate.CreateAccount(accountNumber, initialDeposit, new TestCorrelatedMessage()); + }); + + // Assert + fixture.ThenHasEvent(evt => + { + Assert.Equal(accountId, evt.AccountId); + Assert.Equal(accountNumber, evt.AccountNumber); + Assert.Equal(initialDeposit, evt.InitialDeposit); + }); +} +``` + +### Testing an Event Handler + +```csharp +[Fact] +public void AccountSummaryUpdater_Should_Update_ReadModel_When_Account_Created() +{ + // Arrange + var readModelRepository = new InMemoryReadModelRepository(); + var logger = new TestLogger(); + var handler = new AccountSummaryUpdater(readModelRepository, logger); + + var accountId = Guid.NewGuid(); + var accountNumber = "12345"; + var initialDeposit = 100.0m; + + var @event = new AccountCreated( + accountId, + accountNumber, + initialDeposit, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid()); + + // Act + handler.Handle(@event); + + // Assert + var readModel = readModelRepository.GetById(accountId); + Assert.NotNull(readModel); + Assert.Equal(accountNumber, readModel.AccountNumber); + Assert.Equal(initialDeposit, readModel.CurrentBalance); + Assert.Equal(AccountSummaryReadModel.AccountStatus.Active, readModel.Status); +} +``` + +### Testing a Command Handler + +```csharp +[Fact] +public void TransferFundsHandler_Should_Transfer_Funds_Between_Accounts() +{ + // Arrange + var fixture = new CommandHandlerTestFixture(); + + var sourceAccountId = Guid.NewGuid(); + var targetAccountId = Guid.NewGuid(); + var amount = 50.0m; + var reference = "Test transfer"; + + // Create source account with initial balance + fixture.Given(new AccountCreated( + sourceAccountId, + "12345", + 100.0m, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid())); + + // Create target account + fixture.Given(new AccountCreated( + targetAccountId, + "67890", + 0.0m, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid())); + + // Create transfer command + var command = new TransferFunds( + sourceAccountId, + targetAccountId, + amount, + reference, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.Empty); + + // Act + fixture.When(command); + + // Assert + fixture.ThenHasEvent(evt => + { + Assert.Equal(sourceAccountId, evt.AccountId); + Assert.Equal(amount, evt.Amount); + Assert.Contains(reference, evt.Reference); + }); + + fixture.ThenHasEvent(evt => + { + Assert.Equal(targetAccountId, evt.AccountId); + Assert.Equal(amount, evt.Amount); + Assert.Contains(reference, evt.Reference); + }); + + fixture.ThenHasEvent(evt => + { + Assert.Equal(sourceAccountId, evt.SourceAccountId); + Assert.Equal(targetAccountId, evt.TargetAccountId); + Assert.Equal(amount, evt.Amount); + Assert.Equal(reference, evt.Reference); + }); +} +``` + +## Integration with Other Components + +The Testing component integrates with: + +- **ReactiveDomain.Core**: Uses core interfaces for testing +- **ReactiveDomain.Foundation**: Provides testing utilities for domain components +- **ReactiveDomain.Messaging**: Includes test doubles for messaging components +- **ReactiveDomain.Persistence**: Provides in-memory implementations of persistence components + +## Best Practices + +1. **Test Behavior, Not Implementation**: Focus on testing the behavior of aggregates and handlers, not their implementation details +2. **Use Test Fixtures**: Leverage the provided test fixtures for a consistent testing approach +3. **Test Event Sequences**: Verify that events are raised in the correct sequence +4. **Test Command Validation**: Ensure commands are properly validated +5. **Test Read Model Updates**: Verify that read models are updated correctly in response to events +6. **Test Process Manager Coordination**: Ensure process managers coordinate workflows correctly +7. **Use Given-When-Then Pattern**: Structure tests using the Given-When-Then pattern for clarity + +## Common Testing Scenarios + +### Testing Aggregate Creation + +```csharp +[Fact] +public void Account_Should_Be_Created_With_Initial_Deposit() +{ + // Arrange + var fixture = new AggregateTestFixture(); + + // Act + fixture.When(aggregate => + { + aggregate.CreateAccount("12345", 100.0m, new TestCorrelatedMessage()); + }); + + // Assert + fixture.ThenHasEvent(); +} +``` + +### Testing Business Rules + +```csharp +[Fact] +public void Account_Should_Not_Allow_Withdrawal_When_Insufficient_Funds() +{ + // Arrange + var fixture = new AggregateTestFixture(); + + fixture.Given(new AccountCreated( + Guid.NewGuid(), + "12345", + 100.0m, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid())); + + // Act & Assert + var exception = Assert.Throws(() => + { + fixture.When(aggregate => + { + aggregate.Withdraw(200.0m, "Test withdrawal", new TestCorrelatedMessage()); + }); + }); + + Assert.Contains("Insufficient funds", exception.Message); +} +``` + +### Testing Event Handlers + +```csharp +[Fact] +public void AccountSummaryUpdater_Should_Update_ReadModel_When_Funds_Deposited() +{ + // Arrange + var readModelRepository = new InMemoryReadModelRepository(); + var logger = new TestLogger(); + var handler = new AccountSummaryUpdater(readModelRepository, logger); + + var accountId = Guid.NewGuid(); + var initialBalance = 100.0m; + var depositAmount = 50.0m; + + // Create initial read model + var readModel = new AccountSummaryReadModel + { + Id = accountId, + AccountNumber = "12345", + CurrentBalance = initialBalance, + Status = AccountSummaryReadModel.AccountStatus.Active + }; + + readModelRepository.Save(readModel); + + var @event = new FundsDeposited( + accountId, + depositAmount, + "Test deposit", + DateTime.UtcNow, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid()); + + // Act + handler.Handle(@event); + + // Assert + var updatedReadModel = readModelRepository.GetById(accountId); + Assert.Equal(initialBalance + depositAmount, updatedReadModel.CurrentBalance); +} +``` + +## Related Documentation + +- [Testing Code Examples](../code-examples/testing.md) +- [AggregateRoot API Reference](../api-reference/types/aggregate-root.md) +- [IRepository API Reference](../api-reference/types/irepository.md) +- [ICommandHandler API Reference](../api-reference/types/icommand-handler.md) +- [IEventHandler API Reference](../api-reference/types/ievent-handler.md) + +## Navigation + +**Section Navigation**: +- [← Previous: ReactiveDomain.Transport](transport.md) +- [↑ Parent: Component Documentation](README.md) +- [→ Next: ReactiveDomain.Policy](policy.md) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/components/tools.md b/docs/components/tools.md new file mode 100644 index 00000000..4eac0529 --- /dev/null +++ b/docs/components/tools.md @@ -0,0 +1,356 @@ +# ReactiveDomain.Tools Component + +[← Back to Components](README.md) + +The ReactiveDomain.Tools component provides utilities and tools for developing, testing, and maintaining Reactive Domain applications. These tools help with common tasks such as event store management, diagnostics, code generation, and performance analysis. + +## Key Features + +- Event store management tools +- Diagnostics and monitoring utilities +- Code generation for domain models +- Performance analysis tools +- Migration utilities +- Command-line interface +- Development-time helpers + +## Core Tools + +### Event Store Management + +- **EventStoreManager**: Manages event store instances +- **StreamManager**: Manages event streams +- **EventBrowser**: Browses events in the event store +- **EventExporter**: Exports events from the event store +- **EventImporter**: Imports events into the event store + +### Diagnostics and Monitoring + +- **EventMonitor**: Monitors event flow in real-time +- **CommandMonitor**: Monitors command execution +- **PerformanceMonitor**: Monitors system performance +- **HealthChecker**: Checks system health +- **DiagnosticsCollector**: Collects diagnostic information + +### Code Generation + +- **AggregateGenerator**: Generates aggregate code +- **CommandGenerator**: Generates command code +- **EventGenerator**: Generates event code +- **ReadModelGenerator**: Generates read model code +- **ProjectionGenerator**: Generates projection code + +### Performance Analysis + +- **PerformanceAnalyzer**: Analyzes system performance +- **Benchmarker**: Benchmarks system components +- **LoadGenerator**: Generates load for testing +- **BottleneckDetector**: Detects performance bottlenecks +- **ResourceMonitor**: Monitors resource usage + +## Usage Examples + +### Managing Event Streams + +```csharp +// Create a stream manager +var connectionSettings = ConnectionSettings.Create() + .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")) + .Build(); + +var connection = EventStoreConnection.Create( + connectionSettings, + new Uri("tcp://localhost:1113"), + "StreamManager"); + +await connection.ConnectAsync(); + +var streamManager = new StreamManager(connection); + +// List streams +var streams = await streamManager.ListStreamsAsync(); +foreach (var stream in streams) +{ + Console.WriteLine($"Stream: {stream.StreamId}, Events: {stream.EventCount}"); +} + +// Delete a stream +await streamManager.DeleteStreamAsync("account-123", true); + +// Create a stream +await streamManager.CreateStreamAsync("new-account-456"); + +// Copy a stream +await streamManager.CopyStreamAsync("account-789", "account-789-backup"); +``` + +### Browsing Events + +```csharp +// Create an event browser +var browser = new EventBrowser(connection); + +// Browse events in a stream +var events = await browser.BrowseEventsAsync("account-123"); +foreach (var evt in events) +{ + Console.WriteLine($"Event: {evt.EventType}, Version: {evt.EventNumber}"); + Console.WriteLine($"Data: {Encoding.UTF8.GetString(evt.Data)}"); + Console.WriteLine($"Metadata: {Encoding.UTF8.GetString(evt.Metadata)}"); + Console.WriteLine(); +} + +// Search for events +var searchResults = await browser.SearchEventsAsync("AccountCreated"); +foreach (var result in searchResults) +{ + Console.WriteLine($"Stream: {result.StreamId}, Event: {result.EventType}, Version: {result.EventNumber}"); +} + +// Get event details +var eventDetails = await browser.GetEventDetailsAsync("account-123", 0); +Console.WriteLine($"Event: {eventDetails.EventType}, Version: {eventDetails.EventNumber}"); +Console.WriteLine($"Data: {Encoding.UTF8.GetString(eventDetails.Data)}"); +Console.WriteLine($"Metadata: {Encoding.UTF8.GetString(eventDetails.Metadata)}"); +``` + +### Generating Code + +```csharp +// Create an aggregate generator +var generator = new AggregateGenerator(); + +// Define the aggregate +var aggregateDefinition = new AggregateDefinition +{ + Name = "Account", + Namespace = "BankingDomain", + Properties = new List + { + new PropertyDefinition { Name = "Balance", Type = "decimal" }, + new PropertyDefinition { Name = "AccountNumber", Type = "string" }, + new PropertyDefinition { Name = "IsActive", Type = "bool" } + }, + Commands = new List + { + new CommandDefinition + { + Name = "CreateAccount", + Parameters = new List + { + new ParameterDefinition { Name = "accountNumber", Type = "string" }, + new ParameterDefinition { Name = "initialDeposit", Type = "decimal" } + }, + Events = new List { "AccountCreated" } + }, + new CommandDefinition + { + Name = "Deposit", + Parameters = new List + { + new ParameterDefinition { Name = "amount", Type = "decimal" } + }, + Events = new List { "FundsDeposited" } + }, + new CommandDefinition + { + Name = "Withdraw", + Parameters = new List + { + new ParameterDefinition { Name = "amount", Type = "decimal" } + }, + Events = new List { "FundsWithdrawn" } + } + }, + Events = new List + { + new EventDefinition + { + Name = "AccountCreated", + Properties = new List + { + new PropertyDefinition { Name = "AccountId", Type = "Guid" }, + new PropertyDefinition { Name = "AccountNumber", Type = "string" }, + new PropertyDefinition { Name = "InitialDeposit", Type = "decimal" } + } + }, + new EventDefinition + { + Name = "FundsDeposited", + Properties = new List + { + new PropertyDefinition { Name = "AccountId", Type = "Guid" }, + new PropertyDefinition { Name = "Amount", Type = "decimal" } + } + }, + new EventDefinition + { + Name = "FundsWithdrawn", + Properties = new List + { + new PropertyDefinition { Name = "AccountId", Type = "Guid" }, + new PropertyDefinition { Name = "Amount", Type = "decimal" } + } + } + } +}; + +// Generate the code +var code = generator.GenerateCode(aggregateDefinition); +File.WriteAllText("Account.cs", code); +``` + +### Performance Analysis + +```csharp +// Create a performance analyzer +var analyzer = new PerformanceAnalyzer(); + +// Start monitoring +analyzer.Start(); + +// Run your code +RunYourCode(); + +// Stop monitoring +var results = analyzer.Stop(); + +// Print results +Console.WriteLine($"Total Execution Time: {results.TotalExecutionTime}ms"); +Console.WriteLine($"Command Execution Time: {results.CommandExecutionTime}ms"); +Console.WriteLine($"Event Processing Time: {results.EventProcessingTime}ms"); +Console.WriteLine($"Repository Access Time: {results.RepositoryAccessTime}ms"); +Console.WriteLine($"Read Model Update Time: {results.ReadModelUpdateTime}ms"); +Console.WriteLine($"Commands Processed: {results.CommandsProcessed}"); +Console.WriteLine($"Events Processed: {results.EventsProcessed}"); +Console.WriteLine($"Commands Per Second: {results.CommandsPerSecond}"); +Console.WriteLine($"Events Per Second: {results.EventsPerSecond}"); +``` + +## Command-Line Interface + +The ReactiveDomain.Tools component includes a command-line interface (CLI) for common tasks: + +```bash +# List streams +rd-tools streams list --connection "tcp://localhost:1113" --credentials "admin:changeit" + +# Export events +rd-tools events export --stream "account-123" --output "account-123-events.json" --connection "tcp://localhost:1113" --credentials "admin:changeit" + +# Import events +rd-tools events import --input "account-123-events.json" --stream "account-123-restored" --connection "tcp://localhost:1113" --credentials "admin:changeit" + +# Generate code +rd-tools generate aggregate --definition "aggregate-definition.json" --output "Account.cs" + +# Run performance analysis +rd-tools analyze performance --connection "tcp://localhost:1113" --credentials "admin:changeit" --duration 60 +``` + +## Integration with Other Components + +The Tools component integrates with: + +- **ReactiveDomain.Core**: Uses core interfaces and types +- **ReactiveDomain.Foundation**: Works with domain components +- **ReactiveDomain.Messaging**: Analyzes message flow +- **ReactiveDomain.Persistence**: Manages event store + +## Best Practices + +1. **Use Tools in Development**: Leverage these tools during development to improve productivity +2. **Automate Code Generation**: Automate code generation for repetitive tasks +3. **Monitor Performance**: Regularly monitor performance to identify issues early +4. **Backup Event Store**: Regularly backup your event store using the export/import tools +5. **Version Control Generated Code**: Keep generated code under version control +6. **Validate Generated Code**: Always validate generated code before using it in production +7. **Use CLI in Scripts**: Incorporate CLI tools in automation scripts + +## Common Use Cases + +### Event Store Maintenance + +```csharp +// Compact the event store +await streamManager.CompactStreamAsync("account-123"); + +// Rebuild read models +await readModelManager.RebuildReadModelsAsync(); + +// Verify event store integrity +var integrityReport = await streamManager.VerifyIntegrityAsync(); +if (!integrityReport.IsValid) +{ + Console.WriteLine("Event store integrity check failed:"); + foreach (var issue in integrityReport.Issues) + { + Console.WriteLine($"- {issue}"); + } +} +``` + +### Development Workflows + +```csharp +// Generate a complete domain model +var domainGenerator = new DomainGenerator(); +await domainGenerator.GenerateDomainAsync("domain-definition.json", "output-directory"); + +// Create a test environment +var testEnvironment = new TestEnvironmentManager(); +await testEnvironment.CreateTestEnvironmentAsync("test-environment-definition.json"); + +// Generate sample data +var sampleDataGenerator = new SampleDataGenerator(); +await sampleDataGenerator.GenerateSampleDataAsync("sample-data-definition.json"); +``` + +### Diagnostics and Troubleshooting + +```csharp +// Collect diagnostic information +var diagnosticsCollector = new DiagnosticsCollector(); +var diagnosticInfo = await diagnosticsCollector.CollectDiagnosticsAsync(); +File.WriteAllText("diagnostics.json", JsonConvert.SerializeObject(diagnosticInfo, Formatting.Indented)); + +// Analyze event flow +var eventFlowAnalyzer = new EventFlowAnalyzer(); +var eventFlowReport = await eventFlowAnalyzer.AnalyzeEventFlowAsync("account-123"); +Console.WriteLine(eventFlowReport.ToString()); + +// Check system health +var healthChecker = new HealthChecker(); +var healthReport = await healthChecker.CheckHealthAsync(); +Console.WriteLine($"System Health: {healthReport.Status}"); +foreach (var entry in healthReport.Entries) +{ + Console.WriteLine($"- {entry.Key}: {entry.Value.Status} ({entry.Value.Description})"); +} +``` + +## Related Documentation + +- [Command API Reference](../api-reference/types/command.md) +- [Event API Reference](../api-reference/types/event.md) +- [AggregateRoot API Reference](../api-reference/types/aggregate-root.md) +- [IRepository API Reference](../api-reference/types/irepository.md) +- [IEventProcessor API Reference](../api-reference/types/ievent-processor.md) + +## Navigation + +**Section Navigation**: +- [← Previous: ReactiveDomain.IdentityStorage](identity-storage.md) +- [↑ Parent: Component Documentation](README.md) +- [→ Next: API Reference](../api-reference/README.md) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/components/transport.md b/docs/components/transport.md new file mode 100644 index 00000000..4f35c2ef --- /dev/null +++ b/docs/components/transport.md @@ -0,0 +1,212 @@ +# ReactiveDomain.Transport Component + +[← Back to Components](README.md) + +The ReactiveDomain.Transport component provides messaging infrastructure for communication between different parts of a Reactive Domain application or between different applications. It implements various transport mechanisms for commands and events. + +## Key Features + +- Message routing and delivery +- Pub/sub messaging patterns +- Remote command handling +- Distributed event processing +- Message serialization and deserialization +- Transport-level security + +## Core Types + +### Message Transport + +- **MessageTransport**: Base class for message transport implementations +- **InProcessMessageTransport**: In-process message transport for local communication +- **RabbitMqMessageTransport**: RabbitMQ-based message transport for distributed communication +- **AzureServiceBusTransport**: Azure Service Bus-based message transport + +### Message Routing + +- **MessageRouter**: Routes messages to appropriate handlers +- **SubscriptionManager**: Manages message subscriptions +- **TopicNameFormatter**: Formats topic names for pub/sub messaging + +### Serialization + +- **MessageSerializer**: Serializes and deserializes messages for transport +- **JsonMessageSerializer**: JSON-based message serializer +- **ProtobufMessageSerializer**: Protocol Buffers-based message serializer + +## Usage Examples + +### Configuring In-Process Transport + +```csharp +// Create an in-process message transport +var transport = new InProcessMessageTransport(); + +// Register command handlers +transport.RegisterCommandHandler(cmd => +{ + // Handle command +}); + +// Subscribe to events +transport.Subscribe(evt => +{ + // Handle event +}); +``` + +### Configuring RabbitMQ Transport + +```csharp +// Create RabbitMQ connection settings +var connectionSettings = new RabbitMqConnectionSettings +{ + HostName = "localhost", + UserName = "guest", + Password = "guest", + VirtualHost = "/" +}; + +// Create a RabbitMQ message transport +var transport = new RabbitMqMessageTransport(connectionSettings); + +// Configure message routing +transport.ConfigureRouting(routing => +{ + routing.RouteCommandsToEndpoint("accounts-service"); + routing.RouteEventsToExchange("account-events"); +}); + +// Start the transport +transport.Start(); +``` + +### Publishing Messages + +```csharp +// Create a command +var createAccountCommand = new CreateAccount( + Guid.NewGuid(), + "12345", + 100.0m, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.Empty); + +// Send the command +transport.Send(createAccountCommand); + +// Create an event +var accountCreatedEvent = new AccountCreated( + Guid.NewGuid(), + "12345", + 100.0m, + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid()); + +// Publish the event +transport.Publish(accountCreatedEvent); +``` + +## Integration with Other Components + +The Transport component integrates with: + +- **ReactiveDomain.Core**: Uses core message interfaces +- **ReactiveDomain.Messaging**: Transports messages defined in the messaging component +- **ReactiveDomain.Foundation**: Provides communication infrastructure for domain components + +## Configuration Options + +### Common Transport Settings + +- **Serializer**: Message serializer to use +- **RetryPolicy**: Policy for retrying failed message delivery +- **DeadLetterQueue**: Queue for messages that cannot be delivered +- **MessageTtl**: Time-to-live for messages +- **PrefetchCount**: Number of messages to prefetch + +### RabbitMQ-Specific Settings + +- **HostName**: RabbitMQ host name +- **Port**: RabbitMQ port +- **UserName**: RabbitMQ user name +- **Password**: RabbitMQ password +- **VirtualHost**: RabbitMQ virtual host +- **ExchangeType**: Type of exchange to use (direct, fanout, topic, headers) +- **Durable**: Whether exchanges and queues should be durable +- **AutoDelete**: Whether exchanges and queues should be auto-deleted + +### Azure Service Bus-Specific Settings + +- **ConnectionString**: Azure Service Bus connection string +- **QueueName**: Name of the queue +- **TopicName**: Name of the topic +- **SubscriptionName**: Name of the subscription +- **EnablePartitioning**: Whether to enable partitioning +- **EnableBatchedOperations**: Whether to enable batched operations + +## Best Practices + +1. **Use Correlation IDs**: Always include correlation IDs in messages for tracking +2. **Implement Idempotent Handlers**: Ensure handlers can process the same message multiple times without side effects +3. **Use Dead Letter Queues**: Configure dead letter queues for messages that cannot be processed +4. **Monitor Message Flow**: Implement monitoring for message flow and processing +5. **Secure Transport**: Use secure connections and authentication for distributed messaging +6. **Handle Failures Gracefully**: Implement retry policies and circuit breakers for resilience + +## Common Issues and Solutions + +### Connection Issues + +If you're having trouble connecting to the message broker: + +1. Check that the broker is running +2. Verify connection settings +3. Check network connectivity +4. Verify credentials + +### Message Delivery Issues + +If messages are not being delivered: + +1. Check that the routing is configured correctly +2. Verify that subscribers are registered +3. Check for errors in message serialization +4. Verify queue and exchange configuration + +### Performance Issues + +If you're experiencing performance issues: + +1. Adjust prefetch count +2. Consider batching messages +3. Optimize message serialization +4. Scale out message brokers + +## Related Documentation + +- [ICommandBus API Reference](../api-reference/types/icommand-bus.md) +- [IEventBus API Reference](../api-reference/types/ievent-bus.md) +- [Command API Reference](../api-reference/types/command.md) +- [Event API Reference](../api-reference/types/event.md) +- [ICorrelatedMessage API Reference](../api-reference/types/icorrelated-message.md) + +## Navigation + +**Section Navigation**: +- [← Previous: ReactiveDomain.Persistence](persistence.md) +- [↑ Parent: Component Documentation](README.md) +- [→ Next: ReactiveDomain.Testing](testing.md) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/interfaces/correlated-repository.md b/docs/interfaces/correlated-repository.md new file mode 100644 index 00000000..667189d4 --- /dev/null +++ b/docs/interfaces/correlated-repository.md @@ -0,0 +1,214 @@ +# ICorrelatedRepository Interface + +[← Back to Interfaces](README.md) + +The `ICorrelatedRepository` interface extends the standard [IRepository](repository.md) interface with correlation tracking capabilities. It allows for tracking the flow of messages through the system by maintaining correlation and causation IDs across command and event boundaries. + +## Interface Definition + +```csharp +public interface ICorrelatedRepository : IRepository +{ + bool TryGetById(Guid id, out TAggregate aggregate, ICorrelatedMessage source) + where TAggregate : AggregateRoot, IEventSource; + + TAggregate GetById(Guid id, ICorrelatedMessage source) + where TAggregate : AggregateRoot, IEventSource; +} +``` + +## Methods + +### TryGetById with Correlation + +```csharp +bool TryGetById(Guid id, out TAggregate aggregate, ICorrelatedMessage source) + where TAggregate : AggregateRoot, IEventSource; +``` + +Attempts to retrieve an aggregate by its ID, using the provided correlated message as the source for correlation tracking. + +**Parameters:** +- `id`: The unique identifier of the aggregate to retrieve. +- `aggregate`: When this method returns, contains the aggregate associated with the specified ID, if the aggregate is found; otherwise, the default value for the type of the aggregate parameter. +- `source`: The source message that contains correlation and causation IDs. + +**Returns:** +- `true` if the aggregate is found; otherwise, `false`. + +**Type Parameters:** +- `TAggregate`: The type of the aggregate to retrieve. Must be a subclass of `AggregateRoot` and implement `IEventSource`. + +**Example:** +```csharp +if (repository.TryGetById(command.AccountId, out var account, command)) +{ + // Account found, use it + account.Deposit(command.Amount, command.Reference, command); + repository.Save(account); +} +else +{ + // Account not found, handle the case + throw new AccountNotFoundException(command.AccountId); +} +``` + +### GetById with Correlation + +```csharp +TAggregate GetById(Guid id, ICorrelatedMessage source) + where TAggregate : AggregateRoot, IEventSource; +``` + +Retrieves an aggregate by its ID, using the provided correlated message as the source for correlation tracking. Throws an exception if the aggregate is not found. + +**Parameters:** +- `id`: The unique identifier of the aggregate to retrieve. +- `source`: The source message that contains correlation and causation IDs. + +**Returns:** +- The aggregate associated with the specified ID. + +**Type Parameters:** +- `TAggregate`: The type of the aggregate to retrieve. Must be a subclass of `AggregateRoot` and implement `IEventSource`. + +**Exceptions:** +- `AggregateNotFoundException`: Thrown when the aggregate with the specified ID is not found. + +**Example:** +```csharp +try +{ + var account = repository.GetById(command.AccountId, command); + account.Deposit(command.Amount, command.Reference, command); + repository.Save(account); +} +catch (AggregateNotFoundException ex) +{ + // Handle the case where the account is not found + Console.WriteLine($"Account not found: {ex.Message}"); +} +``` + +## Correlation Tracking + +The `ICorrelatedRepository` interface is designed to work with the [ICorrelatedMessage](correlated-message.md) interface to provide end-to-end correlation tracking. When an aggregate is loaded using a correlated message as the source, the correlation and causation IDs are propagated to any events raised by the aggregate. + +This enables tracking the flow of messages through the system, which is particularly useful for: + +1. **Debugging**: Tracing the flow of messages through the system to identify issues. +2. **Auditing**: Tracking who initiated a particular action and when. +3. **Distributed Tracing**: Tracking messages across service boundaries in a distributed system. +4. **Causality Tracking**: Understanding the cause-and-effect relationships between messages. + +## Implementation Considerations + +When implementing the `ICorrelatedRepository` interface, consider the following: + +1. **Correlation Propagation**: Ensure that correlation and causation IDs are properly propagated from the source message to any events raised by the aggregate. +2. **Concurrency Control**: Implement optimistic concurrency control to handle concurrent modifications to the same aggregate. +3. **Event Metadata**: Store correlation and causation IDs in the event metadata for later retrieval. +4. **Error Handling**: Implement proper error handling for event store communication issues. + +## Common Implementations + +### CorrelatedStreamStoreRepository + +The `CorrelatedStreamStoreRepository` is the standard implementation of `ICorrelatedRepository` that uses EventStoreDB as the underlying event store. + +```csharp +public class CorrelatedStreamStoreRepository : ICorrelatedRepository +{ + private readonly IStreamStoreConnection _connection; + private readonly ISnapshotStore _snapshotStore; + private readonly ISnapshotStrategy _snapshotStrategy; + + public CorrelatedStreamStoreRepository( + IStreamStoreConnection connection, + ISnapshotStore snapshotStore = null, + ISnapshotStrategy snapshotStrategy = null) + { + _connection = connection; + _snapshotStore = snapshotStore; + _snapshotStrategy = snapshotStrategy; + } + + // Implementation of ICorrelatedRepository methods +} +``` + +## Related Interfaces + +- [IRepository](repository.md): The base repository interface. +- [ICorrelatedMessage](correlated-message.md): Interface for messages with correlation tracking. +- [ICorrelatedEventSource](correlated-event-source.md): Interface for event sources with correlation tracking. +- [IEventSource](event-source.md): The core interface for event-sourced entities. + +## Best Practices + +1. **Always Use Correlation**: Use `ICorrelatedRepository` instead of `IRepository` when correlation tracking is needed. +2. **Pass the Original Command**: Always pass the original command as the source message to maintain the correlation chain. +3. **Use MessageBuilder**: Use the `MessageBuilder` class to create correlated messages. +4. **Include Correlation in Logging**: Include correlation IDs in log messages for easier debugging. +5. **Monitor Correlation Chains**: Implement monitoring for correlation chains to identify issues. + +## Example Usage in a Command Handler + +```csharp +public class TransferFundsHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly IEventBus _eventBus; + + public TransferFundsHandler( + ICorrelatedRepository repository, + IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(TransferFunds command) + { + // Load source account with correlation + var sourceAccount = _repository.GetById(command.SourceAccountId, command); + + // Load target account with correlation + var targetAccount = _repository.GetById(command.TargetAccountId, command); + + // Execute transfer with correlation + sourceAccount.Withdraw(command.Amount, $"Transfer to {command.TargetAccountId}", command); + targetAccount.Deposit(command.Amount, $"Transfer from {command.SourceAccountId}", command); + + // Save both accounts + _repository.Save(sourceAccount); + _repository.Save(targetAccount); + + // Publish transfer completed event with correlation + _eventBus.Publish(MessageBuilder.From(command, () => new TransferCompleted( + command.SourceAccountId, + command.TargetAccountId, + command.Amount, + DateTime.UtcNow))); + } +} +``` + +## Navigation + +**Section Navigation**: +- [← Previous: IRepository](repository.md) +- [↑ Parent: Interfaces](README.md) +- [→ Next: IListener](listener.md) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/interfaces/repository.md b/docs/interfaces/repository.md new file mode 100644 index 00000000..f58b56c6 --- /dev/null +++ b/docs/interfaces/repository.md @@ -0,0 +1,222 @@ +# IRepository Interface + +[← Back to Interfaces](README.md) + +The `IRepository` interface is a core component of the Reactive Domain library, implementing the Repository pattern for event-sourced aggregates. It provides methods for loading and saving aggregates from and to an event store. + +## Interface Definition + +```csharp +public interface IRepository +{ + bool TryGetById(Guid id, out TAggregate aggregate) + where TAggregate : AggregateRoot, IEventSource; + + TAggregate GetById(Guid id) + where TAggregate : AggregateRoot, IEventSource; + + void Save(TAggregate aggregate) + where TAggregate : AggregateRoot, IEventSource; +} +``` + +## Methods + +### TryGetById + +```csharp +bool TryGetById(Guid id, out TAggregate aggregate) + where TAggregate : AggregateRoot, IEventSource; +``` + +Attempts to retrieve an aggregate by its ID. Returns `true` if the aggregate is found, `false` otherwise. + +**Parameters:** +- `id`: The unique identifier of the aggregate to retrieve. +- `aggregate`: When this method returns, contains the aggregate associated with the specified ID, if the aggregate is found; otherwise, the default value for the type of the aggregate parameter. + +**Returns:** +- `true` if the aggregate is found; otherwise, `false`. + +**Type Parameters:** +- `TAggregate`: The type of the aggregate to retrieve. Must be a subclass of `AggregateRoot` and implement `IEventSource`. + +**Example:** +```csharp +if (repository.TryGetById(accountId, out var account)) +{ + // Account found, use it + account.Deposit(amount); + repository.Save(account); +} +else +{ + // Account not found, handle the case + throw new AccountNotFoundException(accountId); +} +``` + +### GetById + +```csharp +TAggregate GetById(Guid id) + where TAggregate : AggregateRoot, IEventSource; +``` + +Retrieves an aggregate by its ID. Throws an exception if the aggregate is not found. + +**Parameters:** +- `id`: The unique identifier of the aggregate to retrieve. + +**Returns:** +- The aggregate associated with the specified ID. + +**Type Parameters:** +- `TAggregate`: The type of the aggregate to retrieve. Must be a subclass of `AggregateRoot` and implement `IEventSource`. + +**Exceptions:** +- `AggregateNotFoundException`: Thrown when the aggregate with the specified ID is not found. + +**Example:** +```csharp +try +{ + var account = repository.GetById(accountId); + account.Deposit(amount); + repository.Save(account); +} +catch (AggregateNotFoundException ex) +{ + // Handle the case where the account is not found + Console.WriteLine($"Account not found: {ex.Message}"); +} +``` + +### Save + +```csharp +void Save(TAggregate aggregate) + where TAggregate : AggregateRoot, IEventSource; +``` + +Saves an aggregate to the event store. + +**Parameters:** +- `aggregate`: The aggregate to save. + +**Type Parameters:** +- `TAggregate`: The type of the aggregate to save. Must be a subclass of `AggregateRoot` and implement `IEventSource`. + +**Exceptions:** +- `ConcurrencyException`: Thrown when there is a concurrency conflict during the save operation. +- `EventStoreException`: Thrown when there is an error communicating with the event store. + +**Example:** +```csharp +var account = repository.GetById(accountId); +account.Deposit(amount); +repository.Save(account); +``` + +## Implementation Considerations + +When implementing the `IRepository` interface, consider the following: + +1. **Concurrency Control**: Implement optimistic concurrency control to handle concurrent modifications to the same aggregate. +2. **Event Serialization**: Ensure proper serialization and deserialization of events. +3. **Snapshot Support**: Consider implementing snapshot support for performance optimization. +4. **Caching**: Implement caching to improve performance for frequently accessed aggregates. +5. **Error Handling**: Implement proper error handling for event store communication issues. + +## Common Implementations + +### StreamStoreRepository + +The `StreamStoreRepository` is the standard implementation of `IRepository` that uses EventStoreDB as the underlying event store. + +```csharp +public class StreamStoreRepository : IRepository +{ + private readonly IStreamStoreConnection _connection; + private readonly ISnapshotStore _snapshotStore; + private readonly ISnapshotStrategy _snapshotStrategy; + + public StreamStoreRepository( + IStreamStoreConnection connection, + ISnapshotStore snapshotStore = null, + ISnapshotStrategy snapshotStrategy = null) + { + _connection = connection; + _snapshotStore = snapshotStore; + _snapshotStrategy = snapshotStrategy; + } + + // Implementation of IRepository methods +} +``` + +### InMemoryRepository + +The `InMemoryRepository` is an in-memory implementation of `IRepository` used primarily for testing. + +```csharp +public class InMemoryRepository : IRepository +{ + private readonly Dictionary> _eventStore = new Dictionary>(); + + // Implementation of IRepository methods +} +``` + +## Related Interfaces + +- [IEventSource](event-source.md): The core interface for event-sourced entities. +- [ICorrelatedRepository](correlated-repository.md): Extends `IRepository` with correlation support. +- [ISnapshotSource](snapshot-source.md): Interface for snapshot support. + +## Best Practices + +1. **Use Dependency Injection**: Inject the repository into your services and command handlers. +2. **Handle Concurrency Exceptions**: Implement retry logic for concurrency exceptions. +3. **Use TryGetById When Appropriate**: Use `TryGetById` when the aggregate might not exist to avoid exceptions. +4. **Consider Performance**: Use snapshots for large aggregates to improve performance. +5. **Implement Unit Tests**: Write comprehensive unit tests for your repository implementations. + +## Example Usage in a Command Handler + +```csharp +public class DepositFundsHandler : ICommandHandler +{ + private readonly IRepository _repository; + + public DepositFundsHandler(IRepository repository) + { + _repository = repository; + } + + public void Handle(DepositFunds command) + { + var account = _repository.GetById(command.AccountId); + account.Deposit(command.Amount); + _repository.Save(account); + } +} +``` + +## Navigation + +**Section Navigation**: +- [← Previous: IEventSource](event-source.md) +- [↑ Parent: Interfaces](README.md) +- [→ Next: ICorrelatedRepository](correlated-repository.md) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/navigation-index.md b/docs/navigation-index.md new file mode 100644 index 00000000..cf3ab33b --- /dev/null +++ b/docs/navigation-index.md @@ -0,0 +1,107 @@ +# Reactive Domain Documentation Navigation Index + +This index provides quick access to all documentation sections and shows relationships between different components and concepts in Reactive Domain. + +## Documentation Sections + +- [Home](README.md) - Main documentation page +- [Core Concepts](core-concepts.md) - Fundamental principles of event sourcing and CQRS +- [Component Documentation](components/README.md) - Documentation for major components +- [Interface Documentation](interfaces/README.md) - Documentation for key interfaces +- [Usage Patterns](usage-patterns.md) - Common usage patterns and best practices +- [Code Examples](code-examples/README.md) - Practical code examples +- [Troubleshooting Guide](troubleshooting.md) - Solutions to common issues +- [API Reference](api-reference/README.md) - Detailed API documentation +- [Architecture Guide](architecture.md) - System architecture overview +- [Component Relationships](component-relationships.md) - Visual guide to component relationships +- [Migration Guide](migration.md) - Upgrading between versions +- [Glossary](glossary.md) - Terminology reference +- [FAQ](faq.md) - Frequently asked questions +- [Deployment Guide](deployment.md) - Deployment instructions +- [Performance Optimization Guide](performance.md) - Performance tuning +- [Security Guide](security.md) - Security best practices +- [Integration Guide](integration.md) - Integration with other systems +- [Workshop Materials](workshop-materials.md) - Training materials + +## Core Concepts Index + +| Concept | Description | Documentation | Related Concepts | +|---------|-------------|---------------|------------------| +| Event Sourcing | Pattern of storing state changes as events | [Core Concepts](core-concepts.md#event-sourcing-fundamentals) | [Event Store Architecture](core-concepts.md#event-store-architecture), [Snapshots](core-concepts.md#snapshots) | +| CQRS | Command Query Responsibility Segregation | [Core Concepts](core-concepts.md#cqrs-implementation) | [Commands](core-concepts.md#commands), [Queries](core-concepts.md#queries), [Read Models](core-concepts.md#read-models) | +| Domain-Driven Design | Approach to software development | [Core Concepts](core-concepts.md#domain-driven-design-concepts) | [Aggregates](core-concepts.md#aggregates), [Value Objects](core-concepts.md#value-objects), [Entities](core-concepts.md#entities) | +| Reactive Programming | Programming with asynchronous data streams | [Core Concepts](core-concepts.md#reactive-programming-principles) | [Event Streams](core-concepts.md#event-streams), [Observers](core-concepts.md#observers) | +| Correlation and Causation | Tracking relationships between messages | [Core Concepts](core-concepts.md#correlation-and-causation-tracking) | [Message Flow](core-concepts.md#message-flow), [Distributed Tracing](core-concepts.md#distributed-tracing) | + +## Component Index + +| Component | Description | Documentation | Related Components | +|-----------|-------------|---------------|-------------------| +| AggregateRoot | Base class for domain aggregates | [API Reference](api-reference/types/aggregate-root.md) | [IEventSource](api-reference/types/ievent-source.md), [EventRecorder](api-reference/types/event-recorder.md) | +| Command | Base class for command messages | [API Reference](api-reference/types/command.md) | [ICommand](api-reference/types/icommand.md), [ICommandHandler](api-reference/types/icommand-handler.md), [ICommandBus](api-reference/types/icommand-bus.md) | +| Event | Base class for event messages | [API Reference](api-reference/types/event.md) | [IEvent](api-reference/types/ievent.md), [IEventHandler](api-reference/types/ievent-handler.md), [IEventBus](api-reference/types/ievent-bus.md) | +| MessageBuilder | Factory for creating correlated messages | [API Reference](api-reference/types/message-builder.md) | [ICorrelatedMessage](api-reference/types/icorrelated-message.md) | +| Repository | Storage for aggregates | [API Reference](api-reference/types/irepository.md) | [ICorrelatedRepository](api-reference/types/icorrelated-repository.md), [StreamStoreRepository](api-reference/types/stream-store-repository.md) | +| EventProcessor | Processes events from the event store | [API Reference](api-reference/types/ievent-processor.md) | [ICheckpointStore](api-reference/types/icheckpoint-store.md), [IEventBus](api-reference/types/ievent-bus.md) | +| ReadModelBase | Base class for read models | [API Reference](api-reference/types/read-model-base.md) | [IReadModelRepository](api-reference/types/iread-model-repository.md) | +| ProcessManager | Coordinates complex business processes | [API Reference](api-reference/types/process-manager.md) | [ICommandBus](api-reference/types/icommand-bus.md), [IEventBus](api-reference/types/ievent-bus.md) | + +## Interface Index + +| Interface | Description | Documentation | Implementations | +|-----------|-------------|---------------|----------------| +| IEventSource | Core interface for event-sourced entities | [API Reference](api-reference/types/ievent-source.md) | [AggregateRoot](api-reference/types/aggregate-root.md), [EventDrivenStateMachine](api-reference/types/event-driven-state-machine.md) | +| IRepository | Interface for repositories | [API Reference](api-reference/types/irepository.md) | [StreamStoreRepository](api-reference/types/stream-store-repository.md) | +| ICorrelatedRepository | Repository with correlation support | [API Reference](api-reference/types/icorrelated-repository.md) | [CorrelatedStreamStoreRepository](api-reference/types/correlated-stream-store-repository.md) | +| ICommand | Interface for commands | [API Reference](api-reference/types/icommand.md) | [Command](api-reference/types/command.md) | +| IEvent | Interface for events | [API Reference](api-reference/types/ievent.md) | [Event](api-reference/types/event.md) | +| ICorrelatedMessage | Interface for correlated messages | [API Reference](api-reference/types/icorrelated-message.md) | [Command](api-reference/types/command.md), [Event](api-reference/types/event.md) | +| ICommandBus | Interface for command bus | [API Reference](api-reference/types/icommand-bus.md) | [CommandBus](api-reference/types/command-bus.md) | +| IEventBus | Interface for event bus | [API Reference](api-reference/types/ievent-bus.md) | [EventBus](api-reference/types/event-bus.md) | +| IEventProcessor | Interface for event processors | [API Reference](api-reference/types/ievent-processor.md) | [EventProcessor](api-reference/types/event-processor.md) | +| ICheckpointStore | Interface for checkpoint stores | [API Reference](api-reference/types/icheckpoint-store.md) | [CheckpointStore](api-reference/types/checkpoint-store.md) | + +## Code Examples Index + +| Example | Description | Documentation | Related Examples | +|---------|-------------|---------------|------------------| +| Creating a New Aggregate Root | How to create a new aggregate root | [Code Examples](code-examples/creating-aggregate-root.md) | [Handling Commands and Events](code-examples/handling-commands-events.md) | +| Handling Commands and Events | How to handle commands and generate events | [Code Examples](code-examples/handling-commands-events.md) | [Creating a New Aggregate Root](code-examples/creating-aggregate-root.md), [Saving and Retrieving Aggregates](code-examples/saving-retrieving-aggregates.md) | +| Saving and Retrieving Aggregates | How to save and retrieve aggregates | [Code Examples](code-examples/saving-retrieving-aggregates.md) | [Handling Commands and Events](code-examples/handling-commands-events.md) | +| Setting Up Event Listeners | How to set up event listeners | [Code Examples](code-examples/event-listeners.md) | [Implementing Projections](code-examples/implementing-projections.md) | +| Implementing Projections | How to implement projections | [Code Examples](code-examples/implementing-projections.md) | [Setting Up Event Listeners](code-examples/event-listeners.md) | +| Handling Correlation and Causation | How to handle correlation and causation | [Code Examples](code-examples/correlation-causation.md) | [Handling Commands and Events](code-examples/handling-commands-events.md) | +| Implementing Snapshots | How to implement snapshots | [Code Examples](code-examples/implementing-snapshots.md) | [Saving and Retrieving Aggregates](code-examples/saving-retrieving-aggregates.md) | +| Testing Aggregates and Event Handlers | How to test aggregates and event handlers | [Code Examples](code-examples/testing.md) | [Creating a New Aggregate Root](code-examples/creating-aggregate-root.md), [Handling Commands and Events](code-examples/handling-commands-events.md) | +| Integration with ASP.NET Core | How to integrate with ASP.NET Core | [Code Examples](code-examples/aspnet-integration.md) | [Complete Sample Applications](code-examples/sample-applications.md) | +| Banking Domain Example | Real-world banking domain example | [Code Examples](code-examples/banking-domain-example.md) | [E-Commerce Domain Example](code-examples/ecommerce-domain-example.md), [Inventory Management Example](code-examples/inventory-management-example.md) | +| E-Commerce Domain Example | Real-world e-commerce domain example | [Code Examples](code-examples/ecommerce-domain-example.md) | [Banking Domain Example](code-examples/banking-domain-example.md), [Inventory Management Example](code-examples/inventory-management-example.md) | +| Inventory Management Example | Real-world inventory management example | [Code Examples](code-examples/inventory-management-example.md) | [Banking Domain Example](code-examples/banking-domain-example.md), [E-Commerce Domain Example](code-examples/ecommerce-domain-example.md) | + +## Common Tasks Index + +| Task | Documentation | Related Tasks | +|------|---------------|---------------| +| Understanding event sourcing | [Core Concepts](core-concepts.md) | [Understanding CQRS](core-concepts.md#cqrs-implementation) | +| Starting a new project | [Usage Patterns](usage-patterns.md#setting-up-a-new-reactive-domain-project) | [Creating an aggregate](code-examples/creating-aggregate-root.md) | +| Creating an aggregate | [Code Examples](code-examples/creating-aggregate-root.md) | [Handling commands and events](code-examples/handling-commands-events.md) | +| Implementing commands and events | [Code Examples](code-examples/handling-commands-events.md) | [Creating an aggregate](code-examples/creating-aggregate-root.md) | +| Setting up repositories | [Code Examples](code-examples/saving-retrieving-aggregates.md) | [Creating an aggregate](code-examples/creating-aggregate-root.md) | +| Creating read models | [Code Examples](code-examples/implementing-projections.md) | [Setting up event listeners](code-examples/event-listeners.md) | +| Implementing correlation | [Code Examples](code-examples/correlation-causation.md) | [Handling commands and events](code-examples/handling-commands-events.md) | +| Testing your application | [Code Examples](code-examples/testing.md) | [Creating an aggregate](code-examples/creating-aggregate-root.md) | +| Fixing a common issue | [Troubleshooting Guide](troubleshooting.md) | [FAQ](faq.md) | +| Optimizing performance | [Performance Optimization Guide](performance.md) | [Implementing snapshots](code-examples/implementing-snapshots.md) | +| Deploying to production | [Deployment Guide](deployment.md) | [Performance Optimization Guide](performance.md), [Security Guide](security.md) | + +## Troubleshooting Index + +| Issue | Solution | Documentation | Related Issues | +|-------|----------|---------------|----------------| +| Event versioning and schema evolution | How to handle event versioning | [Troubleshooting Guide](troubleshooting.md#event-versioning-and-schema-evolution) | [Handling concurrency conflicts](troubleshooting.md#handling-concurrency-conflicts) | +| Handling concurrency conflicts | How to handle concurrency conflicts | [Troubleshooting Guide](troubleshooting.md#handling-concurrency-conflicts) | [Event versioning and schema evolution](troubleshooting.md#event-versioning-and-schema-evolution) | +| Debugging event-sourced systems | How to debug event-sourced systems | [Troubleshooting Guide](troubleshooting.md#debugging-event-sourced-systems) | [Testing strategies and common issues](troubleshooting.md#testing-strategies-and-common-issues) | +| Performance issues and optimization | How to optimize performance | [Troubleshooting Guide](troubleshooting.md#performance-issues-and-optimization) | [Deployment considerations](troubleshooting.md#deployment-considerations) | +| Integration challenges | How to handle integration challenges | [Troubleshooting Guide](troubleshooting.md#integration-challenges) | [Deployment considerations](troubleshooting.md#deployment-considerations) | +| Testing strategies and common issues | How to test event-sourced systems | [Troubleshooting Guide](troubleshooting.md#testing-strategies-and-common-issues) | [Debugging event-sourced systems](troubleshooting.md#debugging-event-sourced-systems) | +| Deployment considerations | How to deploy event-sourced systems | [Troubleshooting Guide](troubleshooting.md#deployment-considerations) | [Performance issues and optimization](troubleshooting.md#performance-issues-and-optimization) | diff --git a/docs/scripts/verify-links.sh b/docs/scripts/verify-links.sh new file mode 100755 index 00000000..57d38f0f --- /dev/null +++ b/docs/scripts/verify-links.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Script to verify links in markdown documentation + +DOCS_DIR="/Users/leopoldodonnell/dev/reactive-domain/docs" +BROKEN_LINKS_FILE="$DOCS_DIR/broken-links.txt" + +# Clear the broken links file +> "$BROKEN_LINKS_FILE" + +# Function to extract links from markdown files +extract_links() { + local file="$1" + grep -o '\[[^]]*\]([^)]*)' "$file" | sed 's/\[[^]]*\](\([^)]*\))/\1/g' +} + +# Function to check if a link is valid +check_link() { + local file="$1" + local link="$2" + local base_dir=$(dirname "$file") + + # Skip external links and anchors + if [[ "$link" == http* || "$link" == "#"* ]]; then + return 0 + fi + + # Handle relative links + if [[ "$link" != /* ]]; then + link="$base_dir/$link" + fi + + # Normalize the path + link=$(echo "$link" | sed 's|/\./|/|g' | sed 's|/[^/]*/\.\./|/|g') + + # Check if the file exists + if [[ ! -f "$link" ]]; then + echo "Broken link in $file: $link" >> "$BROKEN_LINKS_FILE" + return 1 + fi + + return 0 +} + +# Find all markdown files and check their links +find "$DOCS_DIR" -name "*.md" -type f | while read -r file; do + echo "Checking links in $file" + extract_links "$file" | while read -r link; do + check_link "$file" "$link" + done +done + +# Report results +if [[ -s "$BROKEN_LINKS_FILE" ]]; then + echo "Found broken links:" + cat "$BROKEN_LINKS_FILE" +else + echo "No broken links found." +fi diff --git a/docs/templates/navigation-template.md b/docs/templates/navigation-template.md new file mode 100644 index 00000000..f60bdcc3 --- /dev/null +++ b/docs/templates/navigation-template.md @@ -0,0 +1,17 @@ +## Navigation + +**Section Navigation**: +- [← Previous: {PreviousPage}]({PreviousPageLink}) +- [↑ Parent: {ParentPage}]({ParentPageLink}) +- [→ Next: {NextPage}]({NextPageLink}) + +**Quick Links**: +- [Home](../README.md) +- [Core Concepts](../core-concepts.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/todo-pr-169.md b/todo-pr-169.md index 237be535..2ff3156e 100644 --- a/todo-pr-169.md +++ b/todo-pr-169.md @@ -44,10 +44,10 @@ This todo list contains items that need to be addressed for PR 169, which adds c ## Navigation and Structure -- [ ] Ensure logical progression through documentation -- [ ] Fix any broken links between documentation pages -- [ ] Improve component navigation to show relationships -- [ ] Verify that the table of contents is accurate and complete +- [x] Ensure logical progression through documentation +- [x] Fix any broken links between documentation pages +- [x] Improve component navigation to show relationships +- [x] Verify that the table of contents is accurate and complete ## Learning Resources From 97f86673eae1c1a82761be4f2f5040174e944b82 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 19:13:18 -0400 Subject: [PATCH 22/41] Enhance learning resources with comprehensive learning path and troubleshooting guide --- docs/README.md | 18 ++++ docs/learning-path.md | 196 ++++++++++++++++++++++++++++++++++++++++ docs/troubleshooting.md | 161 +++++++++++++++++++++++++++++++++ todo-pr-169.md | 6 +- 4 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 docs/learning-path.md diff --git a/docs/README.md b/docs/README.md index 7427778e..cc777ab7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,15 +28,33 @@ Reactive Domain is built on several key design principles: ## Learning Path +> **New to Reactive Domain?** Check out our [Complete Learning Path Guide](learning-path.md) for a structured approach to mastering event sourcing and CQRS with Reactive Domain. + For the best learning experience, we recommend following this progression: 1. **Start Here**: [Core Concepts](core-concepts.md) - Understand the fundamentals of event sourcing and CQRS 2. **Next**: [Usage Patterns](usage-patterns.md) - Learn how to apply these concepts in practice 3. **Then**: [Code Examples](code-examples/README.md) - See concrete implementations + - Begin with [Creating a New Aggregate Root](code-examples/creating-aggregate-root.md) + - Move on to [Handling Commands and Events](code-examples/handling-commands-events.md) + - Explore real-world examples like our [Banking Domain Example](code-examples/banking-domain-example.md) 4. **Explore**: [API Reference](api-reference/README.md) - Dive into the details of specific components 5. **Advanced**: [Architecture Guide](architecture.md) - Understand the system architecture 6. **Production**: [Deployment Guide](deployment.md) and [Performance Optimization](performance.md) - Prepare for production +### Learning Resources + +- **Books and Articles** + - "Domain-Driven Design" by Eric Evans + - "Implementing Domain-Driven Design" by Vaughn Vernon + - [Martin Fowler on Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) + - [CQRS Journey by Microsoft](https://docs.microsoft.com/en-us/previous-versions/msp-n-p/jj554200(v=pandp.10)) + +- **Community Resources** + - [EventStoreDB Documentation](https://developers.eventstore.com/) + - [DDD/CQRS Google Group](https://groups.google.com/g/dddcqrs) + - [Stack Overflow - Event Sourcing Tag](https://stackoverflow.com/questions/tagged/event-sourcing) + ## Navigation Resources To help you navigate the documentation more effectively, we've created these resources: diff --git a/docs/learning-path.md b/docs/learning-path.md new file mode 100644 index 00000000..f36ada0c --- /dev/null +++ b/docs/learning-path.md @@ -0,0 +1,196 @@ +# Reactive Domain Learning Path + +[← Back to Table of Contents](README.md) + +This document provides a structured learning path for developers new to Reactive Domain, Event Sourcing, and CQRS. Follow this path to gain a comprehensive understanding of the framework and its underlying concepts. + +## Learning Stages + +### Stage 1: Core Concepts + +Start by understanding the fundamental concepts behind Reactive Domain: + +1. **Event Sourcing Basics** + - [Core Concepts: Event Sourcing Fundamentals](core-concepts.md#event-sourcing-fundamentals) + - [Event Store Architecture](core-concepts.md#event-store-architecture) + - [Event, Command, and Message Flow](core-concepts.md#event-command-and-message-flow) + +2. **CQRS Fundamentals** + - [Core Concepts: CQRS Implementation](core-concepts.md#cqrs-implementation) + - [Command and Query Separation](core-concepts.md#command-and-query-separation) + - [Read Models and Projections](core-concepts.md#read-models-and-projections) + +3. **Domain-Driven Design Concepts** + - [Core Concepts: Domain-Driven Design](core-concepts.md#domain-driven-design-concepts) + - [Aggregates and Aggregate Roots](core-concepts.md#aggregates) + - [Value Objects and Entities](core-concepts.md#value-objects) + - [Bounded Contexts](core-concepts.md#bounded-contexts) + +### Stage 2: Practical Implementations + +Once you understand the core concepts, move on to practical implementations: + +1. **Setting Up Your Environment** + - [Usage Patterns: Setting Up a New Reactive Domain Project](usage-patterns.md#setting-up-a-new-reactive-domain-project) + - [Development Environment Configuration](deployment.md#development-environment-setup) + - [Required Dependencies](usage-patterns.md#required-dependencies) + +2. **Creating Your First Aggregate** + - [Code Examples: Creating a New Aggregate Root](code-examples/creating-aggregate-root.md) + - [Handling Commands and Generating Events](code-examples/handling-commands-events.md) + - [Saving and Retrieving Aggregates](code-examples/saving-retrieving-aggregates.md) + +3. **Building Read Models** + - [Code Examples: Implementing Projections](code-examples/implementing-projections.md) + - [Setting Up Event Listeners](code-examples/event-listeners.md) + - [Querying Read Models](usage-patterns.md#querying-read-models) + +### Stage 3: Advanced Topics + +After mastering the basics, explore advanced topics: + +1. **Message Correlation and Causation** + - [Core Concepts: Correlation and Causation Tracking](core-concepts.md#correlation-and-causation-tracking) + - [Code Examples: Handling Correlation and Causation](code-examples/correlation-causation.md) + - [MessageBuilder and Correlated Messages](api-reference/types/message-builder.md) + +2. **Snapshots and Performance** + - [Code Examples: Implementing Snapshots](code-examples/implementing-snapshots.md) + - [Performance Optimization Techniques](performance.md#snapshot-strategies) + - [When to Use Snapshots](usage-patterns.md#when-to-use-snapshots) + +3. **Testing Event-Sourced Systems** + - [Code Examples: Testing Aggregates and Event Handlers](code-examples/testing.md) + - [Test Fixtures and Helpers](api-reference/types/aggregate-test-fixture.md) + - [Testing Strategies for Event-Sourced Systems](troubleshooting.md#testing-strategies-and-common-issues) + +### Stage 4: Real-World Applications + +Finally, see how everything comes together in real-world applications: + +1. **Domain-Specific Examples** + - [Banking Domain Example](code-examples/banking-domain-example.md) + - [E-Commerce Domain Example](code-examples/ecommerce-domain-example.md) + - [Inventory Management Example](code-examples/inventory-management-example.md) + +2. **Integration and Deployment** + - [Integration with ASP.NET Core](code-examples/aspnet-integration.md) + - [Deployment Considerations](deployment.md#production-deployment-considerations) + - [Scaling Strategies](deployment.md#scaling-strategies) + +3. **Advanced Patterns and Practices** + - [Process Managers and Sagas](api-reference/types/process-manager.md) + - [Event Versioning and Schema Evolution](troubleshooting.md#event-versioning-and-schema-evolution) + - [Handling Concurrency Conflicts](troubleshooting.md#handling-concurrency-conflicts) + +## Recommended Learning Sequence + +For the most effective learning experience, we recommend following this sequence: + +1. **Week 1: Foundation** + - Read the [Core Concepts](core-concepts.md) documentation + - Set up your development environment + - Create a simple "Hello World" aggregate + +2. **Week 2: Basic Implementation** + - Implement a basic aggregate with commands and events + - Create a read model for your aggregate + - Write tests for your aggregate and read model + +3. **Week 3: Advanced Features** + - Implement correlation and causation tracking + - Add snapshot support + - Explore performance optimization techniques + +4. **Week 4: Real-World Application** + - Build a complete application using Reactive Domain + - Integrate with ASP.NET Core + - Deploy your application + +## Learning Resources + +### Books + +- **Event Sourcing and CQRS** + - "Domain-Driven Design" by Eric Evans + - "Implementing Domain-Driven Design" by Vaughn Vernon + - "CQRS Documents" by Greg Young + - "Event Sourcing and CQRS" by Martin Fowler + +### Online Resources + +- **Blogs and Articles** + - [Martin Fowler on Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) + - [Greg Young's Blog](https://goodenoughsoftware.net/) + - [CQRS Journey by Microsoft](https://docs.microsoft.com/en-us/previous-versions/msp-n-p/jj554200(v=pandp.10)) + +- **Videos and Presentations** + - [Greg Young - CQRS and Event Sourcing](https://www.youtube.com/watch?v=JHGkaShoyNs) + - [Udi Dahan - CQRS Deep Dive](https://www.youtube.com/watch?v=EqpalkqJD8M) + - [Event Sourcing You are doing it wrong by David Schmitz](https://www.youtube.com/watch?v=GzrZworHpIk) + +### Community Resources + +- **Forums and Discussion Groups** + - [DDD/CQRS Google Group](https://groups.google.com/g/dddcqrs) + - [EventStoreDB Discussion](https://discuss.eventstore.com/) + - [Stack Overflow - Event Sourcing Tag](https://stackoverflow.com/questions/tagged/event-sourcing) + +- **GitHub Repositories** + - [EventStoreDB](https://github.com/EventStore/EventStore) + - [Sample Applications](https://github.com/ReactiveDomain/sample-applications) + - [Workshop Materials](https://github.com/ReactiveDomain/workshop-materials) + +## Interactive Learning + +### Workshops and Exercises + +1. **Basic Workshop: Account Management** + - Create a simple banking application with accounts, deposits, and withdrawals + - Implement event sourcing for account transactions + - Build read models for account balances and transaction history + +2. **Intermediate Workshop: E-Commerce System** + - Implement an order processing system + - Handle inventory management + - Create read models for order history and product catalog + +3. **Advanced Workshop: Distributed System** + - Build a multi-service application + - Implement process managers for cross-aggregate coordination + - Handle distributed transactions and eventual consistency + +### Coding Exercises + +1. **Exercise 1: Create a Simple Aggregate** + - Create an aggregate for a todo list item + - Implement commands for creating, updating, and completing todos + - Write tests for the aggregate + +2. **Exercise 2: Build a Read Model** + - Create a read model for todo lists + - Implement an event handler to update the read model + - Write queries against the read model + +3. **Exercise 3: Implement Correlation** + - Add correlation tracking to your todo list application + - Implement a process manager for managing todo lists + - Track message flow through the system + +## Navigation + +**Section Navigation**: +- [← Previous: Core Concepts](core-concepts.md) +- [↑ Parent: Home](README.md) +- [→ Next: Usage Patterns](usage-patterns.md) + +**Quick Links**: +- [Home](README.md) +- [Core Concepts](core-concepts.md) +- [API Reference](api-reference/README.md) +- [Code Examples](code-examples/README.md) +- [Troubleshooting](troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9b3af925..4fb97b87 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -19,6 +19,71 @@ This guide addresses common issues and challenges when working with Reactive Dom ### Problem: Events Need to Change Over Time +As your domain model evolves, you'll need to modify your event schemas. However, you still need to be able to process historical events that were serialized with the old schema. + +### Solution: Implement Event Versioning + +1. **Use Event Upcasting** + +```csharp +public class EventUpcastingService : IEventUpcastingService +{ + public object Upcast(object @event) + { + if (@event is AccountCreatedV1 v1Event) + { + return new AccountCreatedV2 + { + AccountId = v1Event.AccountId, + AccountNumber = v1Event.AccountNumber, + CustomerName = v1Event.CustomerName, + // New field in V2 + CreatedDate = DateTime.UtcNow + }; + } + + return @event; + } +} +``` + +2. **Use Event Wrappers with Version Information** + +```csharp +public class EventWrapper +{ + public int Version { get; set; } + public T Data { get; set; } +} +``` + +3. **Handle Missing Properties Gracefully** + +```csharp +public void Apply(AccountCreated @event) +{ + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + + // Handle optional property that might not exist in older events + if (@event.GetType().GetProperty("CreatedDate") != null) + { + _createdDate = (DateTime)@event.GetType().GetProperty("CreatedDate").GetValue(@event); + } + else + { + _createdDate = DateTime.UtcNow; // Default value + } +} +``` + +### Best Practices + +1. **Never Delete or Modify Existing Events** - Always create new versions and implement upcasting. +2. **Use Semantic Versioning** - Follow a consistent versioning scheme for your events. +3. **Document Event Schema Changes** - Maintain a changelog of event schema modifications. +4. **Test Event Upcasting** - Ensure that historical events can be properly upcasted to current versions. + As your system evolves, you'll need to modify your event schemas to add, remove, or change properties. ### Solution: Event Versioning Strategies @@ -133,6 +198,102 @@ As your system evolves, you'll need to modify your event schemas to add, remove, ## Handling Concurrency Conflicts +### Problem: Concurrent Modifications to the Same Aggregate + +In event-sourced systems, concurrent modifications to the same aggregate can lead to concurrency conflicts when the second operation tries to save its changes. + +### Solution: Implement Optimistic Concurrency Control + +1. **Use Expected Version When Saving Events** + +```csharp +public void Save(TAggregate aggregate) where TAggregate : AggregateRoot, IEventSource +{ + var events = aggregate.GetUncommittedEvents().ToArray(); + if (!events.Any()) return; + + var streamName = GetStreamName(aggregate.GetType(), aggregate.Id); + var expectedVersion = aggregate.Version - events.Length; + + try + { + _connection.AppendToStream(streamName, expectedVersion, events); + aggregate.ClearUncommittedEvents(); + } + catch (WrongExpectedVersionException ex) + { + // Handle concurrency conflict + throw new ConcurrencyException($"Concurrency conflict when saving {aggregate.GetType().Name} with ID {aggregate.Id}", ex); + } +} +``` + +2. **Implement Retry Logic with Conflict Resolution** + +```csharp +public void HandleWithRetry(TransferFunds command) +{ + const int maxRetries = 3; + int retryCount = 0; + + while (true) + { + try + { + var account = _repository.GetById(command.AccountId); + account.Withdraw(command.Amount, command); + _repository.Save(account); + break; // Success, exit the loop + } + catch (ConcurrencyException) + { + if (++retryCount >= maxRetries) + throw new MaxRetriesExceededException($"Failed to process command after {maxRetries} attempts"); + + // Optional: Add exponential backoff + Thread.Sleep(100 * (int)Math.Pow(2, retryCount)); + } + } +} +``` + +3. **Implement Command Merging for Conflict Resolution** + +```csharp +public void HandleWithMerge(DepositFunds command) +{ + try + { + var account = _repository.GetById(command.AccountId); + account.Deposit(command.Amount, command); + _repository.Save(account); + } + catch (ConcurrencyException) + { + // Reload the latest state + var account = _repository.GetById(command.AccountId, true); // Force reload + + // Check if it's safe to apply the command to the updated state + if (account.IsClosed) + { + throw new AccountClosedException("Cannot deposit to a closed account"); + } + + // Apply command to the updated state + account.Deposit(command.Amount, command); + _repository.Save(account); + } +} +``` + +### Best Practices + +1. **Design Commands to be Idempotent** - Commands should be safely reapplied without causing duplicate effects. +2. **Use Command IDs for Deduplication** - Assign unique IDs to commands to detect and prevent duplicate processing. +3. **Consider Domain-Specific Conflict Resolution** - Implement business rules for merging conflicting changes. +4. **Log Concurrency Conflicts** - Monitor and analyze patterns of concurrency conflicts to optimize your system. +5. **Use Appropriate Retry Strategies** - Implement exponential backoff or circuit breakers for retry logic. + ### Problem: Concurrent Updates to the Same Aggregate When multiple processes attempt to update the same aggregate simultaneously, concurrency conflicts can occur. diff --git a/todo-pr-169.md b/todo-pr-169.md index 2ff3156e..d9e8cd00 100644 --- a/todo-pr-169.md +++ b/todo-pr-169.md @@ -51,6 +51,6 @@ This todo list contains items that need to be addressed for PR 169, which adds c ## Learning Resources -- [ ] Enhance the learning path for new users -- [ ] Add links to additional resources and examples -- [ ] Include troubleshooting section for common issues +- [x] Enhance the learning path for new users +- [x] Add links to additional resources and examples +- [x] Include troubleshooting section for common issues From 0e0546ecc1912af74bdfcc34eff5409b88d5d1a6 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 19:14:10 -0400 Subject: [PATCH 23/41] not needed --- todo-pr-169.md | 56 -------------------------------------------------- 1 file changed, 56 deletions(-) delete mode 100644 todo-pr-169.md diff --git a/todo-pr-169.md b/todo-pr-169.md deleted file mode 100644 index d9e8cd00..00000000 --- a/todo-pr-169.md +++ /dev/null @@ -1,56 +0,0 @@ -# Todo List for PR 169 - Reactive Documentation - -This todo list contains items that need to be addressed for PR 169, which adds comprehensive documentation for the Reactive Domain library. - -## Badge and Reference Fixes - -- [x] Fix Badge URLs in README.md to point to the main ReactiveDomain repository instead of leopoldodonnell's fork (already completed) -- [x] Update Travis CI badge to point to ReactiveDomain organization (already completed) -- [x] Ensure all documentation links point to the correct repositories (updated links in README.md, sample-applications.md, and workshop-materials.md) - -## Documentation for Key Components - -- [x] Add/enhance documentation for ReadModelBase (documentation is comprehensive with good examples) -- [x] Add/enhance documentation for MessageBuilder factory (documentation is comprehensive) -- [x] Improve documentation for Command and Event classes that implement ICorrelatedMessage (documentation exists and is detailed) -- [x] Document the relationship between different components (added new Key Component Relationships section to architecture.md and cross-references in component documentation) - -## Code Example Corrections - -- [x] Fix the use of `Apply()` vs `RaiseEvent()` methods in aggregates (Apply methods are for rehydration, not for creating new events) -- [x] Update examples in event.md to use `RaiseEvent(new AccountCreated(...))` instead of `Apply(...)` -- [x] Review all code examples for technical accuracy in command.md and message-builder.md -- [x] Add more real-world examples to illustrate concepts - -## Architecture Documentation Improvements - -- [x] Add more detailed explanations of the CQRS pattern (added comprehensive section with core principles and benefits) -- [x] Enhance documentation of Event Sourcing principles (expanded with detailed explanations and flow diagrams) -- [x] Include diagrams showing the flow of commands and events (added multiple mermaid diagrams) -- [x] Document the relationship between different architectural components (added new section on CQRS and Event Sourcing integration) - -## Terminology and Consistency - -- [x] Ensure consistent use of terminology throughout the documentation -- [x] Review and correct any technical inaccuracies -- [x] Standardize formatting and style across all documentation files - -## API Reference Enhancements - -- [x] Add missing classes and interfaces to API reference -- [x] Ensure all public APIs are properly documented -- [x] Add parameter descriptions for important methods -- [x] Document return values and exceptions - -## Navigation and Structure - -- [x] Ensure logical progression through documentation -- [x] Fix any broken links between documentation pages -- [x] Improve component navigation to show relationships -- [x] Verify that the table of contents is accurate and complete - -## Learning Resources - -- [x] Enhance the learning path for new users -- [x] Add links to additional resources and examples -- [x] Include troubleshooting section for common issues From 5c48637b8b0c6c14ec7d77b2bf5787cef5fc64c0 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 19:20:18 -0400 Subject: [PATCH 24/41] Add comprehensive documentation for Event-Driven State Machine --- docs/core-concepts.md | 81 ++++++++ docs/core-concepts/state-machine.md | 309 ++++++++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 docs/core-concepts/state-machine.md diff --git a/docs/core-concepts.md b/docs/core-concepts.md index ee602110..fdc3fc30 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -8,6 +8,7 @@ This document explains the fundamental concepts of event sourcing as implemented - [Event Sourcing Fundamentals](#event-sourcing-fundamentals) - [Event Store Architecture](#event-store-architecture) +- [Event-Driven State Machine](core-concepts/state-machine.md) - [CQRS Implementation](#cqrs-implementation) - [Reactive Programming Principles](#reactive-programming-principles) - [Domain-Driven Design Concepts](#domain-driven-design-concepts) @@ -28,6 +29,7 @@ In Reactive Domain, event sourcing is implemented through the following key comp - **Aggregates**: Domain entities that encapsulate state and behavior - **Event Store**: A specialized database for storing and retrieving events - **Projections**: Components that transform events into queryable state +- **Event-Driven State Machine**: The foundation for implementing event-sourced entities ### Benefits of Event Sourcing @@ -53,6 +55,85 @@ graph TD F[Event Serializer] --> C ``` +## Event-Driven State Machine + +The `EventDrivenStateMachine` is a core component in Reactive Domain that implements the finite state machine pattern for event-sourced entities. It serves as the base class for both aggregate roots and process managers, providing essential functionality for event handling, state transitions, and event recording. + +### Key Components + +- **Event Router**: Directs events to the appropriate handler methods based on their type +- **Event Recorder**: Tracks new events that have been applied to the entity +- **Version Control**: Manages optimistic concurrency through version tracking + +### Core Operations + +1. **Registering Event Handlers**: Associate event types with handler methods + ```csharp + Register(Apply); + Register(Apply); + ``` + +2. **Raising Events**: Apply events to the current state and record them for persistence + ```csharp + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount, reference))); + ``` + +3. **Restoring State**: Rebuild entity state by replaying historical events + ```csharp + RestoreFromEvents(events); + ``` + +4. **Taking Uncommitted Events**: Retrieve new events for persistence + ```csharp + var newEvents = TakeEvents(); + ``` + +### Benefits + +- **Clear Separation of Concerns**: Command methods validate business rules and raise events, while event handlers update state +- **Audit Trail**: All state changes are recorded as events, providing a complete history of the entity +- **Temporal Queries**: The ability to determine the state of an entity at any point in time +- **Testability**: Easy to test by verifying that the correct events are raised in response to commands + +### Example: Account State Machine + +```csharp +public class Account : AggregateRoot // AggregateRoot inherits from EventDrivenStateMachine +{ + private decimal _balance; + private bool _isActive; + + public Account(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Command handler + public void Deposit(decimal amount, ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Cannot deposit to a closed account"); + + if (amount <= 0) + throw new ArgumentException("Deposit amount must be positive", nameof(amount)); + + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + } + + // Event handler + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } +} +``` + +For a comprehensive explanation of the Event-Driven State Machine, see the [detailed documentation](core-concepts/state-machine.md). + ## CQRS Implementation Command Query Responsibility Segregation (CQRS) is a pattern that separates read and write operations. In Reactive Domain, CQRS is implemented through: diff --git a/docs/core-concepts/state-machine.md b/docs/core-concepts/state-machine.md new file mode 100644 index 00000000..6167a399 --- /dev/null +++ b/docs/core-concepts/state-machine.md @@ -0,0 +1,309 @@ +# Event-Driven State Machine + +[← Back to Core Concepts](../core-concepts.md) + +## Overview + +The `EventDrivenStateMachine` is a fundamental component in Reactive Domain that implements the finite state machine pattern for event-sourced entities. It serves as the base class for both aggregate roots and process managers, providing the core functionality for event handling, state transitions, and event recording. + +## Purpose + +The primary purposes of the `EventDrivenStateMachine` are: + +1. **State Management**: Maintain the current state of an entity based on the events it has processed +2. **Event Routing**: Direct events to the appropriate handlers based on their type +3. **Event Recording**: Track new events that have been applied to the entity +4. **Event Sourcing Support**: Provide mechanisms for restoring entity state from an event stream + +## Key Components + +### Event Router + +The `EventRouter` handles the dispatching of events to the appropriate handler methods. When an event is received, the router determines which handler method should process it based on the event type. + +```csharp +// Inside EventDrivenStateMachine +protected readonly EventRouter Router; + +// Registering an event handler +protected void Register(Action route) { + Router.RegisterRoute(route); +} +``` + +### Event Recorder + +The `EventRecorder` keeps track of all new events that have been applied to the entity. These events represent state changes that need to be persisted to the event store. + +```csharp +// Inside EventDrivenStateMachine +private readonly EventRecorder _recorder; + +// Recording an event +protected void Raise(object @event) { + OnEventRaised(@event); + Router.Route(@event); + _recorder.Record(@event); +} +``` + +### Version Tracking + +The `EventDrivenStateMachine` maintains a version number that represents the number of events that have been applied to the entity. This is used for optimistic concurrency control when saving changes. + +```csharp +private long _version; +public long Version => _version; +``` + +## Core Operations + +### Registering Event Handlers + +Event handlers are registered using the `Register` method, which associates an event type with a handler method: + +```csharp +public class Account : AggregateRoot // AggregateRoot inherits from EventDrivenStateMachine +{ + private decimal _balance; + private bool _isActive; + + public Account(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _isActive = true; + _balance = @event.InitialDeposit; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + // More event handlers... +} +``` + +### Raising Events + +New events are raised using the `Raise` method, which: +1. Applies the event to the current state by routing it to the appropriate handler +2. Records the event for later persistence + +```csharp +public void Deposit(decimal amount, string reference, ICorrelatedMessage source) +{ + if (!_isActive) + throw new InvalidOperationException("Cannot deposit to a closed account"); + + if (amount <= 0) + throw new ArgumentException("Deposit amount must be positive", nameof(amount)); + + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount, reference))); +} +``` + +### Restoring State from Events + +The `RestoreFromEvents` method is used to rebuild the entity's state from its event history: + +```csharp +// Inside EventDrivenStateMachine +public void RestoreFromEvents(IEnumerable events) +{ + if (events == null) + throw new ArgumentNullException(nameof(events)); + + if (_recorder.HasRecordedEvents) + throw new InvalidOperationException("Restoring from events is not possible when an instance has recorded events."); + + foreach (var @event in events) + { + if (_version < 0) + _version = 0; + else + _version++; + + Router.Route(@event); + } +} +``` + +### Taking Uncommitted Events + +The `TakeEvents` method returns all new events that have been raised since the entity was loaded or since the last time `TakeEvents` was called: + +```csharp +// Inside EventDrivenStateMachine +public object[] TakeEvents() +{ + TakeEventStarted(); + var records = _recorder.RecordedEvents; + _recorder.Reset(); + _version += records.Length; + TakeEventsCompleted(); + return records; +} +``` + +## State Machine in Action + +The event-driven state machine pattern in Reactive Domain follows these steps: + +1. **Initialization**: Create a new instance of an entity (aggregate or process manager) +2. **Registration**: Register event handlers for each event type the entity needs to handle +3. **Command Handling**: Process commands by validating business rules and raising appropriate events +4. **Event Application**: Apply events to the entity's state through the registered event handlers +5. **Event Recording**: Record new events for later persistence +6. **State Persistence**: Save the new events to the event store +7. **State Restoration**: When loading an entity, restore its state by replaying all historical events + +## Example: Account State Machine + +Here's a complete example of an `Account` aggregate that uses the event-driven state machine pattern: + +```csharp +public class Account : AggregateRoot +{ + // State variables + private decimal _balance; + private bool _isActive; + private string _accountNumber; + + // Constructor + public Account(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Command handlers + public void CreateAccount(string accountNumber, decimal initialDeposit, ICorrelatedMessage source) + { + if (_isActive) + throw new InvalidOperationException("Account already exists"); + + if (initialDeposit < 0) + throw new ArgumentException("Initial deposit cannot be negative", nameof(initialDeposit)); + + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(Id, accountNumber, initialDeposit))); + } + + public void Deposit(decimal amount, string reference, ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Cannot deposit to a closed account"); + + if (amount <= 0) + throw new ArgumentException("Deposit amount must be positive", nameof(amount)); + + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount, reference))); + } + + public void Withdraw(decimal amount, string reference, ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Cannot withdraw from a closed account"); + + if (amount <= 0) + throw new ArgumentException("Withdrawal amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + RaiseEvent(MessageBuilder.From(source, () => new FundsWithdrawn(Id, amount, reference))); + } + + public void CloseAccount(ICorrelatedMessage source) + { + if (!_isActive) + throw new InvalidOperationException("Account is already closed"); + + if (_balance > 0) + throw new InvalidOperationException("Cannot close account with positive balance"); + + RaiseEvent(MessageBuilder.From(source, () => new AccountClosed(Id))); + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _isActive = true; + _accountNumber = @event.AccountNumber; + _balance = @event.InitialDeposit; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void Apply(AccountClosed @event) + { + _isActive = false; + } +} +``` + +## Benefits of the Event-Driven State Machine + +1. **Clear Separation of Concerns**: Command methods validate business rules and raise events, while event handlers update state. +2. **Audit Trail**: All state changes are recorded as events, providing a complete history of the entity. +3. **Temporal Queries**: The ability to determine the state of an entity at any point in time by replaying events up to that point. +4. **Testability**: Easy to test by verifying that the correct events are raised in response to commands. +5. **Event Replay**: The ability to rebuild the state of an entity by replaying its event history. + +## Best Practices + +1. **Keep Event Handlers Simple**: Event handlers should only update the entity's state and not perform any validation or raise additional events. +2. **Validate in Command Methods**: All business rules should be validated in command methods before raising events. +3. **Use Meaningful Event Names**: Events should be named in the past tense and clearly describe what happened (e.g., `AccountCreated`, `FundsDeposited`). +4. **Register All Event Handlers**: Ensure that all event types that can be applied to an entity have registered handlers. +5. **Avoid Side Effects in Event Handlers**: Event handlers should only update the entity's internal state and not interact with external systems. +6. **Use Correlation for Message Tracking**: Always use the `MessageBuilder` to create correlated events from command sources. +7. **Implement Proper Error Handling**: Add appropriate null checks and validation in command handlers. + +## Relationship with Other Components + +The `EventDrivenStateMachine` is a foundational component in Reactive Domain that interacts with several other key components: + +1. **Repository**: Loads and saves aggregates by retrieving and storing their events. +2. **Event Store**: Persists the events generated by aggregates. +3. **Command Bus**: Routes commands to the appropriate handlers, which then operate on aggregates. +4. **Event Bus**: Publishes events to interested subscribers after they are persisted. +5. **Process Manager**: Coordinates activities across multiple aggregates in response to events. + +## Navigation + +**Section Navigation**: +- [← Previous: Event Sourcing](event-sourcing.md) +- [↑ Parent: Core Concepts](../core-concepts.md) +- [→ Next: CQRS](cqrs.md) + +**Quick Links**: +- [Home](../README.md) +- [API Reference](../api-reference/README.md) +- [Code Examples](../code-examples/README.md) +- [Troubleshooting](../troubleshooting.md) + +--- + +*This documentation is part of the [Reactive Domain](https://github.com/ReactiveDomain/reactive-domain) project.* From 1ef0bbfb79c793c0c4015310b9b942dfcf864805 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 19:40:53 -0400 Subject: [PATCH 25/41] Fix MessageBuilder documentation to clarify correlation tracking requirements --- docs/api-reference/types/message-builder.md | 42 ++++++++++++--------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/api-reference/types/message-builder.md b/docs/api-reference/types/message-builder.md index 7146de8d..229c6b05 100644 --- a/docs/api-reference/types/message-builder.md +++ b/docs/api-reference/types/message-builder.md @@ -8,6 +8,8 @@ In event-sourced systems, tracking the flow of messages is crucial for debugging, auditing, and understanding causal relationships. The `MessageBuilder` factory provides a consistent way to create messages with properly set correlation and causation IDs. It is particularly important when using the `RaiseEvent()` method in aggregates to ensure that events maintain their correlation with the original commands. +> **Important**: When using `RaiseEvent()` in an aggregate, you must explicitly use `MessageBuilder.From(command, ...)` to maintain correlation information. The `AggregateRoot` class does not automatically add correlation information to events. Failing to use `MessageBuilder` will result in lost correlation tracking, making it difficult to trace message flows through your system. + Correlation tracking is essential in distributed systems where a single business transaction might span multiple services, processes, or message handlers. The `MessageBuilder` ensures that all messages related to the same business transaction are properly linked, making it possible to trace the entire transaction flow. ## Class Definition @@ -152,7 +154,7 @@ In this flow: ### In an Aggregate -Messages are often created within aggregates in response to commands: +Messages are often created within aggregates in response to commands. When using `RaiseEvent()` in an aggregate, you must explicitly use `MessageBuilder` to maintain correlation information: ```csharp public class Account : AggregateRoot @@ -161,46 +163,50 @@ public class Account : AggregateRoot private bool _isActive; private string _ownerName; - public Account(Guid id, ICorrelatedMessage source) : base(id) + // When handling a command that needs correlation tracking + public void ProcessCreateAccountCommand(CreateAccount command) { - // Create a new event from the source command - RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(id, ((CreateAccount)source).CustomerName))); + // Use MessageBuilder to maintain correlation information + RaiseEvent(MessageBuilder.From(command, () => new AccountCreated(Id, command.CustomerName))); } - public void Deposit(decimal amount, ICorrelatedMessage source) + // When handling a deposit command + public void ProcessDepositCommand(DepositFunds command) { if (!_isActive) throw new InvalidOperationException("Cannot deposit to inactive account"); - if (amount <= 0) - throw new ArgumentException("Amount must be positive", nameof(amount)); + if (command.Amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(command.Amount)); - // Create a new event from the source command - RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + // Use MessageBuilder to maintain correlation information + RaiseEvent(MessageBuilder.From(command, () => new FundsDeposited(Id, command.Amount))); } - public void Withdraw(decimal amount, ICorrelatedMessage source) + // When handling a withdrawal command + public void ProcessWithdrawCommand(WithdrawFunds command) { if (!_isActive) throw new InvalidOperationException("Cannot withdraw from inactive account"); - if (amount <= 0) - throw new ArgumentException("Amount must be positive", nameof(amount)); + if (command.Amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(command.Amount)); - if (_balance < amount) + if (_balance < command.Amount) throw new InvalidOperationException("Insufficient funds"); - // Create a new event from the source command - RaiseEvent(MessageBuilder.From(source, () => new FundsWithdrawn(Id, amount))); + // Use MessageBuilder to maintain correlation information + RaiseEvent(MessageBuilder.From(command, () => new FundsWithdrawn(Id, command.Amount))); } - public void Close(ICorrelatedMessage source) + // When handling a close account command + public void ProcessCloseAccountCommand(CloseAccount command) { if (!_isActive) throw new InvalidOperationException("Account already closed"); - // Create a new event from the source command - RaiseEvent(MessageBuilder.From(source, () => new AccountClosed(Id))); + // Use MessageBuilder to maintain correlation information + RaiseEvent(MessageBuilder.From(command, () => new AccountClosed(Id))); } private void Apply(AccountCreated @event) From 6035ff260168c7442edc1bbe038dd152124eb2f3 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 20:05:46 -0400 Subject: [PATCH 26/41] Update command.md to use MessageBuilder.From(source).Build() syntax instead of From(source, () => ...) --- docs/api-reference/types/command.md | 85 +++++++++-- docs/api-reference/types/read-model-base.md | 151 ++++++++++++-------- 2 files changed, 163 insertions(+), 73 deletions(-) diff --git a/docs/api-reference/types/command.md b/docs/api-reference/types/command.md index d1bfdfd2..a9fc2816 100644 --- a/docs/api-reference/types/command.md +++ b/docs/api-reference/types/command.md @@ -183,9 +183,10 @@ public class CreateAccount : Command public readonly decimal InitialDeposit; public readonly AccountType AccountType; + // Simple constructor for command properties public CreateAccount(Guid accountId, string accountNumber, string customerName, decimal initialDeposit, AccountType accountType) - : base() + : base() // Default constructor creates new correlation IDs { AccountId = accountId; AccountNumber = accountNumber; @@ -194,12 +195,37 @@ public class CreateAccount : Command AccountType = accountType; } - // Constructor for correlated commands - public CreateAccount(Guid accountId, string accountNumber, string customerName, + // IMPORTANT: Do not create constructors that take correlation IDs directly + // Instead, use MessageBuilder to create correlated commands. + // + // MessageBuilder will automatically set the correlation IDs as follows: + // 1. For MessageBuilder.New(): + // - MsgId = new Guid() // A new unique ID + // - CorrelationId = MsgId // Same as MsgId + // - CausationId = MsgId // Same as MsgId + // + // 2. For MessageBuilder.From(source, ...): + // - MsgId = new Guid() // A new unique ID + // - CorrelationId = source.CorrelationId // Copied from source + // - CausationId = source.MsgId // Set to source message ID + // + // Example usage: + // ICorrelatedMessage sourceCommand = ... + // var newCommand = MessageBuilder.From(sourceCommand).Build(() => + // new CreateAccount(accountId, accountNumber, customerName, initialDeposit, accountType)); + + // Private constructor for MessageBuilder + // This is used internally by MessageBuilder and should not be called directly + private CreateAccount(Guid accountId, string accountNumber, string customerName, decimal initialDeposit, AccountType accountType, - Guid correlationId, Guid causationId) - : base(correlationId, causationId) + Guid msgId, Guid correlationId, Guid causationId) { + // Properties set automatically by MessageBuilder + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + + // Command-specific properties AccountId = accountId; AccountNumber = accountNumber; CustomerName = customerName; @@ -217,29 +243,58 @@ public enum AccountType } ``` -### Using MessageBuilder with Commands +### Creating Correlated Commands + +> **Important**: Always use `MessageBuilder` to create correlated commands. Do not manually set correlation and causation IDs by calling constructors directly. + +The recommended way to create commands that continue an existing correlation chain is to use the `MessageBuilder` class. This ensures proper correlation tracking and maintains the causality chain. -It's recommended to use the `MessageBuilder` factory to create commands with proper correlation: +#### How MessageBuilder Sets Correlation IDs + +When using MessageBuilder, correlation IDs are set automatically according to these rules: + +1. For `MessageBuilder.New()` (starting a new correlation chain): + - `MsgId` = new Guid() (a new unique ID) + - `CorrelationId` = MsgId (same as MsgId) + - `CausationId` = MsgId (same as MsgId) + +2. For `MessageBuilder.From(source).Build(...)` (continuing an existing correlation chain): + - `MsgId` = new Guid() (a new unique ID) + - `CorrelationId` = source.CorrelationId (copied from source message) + - `CausationId` = source.MsgId (set to the source message's ID) + +This approach ensures proper tracking of message relationships without exposing correlation details in your public API. + +#### Example Usage ```csharp -// Create a new command that starts a correlation chain +// Starting a new correlation chain with MessageBuilder.New() var createCommand = MessageBuilder.New(() => new CreateAccount( - Guid.NewGuid(), - "ACC-123", + Guid.NewGuid(), + "ACC-123456", "John Doe", 1000.00m, AccountType.Checking )); -// Create a command from an existing message -var depositCommand = MessageBuilder.From(createCommand, () => new DepositFunds( - ((CreateAccount)createCommand).AccountId, +// INCORRECT - Do not create correlated commands this way: +// var depositCommand = new DepositFunds( +// ((CreateAccount)createCommand).AccountId, +// 500.00m, +// "Initial deposit", +// createCommand.CorrelationId, // Don't pass correlation IDs directly +// createCommand.MsgId +// ); + +// CORRECT - Use MessageBuilder.From() to continue the correlation chain: +var depositCommand = MessageBuilder.From(createCommand).Build(() => new DepositFunds( + ((CreateAccount)createCommand).AccountId, 500.00m, "Initial deposit" )); // Create another command in the same correlation chain -var setOverdraftCommand = MessageBuilder.From(createCommand, () => new SetOverdraftLimit( +var setOverdraftCommand = MessageBuilder.From(createCommand).Build(() => new SetOverdraftLimit( ((CreateAccount)createCommand).AccountId, 250.00m )); @@ -378,7 +433,7 @@ public class CommandBusExample _commandBus.Send(createCommand); // Create a related command - var depositCommand = MessageBuilder.From(createCommand, () => new DepositFunds( + var depositCommand = MessageBuilder.From(createCommand).Build(() => new DepositFunds( ((CreateAccount)createCommand).AccountId, 500.00m, "Bonus deposit" diff --git a/docs/api-reference/types/read-model-base.md b/docs/api-reference/types/read-model-base.md index 192d1184..a511ba47 100644 --- a/docs/api-reference/types/read-model-base.md +++ b/docs/api-reference/types/read-model-base.md @@ -126,96 +126,131 @@ public class CustomerDashboard : ReadModelBase ## Integration with Event Handlers -Read models are typically updated by event handlers that subscribe to domain events raised by aggregates through the `RaiseEvent()` method. This creates an eventually consistent projection of the domain state optimized for querying. Here's a comprehensive example showing how to update read models in response to various events: +In Reactive Domain, read models typically implement the event handler interfaces directly. This pattern allows the read model to handle its own updates in response to domain events. Here's how read models should be implemented to handle events: ```csharp -public class AccountEventHandler : +// The read model itself implements the event handler interfaces +public class AccountSummaryReadModel : ReadModelBase, IEventHandler, IEventHandler, IEventHandler, IEventHandler { - private readonly IReadModelRepository _accountRepository; - private readonly IReadModelRepository _dashboardRepository; + private readonly IReadModelRepository _repository; - public AccountEventHandler( - IReadModelRepository accountRepository, - IReadModelRepository dashboardRepository) + public string AccountNumber { get; private set; } + public string CustomerName { get; private set; } + public decimal Balance { get; private set; } + public bool IsActive { get; private set; } + public DateTime LastUpdated { get; private set; } + + // Constructor for creating a new read model instance + public AccountSummaryReadModel(Guid id, IReadModelRepository repository) : base(id) { - _accountRepository = accountRepository; - _dashboardRepository = dashboardRepository; + _repository = repository; } + // Event handler for AccountCreated public void Handle(AccountCreated @event) { - // Update the account summary read model - var accountSummary = new AccountSummary(@event.AccountId); - accountSummary.Update(@event.AccountNumber, @event.CustomerName, @event.InitialBalance); - _accountRepository.Save(accountSummary); - - // Update the customer dashboard read model - var dashboard = _dashboardRepository.GetById(@event.CustomerId) - ?? new CustomerDashboard(@event.CustomerId); + // Update the read model state + AccountNumber = @event.AccountNumber; + CustomerName = @event.CustomerName; + Balance = @event.InitialBalance; + IsActive = true; + LastUpdated = DateTime.UtcNow; - dashboard.UpdateCustomerInfo(@event.CustomerName, @event.CustomerEmail); - dashboard.AddAccount(accountSummary); - _dashboardRepository.Save(dashboard); + // Save the updated read model + _repository.Save(this); } + // Event handler for FundsDeposited public void Handle(FundsDeposited @event) { - // Update the account summary read model - var accountSummary = _accountRepository.GetById(@event.AccountId); - if (accountSummary != null) + // Ensure this is the correct account + if (@event.AccountId == Id) { - accountSummary.Update( - accountSummary.AccountNumber, - accountSummary.CustomerName, - accountSummary.Balance + @event.Amount); - _accountRepository.Save(accountSummary); + // Update the read model state + Balance += @event.Amount; + LastUpdated = DateTime.UtcNow; - // Update the customer dashboard read model - var customerId = GetCustomerIdForAccount(@event.AccountId); - var dashboard = _dashboardRepository.GetById(customerId); - if (dashboard != null) - { - dashboard.UpdateAccount(accountSummary); - - var transaction = new TransactionSummary - { - Id = Guid.NewGuid(), - AccountId = @event.AccountId, - AccountNumber = accountSummary.AccountNumber, - Type = "Deposit", - Amount = @event.Amount, - Balance = accountSummary.Balance, - Timestamp = DateTime.UtcNow - }; - - dashboard.AddTransaction(transaction); - _dashboardRepository.Save(dashboard); - } + // Save the updated read model + _repository.Save(this); } } + // Event handler for FundsWithdrawn public void Handle(FundsWithdrawn @event) { - // Similar implementation to FundsDeposited - // ... + // Ensure this is the correct account + if (@event.AccountId == Id) + { + // Update the read model state + Balance -= @event.Amount; + LastUpdated = DateTime.UtcNow; + + // Save the updated read model + _repository.Save(this); + } } + // Event handler for AccountClosed public void Handle(AccountClosed @event) { - // Implementation for account closure - // ... + // Ensure this is the correct account + if (@event.AccountId == Id) + { + // Update the read model state + IsActive = false; + LastUpdated = DateTime.UtcNow; + + // Save the updated read model + _repository.Save(this); + } } - private Guid GetCustomerIdForAccount(Guid accountId) +} +``` + +## Registering Read Models with the Event Bus + +To make read models receive events, they need to be registered with the event bus. Here's how to register a read model as an event handler: + +```csharp +public class ReadModelRegistration +{ + private readonly IEventBus _eventBus; + private readonly IReadModelRepository _repository; + + public ReadModelRegistration(IEventBus eventBus, IReadModelRepository repository) { - // In a real implementation, this would look up the customer ID - // from a mapping or another read model - // This is just a placeholder for the example - return Guid.Empty; + _eventBus = eventBus; + _repository = repository; + } + + public void RegisterReadModels() + { + // Create and register the read model for each account + var accounts = GetAllAccountIds(); + foreach (var accountId in accounts) + { + // Create or retrieve the read model + var readModel = _repository.GetById(accountId) ?? + new AccountSummaryReadModel(accountId, _repository); + + // Register the read model as an event handler + _eventBus.Subscribe(readModel); + _eventBus.Subscribe(readModel); + _eventBus.Subscribe(readModel); + _eventBus.Subscribe(readModel); + } + } + + private IEnumerable GetAllAccountIds() + { + // In a real implementation, this would retrieve all account IDs + // from the event store or another source + return new List(); } } ``` From d232936900fe4169e4e9cf4e3f8a8c3a2f9752ae Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 20:08:07 -0400 Subject: [PATCH 27/41] Update aggregate-root.md to use MessageBuilder.From(source).Build() syntax instead of From(source, () => ...) --- docs/api-reference/types/aggregate-root.md | 27 ++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/api-reference/types/aggregate-root.md b/docs/api-reference/types/aggregate-root.md index 40f5ee41..5f2c6b89 100644 --- a/docs/api-reference/types/aggregate-root.md +++ b/docs/api-reference/types/aggregate-root.md @@ -52,7 +52,7 @@ public class Account : AggregateRoot public Account(Guid id, ICorrelatedMessage source) : base(id, source) { // Initialize a new account with correlation - RaiseEvent(MessageBuilder.From(source, () => new AccountCreated(id))); + RaiseEvent(MessageBuilder.From(source).Build(() => new AccountCreated(id))); } } ``` @@ -149,21 +149,34 @@ protected void RaiseEvent(object @event); **Parameters**: - `event` (`System.Object`): The event to raise. This is typically created using the `MessageBuilder` to ensure proper correlation tracking. +> **Important**: The `RaiseEvent()` method does NOT automatically add correlation information to events. You must explicitly use `MessageBuilder.From(source).Build(...)` to create events with proper correlation information. Simply passing an event to `RaiseEvent()` without using MessageBuilder will result in lost correlation tracking. + **Example**: ```csharp public class Account : AggregateRoot { private decimal _balance; - public void Deposit(decimal amount) + // INCORRECT: This will lose correlation tracking + public void DepositIncorrect(decimal amount) { if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); - // Record and apply the event + // This will NOT maintain correlation information RaiseEvent(new AmountDeposited(Id, amount)); } + // CORRECT: This maintains correlation tracking + public void Deposit(decimal amount, ICorrelatedMessage source) + { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + // This properly maintains correlation information + RaiseEvent(MessageBuilder.From(source).Build(() => new AmountDeposited(Id, amount))); + } + private void Apply(AmountDeposited @event) { _balance += @event.Amount; @@ -276,7 +289,7 @@ public class Account : AggregateRoot public Account(Guid id, ICorrelatedMessage source) : base(id, source) { // Initialize with default values and maintain correlation - RaiseEvent(MessageBuilder.From(source, () => + RaiseEvent(MessageBuilder.From(source).Build(() => new AccountCreated(id, "ACC-" + id.ToString().Substring(0, 8), "New Customer"))); } @@ -299,7 +312,7 @@ public class Account : AggregateRoot // Create and apply the event if (source != null) { - RaiseEvent(MessageBuilder.From(source, () => new AmountDeposited(Id, amount))); + RaiseEvent(MessageBuilder.From(source).Build(() => new AmountDeposited(Id, amount))); } else { @@ -323,7 +336,7 @@ public class Account : AggregateRoot // Create and apply the event if (source != null) { - RaiseEvent(MessageBuilder.From(source, () => new AmountWithdrawn(Id, amount))); + RaiseEvent(MessageBuilder.From(source).Build(() => new AmountWithdrawn(Id, amount))); } else { @@ -344,7 +357,7 @@ public class Account : AggregateRoot // Create and apply the event if (source != null) { - RaiseEvent(MessageBuilder.From(source, () => new AccountClosed(Id))); + RaiseEvent(MessageBuilder.From(source).Build(() => new AccountClosed(Id))); } else { From 33309f8cc46336b0779a9d4be5425c8afbd6b267 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Sun, 11 May 2025 20:13:22 -0400 Subject: [PATCH 28/41] Update event.md to use MessageBuilder.From(source).Build() syntax instead of From(source, () => ...) --- docs/api-reference/types/event.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-reference/types/event.md b/docs/api-reference/types/event.md index e55e423d..b80fb3c6 100644 --- a/docs/api-reference/types/event.md +++ b/docs/api-reference/types/event.md @@ -137,7 +137,7 @@ public class Account : AggregateRoot public Account(Guid id, ICorrelatedMessage source) : base(id) { // Create and raise the AccountCreated event - RaiseEvent(MessageBuilder.From(source, () => new AccountCreated( + RaiseEvent(MessageBuilder.From(source).Build(() => new AccountCreated( id, "ACC-" + id.ToString().Substring(0, 8), "New Customer" @@ -159,7 +159,7 @@ public class Account : AggregateRoot throw new ArgumentException("Amount must be positive", nameof(amount)); // Create and raise the FundsDeposited event - RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited(Id, amount))); + RaiseEvent(MessageBuilder.From(source).Build(() => new FundsDeposited(Id, amount))); } // Event handler for FundsDeposited From 6872ea6b7f724037faed10f4bd53e891b5a0dcec Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 05:26:38 -0400 Subject: [PATCH 29/41] Update event handling documentation to match best practices --- docs/api-reference/types/event.md | 465 ++++++++++++++++++++++--- docs/documentation-update-checklist.md | 92 +++++ 2 files changed, 510 insertions(+), 47 deletions(-) create mode 100644 docs/documentation-update-checklist.md diff --git a/docs/api-reference/types/event.md b/docs/api-reference/types/event.md index b80fb3c6..7b90b933 100644 --- a/docs/api-reference/types/event.md +++ b/docs/api-reference/types/event.md @@ -2,7 +2,7 @@ [← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) -`Event` is a base class in Reactive Domain that implements the `ICorrelatedMessage` interface and serves as the foundation for all event messages in the system. Events represent facts that have occurred in the domain and are a critical component of event-sourced systems. +`Event` is a base class in Reactive Domain that implements the `ICorrelatedMessage` interface and serves as the foundation for all event messages in the system. Events represent immutable facts that have occurred in the domain and are a critical component of event-sourced systems. ## Overview @@ -39,19 +39,76 @@ public abstract class Event : IEvent, ICorrelatedMessage - **Correlation Tracking**: Implements `ICorrelatedMessage` for tracking related messages across the system - **Immutability**: Ensures events are immutable after creation, preserving the historical record - **Type Safety**: Provides a type-safe base for all event implementations in the domain +- **Serialization**: Designed to be easily serializable for storage in event stores ## Usage ### Defining an Event -To create a new event type, inherit from the `Event` base class: +There are two recommended patterns for defining events in Reactive Domain: + +#### Pattern 1: Using Factory Methods with MessageBuilder (Recommended) + +This pattern uses a private constructor and a static factory method to ensure proper correlation: + +```csharp +public class AccountCreated : Event, ICorrelatedMessage +{ + public Guid AccountId { get; } + public string AccountNumber { get; } + public string CustomerName { get; } + + // Correlation properties (explicitly implementing ICorrelatedMessage) + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Factory method using MessageBuilder + public static AccountCreated Create( + Guid accountId, + string accountNumber, + string customerName, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new AccountCreated( + accountId, + accountNumber, + customerName, + Guid.NewGuid(), + source.CorrelationId, + source.MsgId)); + } + + // Private constructor ensures events are created through the factory method + private AccountCreated( + Guid accountId, + string accountNumber, + string customerName, + Guid msgId, + Guid correlationId, + Guid causationId) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; + } +} +``` + +#### Pattern 2: Using Base Class Constructors + +This is a simpler pattern that relies on the base class for correlation handling: ```csharp public class AccountCreated : Event { - public readonly Guid AccountId; - public readonly string AccountNumber; - public readonly string CustomerName; + public Guid AccountId { get; } + public string AccountNumber { get; } + public string CustomerName { get; } // Constructor for new events (starts a new correlation chain) public AccountCreated(Guid accountId, string accountNumber, string customerName) @@ -76,17 +133,62 @@ public class AccountCreated : Event ### Using MessageBuilder with Events -It's recommended to use the `MessageBuilder` factory to create events with proper correlation information: +The `MessageBuilder` factory is the recommended way to create events with proper correlation information. There are two main approaches: + +#### Approach 1: Using MessageBuilder in Aggregates (Recommended) + +When raising events from within an aggregate: ```csharp -// Create an event from a command (maintains correlation chain) -ICorrelatedMessage command = // ... existing command -var createdEvent = MessageBuilder.From(command, () => new AccountCreated( - Guid.NewGuid(), - "ACC-123", - "John Doe" -)); +// Inside an aggregate method +public void CreateAccount(string accountNumber, string customerName, ICorrelatedMessage source) +{ + // Validate business rules + if (string.IsNullOrEmpty(accountNumber)) + throw new ArgumentException("Account number is required", nameof(accountNumber)); + + if (string.IsNullOrEmpty(customerName)) + throw new ArgumentException("Customer name is required", nameof(customerName)); + + // Raise the event using MessageBuilder + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated( + Id, + accountNumber, + customerName, + Guid.NewGuid(), + source.CorrelationId, + source.MsgId + ))); +} +``` + +#### Approach 2: Using Factory Methods + +When using the factory method pattern: + +```csharp +// In a command handler or service +public void HandleCreateAccount(CreateAccount command) +{ + // Create the event using the factory method + var accountCreatedEvent = AccountCreated.Create( + Guid.NewGuid(), + $"ACC-{Guid.NewGuid().ToString().Substring(0, 8)}", + command.CustomerName, + command // Source message for correlation + ); + + // Use the event + _eventStore.Save(accountCreatedEvent); + _eventBus.Publish(accountCreatedEvent); +} +``` + +#### Starting a New Correlation Chain +When you need to start a new correlation chain: + +```csharp // Create a new event (starts a new correlation chain) var newEvent = MessageBuilder.New(() => new AccountCreated( Guid.NewGuid(), @@ -97,14 +199,18 @@ var newEvent = MessageBuilder.New(() => new AccountCreated( ### Handling Events -Events are typically handled by event handlers: +Events are typically handled by event handlers which implement the `IEventHandler` interface. There are several common patterns for event handling: + +#### Pattern 1: Projection Event Handlers + +These handlers update read models or projections: ```csharp -public class AccountCreatedHandler : IEventHandler +public class AccountSummaryProjection : IEventHandler, IEventHandler { private readonly IReadModelRepository _repository; - public AccountCreatedHandler(IReadModelRepository repository) + public AccountSummaryProjection(IReadModelRepository repository) { _repository = repository; } @@ -115,6 +221,113 @@ public class AccountCreatedHandler : IEventHandler accountSummary.Update(@event.AccountNumber, @event.CustomerName, 0); _repository.Save(accountSummary); } + + public void Handle(FundsDeposited @event) + { + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary != null) + { + accountSummary.UpdateBalance(accountSummary.Balance + @event.Amount); + _repository.Save(accountSummary); + } + } +} +``` + +#### Pattern 2: Integration Event Handlers + +These handlers integrate with external systems: + +```csharp +public class AccountCreatedNotificationHandler : IEventHandler +{ + private readonly INotificationService _notificationService; + private readonly ILogger _logger; + + public AccountCreatedNotificationHandler( + INotificationService notificationService, + ILogger logger) + { + _notificationService = notificationService; + _logger = logger; + } + + public void Handle(AccountCreated @event) + { + try + { + _notificationService.SendWelcomeNotification( + @event.AccountId, + @event.CustomerName, + @event.AccountNumber); + + _logger.LogInformation( + "Welcome notification sent for account {AccountId}", + @event.AccountId); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to send welcome notification for account {AccountId}", + @event.AccountId); + + // Consider retry or compensation strategies + } + } +} +``` + +#### Pattern 3: Process Manager Event Handlers + +These handlers coordinate complex workflows across multiple aggregates: + +```csharp +public class AccountOpeningProcessManager : + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + private readonly IProcessManagerRepository _repository; + + public AccountOpeningProcessManager( + ICommandBus commandBus, + IProcessManagerRepository repository) + { + _commandBus = commandBus; + _repository = repository; + } + + public void Handle(AccountCreated @event) + { + // Create or update process state + var process = new AccountOpeningProcess(@event.AccountId); + process.AccountCreated(); + _repository.Save(process); + + // Send next command in the process + _commandBus.Send(SendWelcomePackage.Create( + @event.AccountId, + @event.CustomerName, + @event.AccountNumber, + @event)); // Pass event for correlation + } + + public void Handle(WelcomePackageSent @event) + { + // Update process state + var process = _repository.GetById(@event.AccountId); + process.WelcomePackageSent(); + _repository.Save(process); + + // Continue the process if needed + if (process.IsReadyForActivation) + { + _commandBus.Send(ActivateAccount.Create( + @event.AccountId, + @event)); // Pass event for correlation + } + } } ``` @@ -126,76 +339,234 @@ Events are produced by aggregates in response to commands using the `RaiseEvent( 2. The event is recorded for persistence in the event store 3. Once persisted, the event can be published to event handlers and projections +### Recommended Aggregate Pattern + ```csharp public class Account : AggregateRoot { private string _accountNumber; private string _customerName; private decimal _balance; + private bool _isActive; - // Constructor for creating a new account - public Account(Guid id, ICorrelatedMessage source) : base(id) + // Constructor for creating a new aggregate + public Account(Guid id) : base(id) { - // Create and raise the AccountCreated event - RaiseEvent(MessageBuilder.From(source).Build(() => new AccountCreated( - id, - "ACC-" + id.ToString().Substring(0, 8), - "New Customer" - ))); + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); } - // Event handler for AccountCreated - private void Apply(AccountCreated @event) + // Constructor for creating a new account with correlation + public Account(Guid id, ICorrelatedMessage source) : base(id, source) { - _accountNumber = @event.AccountNumber; - _customerName = @event.CustomerName; - _balance = 0; + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + + // Initialize the aggregate by raising an event + RaiseEvent(AccountCreated.Create( + id, + $"ACC-{id.ToString().Substring(0, 8)}", + "New Customer", + source + )); } // Command handler for deposit public void Deposit(decimal amount, ICorrelatedMessage source) { + // Validate business rules + if (!_isActive) + throw new InvalidOperationException("Cannot deposit to an inactive account"); + if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); - // Create and raise the FundsDeposited event - RaiseEvent(MessageBuilder.From(source).Build(() => new FundsDeposited(Id, amount))); + // Raise the event + RaiseEvent(FundsDeposited.Create(Id, amount, source)); + } + + // Command handler for withdrawal + public void Withdraw(decimal amount, ICorrelatedMessage source) + { + // Validate business rules + if (!_isActive) + throw new InvalidOperationException("Cannot withdraw from an inactive account"); + + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + + if (_balance < amount) + throw new InsufficientFundsException($"Insufficient funds. Available: {_balance}, Requested: {amount}"); + + // Raise the event + RaiseEvent(FundsWithdrawn.Create(Id, amount, source)); + } + + // Command handler for closing account + public void Close(ICorrelatedMessage source) + { + // Validate business rules + if (!_isActive) + throw new InvalidOperationException("Account is already closed"); + + // Raise the event + RaiseEvent(AccountClosed.Create(Id, source)); + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + _balance = 0; + _isActive = true; } - // Event handler for FundsDeposited private void Apply(FundsDeposited @event) { _balance += @event.Amount; } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } + + private void Apply(AccountClosed @event) + { + _isActive = false; + } + + // Custom exception + public class InsufficientFundsException : Exception + { + public InsufficientFundsException(string message) : base(message) { } + } } ``` ## Event Sourcing -Events are the foundation of event sourcing, where the state of an aggregate is reconstructed by replaying events. This process, often called rehydration, applies each event in sequence to rebuild the aggregate's state: +Events are the foundation of event sourcing, where the state of an aggregate is reconstructed by replaying events. This process, often called rehydration, applies each event in sequence to rebuild the aggregate's state. + +### Event Registration and Dispatch + +In Reactive Domain, the `EventDrivenStateMachine` base class (which `AggregateRoot` inherits from) handles event registration and dispatch: ```csharp -public void LoadFromHistory(IEnumerable history) +// In the aggregate constructor +public Account(Guid id) : base(id) { - foreach (var @event in history) + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); +} +``` + +This registration approach is more efficient than using reflection at runtime, as it builds a dispatch dictionary during initialization. + +### Loading from History + +When loading an aggregate from the event store, the repository typically calls: + +```csharp +// Inside the repository implementation +public T GetById(Guid id) where T : AggregateRoot, new() +{ + // Create a new instance of the aggregate + var aggregate = new T(); + + // Load events from the event store + var events = _eventStore.GetEvents(GetStreamName(typeof(T), id)); + + // Restore the aggregate state by replaying events + aggregate.RestoreFromEvents(events); + + return aggregate; +} +``` + +The `RestoreFromEvents` method in the `EventDrivenStateMachine` base class handles applying each event to rebuild the aggregate state: + +```csharp +// In EventDrivenStateMachine +public void RestoreFromEvents(IEnumerable events) +{ + foreach (var @event in events) { - Dispatch(@event); + // Apply the event to update state + ApplyEvent(@event); + + // Update the version + Version++; } } -private void Dispatch(IEvent @event) +private void ApplyEvent(IEvent @event) { - // Use reflection or a dictionary to find the appropriate Apply method - // This is a simplified example - var method = GetType().GetMethod("Apply", - BindingFlags.NonPublic | BindingFlags.Instance, - null, - new[] { @event.GetType() }, - null); - - if (method != null) + // Look up the handler in the registration dictionary + if (_eventHandlers.TryGetValue(@event.GetType(), out var handler)) + { + // Invoke the handler + handler.Invoke(this, new object[] { @event }); + } + else + { + throw new InvalidOperationException($"No handler registered for event type {@event.GetType().Name}"); + } +} +``` + +### Event Versioning and Evolution + +As your system evolves, you may need to handle different versions of events. Reactive Domain supports this through explicit event versioning: + +```csharp +// Original event +public class FundsDeposited : Event { /* ... */ } + +// New version with additional fields +public class FundsDepositedV2 : Event +{ + public string Currency { get; } + // Other properties... + + // Factory method + public static FundsDepositedV2 Create(/* ... */) { /* ... */ } +} + +// In the aggregate +public Account(Guid id) : base(id) +{ + // Register handlers for both versions + Register(ApplyV1); + Register(ApplyV2); +} + +private void ApplyV1(FundsDeposited @event) +{ + // Handle original version + _balance += @event.Amount; +} + +private void ApplyV2(FundsDepositedV2 @event) +{ + // Handle new version with currency + if (@event.Currency == "USD") + { + _balance += @event.Amount; + } + else { - method.Invoke(this, new object[] { @event }); + // Convert currency if needed + _balance += _currencyConverter.Convert(@event.Amount, @event.Currency, "USD"); } } ``` diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md new file mode 100644 index 00000000..e055719a --- /dev/null +++ b/docs/documentation-update-checklist.md @@ -0,0 +1,92 @@ +# To-Do Checklist for Correcting Reactive Domain Documentation + +## 1. Review and Update Event Handling +- [x] Compare how PowerModels implements event handling with current documentation +- [x] Update event registration patterns if they differ from documentation +- [x] Verify correct usage of `MessageBuilder` for event creation +- [x] Ensure event correlation and causation tracking examples are accurate +- [x] Review event naming conventions (past tense) and implementation + +## 2. Command Processing +- [ ] Verify command handling patterns used in PowerModels +- [ ] Update command validation examples if needed +- [ ] Check command naming conventions (imperative form) +- [ ] Ensure command correlation examples match actual usage +- [ ] Review command handler implementation patterns + +## 3. Aggregate Implementation +- [ ] Compare aggregate initialization patterns with PowerModels +- [ ] Verify how state is maintained in aggregates +- [ ] Check event application methods (`Apply` methods) +- [ ] Update aggregate root inheritance and usage patterns +- [ ] Verify event registration in constructor vs. other approaches + +## 4. Repository Usage +- [ ] Verify repository patterns used in PowerModels +- [ ] Update examples of loading and saving aggregates +- [ ] Check optimistic concurrency control implementation +- [ ] Ensure correlation tracking in repositories is correctly documented +- [ ] Update stream naming conventions if different + +## 5. CQRS Implementation +- [ ] Verify separation of command and query models +- [ ] Update projection examples based on actual usage +- [ ] Check read model implementation patterns +- [ ] Ensure query handling examples match actual usage +- [ ] Verify event subscription mechanisms + +## 6. Event Sourcing Patterns +- [ ] Update event replay and state reconstruction examples +- [ ] Verify snapshot implementation if used +- [ ] Check versioning strategies for events +- [ ] Update stream management examples +- [ ] Verify event serialization approaches + +## 7. Saga/Process Manager Implementation +- [ ] Compare saga implementation with PowerModels examples +- [ ] Update saga state management documentation +- [ ] Verify saga event handling patterns +- [ ] Check saga persistence mechanisms +- [ ] Update saga correlation tracking examples + +## 8. Error Handling and Recovery +- [ ] Verify error handling patterns in PowerModels +- [ ] Update exception handling examples +- [ ] Check retry strategies +- [ ] Verify compensation patterns for failed operations +- [ ] Update error logging examples + +## 9. Testing Approaches +- [ ] Review testing patterns used in PowerModels +- [ ] Update unit testing examples for aggregates +- [ ] Check integration testing approaches +- [ ] Verify event testing methodologies +- [ ] Update test fixture examples + +## 10. Infrastructure Setup +- [ ] Verify EventStoreDB connection setup +- [ ] Update dependency injection examples +- [ ] Check message bus configuration +- [ ] Verify serialization configuration +- [ ] Update deployment examples + +## 11. Performance Considerations +- [ ] Review any performance optimizations in PowerModels +- [ ] Update documentation on handling large event streams +- [ ] Check snapshot strategies for performance +- [ ] Verify read model optimization techniques +- [ ] Update caching strategies if used + +## 12. Code Examples +- [ ] Update all code examples to match actual usage patterns +- [ ] Ensure consistency in naming and patterns across examples +- [ ] Add more real-world examples based on PowerModels +- [ ] Remove any examples that don't match actual usage +- [ ] Verify that all examples compile and work correctly + +## 13. Documentation Structure +- [ ] Reorganize documentation to better reflect actual usage +- [ ] Ensure consistent terminology throughout +- [ ] Add more diagrams to illustrate actual patterns +- [ ] Create a "best practices" section based on PowerModels +- [ ] Update quickstart guide to match actual implementation patterns From ad7750ef84ddb06f4bd17a0f3b4151e06bc14d26 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 05:35:57 -0400 Subject: [PATCH 30/41] Update command processing documentation to match best practices --- docs/api-reference/types/command.md | 877 +++++++++++++++++++++---- docs/documentation-update-checklist.md | 10 +- 2 files changed, 765 insertions(+), 122 deletions(-) diff --git a/docs/api-reference/types/command.md b/docs/api-reference/types/command.md index a9fc2816..2b69e6b7 100644 --- a/docs/api-reference/types/command.md +++ b/docs/api-reference/types/command.md @@ -10,6 +10,14 @@ Commands in Reactive Domain represent requests for the system to perform an acti In the Command Query Responsibility Segregation (CQRS) pattern, commands represent intentions to change the system state. Unlike events, which represent facts that have occurred, commands can be rejected if they violate business rules or if the system is in an inappropriate state to handle them. When a command is processed successfully, it typically results in one or more events being raised via the `RaiseEvent()` method in the aggregate. +Commands in Reactive Domain follow these key principles: + +1. **Single Responsibility**: Each command represents a single action or intention +2. **Immutability**: Commands are immutable after creation +3. **Validation**: Commands are validated before they are processed +4. **Explicit Intent**: Command names clearly communicate their purpose +5. **Correlation**: Commands maintain correlation and causation information for tracing + ## Class Definition ```csharp @@ -54,15 +62,20 @@ public abstract class Command : ICommand, ICorrelatedMessage } ``` +The `Command` base class provides the foundation for all command messages in Reactive Domain. It implements the `ICommand` and `ICorrelatedMessage` interfaces, which define the core contract for commands in the system. + +> **Note**: In most cases, you should not call these constructors directly. Instead, use the `MessageBuilder` class to create commands with proper correlation tracking. + ## Key Features - **Message Identity**: Provides a unique `MsgId` for each command -- **Correlation Tracking**: Implements `ICorrelatedMessage` for tracking related messages -- **Immutability**: Ensures commands are immutable after creation -- **Type Safety**: Provides a type-safe base for all command implementations +- **Correlation Tracking**: Implements `ICorrelatedMessage` for tracking related messages across system boundaries +- **Immutability**: Ensures commands are immutable after creation, preventing unexpected changes +- **Type Safety**: Provides a type-safe base for all command implementations in the domain - **Intent Communication**: Clearly communicates the intention to change system state - **Validation Support**: Facilitates validation before state changes occur - **Audit Trail**: Contributes to a complete audit trail when combined with events +- **Serialization**: Designed to be easily serializable for transport across process boundaries ## Command Types @@ -73,22 +86,83 @@ In Reactive Domain, commands typically fall into several categories: Commands that create new entities in the system: ```csharp -public class CreateCustomer : Command +public class CreateCustomer : Command, ICorrelatedMessage { - public readonly Guid CustomerId; - public readonly string FirstName; - public readonly string LastName; - public readonly string Email; - public readonly DateTime DateOfBirth; + // Identity properties + public Guid CustomerId { get; } - public CreateCustomer(Guid customerId, string firstName, string lastName, string email, DateTime dateOfBirth) - : base() + // Data properties + public string FirstName { get; } + public string LastName { get; } + public string Email { get; } + public DateTime DateOfBirth { get; } + + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Factory method for creating new commands with MessageBuilder + public static CreateCustomer Create( + Guid customerId, + string firstName, + string lastName, + string email, + DateTime dateOfBirth, + ICorrelatedMessage source = null) { + if (source != null) + { + return MessageBuilder.From(source, () => new CreateCustomer( + customerId, firstName, lastName, email, dateOfBirth, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + else + { + return MessageBuilder.New(() => new CreateCustomer( + customerId, firstName, lastName, email, dateOfBirth, + Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid())); + } + } + + // Private constructor for MessageBuilder + private CreateCustomer( + Guid customerId, + string firstName, + string lastName, + string email, + DateTime dateOfBirth, + Guid msgId, + Guid correlationId, + Guid causationId) + { + // Validate parameters + if (customerId == Guid.Empty) + throw new ArgumentException("Customer ID cannot be empty", nameof(customerId)); + + if (string.IsNullOrWhiteSpace(firstName)) + throw new ArgumentException("First name is required", nameof(firstName)); + + if (string.IsNullOrWhiteSpace(lastName)) + throw new ArgumentException("Last name is required", nameof(lastName)); + + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email is required", nameof(email)); + + if (dateOfBirth == default) + throw new ArgumentException("Date of birth is required", nameof(dateOfBirth)); + + // Set properties CustomerId = customerId; FirstName = firstName; LastName = lastName; Email = email; DateOfBirth = dateOfBirth; + + // Set correlation properties + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; } } ``` @@ -100,17 +174,54 @@ Commands that modify existing entities: ```csharp public class ChangeCustomerAddress : Command { - public readonly Guid CustomerId; - public readonly string StreetAddress; - public readonly string City; - public readonly string State; - public readonly string PostalCode; - public readonly string Country; - - public ChangeCustomerAddress(Guid customerId, string streetAddress, string city, + // Identity properties + public Guid CustomerId { get; } + + // Data properties + public string StreetAddress { get; } + public string City { get; } + public string State { get; } + public string PostalCode { get; } + public string Country { get; } + + // Factory method + public static ChangeCustomerAddress Create( + Guid customerId, + string streetAddress, + string city, + string state, + string postalCode, + string country, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new ChangeCustomerAddress( + customerId, streetAddress, city, state, postalCode, country)); + } + + // Constructor for MessageBuilder + private ChangeCustomerAddress(Guid customerId, string streetAddress, string city, string state, string postalCode, string country) - : base() { + // Validate parameters + if (customerId == Guid.Empty) + throw new ArgumentException("Customer ID cannot be empty", nameof(customerId)); + + if (string.IsNullOrWhiteSpace(streetAddress)) + throw new ArgumentException("Street address is required", nameof(streetAddress)); + + if (string.IsNullOrWhiteSpace(city)) + throw new ArgumentException("City is required", nameof(city)); + + if (string.IsNullOrWhiteSpace(state)) + throw new ArgumentException("State is required", nameof(state)); + + if (string.IsNullOrWhiteSpace(postalCode)) + throw new ArgumentException("Postal code is required", nameof(postalCode)); + + if (string.IsNullOrWhiteSpace(country)) + throw new ArgumentException("Country is required", nameof(country)); + + // Set properties CustomerId = customerId; StreetAddress = streetAddress; City = city; @@ -128,12 +239,29 @@ Commands that delete or deactivate entities: ```csharp public class DeactivateCustomer : Command { - public readonly Guid CustomerId; - public readonly string Reason; + // Identity properties + public Guid CustomerId { get; } - public DeactivateCustomer(Guid customerId, string reason) - : base() + // Data properties + public string Reason { get; } + + // Factory method + public static DeactivateCustomer Create(Guid customerId, string reason, ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new DeactivateCustomer(customerId, reason)); + } + + // Constructor for MessageBuilder + private DeactivateCustomer(Guid customerId, string reason) { + // Validate parameters + if (customerId == Guid.Empty) + throw new ArgumentException("Customer ID cannot be empty", nameof(customerId)); + + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Reason is required", nameof(reason)); + + // Set properties CustomerId = customerId; Reason = reason; } @@ -147,17 +275,54 @@ Commands that trigger business processes: ```csharp public class PlaceOrder : Command { - public readonly Guid OrderId; - public readonly Guid CustomerId; - public readonly IReadOnlyList Items; - public readonly string ShippingAddress; - public readonly string BillingAddress; - public readonly PaymentMethod PaymentMethod; - - public PlaceOrder(Guid orderId, Guid customerId, IReadOnlyList items, + // Identity properties + public Guid OrderId { get; } + public Guid CustomerId { get; } + + // Data properties + public IReadOnlyList Items { get; } + public string ShippingAddress { get; } + public string BillingAddress { get; } + public PaymentMethod PaymentMethod { get; } + + // Factory method + public static PlaceOrder Create( + Guid orderId, + Guid customerId, + IReadOnlyList items, + string shippingAddress, + string billingAddress, + PaymentMethod paymentMethod, + ICorrelatedMessage source) + { + return MessageBuilder.From(source, () => new PlaceOrder( + orderId, customerId, items, shippingAddress, billingAddress, paymentMethod)); + } + + // Constructor for MessageBuilder + private PlaceOrder(Guid orderId, Guid customerId, IReadOnlyList items, string shippingAddress, string billingAddress, PaymentMethod paymentMethod) - : base() { + // Validate parameters + if (orderId == Guid.Empty) + throw new ArgumentException("Order ID cannot be empty", nameof(orderId)); + + if (customerId == Guid.Empty) + throw new ArgumentException("Customer ID cannot be empty", nameof(customerId)); + + if (items == null || !items.Any()) + throw new ArgumentException("Order must contain at least one item", nameof(items)); + + if (string.IsNullOrWhiteSpace(shippingAddress)) + throw new ArgumentException("Shipping address is required", nameof(shippingAddress)); + + if (string.IsNullOrWhiteSpace(billingAddress)) + throw new ArgumentException("Billing address is required", nameof(billingAddress)); + + if (paymentMethod == null) + throw new ArgumentException("Payment method is required", nameof(paymentMethod)); + + // Set properties OrderId = orderId; CustomerId = customerId; Items = items; @@ -172,65 +337,88 @@ public class PlaceOrder : Command ### Defining a Command -To create a new command type, inherit from the `Command` base class: +There are two recommended patterns for defining commands in Reactive Domain: + +#### Pattern 1: Using Factory Methods with MessageBuilder (Recommended) + +This pattern uses a private constructor and static factory methods to ensure proper correlation: ```csharp -public class CreateAccount : Command +public class CreateAccount : Command, ICorrelatedMessage { - public readonly Guid AccountId; - public readonly string AccountNumber; - public readonly string CustomerName; - public readonly decimal InitialDeposit; - public readonly AccountType AccountType; + // Identity properties + public Guid AccountId { get; } + + // Data properties + public string AccountNumber { get; } + public string CustomerName { get; } + public decimal InitialDeposit { get; } + public AccountType AccountType { get; } - // Simple constructor for command properties - public CreateAccount(Guid accountId, string accountNumber, string customerName, - decimal initialDeposit, AccountType accountType) - : base() // Default constructor creates new correlation IDs + // Correlation properties + public Guid MsgId { get; } + public Guid CorrelationId { get; } + public Guid CausationId { get; } + + // Factory method for creating a new command + public static CreateAccount Create( + Guid accountId, + string accountNumber, + string customerName, + decimal initialDeposit, + AccountType accountType, + ICorrelatedMessage source = null) { - AccountId = accountId; - AccountNumber = accountNumber; - CustomerName = customerName; - InitialDeposit = initialDeposit; - AccountType = accountType; + // Validate business rules + if (accountId == Guid.Empty) + throw new ArgumentException("Account ID cannot be empty", nameof(accountId)); + + if (string.IsNullOrWhiteSpace(accountNumber)) + throw new ArgumentException("Account number is required", nameof(accountNumber)); + + if (string.IsNullOrWhiteSpace(customerName)) + throw new ArgumentException("Customer name is required", nameof(customerName)); + + if (initialDeposit < 0) + throw new ArgumentException("Initial deposit cannot be negative", nameof(initialDeposit)); + + // Create the command with proper correlation + if (source != null) + { + return MessageBuilder.From(source, () => new CreateAccount( + accountId, accountNumber, customerName, initialDeposit, accountType, + Guid.NewGuid(), source.CorrelationId, source.MsgId)); + } + else + { + return MessageBuilder.New(() => new CreateAccount( + accountId, accountNumber, customerName, initialDeposit, accountType, + Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid())); + } } - // IMPORTANT: Do not create constructors that take correlation IDs directly - // Instead, use MessageBuilder to create correlated commands. - // - // MessageBuilder will automatically set the correlation IDs as follows: - // 1. For MessageBuilder.New(): - // - MsgId = new Guid() // A new unique ID - // - CorrelationId = MsgId // Same as MsgId - // - CausationId = MsgId // Same as MsgId - // - // 2. For MessageBuilder.From(source, ...): - // - MsgId = new Guid() // A new unique ID - // - CorrelationId = source.CorrelationId // Copied from source - // - CausationId = source.MsgId // Set to source message ID - // - // Example usage: - // ICorrelatedMessage sourceCommand = ... - // var newCommand = MessageBuilder.From(sourceCommand).Build(() => - // new CreateAccount(accountId, accountNumber, customerName, initialDeposit, accountType)); - // Private constructor for MessageBuilder - // This is used internally by MessageBuilder and should not be called directly - private CreateAccount(Guid accountId, string accountNumber, string customerName, - decimal initialDeposit, AccountType accountType, - Guid msgId, Guid correlationId, Guid causationId) + private CreateAccount( + Guid accountId, + string accountNumber, + string customerName, + decimal initialDeposit, + AccountType accountType, + Guid msgId, + Guid correlationId, + Guid causationId) { - // Properties set automatically by MessageBuilder - MsgId = msgId; - CorrelationId = correlationId; - CausationId = causationId; - - // Command-specific properties + // Set properties AccountId = accountId; AccountNumber = accountNumber; CustomerName = customerName; InitialDeposit = initialDeposit; AccountType = accountType; + + // Set correlation properties + MsgId = msgId; + CorrelationId = correlationId; + CausationId = causationId; } } @@ -243,6 +431,80 @@ public enum AccountType } ``` +#### Pattern 2: Using MessageBuilder Directly + +This pattern is simpler but requires using MessageBuilder at the call site: + +```csharp +public class CreateAccount : Command +{ + // Identity properties + public Guid AccountId { get; } + + // Data properties + public string AccountNumber { get; } + public string CustomerName { get; } + public decimal InitialDeposit { get; } + public AccountType AccountType { get; } + + // Constructor for MessageBuilder + public CreateAccount( + Guid accountId, + string accountNumber, + string customerName, + decimal initialDeposit, + AccountType accountType) + { + // Validate business rules + if (accountId == Guid.Empty) + throw new ArgumentException("Account ID cannot be empty", nameof(accountId)); + + if (string.IsNullOrWhiteSpace(accountNumber)) + throw new ArgumentException("Account number is required", nameof(accountNumber)); + + if (string.IsNullOrWhiteSpace(customerName)) + throw new ArgumentException("Customer name is required", nameof(customerName)); + + if (initialDeposit < 0) + throw new ArgumentException("Initial deposit cannot be negative", nameof(initialDeposit)); + + // Set properties + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + InitialDeposit = initialDeposit; + AccountType = accountType; + } +} + +// Usage with MessageBuilder: +// Starting a new correlation chain +var createCommand = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "ACC-123456", + "John Doe", + 1000.00m, + AccountType.Checking +)); + +// Continuing an existing correlation chain +var depositCommand = MessageBuilder.From(createCommand).Build(() => new DepositFunds( + ((CreateAccount)createCommand).AccountId, + 500.00m, + "Initial deposit" +)); +``` + +### Command Design Best Practices + +1. **Use Properties Instead of Fields**: Use properties with getters only to ensure immutability +2. **Validate in Constructor**: Perform validation in the constructor to ensure commands are always valid +3. **Use Factory Methods**: Provide static factory methods to create commands with proper correlation +4. **Hide Correlation Details**: Keep correlation IDs internal to the command implementation +5. **Include Identity Properties**: Always include identity properties to identify the target aggregate +6. **Use Meaningful Names**: Name commands clearly to express their intent (e.g., `CreateAccount`, `DepositFunds`) +7. **Keep Commands Focused**: Each command should represent a single action or intention + ### Creating Correlated Commands > **Important**: Always use `MessageBuilder` to create correlated commands. Do not manually set correlation and causation IDs by calling constructors directly. @@ -302,38 +564,87 @@ var setOverdraftCommand = MessageBuilder.From(createCommand).Build(() => new Set ### Command Validation -Commands should be validated before they are processed. This can be done in the command handler or using a validation framework: +Command validation is a critical aspect of maintaining system integrity. There are three common approaches to validation in Reactive Domain: + +#### 1. Self-Validation in Commands + +Commands can validate their own parameters in the constructor or factory method: + +```csharp +public static CreateAccount Create( + Guid accountId, + string accountNumber, + string customerName, + decimal initialDeposit, + AccountType accountType, + ICorrelatedMessage source = null) +{ + // Validate business rules + if (accountId == Guid.Empty) + throw new ArgumentException("Account ID cannot be empty", nameof(accountId)); + + if (string.IsNullOrWhiteSpace(accountNumber)) + throw new ArgumentException("Account number is required", nameof(accountNumber)); + + if (!Regex.IsMatch(accountNumber, @"^ACC-\d{3,6}$")) + throw new ArgumentException("Account number must be in the format ACC-XXXXXX", nameof(accountNumber)); + + if (string.IsNullOrWhiteSpace(customerName)) + throw new ArgumentException("Customer name is required", nameof(customerName)); + + if (initialDeposit < 0) + throw new ArgumentException("Initial deposit cannot be negative", nameof(initialDeposit)); + + if (accountType == AccountType.Savings && initialDeposit < 100) + throw new ArgumentException("Savings accounts require a minimum initial deposit of $100", nameof(initialDeposit)); + + // Create the command with proper correlation + // ... +} +``` + +#### 2. External Validators + +Separate validator classes can be used for more complex validation rules, especially those requiring external dependencies: ```csharp public class CreateAccountValidator : ICommandValidator { private readonly ICustomerRepository _customerRepository; + private readonly IAccountNumberValidator _accountNumberValidator; - public CreateAccountValidator(ICustomerRepository customerRepository) + public CreateAccountValidator( + ICustomerRepository customerRepository, + IAccountNumberValidator accountNumberValidator) { _customerRepository = customerRepository; + _accountNumberValidator = accountNumberValidator; } public ValidationResult Validate(CreateAccount command) { var result = new ValidationResult(); - // Validate account number format - if (!Regex.IsMatch(command.AccountNumber, @"^ACC-\d{3,6}$")) + // Validate account number format and uniqueness + if (!_accountNumberValidator.IsValid(command.AccountNumber)) { - result.AddError("AccountNumber", "Account number must be in the format ACC-XXXXXX"); + result.AddError("AccountNumber", "Invalid account number format"); } - // Validate initial deposit - if (command.InitialDeposit < 0) + if (_accountNumberValidator.IsDuplicate(command.AccountNumber)) { - result.AddError("InitialDeposit", "Initial deposit cannot be negative"); + result.AddError("AccountNumber", "Account number already exists"); } + // Validate initial deposit based on account type if (command.AccountType == AccountType.Savings && command.InitialDeposit < 100) { result.AddError("InitialDeposit", "Savings accounts require a minimum initial deposit of $100"); } + else if (command.AccountType == AccountType.MoneyMarket && command.InitialDeposit < 1000) + { + result.AddError("InitialDeposit", "Money market accounts require a minimum initial deposit of $1,000"); + } // Validate customer exists if (!_customerRepository.Exists(command.CustomerName)) @@ -346,21 +657,68 @@ public class CreateAccountValidator : ICommandValidator } ``` +#### 3. Aggregate-Level Validation + +Aggregates can perform domain-specific validation when handling commands: + +```csharp +public void Withdraw(decimal amount, ICorrelatedMessage source) +{ + // Validate state and parameters + if (!_isActive) + throw new InvalidOperationException("Cannot withdraw from an inactive account"); + + if (amount <= 0) + throw new ArgumentException("Withdrawal amount must be positive", nameof(amount)); + + if (_balance + _overdraftLimit < amount) + throw new InsufficientFundsException($"Insufficient funds. Balance: {_balance}, Overdraft Limit: {_overdraftLimit}"); + + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new FundsWithdrawn( + Id, + amount, + _balance - amount, + DateTime.UtcNow + ))); +} +``` + +#### Recommended Validation Strategy + +For a robust validation approach, combine these strategies: + +1. **Command Self-Validation**: Validate basic parameter constraints in the command itself +2. **External Validators**: Use separate validators for complex rules requiring external dependencies +3. **Aggregate Validation**: Perform domain-specific validation in the aggregate + +This layered approach ensures that: +- Commands are always well-formed before being processed +- Complex business rules are enforced consistently +- Domain invariants are protected at the aggregate level + ### Handling Commands -Commands are typically handled by command handlers: +Commands are processed by command handlers that implement the `ICommandHandler` interface. Command handlers are responsible for: + +1. Validating the command +2. Loading or creating the appropriate aggregate +3. Calling the appropriate method on the aggregate +4. Saving the aggregate to persist any changes + +#### Standard Command Handler Pattern ```csharp public class CreateAccountHandler : ICommandHandler { private readonly ICorrelatedRepository _repository; private readonly ICommandValidator _validator; - private readonly ILogger _logger; + private readonly ILogger _logger; public CreateAccountHandler( ICorrelatedRepository repository, ICommandValidator validator, - ILogger logger) + ILogger logger) { _repository = repository; _validator = validator; @@ -369,82 +727,367 @@ public class CreateAccountHandler : ICommandHandler public void Handle(CreateAccount command) { - _logger.LogInformation($"Handling CreateAccount command for {command.CustomerName}", command); + _logger.LogInformation("Handling CreateAccount command for {CustomerName}", command.CustomerName); - // Validate the command + // 1. Validate the command var validationResult = _validator.Validate(command); if (!validationResult.IsValid) { - _logger.LogWarning($"Command validation failed: {string.Join(", ", validationResult.Errors)}", command); + _logger.LogWarning("Command validation failed: {Errors}", + string.Join(", ", validationResult.Errors)); throw new CommandValidationException(validationResult.Errors); } try { - // Create and save the aggregate + // 2. Create the aggregate var account = new Account(command.AccountId, command); - // If initial deposit is provided, perform the deposit + // 3. Apply additional commands if needed if (command.InitialDeposit > 0) { account.Deposit(command.InitialDeposit, command); } - // Save the aggregate with correlation information - _repository.Save(account, command); + // 4. Save the aggregate with correlation information + _repository.Save(account); - _logger.LogInformation($"Account {command.AccountNumber} created successfully", command); + _logger.LogInformation("Account {AccountNumber} created successfully", + command.AccountNumber); } catch (Exception ex) { - _logger.LogError($"Error creating account: {ex.Message}", command); + _logger.LogError(ex, "Error creating account: {ErrorMessage}", ex.Message); throw; } } } ``` +#### Command Handler for Existing Aggregates + +```csharp +public class DepositFundsHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly ILogger _logger; + + public DepositFundsHandler( + ICorrelatedRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public void Handle(DepositFunds command) + { + _logger.LogInformation("Handling DepositFunds command for account {AccountId}", + command.AccountId); + + try + { + // 1. Load the aggregate + var account = _repository.GetById(command.AccountId); + if (account == null) + { + throw new AggregateNotFoundException(typeof(Account), command.AccountId); + } + + // 2. Call the appropriate method on the aggregate + account.Deposit(command.Amount, command); + + // 3. Save the aggregate + _repository.Save(account); + + _logger.LogInformation("Deposited {Amount} to account {AccountId} successfully", + command.Amount, command.AccountId); + } + catch (AggregateNotFoundException ex) + { + _logger.LogWarning(ex, "Account {AccountId} not found", command.AccountId); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error depositing funds: {ErrorMessage}", ex.Message); + throw; + } + } +} +``` + +#### Command Handler with Optimistic Concurrency + +```csharp +public class WithdrawFundsHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly ILogger _logger; + private readonly int _maxRetries = 3; + + public WithdrawFundsHandler( + ICorrelatedRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public void Handle(WithdrawFunds command) + { + _logger.LogInformation("Handling WithdrawFunds command for account {AccountId}", + command.AccountId); + + int retryCount = 0; + while (true) + { + try + { + // 1. Load the aggregate + var account = _repository.GetById(command.AccountId); + if (account == null) + { + throw new AggregateNotFoundException(typeof(Account), command.AccountId); + } + + // 2. Call the appropriate method on the aggregate + account.Withdraw(command.Amount, command); + + // 3. Save the aggregate + _repository.Save(account); + + _logger.LogInformation("Withdrew {Amount} from account {AccountId} successfully", + command.Amount, command.AccountId); + + return; // Success, exit the retry loop + } + catch (AggregateVersionException ex) when (retryCount < _maxRetries) + { + // Handle optimistic concurrency conflict + retryCount++; + _logger.LogWarning(ex, "Optimistic concurrency conflict detected, retry {RetryCount}/{MaxRetries}", + retryCount, _maxRetries); + + // Add a small delay before retrying + Thread.Sleep(50 * retryCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error withdrawing funds: {ErrorMessage}", ex.Message); + throw; + } + } + } +} +``` + +#### Command Handler Best Practices + +1. **Single Responsibility**: Each command handler should handle exactly one command type +2. **Proper Error Handling**: Use try-catch blocks to handle exceptions and provide meaningful error messages +3. **Logging**: Log all important operations, including command receipt, validation results, and success/failure +4. **Optimistic Concurrency**: Implement retry logic for handling optimistic concurrency conflicts +5. **Correlation**: Maintain correlation information throughout the command handling process +6. **Validation**: Validate commands before processing them +7. **Transaction Boundaries**: Each command handler represents a single transaction boundary +``` + ### Command Bus -Commands are typically sent through a command bus, which routes them to the appropriate handlers: +The Command Bus is a central component in Reactive Domain that routes commands to their appropriate handlers. It provides a clean separation between command senders and handlers, allowing for a more decoupled architecture. + +#### Command Bus Interface + +```csharp +public interface ICommandBus +{ + void Send(TCommand command) where TCommand : class, ICommand; + Task SendAsync(TCommand command) where TCommand : class, ICommand; + TResult Send(TCommand command) where TCommand : class, ICommand; + Task SendAsync(TCommand command) where TCommand : class, ICommand; +} +``` + +#### Using the Command Bus ```csharp -public class CommandBusExample +public class AccountService { private readonly ICommandBus _commandBus; + private readonly ILogger _logger; - public CommandBusExample(ICommandBus commandBus) + public AccountService(ICommandBus commandBus, ILogger logger) { _commandBus = commandBus; + _logger = logger; } - public void SendCommands() + public Guid CreateNewAccount(string customerName, decimal initialDeposit, AccountType accountType) { - // Create a new command - var createCommand = MessageBuilder.New(() => new CreateAccount( - Guid.NewGuid(), - "ACC-12345", - "Jane Smith", - 1000.00m, - AccountType.Checking - )); + _logger.LogInformation("Creating new account for {CustomerName}", customerName); + + // Generate a new account ID + var accountId = Guid.NewGuid(); + + // Generate a unique account number + var accountNumber = $"ACC-{Guid.NewGuid().ToString().Substring(0, 6)}"; + + // Create the command using the factory method + var createCommand = CreateAccount.Create( + accountId, + accountNumber, + customerName, + initialDeposit, + accountType + ); // Send the command _commandBus.Send(createCommand); - // Create a related command - var depositCommand = MessageBuilder.From(createCommand).Build(() => new DepositFunds( - ((CreateAccount)createCommand).AccountId, - 500.00m, - "Bonus deposit" - )); + _logger.LogInformation("Account creation command sent for {AccountId}", accountId); - // Send the related command + return accountId; + } + + public void DepositFunds(Guid accountId, decimal amount, string reference) + { + _logger.LogInformation("Depositing {Amount} to account {AccountId}", amount, accountId); + + // Create the command + var depositCommand = DepositFunds.Create( + accountId, + amount, + reference, + null // No source message in this context + ); + + // Send the command _commandBus.Send(depositCommand); + + _logger.LogInformation("Deposit command sent for {AccountId}", accountId); + } + + public async Task TransferFundsAsync( + Guid sourceAccountId, + Guid targetAccountId, + decimal amount, + string reference) + { + _logger.LogInformation("Transferring {Amount} from {SourceAccountId} to {TargetAccountId}", + amount, sourceAccountId, targetAccountId); + + // Create the command + var transferCommand = TransferFunds.Create( + Guid.NewGuid(), // Transfer ID + sourceAccountId, + targetAccountId, + amount, + reference, + null // No source message in this context + ); + + // Send the command and await the result + var result = await _commandBus.SendAsync(transferCommand); + + _logger.LogInformation("Transfer completed with status {Status}", result.Status); + + return result; } } ``` +#### Command Bus Implementation + +Reactive Domain provides a default implementation of the command bus that uses dependency injection to resolve command handlers: + +```csharp +public class DefaultCommandBus : ICommandBus +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public DefaultCommandBus(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public void Send(TCommand command) where TCommand : class, ICommand + { + _logger.LogDebug("Sending command of type {CommandType}", typeof(TCommand).Name); + + var handler = ResolveHandler(); + handler.Handle(command); + } + + public async Task SendAsync(TCommand command) where TCommand : class, ICommand + { + _logger.LogDebug("Sending async command of type {CommandType}", typeof(TCommand).Name); + + var handler = ResolveHandler(); + + if (handler is IAsyncCommandHandler asyncHandler) + { + await asyncHandler.HandleAsync(command); + } + else + { + handler.Handle(command); + } + } + + // Other methods omitted for brevity + + private ICommandHandler ResolveHandler() where TCommand : class, ICommand + { + var handler = _serviceProvider.GetService>(); + + if (handler == null) + { + throw new CommandHandlerNotFoundException(typeof(TCommand)); + } + + return handler; + } +} +``` + +#### Command Bus Registration + +To use the command bus, you need to register it and all command handlers in your dependency injection container: + +```csharp +// In Startup.ConfigureServices or equivalent +public void ConfigureServices(IServiceCollection services) +{ + // Register the command bus + services.AddSingleton(); + + // Register command handlers + services.AddTransient, CreateAccountHandler>(); + services.AddTransient, DepositFundsHandler>(); + services.AddTransient, WithdrawFundsHandler>(); + services.AddTransient, TransferFundsHandler>(); + + // Register validators + services.AddTransient, CreateAccountValidator>(); + + // Register other dependencies + services.AddSingleton(); + // ... +} +``` + +#### Command Bus Benefits + +1. **Decoupling**: Separates command senders from handlers +2. **Centralized Dispatch**: Provides a single entry point for all commands +3. **Extensibility**: Easily add cross-cutting concerns like logging, validation, and authorization +4. **Testability**: Makes it easy to mock the command bus for testing +5. **Async Support**: Supports both synchronous and asynchronous command handling +``` + ## Integration with Aggregates Commands are used to modify aggregates, which then produce events. The typical flow is: diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index e055719a..857920fd 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -8,11 +8,11 @@ - [x] Review event naming conventions (past tense) and implementation ## 2. Command Processing -- [ ] Verify command handling patterns used in PowerModels -- [ ] Update command validation examples if needed -- [ ] Check command naming conventions (imperative form) -- [ ] Ensure command correlation examples match actual usage -- [ ] Review command handler implementation patterns +- [x] Verify command handling patterns used in PowerModels +- [x] Update command validation examples if needed +- [x] Check command naming conventions (imperative form) +- [x] Ensure command correlation examples match actual usage +- [x] Review command handler implementation patterns ## 3. Aggregate Implementation - [ ] Compare aggregate initialization patterns with PowerModels From b658357c7ec7e6b047dab2090e6a88ffe82ef6fa Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 05:43:23 -0400 Subject: [PATCH 31/41] Update aggregate implementation documentation to reflect best practices --- docs/api-reference/types/aggregate-root.md | 485 +++++++++++++++------ docs/documentation-update-checklist.md | 11 +- 2 files changed, 357 insertions(+), 139 deletions(-) diff --git a/docs/api-reference/types/aggregate-root.md b/docs/api-reference/types/aggregate-root.md index 5f2c6b89..e670e674 100644 --- a/docs/api-reference/types/aggregate-root.md +++ b/docs/api-reference/types/aggregate-root.md @@ -4,15 +4,29 @@ ## Overview -The `AggregateRoot` class is a base class for domain aggregates in Reactive Domain. It implements the `IEventSource` interface and provides common functionality for event sourcing. Aggregates are the central building blocks in Domain-Driven Design (DDD) and serve as the primary consistency boundary for business rules and invariants. +The `AggregateRoot` class is a base class for domain aggregates in Reactive Domain. It inherits from `EventDrivenStateMachine` and implements the `IEventSource` interface, providing comprehensive functionality for event sourcing. Aggregates are the central building blocks in Domain-Driven Design (DDD) and serve as the primary consistency boundary for business rules and invariants. -In event-sourced systems, aggregates don't store their state directly but instead derive it from a sequence of events. The `AggregateRoot` class provides the infrastructure to record, apply, and retrieve these events, making it easier to implement event-sourced aggregates. +In event-sourced systems, aggregates don't store their state directly but instead derive it from a sequence of events. The `AggregateRoot` class provides the infrastructure to: + +1. **Record Events**: Capture domain events that represent state changes +2. **Apply Events**: Update the aggregate's state based on these events +3. **Enforce Invariants**: Validate business rules before state changes +4. **Maintain Version**: Track the version for optimistic concurrency control +5. **Support Correlation**: Maintain correlation and causation chains for traceability + +Aggregates in Reactive Domain follow the Command-Event pattern, where: + +- **Commands** are requests to change the aggregate's state, which may be rejected if they violate business rules +- **Events** are facts that have occurred, representing actual state changes +- **Apply Methods** update the aggregate's state in response to events + +This pattern ensures that all state changes are explicit, traceable, and can be replayed to reconstruct the aggregate's state at any point in time. ## Constructors ### AggregateRoot(Guid) -Initializes a new instance of the `AggregateRoot` class with the specified ID. This constructor is typically used when creating a new aggregate. +Initializes a new instance of the `AggregateRoot` class with the specified ID. This constructor is typically used when creating a new aggregate or when loading an aggregate from the repository. ```csharp protected AggregateRoot(Guid id); @@ -25,10 +39,32 @@ protected AggregateRoot(Guid id); ```csharp public class Account : AggregateRoot { + private decimal _balance; + private bool _isActive; + private string _accountNumber; + public Account(Guid id) : base(id) { - // Initialize a new account - RaiseEvent(new AccountCreated(id)); + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Method to initialize a new account + public void Initialize(string accountNumber, string customerName, ICorrelatedMessage source) + { + // Enforce business rules + if (string.IsNullOrEmpty(accountNumber)) + throw new ArgumentException("Account number is required", nameof(accountNumber)); + + if (string.IsNullOrEmpty(customerName)) + throw new ArgumentException("Customer name is required", nameof(customerName)); + + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new AccountCreated( + Id, accountNumber, customerName, DateTime.UtcNow))); } } ``` @@ -49,10 +85,35 @@ protected AggregateRoot(Guid id, ICorrelatedMessage source); ```csharp public class Account : AggregateRoot { - public Account(Guid id, ICorrelatedMessage source) : base(id, source) + private decimal _balance; + private bool _isActive; + private string _accountNumber; + + // Constructor for creating a new account with correlation + public Account(Guid id, CreateAccount command) : base(id, command) { - // Initialize a new account with correlation - RaiseEvent(MessageBuilder.From(source).Build(() => new AccountCreated(id))); + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + + // Initialize the aggregate by raising an event + RaiseEvent(MessageBuilder.From(command, () => new AccountCreated( + id, + command.AccountNumber, + command.CustomerName, + DateTime.UtcNow))); + + // If initial deposit is provided, perform the deposit + if (command.InitialDeposit > 0) + { + RaiseEvent(MessageBuilder.From(command, () => new FundsDeposited( + id, + command.InitialDeposit, + "Initial deposit", + DateTime.UtcNow))); + } } } ``` @@ -135,6 +196,38 @@ public void Save(AggregateRoot aggregate) ## Methods +### Register + +Registers an event handler method for a specific event type. This method is typically called in the constructor of the aggregate to set up event handling. + +```csharp +protected void Register(Action handler); +``` + +**Parameters**: +- `handler` (`System.Action`): The method that will handle events of type `TEvent`. + +**Example**: +```csharp +public class Account : AggregateRoot +{ + public Account(Guid id) : base(id) + { + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + } + + // Event handlers + private void Apply(AccountCreated @event) { /* ... */ } + private void Apply(FundsDeposited @event) { /* ... */ } + private void Apply(FundsWithdrawn @event) { /* ... */ } + private void Apply(AccountClosed @event) { /* ... */ } +} +``` + ### RaiseEvent Raises an event, which will be recorded and applied to the aggregate. This is the primary method for creating and handling new events in an event-sourced system. When called, `RaiseEvent()` does two things: @@ -147,37 +240,37 @@ protected void RaiseEvent(object @event); ``` **Parameters**: -- `event` (`System.Object`): The event to raise. This is typically created using the `MessageBuilder` to ensure proper correlation tracking. +- `event` (`System.Object`): The event to raise. This should be created using the `MessageBuilder` to ensure proper correlation tracking. -> **Important**: The `RaiseEvent()` method does NOT automatically add correlation information to events. You must explicitly use `MessageBuilder.From(source).Build(...)` to create events with proper correlation information. Simply passing an event to `RaiseEvent()` without using MessageBuilder will result in lost correlation tracking. +> **Important**: The `RaiseEvent()` method does NOT automatically add correlation information to events. You must explicitly use `MessageBuilder.From(source, () => new Event(...))` to create events with proper correlation information. Simply passing an event to `RaiseEvent()` without using MessageBuilder will result in lost correlation tracking. **Example**: ```csharp public class Account : AggregateRoot { private decimal _balance; + private bool _isActive; - // INCORRECT: This will lose correlation tracking - public void DepositIncorrect(decimal amount) + // Command handler method + public void Deposit(decimal amount, string reference, ICorrelatedMessage source) { - if (amount <= 0) - throw new ArgumentException("Amount must be positive", nameof(amount)); + // Validate business rules + if (!_isActive) + throw new InvalidOperationException("Cannot deposit to an inactive account"); - // This will NOT maintain correlation information - RaiseEvent(new AmountDeposited(Id, amount)); - } - - // CORRECT: This maintains correlation tracking - public void Deposit(decimal amount, ICorrelatedMessage source) - { if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); - // This properly maintains correlation information - RaiseEvent(MessageBuilder.From(source).Build(() => new AmountDeposited(Id, amount))); + // Raise the event with proper correlation + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited( + Id, + amount, + reference, + DateTime.UtcNow))); } - private void Apply(AmountDeposited @event) + // Event handler method + private void Apply(FundsDeposited @event) { _balance += @event.Amount; } @@ -200,10 +293,13 @@ public void RestoreFromEvents(IEnumerable events); // Inside a repository implementation public TAggregate GetById(Guid id) where TAggregate : AggregateRoot, new() { - var events = _eventStore.GetEvents(id); + // Retrieve events from the event store + var events = _eventStore.GetEvents(GetStreamName(typeof(TAggregate), id)); + + // Create a new instance of the aggregate var aggregate = new TAggregate(); - // Use reflection to set the Id property + // Set the ID property typeof(TAggregate) .GetProperty("Id", BindingFlags.Public | BindingFlags.Instance) .SetValue(aggregate, id); @@ -211,22 +307,13 @@ public TAggregate GetById(Guid id) where TAggregate : AggregateRoot, // Restore the aggregate state from events aggregate.RestoreFromEvents(events); + // Set the expected version for optimistic concurrency + aggregate.ExpectedVersion = events.Count(); + return aggregate; } ``` -### UpdateWithEvents - -Updates this aggregate with the provided events, starting from the expected version. This method is used when new events need to be applied to an existing aggregate, such as when handling concurrent modifications. - -```csharp -public void UpdateWithEvents(IEnumerable events, long expectedVersion); -``` - -**Parameters**: -- `events` (`System.Collections.Generic.IEnumerable`): The events to update with. -- `expectedVersion` (`System.Int64`): The expected version to start from. - ### TakeEvents Takes the recorded history of events from this aggregate. This method is typically called by a repository when saving the aggregate to extract the new events that need to be persisted. @@ -247,85 +334,188 @@ public void Save(AggregateRoot aggregate) if (events.Length > 0) { - // Persist the events to the event store - _eventStore.AppendToStream( - aggregate.Id, - aggregate.ExpectedVersion, - events); + try + { + // Persist the events to the event store + _eventStore.AppendToStream( + GetStreamName(aggregate.GetType(), aggregate.Id), + aggregate.ExpectedVersion, + events); + + // Update the expected version for next save + aggregate.ExpectedVersion += events.Length; - // Update the expected version for next save - aggregate.ExpectedVersion += events.Length; + // Publish events to event handlers + foreach (var @event in events) + { + _eventPublisher.Publish(@event); + } + } + catch (ConcurrencyException ex) + { + // Handle optimistic concurrency conflicts + throw new AggregateVersionException( + $"Aggregate {aggregate.Id} has been modified concurrently", + ex); + } + } +} +``` + +### Initialize + +A common pattern in Reactive Domain is to use an `Initialize` method instead of raising events directly in the constructor. This allows for more explicit validation and better control over the initialization process. + +```csharp +public void Initialize(/* parameters */, ICorrelatedMessage source) +{ + // Validate parameters + // ... + + // Raise initialization event + RaiseEvent(MessageBuilder.From(source, () => new EntityCreated(/* ... */))); +} +``` + +**Example**: +```csharp +public class Product : AggregateRoot +{ + private string _name; + private string _sku; + private decimal _price; + private bool _isActive; + + public Product(Guid id) : base(id) + { + Register(Apply); + Register(Apply); + Register(Apply); + } + + public void Initialize(string name, string sku, decimal price, ICorrelatedMessage source) + { + // Validate business rules + if (string.IsNullOrEmpty(name)) + throw new ArgumentException("Product name is required", nameof(name)); + + if (string.IsNullOrEmpty(sku)) + throw new ArgumentException("SKU is required", nameof(sku)); + + if (price <= 0) + throw new ArgumentException("Price must be greater than zero", nameof(price)); + + // Raise the event + RaiseEvent(MessageBuilder.From(source, () => new ProductCreated( + Id, name, sku, price, DateTime.UtcNow))); + } + + private void Apply(ProductCreated @event) + { + _name = @event.Name; + _sku = @event.Sku; + _price = @event.Price; + _isActive = true; } } ``` ## Usage -The `AggregateRoot` class is designed to be subclassed by domain aggregates. Subclasses should: +The `AggregateRoot` class is designed to be subclassed by domain aggregates. Here's a step-by-step guide for implementing aggregates in Reactive Domain: -1. Define private `Apply` methods for each event type to update the aggregate's state (these are event handlers) -2. Use the `RaiseEvent` method to create, record, and apply new events when handling commands -3. Define public methods that represent domain operations and enforce business rules -4. Keep the aggregate's state private and expose it through controlled methods +1. **Create a Class**: Create a new class that inherits from `AggregateRoot` +2. **Define State**: Define private fields to hold the aggregate's state +3. **Register Event Handlers**: In the constructor, register event handlers using the `Register(Apply)` method +4. **Implement Command Methods**: Create public methods that handle commands, validate business rules, and raise events +5. **Implement Event Handlers**: Create private `Apply` methods for each event type to update the aggregate's state +6. **Implement Initialization**: Use an `Initialize` method or command-handling constructor to set up new aggregates ## Example Implementation ```csharp public class Account : AggregateRoot { + // State fields private decimal _balance; - private bool _isClosed; + private bool _isActive; private string _accountNumber; private string _customerName; + private AccountType _accountType; + private DateTime _createdAt; + private DateTime? _closedAt; - // Constructor for creating a new account + // Constructor for new or loaded aggregates public Account(Guid id) : base(id) { - // Initialize with default values - RaiseEvent(new AccountCreated(id, "ACC-" + id.ToString().Substring(0, 8), "New Customer")); + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); } - // Constructor for creating a new account with correlation - public Account(Guid id, ICorrelatedMessage source) : base(id, source) + // Constructor for handling creation commands + public Account(Guid id, CreateAccount command) : base(id, command) { - // Initialize with default values and maintain correlation - RaiseEvent(MessageBuilder.From(source).Build(() => - new AccountCreated(id, "ACC-" + id.ToString().Substring(0, 8), "New Customer"))); - } - - // Constructor for restoring an account from events - protected Account(Guid id, IEnumerable events) : base(id, events) - { - // The base constructor will call RestoreFromEvents + // Register event handlers + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + Register(Apply); + + // Validate command + if (string.IsNullOrEmpty(command.AccountNumber)) + throw new ArgumentException("Account number is required", nameof(command.AccountNumber)); + + if (string.IsNullOrEmpty(command.CustomerName)) + throw new ArgumentException("Customer name is required", nameof(command.CustomerName)); + + // Raise creation event + RaiseEvent(MessageBuilder.From(command, () => new AccountCreated( + id, + command.AccountNumber, + command.CustomerName, + command.AccountType, + DateTime.UtcNow))); + + // If initial deposit is provided, perform the deposit + if (command.InitialDeposit > 0) + { + RaiseEvent(MessageBuilder.From(command, () => new FundsDeposited( + id, + command.InitialDeposit, + "Initial deposit", + DateTime.UtcNow))); + } } // Command handler for deposit - public void Deposit(decimal amount, ICorrelatedMessage source = null) + public void Deposit(decimal amount, string reference, ICorrelatedMessage source) { // Enforce business rules - if (_isClosed) - throw new InvalidOperationException("Cannot deposit to a closed account"); + if (!_isActive) + throw new InvalidOperationException("Cannot deposit to an inactive account"); if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); - // Create and apply the event - if (source != null) - { - RaiseEvent(MessageBuilder.From(source).Build(() => new AmountDeposited(Id, amount))); - } - else - { - RaiseEvent(new AmountDeposited(Id, amount)); - } + // Create and apply the event with proper correlation + RaiseEvent(MessageBuilder.From(source, () => new FundsDeposited( + Id, + amount, + reference, + DateTime.UtcNow))); } // Command handler for withdrawal - public void Withdraw(decimal amount, ICorrelatedMessage source = null) + public void Withdraw(decimal amount, string reference, ICorrelatedMessage source) { // Enforce business rules - if (_isClosed) - throw new InvalidOperationException("Cannot withdraw from a closed account"); + if (!_isActive) + throw new InvalidOperationException("Cannot withdraw from an inactive account"); if (amount <= 0) throw new ArgumentException("Amount must be positive", nameof(amount)); @@ -333,99 +523,126 @@ public class Account : AggregateRoot if (_balance < amount) throw new InvalidOperationException("Insufficient funds"); - // Create and apply the event - if (source != null) - { - RaiseEvent(MessageBuilder.From(source).Build(() => new AmountWithdrawn(Id, amount))); - } - else - { - RaiseEvent(new AmountWithdrawn(Id, amount)); - } + // Create and apply the event with proper correlation + RaiseEvent(MessageBuilder.From(source, () => new FundsWithdrawn( + Id, + amount, + reference, + DateTime.UtcNow))); } // Command handler for closing the account - public void Close(ICorrelatedMessage source = null) + public void Close(string reason, ICorrelatedMessage source) { // Enforce business rules - if (_isClosed) + if (!_isActive) throw new InvalidOperationException("Account is already closed"); - - if (_balance > 0) - throw new InvalidOperationException("Cannot close account with positive balance"); - // Create and apply the event - if (source != null) - { - RaiseEvent(MessageBuilder.From(source).Build(() => new AccountClosed(Id))); - } - else - { - RaiseEvent(new AccountClosed(Id)); - } + // Create and apply the event with proper correlation + RaiseEvent(MessageBuilder.From(source, () => new AccountClosed( + Id, + reason, + DateTime.UtcNow))); } - // Query method for balance - public decimal GetBalance() + // Command handler for reopening the account + public void Reopen(ICorrelatedMessage source) { - return _balance; + // Enforce business rules + if (_isActive) + throw new InvalidOperationException("Account is already active"); + + // Create and apply the event with proper correlation + RaiseEvent(MessageBuilder.From(source, () => new AccountReopened( + Id, + DateTime.UtcNow))); } - // Query method for account status - public bool IsClosed() - { - return _isClosed; - } + // Query methods - expose state in a controlled manner + public decimal GetBalance() => _balance; + public bool IsActive() => _isActive; + public string GetAccountNumber() => _accountNumber; + public string GetCustomerName() => _customerName; + public AccountType GetAccountType() => _accountType; - // Event handler for AccountCreated + // Event handlers - update state based on events private void Apply(AccountCreated @event) { _accountNumber = @event.AccountNumber; _customerName = @event.CustomerName; + _accountType = @event.AccountType; + _createdAt = @event.CreatedAt; _balance = 0; - _isClosed = false; + _isActive = true; } - // Event handler for AmountDeposited - private void Apply(AmountDeposited @event) + private void Apply(FundsDeposited @event) { _balance += @event.Amount; } - // Event handler for AmountWithdrawn - private void Apply(AmountWithdrawn @event) + private void Apply(FundsWithdrawn @event) { _balance -= @event.Amount; } - // Event handler for AccountClosed private void Apply(AccountClosed @event) { - _isClosed = true; + _isActive = false; + _closedAt = @event.ClosedAt; + } + + private void Apply(AccountReopened @event) + { + _isActive = true; + _closedAt = null; } } ``` ## Best Practices -1. **Keep Aggregates Small**: Focus on a single business concept and limit the number of properties and methods -2. **Enforce Invariants**: Use command methods to enforce business rules and maintain consistency -3. **Event-First Design**: Design your events before your commands to focus on the business outcomes -4. **Private State**: Keep aggregate state private and expose it through controlled methods -5. **Idempotent Apply Methods**: Ensure that applying the same event multiple times doesn't cause issues -6. **Immutable Events**: Use immutable events to ensure the event history remains unchanged -7. **Correlation Tracking**: Use the correlation-aware constructor when creating aggregates from commands -8. **Optimistic Concurrency**: Use the ExpectedVersion property to prevent lost updates +1. **Register Event Handlers in Constructor**: Always register event handlers in the constructor to ensure they're available for event replay. + +2. **Separate Command and Query Methods**: Follow Command Query Responsibility Segregation (CQRS) by separating methods that modify state from those that read state. + +3. **Validate in Command Methods**: Validate all business rules in command methods before raising events. Once an event is raised, it's considered a fact that has occurred. + +4. **Use MessageBuilder for Events**: Always use `MessageBuilder.From(source, () => new Event(...))` to create events with proper correlation tracking. + +5. **Keep Apply Methods Simple**: Event handlers should only update state and never contain business logic or raise additional events. + +6. **Encapsulate State**: Keep all state private and expose it through controlled query methods. + +7. **Immutable Events**: Design events to be immutable to ensure the event history remains unchanged. + +8. **Explicit Initialization**: Use an `Initialize` method or command-handling constructor for creating new aggregates, rather than raising events directly in the constructor. + +9. **Optimistic Concurrency**: Use the `ExpectedVersion` property to prevent lost updates in concurrent scenarios. + +10. **Domain-Focused Naming**: Name aggregates, commands, and events using domain language that business stakeholders understand. ## Common Pitfalls -1. **Large Aggregates**: Avoid creating aggregates that are too large or contain too many responsibilities -2. **Public State Modification**: Don't allow direct modification of aggregate state from outside -3. **Missing Business Rules**: Ensure all business rules are enforced in command methods -4. **Ignoring Version Conflicts**: Always handle optimistic concurrency exceptions properly -5. **Complex Apply Methods**: Keep event handlers (Apply methods) simple and focused on updating state -6. **Side Effects in Apply Methods**: Avoid side effects like I/O operations in Apply methods as they run during both event replay and new event creation -7. **Circular Event References**: Never call `RaiseEvent()` from within `Apply()` methods as this creates an infinite loop +1. **Missing Event Registration**: Forgetting to register event handlers in the constructor will cause events to be ignored during replay. + +2. **Direct State Modification**: Modifying aggregate state directly instead of through events breaks the event sourcing pattern. + +3. **Losing Correlation**: Not using `MessageBuilder` when creating events results in lost correlation tracking. + +4. **Complex Aggregates**: Creating aggregates that are too large or contain too many responsibilities makes them difficult to maintain and can lead to performance issues. + +5. **Side Effects in Apply Methods**: Including side effects like I/O operations or external service calls in Apply methods can cause issues during event replay. + +6. **Circular Event References**: Calling `RaiseEvent()` from within `Apply()` methods creates an infinite loop. + +7. **Ignoring Version Conflicts**: Not handling optimistic concurrency exceptions properly can lead to data inconsistencies. + +8. **Exposing Mutable State**: Returning references to internal collections or complex objects allows external code to modify the aggregate's state directly. + +9. **Missing Null Checks**: Not validating inputs in command methods can lead to null reference exceptions or invalid state. + +10. **Inconsistent Event Naming**: Using inconsistent naming conventions for events makes the event stream harder to understand and analyze. ## Inheritance Hierarchy diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index 857920fd..0b058c7b 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -15,11 +15,12 @@ - [x] Review command handler implementation patterns ## 3. Aggregate Implementation -- [ ] Compare aggregate initialization patterns with PowerModels -- [ ] Verify how state is maintained in aggregates -- [ ] Check event application methods (`Apply` methods) -- [ ] Update aggregate root inheritance and usage patterns -- [ ] Verify event registration in constructor vs. other approaches +- [x] Compare aggregate initialization patterns with PowerModels +- [x] Ensure proper event registration in constructors +- [x] Update examples of command handling methods +- [x] Verify event application patterns +- [x] Document best practices for aggregate design +- [x] Verify event registration in constructor vs. other approaches ## 4. Repository Usage - [ ] Verify repository patterns used in PowerModels From 7f8ee642d1fd3fe092532d2622c241cf016a41a5 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 05:50:00 -0400 Subject: [PATCH 32/41] Update repository documentation to reflect best practices and modern usage patterns --- .../types/icorrelated-repository.md | 333 +++++++++++--- docs/api-reference/types/irepository.md | 413 ++++++++++++------ docs/documentation-update-checklist.md | 10 +- 3 files changed, 565 insertions(+), 191 deletions(-) diff --git a/docs/api-reference/types/icorrelated-repository.md b/docs/api-reference/types/icorrelated-repository.md index 6c71932c..4bf228e4 100644 --- a/docs/api-reference/types/icorrelated-repository.md +++ b/docs/api-reference/types/icorrelated-repository.md @@ -4,18 +4,22 @@ ## Overview -The `ICorrelatedRepository` interface extends the repository pattern with correlation support. It allows tracking correlation and causation IDs across message flows when working with event-sourced aggregates. +The `ICorrelatedRepository` interface extends the repository pattern with correlation support. It allows tracking correlation and causation IDs across message flows when working with event-sourced aggregates. This interface is crucial for implementing robust, traceable, and maintainable event-sourced systems. + +In modern distributed applications, correlation tracking is not just a nice-to-have feature but an essential requirement for debugging, auditing, and monitoring business processes. The `ICorrelatedRepository` should be the default choice for all production systems. ## Correlation in Event Sourcing In distributed systems and complex business processes, tracking the flow of messages and events is crucial for: -1. **Debugging and Troubleshooting**: Tracing the path of a business transaction through the system -2. **Auditing**: Maintaining a complete record of what caused each state change -3. **Business Process Monitoring**: Tracking the progress of long-running business processes -4. **Distributed Tracing**: Following transactions across service boundaries +1. **Debugging and Troubleshooting**: Tracing the path of a business transaction through the system, making it easier to identify the root cause of issues +2. **Auditing and Compliance**: Maintaining a complete record of what caused each state change, essential for regulatory compliance and security investigations +3. **Business Process Monitoring**: Tracking the progress of long-running business processes across multiple services and components +4. **Distributed Tracing**: Following transactions across service boundaries in microservice architectures +5. **Idempotency Handling**: Detecting and properly handling duplicate messages to ensure exactly-once processing semantics +6. **Error Management**: Correlating errors with the operations that caused them for better error reporting and recovery -The `ICorrelatedRepository` provides this capability by ensuring that correlation information is propagated from commands to the events they generate. When an aggregate is loaded with a source message, the correlation context is established, and all events raised by that aggregate will inherit the correlation information. +The `ICorrelatedRepository` provides these capabilities by ensuring that correlation information is propagated from commands to the events they generate. When an aggregate is loaded with a source message, the correlation context is established, and all events raised by that aggregate will inherit the correlation information. **Namespace**: `ReactiveDomain.Foundation` **Assembly**: `ReactiveDomain.Foundation.dll` @@ -179,59 +183,51 @@ void HardDelete(IEventSource aggregate); ### Basic Usage with Correlation -The `ICorrelatedRepository` interface is used to store and retrieve event-sourced aggregates with correlation information. It is typically implemented by the `CorrelatedStreamStoreRepository` class. +The `ICorrelatedRepository` interface is used to store and retrieve event-sourced aggregates with correlation information. It is typically implemented by the `CorrelatedStreamStoreRepository` class and registered through dependency injection. ```csharp -// Create a repository -var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder(); -var eventStoreConnection = new StreamStoreConnection("MyApp", connectionSettings, "localhost", 1113); -var serializer = new JsonMessageSerializer(); -var repository = new StreamStoreRepository(streamNameBuilder, eventStoreConnection, serializer); -var correlatedRepository = new CorrelatedStreamStoreRepository(repository); - -// Create a command with correlation information -ICorrelatedMessage command = MessageBuilder.New(() => new CreateAccount(Guid.NewGuid())); - -// Create a new aggregate with correlation information -var account = new Account(Guid.NewGuid()); -account.CreateAccount("12345", command); // Pass the command to establish correlation - -// Save the aggregate -correlatedRepository.Save(account); - -// Retrieve the aggregate with correlation information -var retrievedAccount = correlatedRepository.GetById(account.Id, command); - -// Update the aggregate with correlation -retrievedAccount.Deposit(100, command); -correlatedRepository.Save(retrievedAccount); - -// Delete the aggregate -correlatedRepository.Delete(retrievedAccount); -``` - -### Integration with Command Handlers - -The `ICorrelatedRepository` is particularly useful in command handlers where correlation tracking is important: +// Register repositories in your DI container (typically in Startup.cs or Program.cs) +public void ConfigureServices(IServiceCollection services) +{ + // Register the base repository + services.AddSingleton(); + services.AddSingleton(provider => + new StreamStoreConnection("MyApp", connectionSettings, "localhost", 1113)); + services.AddSingleton(); + services.AddSingleton(); + + // Register the correlated repository as the default implementation + services.AddSingleton(); + + // Register command handlers and services + services.AddTransient, AccountCommandHandler>(); + services.AddTransient, AccountCommandHandler>(); + // ... other registrations +} -```csharp -public class CorrelatedAccountCommandHandler : +// Example command handler using ICorrelatedRepository +public class AccountCommandHandler : ICommandHandler, - ICommandHandler, - ICommandHandler, - ICommandHandler + ICommandHandler { private readonly ICorrelatedRepository _repository; - private readonly IEventBus _eventBus; + private readonly IEventPublisher _eventPublisher; + private readonly ILogger _logger; - public CorrelatedAccountCommandHandler(ICorrelatedRepository repository, IEventBus eventBus) + public AccountCommandHandler( + ICorrelatedRepository repository, + IEventPublisher eventPublisher, + ILogger logger) { _repository = repository; - _eventBus = eventBus; + _eventPublisher = eventPublisher; + _logger = logger; } public void Handle(CreateAccount command) { + _logger.LogInformation("Processing CreateAccount command {CommandId}", command.MsgId); + // Check if account already exists Account account; if (_repository.TryGetById(command.AccountId, out account, command)) @@ -240,29 +236,192 @@ public class CorrelatedAccountCommandHandler : } // Create new account with correlation - account = new Account(command.AccountId); - account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + account = new Account(command.AccountId, command); // Save the account _repository.Save(account); - // Events are automatically correlated with the command - // This allows tracing the entire transaction flow + // Publish events for read models and integration + var events = account.TakeEvents(); + foreach (var @event in events) + { + _eventPublisher.Publish(@event); + _logger.LogDebug("Published event {EventType} with ID {EventId}", + @event.GetType().Name, + (@event as ICorrelatedMessage)?.MsgId); + } } public void Handle(DepositFunds command) { + _logger.LogInformation("Processing DepositFunds command {CommandId}", command.MsgId); + // Get the account with correlation var account = _repository.GetById(command.AccountId, command); // Process the command with correlation - account.Deposit(command.Amount, command); + account.Deposit(command.Amount, command.Reference, command); // Save the account _repository.Save(account); + + // Publish events + var events = account.TakeEvents(); + foreach (var @event in events) + { + _eventPublisher.Publish(@event); + } + } +} +``` + +### Advanced Command Handler Patterns + +The `ICorrelatedRepository` enables sophisticated command handling patterns with robust error handling and correlation tracking: + +```csharp +public abstract class BaseCommandHandler : ICommandHandler + where TCommand : ICommand, ICorrelatedMessage +{ + protected readonly ICorrelatedRepository Repository; + protected readonly IEventPublisher EventPublisher; + protected readonly ILogger Logger; + + protected BaseCommandHandler( + ICorrelatedRepository repository, + IEventPublisher eventPublisher, + ILogger logger) + { + Repository = repository; + EventPublisher = eventPublisher; + Logger = logger; + } + + public void Handle(TCommand command) + { + try + { + Logger.LogInformation( + "Processing command {CommandType} with ID {CommandId}", + typeof(TCommand).Name, + command.MsgId); + + // Execute the command-specific logic + HandleCore(command); + + Logger.LogInformation( + "Successfully processed command {CommandType} with ID {CommandId}", + typeof(TCommand).Name, + command.MsgId); + } + catch (AggregateNotFoundException ex) + { + Logger.LogWarning( + ex, + "Aggregate not found while processing command {CommandType} with ID {CommandId}", + typeof(TCommand).Name, + command.MsgId); + + throw new CommandProcessingException( + $"The requested {GetAggregateTypeName(ex.AggregateType)} with ID {ex.AggregateId} was not found", + ex, + command); + } + catch (AggregateDeletedException ex) + { + Logger.LogWarning( + ex, + "Deleted aggregate accessed while processing command {CommandType} with ID {CommandId}", + typeof(TCommand).Name, + command.MsgId); + + throw new CommandProcessingException( + $"The requested {GetAggregateTypeName(ex.AggregateType)} with ID {ex.AggregateId} has been deleted", + ex, + command); + } + catch (AggregateVersionException ex) + { + Logger.LogWarning( + ex, + "Concurrency conflict detected while processing command {CommandType} with ID {CommandId}", + typeof(TCommand).Name, + command.MsgId); + + throw new ConcurrencyException( + $"The {GetAggregateTypeName(ex.AggregateType)} with ID {ex.AggregateId} has been modified by another process", + ex, + command); + } + catch (Exception ex) when (!(ex is CommandProcessingException)) + { + Logger.LogError( + ex, + "Error processing command {CommandType} with ID {CommandId}", + typeof(TCommand).Name, + command.MsgId); + + throw new CommandProcessingException( + $"An error occurred while processing {typeof(TCommand).Name}", + ex, + command); + } + } + + protected abstract void HandleCore(TCommand command); + + protected void PublishEvents(IEnumerable events) + { + foreach (var @event in events) + { + EventPublisher.Publish(@event); + + if (@event is ICorrelatedMessage correlatedEvent) + { + Logger.LogDebug( + "Published event {EventType} with ID {EventId}, correlation {CorrelationId}", + @event.GetType().Name, + correlatedEvent.MsgId, + correlatedEvent.CorrelationId); + } + } } - // Additional handlers... + private string GetAggregateTypeName(Type aggregateType) + { + return aggregateType?.Name.Replace("Aggregate", "") ?? "entity"; + } +} + +// Concrete implementation for a specific command +public class CreateAccountHandler : BaseCommandHandler +{ + public CreateAccountHandler( + ICorrelatedRepository repository, + IEventPublisher eventPublisher, + ILogger logger) + : base(repository, eventPublisher, logger) + { + } + + protected override void HandleCore(CreateAccount command) + { + // Check if account already exists + Account account; + if (Repository.TryGetById(command.AccountId, out account, command)) + { + throw new InvalidOperationException($"Account {command.AccountId} already exists"); + } + + // Create new account with correlation + account = new Account(command.AccountId, command); + + // Save the account + Repository.Save(account); + + // Publish events + PublishEvents(account.TakeEvents()); + } } ``` @@ -364,13 +523,69 @@ This chain allows tracing the entire business transaction from start to finish, ## Best Practices -1. **Always Use Correlation**: Use correlated repositories for all production systems to maintain traceability -2. **Pass Commands to Aggregates**: Always pass the command to aggregate methods to maintain correlation -3. **Correlation in Sagas**: Use correlation IDs to track long-running business processes (sagas) -4. **Logging with Correlation**: Include correlation IDs in log messages for easier troubleshooting -5. **Monitoring**: Set up monitoring based on correlation IDs to track business processes -6. **Error Handling**: Use correlation IDs to track errors through the system -7. **Testing**: Verify that correlation IDs are properly propagated in unit tests +### Correlation Design + +1. **Default to ICorrelatedRepository**: Always use `ICorrelatedRepository` instead of the base `IRepository` interface in production systems. The minimal overhead is far outweighed by the benefits of correlation tracking. + +2. **Consistent Command Structure**: Ensure all commands implement `ICommand` and `ICorrelatedMessage` interfaces to maintain a consistent correlation chain. + +3. **Command-to-Aggregate Flow**: Always pass the command to aggregate methods to maintain correlation between commands and the events they generate. + +4. **Correlation in Process Managers**: Use correlation IDs to track long-running business processes across multiple aggregates and services. + +5. **Preserve Correlation Chain**: When creating new commands in response to events, use `MessageBuilder.From(sourceEvent, () => new DerivedCommand(...))` to maintain the correlation chain. + +### Operational Excellence + +1. **Structured Logging**: Include correlation IDs in structured log messages for easier troubleshooting and log aggregation. + + ```csharp + _logger.LogInformation( + "Processing transfer {Amount} from {SourceId} to {TargetId} (Correlation: {CorrelationId})", + command.Amount, + command.SourceAccountId, + command.TargetAccountId, + command.CorrelationId); + ``` + +2. **Monitoring and Alerting**: Set up monitoring dashboards based on correlation IDs to track business processes and detect anomalies. + +3. **Distributed Tracing**: Integrate with distributed tracing systems like OpenTelemetry by propagating correlation IDs across service boundaries. + +4. **Error Correlation**: Include correlation IDs in error reports and exception handling to link errors back to the originating commands. + +5. **Performance Tracking**: Measure and track performance metrics for business operations using correlation IDs as identifiers. + +### Testing and Quality Assurance + +1. **Correlation Verification**: Write unit tests that verify correlation IDs are properly propagated from commands to events. + +2. **Test Fixtures**: Create test fixtures that automatically set up correlation for testing command handlers and aggregates. + +3. **End-to-End Testing**: Use correlation IDs to trace operations through all components in end-to-end tests. + +4. **Debugging Support**: Add debugging tools that can filter logs and events by correlation ID during development and testing. + +5. **Correlation Assertions**: Include assertions in tests to verify that events have the expected correlation and causation IDs. + + ```csharp + [Fact] + public void When_DepositingFunds_Should_MaintainCorrelation() + { + // Arrange + var command = MessageBuilder.New(() => new DepositFunds(accountId, 100)); + var account = new Account(accountId, command); + + // Act + account.Deposit(100, "Test deposit", command); + var events = account.TakeEvents(); + + // Assert + var depositedEvent = events.OfType().Single(); + Assert.Equal(command.CorrelationId, depositedEvent.CorrelationId); + Assert.Equal(command.MsgId, depositedEvent.CausationId); + } + ``` ## Advanced Scenarios diff --git a/docs/api-reference/types/irepository.md b/docs/api-reference/types/irepository.md index 09f6de51..f345dc0e 100644 --- a/docs/api-reference/types/irepository.md +++ b/docs/api-reference/types/irepository.md @@ -16,6 +16,7 @@ In event-sourced systems, the repository plays a critical role: 2. **Aggregate Reconstruction**: When loading an aggregate, the repository retrieves its events and replays them to reconstruct the aggregate's state 3. **Concurrency Control**: The repository manages optimistic concurrency through version checking 4. **Transaction Boundaries**: Repository operations typically define transaction boundaries in the domain +5. **Stream Naming**: The repository is responsible for consistent stream naming conventions Unlike traditional repositories that store the current state of entities, event-sourced repositories store the complete history of events that led to the current state. This approach provides several benefits, including: @@ -23,6 +24,7 @@ Unlike traditional repositories that store the current state of entities, event- - **Temporal Queries**: The ability to reconstruct the state of an aggregate at any point in time - **Event Replay**: The ability to replay events for debugging or analysis - **Event Processing**: Events can be processed by other components for various purposes (e.g., building read models) +- **Correlation Tracking**: The ability to track related events across multiple aggregates and services **Namespace**: `ReactiveDomain.Foundation` **Assembly**: `ReactiveDomain.Foundation.dll` @@ -315,67 +317,19 @@ catch (AggregateNotFoundException) The `IRepository` interface is used to store and retrieve event-sourced aggregates. It is typically implemented by the `StreamStoreRepository` class, which stores events in an event store. Here's a comprehensive example of using a repository in a typical application scenario: ```csharp -// Create a repository -var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder(); -var eventStoreConnection = new StreamStoreConnection("MyApp", connectionSettings, "localhost", 1113); -var serializer = new JsonMessageSerializer(); -var repository = new StreamStoreRepository(streamNameBuilder, eventStoreConnection, serializer); - -// Create a new aggregate -var accountId = Guid.NewGuid(); -var account = new Account(accountId); -account.Deposit(1000, command); - -// Save the aggregate -repository.Save(account); -Console.WriteLine($"Created account {accountId} with initial deposit of $1000"); - -// Retrieve the aggregate -var retrievedAccount = repository.GetById(accountId); -Console.WriteLine($"Retrieved account balance: ${retrievedAccount.GetBalance()}"); - -// Perform operations on the aggregate -retrievedAccount.Withdraw(500, command); -Console.WriteLine($"Withdrew $500, new balance: ${retrievedAccount.GetBalance()}"); - -// Save the updated aggregate -repository.Save(retrievedAccount); -Console.WriteLine("Saved account after withdrawal"); - -// Update the aggregate with the latest events -repository.Update(ref retrievedAccount); -Console.WriteLine($"Updated account balance: ${retrievedAccount.GetBalance()}"); - -// Delete the aggregate (soft delete) -repository.Delete(retrievedAccount); -Console.WriteLine("Deleted account (soft delete)"); - -// Hard delete the aggregate (permanent deletion) -// repository.HardDelete(retrievedAccount); -// Console.WriteLine("Permanently deleted account"); -``` - -### Integration with Command Handlers - -Repositories are typically used within command handlers in a CQRS architecture. Here's an example of a command handler that uses a repository: - -```csharp -public class AccountCommandHandler : - ICommandHandler, - ICommandHandler, - ICommandHandler, - ICommandHandler +// Create a repository with dependency injection (preferred approach) +public class AccountService { private readonly IRepository _repository; - private readonly IEventBus _eventBus; + private readonly IEventPublisher _eventPublisher; - public AccountCommandHandler(IRepository repository, IEventBus eventBus) + public AccountService(IRepository repository, IEventPublisher eventPublisher) { _repository = repository; - _eventBus = eventBus; + _eventPublisher = eventPublisher; } - public void Handle(CreateAccount command) + public void CreateAccount(CreateAccount command) { // Check if account already exists Account account; @@ -385,77 +339,245 @@ public class AccountCommandHandler : } // Create new account - account = new Account(command.AccountId); - account.CreateAccount(command.AccountNumber, command.InitialDeposit, command); + account = new Account(command.AccountId, command); - // Save the account + // Save the aggregate _repository.Save(account); - // Publish domain events to the event bus - foreach (var @event in account.TakeEvents()) + // Publish events for read model updates and integration + var events = account.TakeEvents(); + foreach (var @event in events) { - _eventBus.Publish(@event); + _eventPublisher.Publish(@event); } } - public void Handle(DepositFunds command) + public void DepositFunds(DepositFunds command) { // Get the account var account = _repository.GetById(command.AccountId); // Process the command - account.Deposit(command.Amount, command); + account.Deposit(command.Amount, command.Reference, command); // Save the account _repository.Save(account); - // Publish domain events - foreach (var @event in account.TakeEvents()) + // Publish events + var events = account.TakeEvents(); + foreach (var @event in events) { - _eventBus.Publish(@event); + _eventPublisher.Publish(@event); } } - // Additional handlers for other commands... + public decimal GetAccountBalance(Guid accountId) + { + var account = _repository.GetById(accountId); + return account.GetBalance(); + } } ``` -### Handling Concurrency Conflicts +### Integration with Command Handlers -Concurrency conflicts occur when multiple processes attempt to modify the same aggregate simultaneously. Here's an example of handling concurrency conflicts: +Repositories are typically used within command handlers in a CQRS architecture. Here's an example of a modern command handler that uses a repository with proper correlation tracking: ```csharp -public void TransferFunds(TransferFunds command) +public class AccountCommandHandler : + ICommandHandler, + ICommandHandler, + ICommandHandler, + ICommandHandler { - var sourceAccount = _repository.GetById(command.SourceAccountId); - var targetAccount = _repository.GetById(command.TargetAccountId); + private readonly ICorrelatedRepository _repository; + private readonly IEventPublisher _eventPublisher; - try + public AccountCommandHandler(ICorrelatedRepository repository, IEventPublisher eventPublisher) { - // Withdraw from source account - sourceAccount.Withdraw(command.Amount, command); + _repository = repository; + _eventPublisher = eventPublisher; + } + + public void Handle(CreateAccount command) + { + // Check if account already exists + Account account; + if (_repository.TryGetById(command.AccountId, out account, command)) + { + throw new InvalidOperationException($"Account {command.AccountId} already exists"); + } - // Deposit to target account - targetAccount.Deposit(command.Amount, command); + // Create new account using the command constructor pattern + account = new Account(command.AccountId, command); - // Save both accounts - _repository.Save(sourceAccount); - _repository.Save(targetAccount); + try + { + // Save the account + _repository.Save(account); + + // Publish domain events to the event bus + var events = account.TakeEvents(); + foreach (var @event in events) + { + _eventPublisher.Publish(@event); + } + } + catch (AggregateVersionException ex) + { + // Log the concurrency conflict + throw new CommandProcessingException( + $"Concurrent modification detected for account {command.AccountId}", + ex, + command); + } } - catch (AggregateVersionException ex) + + public void Handle(DepositFunds command) { - // Handle concurrency conflict - if (ex.AggregateId == command.SourceAccountId) + try { - // Reload source account and retry - sourceAccount = _repository.GetById(command.SourceAccountId); - // Implement retry logic... + // Get the account with correlation + var account = _repository.GetById(command.AccountId, command); + + // Process the command + account.Deposit(command.Amount, command.Reference, command); + + // Save the account + _repository.Save(account); + + // Publish domain events + var events = account.TakeEvents(); + foreach (var @event in events) + { + _eventPublisher.Publish(@event); + } + } + catch (AggregateNotFoundException) + { + throw new CommandProcessingException( + $"Account {command.AccountId} not found", + null, + command); + } + catch (AggregateVersionException ex) + { + throw new CommandProcessingException( + $"Concurrent modification detected for account {command.AccountId}", + ex, + command); } - else + catch (Exception ex) when (!(ex is CommandProcessingException)) { - // Reload target account and retry - targetAccount = _repository.GetById(command.TargetAccountId); - // Implement retry logic... + throw new CommandProcessingException( + $"Error processing deposit for account {command.AccountId}", + ex, + command); + } + } + + // Additional handlers for other commands... +} +``` + +### Handling Concurrency Conflicts + +Concurrency conflicts occur when multiple processes attempt to modify the same aggregate simultaneously. Here's a modern approach to handling concurrency conflicts with proper retry logic and correlation tracking: + +```csharp +public class TransferFundsHandler : ICommandHandler +{ + private readonly ICorrelatedRepository _repository; + private readonly IEventPublisher _eventPublisher; + private readonly ILogger _logger; + private readonly int _maxRetries = 3; + + public TransferFundsHandler( + ICorrelatedRepository repository, + IEventPublisher eventPublisher, + ILogger logger) + { + _repository = repository; + _eventPublisher = eventPublisher; + _logger = logger; + } + + public void Handle(TransferFunds command) + { + int retryCount = 0; + bool success = false; + + while (!success && retryCount < _maxRetries) + { + try + { + // Load both accounts with correlation + var sourceAccount = _repository.GetById(command.SourceAccountId, command); + var targetAccount = _repository.GetById(command.TargetAccountId, command); + + // Create a transfer record to track the operation + var transfer = new Transfer(Guid.NewGuid(), command); + transfer.Initialize( + command.SourceAccountId, + command.TargetAccountId, + command.Amount, + command.Reference, + command); + + // Execute the transfer + sourceAccount.Withdraw(command.Amount, command.Reference, command); + targetAccount.Deposit(command.Amount, command.Reference, command); + transfer.MarkAsCompleted(command); + + // Save all aggregates in a specific order to minimize deadlocks + _repository.Save(transfer); // Save transfer record first + _repository.Save(sourceAccount); // Save source account (most likely to fail) + _repository.Save(targetAccount); // Save target account + + // Publish all events + PublishEvents(transfer.TakeEvents()); + PublishEvents(sourceAccount.TakeEvents()); + PublishEvents(targetAccount.TakeEvents()); + + success = true; + _logger.LogInformation( + "Transfer {TransferId} completed: {Amount} from {SourceId} to {TargetId}", + transfer.Id, command.Amount, command.SourceAccountId, command.TargetAccountId); + } + catch (AggregateVersionException ex) + { + retryCount++; + + if (retryCount >= _maxRetries) + { + _logger.LogWarning( + "Max retries reached for transfer from {SourceId} to {TargetId}", + command.SourceAccountId, command.TargetAccountId); + throw new CommandProcessingException( + "Transfer failed due to concurrent modifications", ex, command); + } + + _logger.LogInformation( + "Concurrency conflict detected, retrying transfer (attempt {RetryCount}/{MaxRetries})", + retryCount, _maxRetries); + + // Add a small delay with jitter to reduce contention + var delay = (int)(Math.Pow(2, retryCount) * 100 + new Random().Next(50)); + Thread.Sleep(delay); + } + catch (Exception ex) when (!(ex is CommandProcessingException)) + { + _logger.LogError(ex, "Error processing transfer"); + throw new CommandProcessingException("Transfer failed", ex, command); + } + } + } + + private void PublishEvents(IEnumerable events) + { + foreach (var @event in events) + { + _eventPublisher.Publish(@event); } } } @@ -465,56 +587,93 @@ public void TransferFunds(TransferFunds command) ### Repository Design -1. **Optimistic Concurrency**: Always handle `AggregateVersionException` to manage concurrent modifications -2. **Aggregate Lifecycle**: Use `Delete` for logical deletion and `HardDelete` only when data must be permanently removed -3. **Version Management**: Use the `version` parameter in `GetById` and `Update` to work with specific versions of aggregates -4. **Error Handling**: Implement proper exception handling for repository operations -5. **Transaction Boundaries**: Consider repository operations as transaction boundaries in your domain -6. **Repository Abstraction**: Depend on the `IRepository` interface rather than concrete implementations -7. **Correlation Tracking**: Use `ICorrelatedRepository` when correlation information needs to be maintained +1. **Use Dependency Injection**: Always inject repositories into services and command handlers rather than creating them directly. + +2. **Prefer ICorrelatedRepository**: Use `ICorrelatedRepository` instead of `IRepository` to ensure proper correlation tracking across the entire system. + +3. **Consistent Stream Naming**: Use a consistent stream naming convention, typically `{AggregateType}-{AggregateId}`, to avoid collisions and make debugging easier. -### Performance Considerations +4. **Optimistic Concurrency**: Always handle `AggregateVersionException` with appropriate retry logic or user feedback. -1. **Snapshot Support**: Use snapshots for aggregates with many events to improve loading performance -2. **Batch Operations**: Consider batching operations when working with multiple aggregates -3. **Caching**: Implement caching strategies for frequently accessed aggregates -4. **Asynchronous Operations**: Use asynchronous repository implementations for better scalability -5. **Event Size**: Keep events small and focused to improve serialization and deserialization performance +5. **Transactional Boundaries**: Treat each repository save operation as a transaction boundary. If you need to save multiple aggregates atomically, consider using a process manager or saga. -### Testing +6. **Event Publishing**: Always publish events after successful repository operations to update read models and trigger integrations. -1. **In-Memory Repository**: Use an in-memory repository implementation for unit testing -2. **Test Doubles**: Create test doubles (mocks, stubs) for the repository interface -3. **Event Verification**: Verify that the correct events are saved to the repository -4. **Concurrency Testing**: Test concurrent operations to ensure proper handling of version conflicts -5. **Integration Testing**: Use a real repository implementation for integration testing +7. **Soft Deletion**: Prefer `Delete` (soft delete) over `HardDelete` to maintain a complete audit trail. Only use `HardDelete` for regulatory compliance (e.g., GDPR) or data lifecycle management. + +8. **Exception Wrapping**: Wrap repository exceptions in domain-specific exceptions that provide context about the failed operation. + +### Performance Optimization + +1. **Snapshot Support**: Implement snapshot support for aggregates with long event histories to improve loading performance. + +2. **Batched Event Publishing**: Consider batching event publishing for better throughput, especially in high-volume scenarios. + +3. **Asynchronous Operations**: Implement and use asynchronous repository methods (`GetByIdAsync`, `SaveAsync`) for better scalability. + +4. **Caching Strategy**: Implement a short-lived cache for frequently accessed aggregates, but ensure cache invalidation on updates. + +5. **Stream Size Management**: Monitor stream sizes and implement strategies to deal with large streams (e.g., archiving, snapshots, or stream splitting). + +6. **Bulk Loading**: Optimize for scenarios where multiple aggregates need to be loaded by implementing batch loading methods. + +### Testing Strategies + +1. **In-Memory Repository**: Use an in-memory repository implementation for unit testing command handlers and domain services. + +2. **Test-Specific Events**: Create test-specific event factories to make tests more readable and maintainable. + +3. **Event Stream Verification**: Verify the complete sequence of events produced by an operation, not just the final state. + +4. **Concurrency Simulation**: Test concurrent modifications by simulating version conflicts in unit tests. + +5. **Repository Decorators**: Use the decorator pattern to add cross-cutting concerns like logging, metrics, and caching to repositories. ## Common Pitfalls -### Design Issues +### Design and Architecture Issues + +1. **Missing Correlation**: Not using `ICorrelatedRepository` leads to lost correlation tracking, making it difficult to trace business transactions across the system. + +2. **Repository in Domain Model**: Referencing repositories directly in domain entities violates the separation of concerns principle. Repositories should only be used in command handlers and domain services. + +3. **Aggregate Boundaries**: Creating aggregates that are too large or too small. Aggregates should be designed around transactional consistency boundaries. + +4. **Direct State Modification**: Modifying aggregate state directly instead of through events breaks the event sourcing pattern and loses the audit trail. + +5. **Missing Domain Events**: Not raising domain events for important state changes, leading to incomplete audit trails and integration issues. + +6. **Leaking Implementation Details**: Exposing repository implementation details (like stream names or serialization formats) to clients. + +### Implementation Pitfalls + +1. **Inconsistent Stream Naming**: Using different stream naming conventions across the application, leading to confusion and potential collisions. + +2. **Event Schema Evolution**: Not handling changes to event schemas over time, causing deserialization errors when loading older events. + +3. **Missing Retry Logic**: Not implementing proper retry logic for handling concurrency exceptions, leading to poor user experience. + +4. **Excessive Event Data**: Including too much data in events, causing performance issues with serialization and storage. + +5. **Ignoring Expected Version**: Not setting or checking the `ExpectedVersion` property when saving aggregates, bypassing optimistic concurrency control. + +6. **Circular Event References**: Including circular references in event data, causing serialization issues. + +7. **Inefficient Aggregate Loading**: Loading the entire event history when only a specific version or a subset of events is needed. + +### CQRS and Event Processing Issues + +1. **Missing Event Publishing**: Forgetting to publish events after saving aggregates, causing read models to become stale. -1. **Ignoring Concurrency**: Failing to handle `AggregateVersionException` can lead to lost updates -2. **Large Aggregates**: Storing too many events in a single aggregate can impact performance -3. **Missing Version Checks**: Not checking versions when updating aggregates can lead to inconsistent state -4. **Hard Deletion Overuse**: Using `HardDelete` when `Delete` would be more appropriate -5. **Repository Leakage**: Allowing repository implementation details to leak into the domain model -6. **Missing Error Handling**: Not properly handling repository exceptions +2. **Synchronous Read Model Updates**: Updating read models synchronously within the command handling process, slowing down command processing. -### Implementation Challenges +3. **Event Ordering**: Not preserving the order of events when publishing to event handlers, causing inconsistent read models. -1. **Event Schema Evolution**: Not handling changes to event schemas over time -2. **Event Ordering**: Not maintaining the correct order of events -3. **Event Serialization**: Issues with serializing and deserializing complex event structures -4. **Stream Name Collisions**: Using non-unique stream names for different aggregates -5. **Repository Dependencies**: Creating tight coupling between the repository and other components +4. **Command-Side Queries**: Performing complex queries on the command side instead of using dedicated read models. -### CQRS Integration Issues +5. **Event Replay Impact**: Not considering the performance impact of replaying events when designing event handlers and projections. -1. **Read/Write Separation**: Not properly separating read and write repositories -2. **Event Publishing**: Forgetting to publish events after saving aggregates -3. **Command Validation**: Performing validation in the repository instead of in command handlers -4. **Event Replay**: Not considering the impact of event replay on performance -5. **Read Model Updates**: Not updating read models when events are saved +6. **Missing Idempotency**: Not making event handlers idempotent, causing issues when events are processed multiple times. ## Advanced Scenarios diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index 0b058c7b..e48807da 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -23,11 +23,11 @@ - [x] Verify event registration in constructor vs. other approaches ## 4. Repository Usage -- [ ] Verify repository patterns used in PowerModels -- [ ] Update examples of loading and saving aggregates -- [ ] Check optimistic concurrency control implementation -- [ ] Ensure correlation tracking in repositories is correctly documented -- [ ] Update stream naming conventions if different +- [x] Verify repository patterns used in PowerModels +- [x] Update examples of loading and saving aggregates +- [x] Check optimistic concurrency control implementation +- [x] Ensure correlation tracking in repositories is correctly documented +- [x] Update stream naming conventions if different ## 5. CQRS Implementation - [ ] Verify separation of command and query models From d4230746ad7ae61b4ffb3fc9f75d6e26dba70806 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 08:03:56 -0400 Subject: [PATCH 33/41] Update ReadModelBase and IReadModelRepository documentation with modern CQRS best practices --- .../types/iread-model-repository.md | 779 ++++++++++++++++-- docs/api-reference/types/read-model-base.md | 751 ++++++++++++++--- docs/documentation-update-checklist.md | 6 +- 3 files changed, 1356 insertions(+), 180 deletions(-) diff --git a/docs/api-reference/types/iread-model-repository.md b/docs/api-reference/types/iread-model-repository.md index fd8b1a7c..3af97ce1 100644 --- a/docs/api-reference/types/iread-model-repository.md +++ b/docs/api-reference/types/iread-model-repository.md @@ -12,6 +12,8 @@ Read model repositories are typically used by event handlers to persist updated ## Interface Definition +### Basic Interface + ```csharp public interface IReadModelRepository where T : ReadModelBase { @@ -22,6 +24,53 @@ public interface IReadModelRepository where T : ReadModelBase } ``` +### Extended Interface with Async Support + +For modern applications, an asynchronous version is recommended: + +```csharp +public interface IAsyncReadModelRepository where T : ReadModelBase +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task SaveAsync(T item, CancellationToken cancellationToken = default); + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + // Optional: Batch operations for performance + Task SaveManyAsync(IEnumerable items, CancellationToken cancellationToken = default); + Task DeleteManyAsync(IEnumerable ids, CancellationToken cancellationToken = default); +} +``` + +### Query-Enhanced Interface + +For more complex query scenarios: + +```csharp +public interface IQueryableReadModelRepository where T : ReadModelBase +{ + // Basic operations + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task SaveAsync(T item, CancellationToken cancellationToken = default); + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + // Advanced query capabilities + Task> FindAsync( + Expression> predicate, + CancellationToken cancellationToken = default); + + Task> FindAsync( + Expression> predicate, + int skip, + int take, + CancellationToken cancellationToken = default); + + Task CountAsync( + Expression> predicate, + CancellationToken cancellationToken = default); +} +``` + ## Key Features - **Type Safety**: Provides type-safe operations for specific read model types @@ -103,173 +152,733 @@ public class AccountEventHandler : ## Implementation Examples -### In-Memory Repository +### Modern In-Memory Repository with Async Support -A simple in-memory implementation for development or testing: +A thread-safe in-memory implementation with async support for development or testing: ```csharp -public class InMemoryReadModelRepository : IReadModelRepository where T : ReadModelBase +public class InMemoryReadModelRepository : IAsyncReadModelRepository where T : ReadModelBase { - private readonly Dictionary _items = new Dictionary(); + private readonly ConcurrentDictionary _items = new ConcurrentDictionary(); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); - public T GetById(Guid id) + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { + await Task.CompletedTask; // For consistent async behavior return _items.TryGetValue(id, out var item) ? item : null; } - public IEnumerable GetAll() + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + await Task.CompletedTask; // For consistent async behavior + return _items.Values.ToList(); // Return a copy to avoid modification issues + } + + public async Task SaveAsync(T item, CancellationToken cancellationToken = default) + { + if (item == null) throw new ArgumentNullException(nameof(item)); + + await _lock.WaitAsync(cancellationToken); + try + { + _items[item.Id] = item; + } + finally + { + _lock.Release(); + } + } + + public async Task SaveManyAsync(IEnumerable items, CancellationToken cancellationToken = default) { - return _items.Values; + if (items == null) throw new ArgumentNullException(nameof(items)); + + await _lock.WaitAsync(cancellationToken); + try + { + foreach (var item in items) + { + _items[item.Id] = item; + } + } + finally + { + _lock.Release(); + } } - public void Save(T item) + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { - _items[item.Id] = item; + await _lock.WaitAsync(cancellationToken); + try + { + _items.TryRemove(id, out _); + } + finally + { + _lock.Release(); + } } - public void Delete(Guid id) + public async Task DeleteManyAsync(IEnumerable ids, CancellationToken cancellationToken = default) { - _items.Remove(id); + if (ids == null) throw new ArgumentNullException(nameof(ids)); + + await _lock.WaitAsync(cancellationToken); + try + { + foreach (var id in ids) + { + _items.TryRemove(id, out _); + } + } + finally + { + _lock.Release(); + } } } ``` -### SQL Database Repository +### Entity Framework Core Repository -A repository implementation using a SQL database: +A modern repository implementation using Entity Framework Core: ```csharp -public class SqlReadModelRepository : IReadModelRepository where T : ReadModelBase +public class EfCoreReadModelRepository : IQueryableReadModelRepository + where T : ReadModelBase { - private readonly string _connectionString; - private readonly string _tableName; + private readonly ReadModelDbContext _dbContext; + private readonly ILogger> _logger; - public SqlReadModelRepository(string connectionString, string tableName) + public EfCoreReadModelRepository( + ReadModelDbContext dbContext, + ILogger> logger) { - _connectionString = connectionString; - _tableName = tableName; + _dbContext = dbContext; + _logger = logger; } - public T GetById(Guid id) + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - using (var connection = new SqlConnection(_connectionString)) + try + { + return await _dbContext.Set() + .AsNoTracking() // For read-only operations + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + catch (Exception ex) { - connection.Open(); - var sql = $"SELECT * FROM {_tableName} WHERE Id = @Id"; - return connection.QuerySingleOrDefault(sql, new { Id = id }); + _logger.LogError(ex, "Error retrieving read model {ReadModelType} with ID {Id}", + typeof(T).Name, id); + throw; } } - public IEnumerable GetAll() + public async Task> FindAsync( + Expression> predicate, + CancellationToken cancellationToken = default) { - using (var connection = new SqlConnection(_connectionString)) + try + { + return await _dbContext.Set() + .AsNoTracking() + .Where(predicate) + .ToListAsync(cancellationToken); + } + catch (Exception ex) { - connection.Open(); - var sql = $"SELECT * FROM {_tableName}"; - return connection.Query(sql); + _logger.LogError(ex, "Error finding read models of type {ReadModelType}", + typeof(T).Name); + throw; } } - public void Save(T item) + public async Task> FindAsync( + Expression> predicate, + int skip, + int take, + CancellationToken cancellationToken = default) { - using (var connection = new SqlConnection(_connectionString)) + try { - connection.Open(); - - // Check if the item exists - var exists = connection.ExecuteScalar( - $"SELECT COUNT(1) FROM {_tableName} WHERE Id = @Id", - new { Id = item.Id }); + return await _dbContext.Set() + .AsNoTracking() + .Where(predicate) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error finding paged read models of type {ReadModelType}", + typeof(T).Name); + throw; + } + } + + public async Task CountAsync( + Expression> predicate, + CancellationToken cancellationToken = default) + { + try + { + return await _dbContext.Set() + .Where(predicate) + .CountAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error counting read models of type {ReadModelType}", + typeof(T).Name); + throw; + } + } + + public async Task SaveAsync(T item, CancellationToken cancellationToken = default) + { + if (item == null) throw new ArgumentNullException(nameof(item)); + + try + { + // Use a clean approach to avoid tracking issues + var existingEntity = await _dbContext.Set().FindAsync(new object[] { item.Id }, cancellationToken); - if (exists) - { - // Update existing item (simplified example) - connection.Execute( - $"UPDATE {_tableName} SET Data = @Data WHERE Id = @Id", - new { Id = item.Id, Data = JsonConvert.SerializeObject(item) }); - } - else + if (existingEntity != null) { - // Insert new item (simplified example) - connection.Execute( - $"INSERT INTO {_tableName} (Id, Data) VALUES (@Id, @Data)", - new { Id = item.Id, Data = JsonConvert.SerializeObject(item) }); + // Detach existing entity to avoid tracking conflicts + _dbContext.Entry(existingEntity).State = EntityState.Detached; } + + // Attach and mark as modified (or added if new) + _dbContext.Entry(item).State = existingEntity != null ? + EntityState.Modified : + EntityState.Added; + + await _dbContext.SaveChangesAsync(cancellationToken); + + // Detach the entity after saving to avoid tracking issues in future operations + _dbContext.Entry(item).State = EntityState.Detached; + + _logger.LogDebug( + "{Operation} read model {ReadModelType} with ID {Id}", + existingEntity != null ? "Updated" : "Created", + typeof(T).Name, + item.Id); + } + catch (DbUpdateConcurrencyException ex) + { + _logger.LogWarning(ex, + "Concurrency conflict when saving read model {ReadModelType} with ID {Id}", + typeof(T).Name, item.Id); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error saving read model {ReadModelType} with ID {Id}", + typeof(T).Name, item.Id); + throw; } } - public void Delete(Guid id) + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { - using (var connection = new SqlConnection(_connectionString)) + try { - connection.Open(); - connection.Execute($"DELETE FROM {_tableName} WHERE Id = @Id", new { Id = id }); + var entity = await _dbContext.Set().FindAsync(new object[] { id }, cancellationToken); + if (entity != null) + { + _dbContext.Set().Remove(entity); + await _dbContext.SaveChangesAsync(cancellationToken); + + _logger.LogDebug( + "Deleted read model {ReadModelType} with ID {Id}", + typeof(T).Name, + id); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error deleting read model {ReadModelType} with ID {Id}", + typeof(T).Name, id); + throw; } } } ``` -### Document Database Repository +### MongoDB Repository with Async Support -A repository implementation using a document database: +A modern repository implementation using MongoDB with async operations: ```csharp -public class DocumentDbReadModelRepository : IReadModelRepository where T : ReadModelBase +public class MongoDbReadModelRepository : IAsyncReadModelRepository + where T : ReadModelBase { private readonly IMongoCollection _collection; + private readonly ILogger> _logger; - public DocumentDbReadModelRepository(IMongoDatabase database, string collectionName) + public MongoDbReadModelRepository( + IMongoDatabase database, + string collectionName, + ILogger> logger) { _collection = database.GetCollection(collectionName); + _logger = logger; + + // Ensure we have an index on the Id field + var indexKeysDefinition = Builders.IndexKeys.Ascending(x => x.Id); + _collection.Indexes.CreateOne(new CreateIndexModel(indexKeysDefinition)); } - public T GetById(Guid id) + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - return _collection.Find(x => x.Id == id).FirstOrDefault(); + try + { + return await _collection + .Find(x => x.Id == id) + .FirstOrDefaultAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error retrieving read model {ReadModelType} with ID {Id}", + typeof(T).Name, id); + throw; + } } - public IEnumerable GetAll() + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - return _collection.Find(_ => true).ToEnumerable(); + try + { + return await _collection + .Find(_ => true) + .ToListAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error retrieving all read models of type {ReadModelType}", + typeof(T).Name); + throw; + } + } + + public async Task SaveAsync(T item, CancellationToken cancellationToken = default) + { + if (item == null) throw new ArgumentNullException(nameof(item)); + + try + { + await _collection.ReplaceOneAsync( + x => x.Id == item.Id, + item, + new ReplaceOptions { IsUpsert = true }, + cancellationToken); + + _logger.LogDebug( + "Saved read model {ReadModelType} with ID {Id}", + typeof(T).Name, + item.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error saving read model {ReadModelType} with ID {Id}", + typeof(T).Name, item.Id); + throw; + } + } + + public async Task SaveManyAsync(IEnumerable items, CancellationToken cancellationToken = default) + { + if (items == null) throw new ArgumentNullException(nameof(items)); + var itemsList = items.ToList(); + if (!itemsList.Any()) return; + + try + { + var bulkOps = new List>(); + + foreach (var item in itemsList) + { + var filter = Builders.Filter.Eq(x => x.Id, item.Id); + var replaceModel = new ReplaceOneModel(filter, item) { IsUpsert = true }; + bulkOps.Add(replaceModel); + } + + await _collection.BulkWriteAsync(bulkOps, cancellationToken: cancellationToken); + + _logger.LogDebug( + "Saved {Count} read models of type {ReadModelType}", + itemsList.Count, + typeof(T).Name); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error saving multiple read models of type {ReadModelType}", + typeof(T).Name); + throw; + } } - public void Save(T item) + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { - _collection.ReplaceOne( - x => x.Id == item.Id, - item, - new ReplaceOptions { IsUpsert = true }); + try + { + await _collection.DeleteOneAsync(x => x.Id == id, cancellationToken); + + _logger.LogDebug( + "Deleted read model {ReadModelType} with ID {Id}", + typeof(T).Name, + id); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error deleting read model {ReadModelType} with ID {Id}", + typeof(T).Name, id); + throw; + } } - public void Delete(Guid id) + public async Task DeleteManyAsync(IEnumerable ids, CancellationToken cancellationToken = default) { - _collection.DeleteOne(x => x.Id == id); + if (ids == null) throw new ArgumentNullException(nameof(ids)); + var idsList = ids.ToList(); + if (!idsList.Any()) return; + + try + { + var filter = Builders.Filter.In(x => x.Id, idsList); + await _collection.DeleteManyAsync(filter, cancellationToken); + + _logger.LogDebug( + "Deleted {Count} read models of type {ReadModelType}", + idsList.Count, + typeof(T).Name); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error deleting multiple read models of type {ReadModelType}", + typeof(T).Name); + throw; + } } } ``` ## Best Practices -1. **Repository Per Read Model**: Create a separate repository for each read model type -2. **Caching Strategy**: Implement appropriate caching to improve query performance -3. **Optimistic Concurrency**: Consider using optimistic concurrency for read models that might be updated concurrently -4. **Indexing**: Ensure appropriate database indexes for efficient querying -5. **Bulk Operations**: Support bulk operations for better performance when processing multiple items -6. **Transactions**: Use transactions when updating multiple read models to maintain consistency -7. **Error Handling**: Implement proper error handling and retry logic for database operations -8. **Logging**: Include logging for troubleshooting and performance monitoring -9. **Testing**: Create mock implementations for testing event handlers and query services -10. **Storage Selection**: Choose the appropriate storage technology based on query patterns and requirements +### Architecture and Design + +1. **Repository Per Read Model**: Create a separate repository for each read model type to maintain a clean separation of concerns and avoid mixing query patterns that might be optimized differently. + + ```csharp + // Good: Specific repositories for specific read models + public interface IAccountSummaryRepository : IAsyncReadModelRepository { } + public interface ITransactionHistoryRepository : IAsyncReadModelRepository { } + + // Implementation + public class AccountSummaryRepository : MongoDbReadModelRepository, IAccountSummaryRepository + { + public AccountSummaryRepository(IMongoDatabase database, ILogger logger) + : base(database, "account_summaries", logger) { } + + // Additional account-specific query methods can be added here + } + ``` + +2. **Storage Technology Selection**: Choose the storage technology based on query patterns rather than write patterns: + - Use document databases (MongoDB, CosmosDB) for hierarchical data or when schema flexibility is needed + - Use relational databases for complex queries with joins or when ACID compliance is required + - Use search engines (Elasticsearch) for full-text search and complex filtering + - Use in-memory databases or caches for high-performance needs with small datasets + +3. **Interface Segregation**: Define specialized repository interfaces for specific query needs rather than creating a one-size-fits-all interface. + + ```csharp + // Good: Specialized interface for specific query needs + public interface ICustomerDashboardRepository : IAsyncReadModelRepository + { + Task> FindByActivityDateAsync( + DateTime startDate, + DateTime endDate, + CancellationToken cancellationToken = default); + + Task> FindTopCustomersByBalanceAsync( + int count, + CancellationToken cancellationToken = default); + } + ``` + +4. **Dependency Injection**: Register repositories with appropriate lifetimes in your dependency injection container. + + ```csharp + // In your DI configuration + services.AddScoped(); + services.AddScoped(); + ``` + +### Performance Optimization + +5. **Caching Strategy**: Implement appropriate caching to improve query performance, especially for frequently accessed read models. + + ```csharp + public class CachedReadModelRepository : IAsyncReadModelRepository where T : ReadModelBase + { + private readonly IAsyncReadModelRepository _repository; + private readonly IMemoryCache _cache; + private readonly TimeSpan _cacheDuration; + + public CachedReadModelRepository( + IAsyncReadModelRepository repository, + IMemoryCache cache, + TimeSpan cacheDuration) + { + _repository = repository; + _cache = cache; + _cacheDuration = cacheDuration; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + var cacheKey = $"{typeof(T).Name}:{id}"; + + if (!_cache.TryGetValue(cacheKey, out T item)) + { + item = await _repository.GetByIdAsync(id, cancellationToken); + + if (item != null) + { + _cache.Set(cacheKey, item, _cacheDuration); + } + } + + return item; + } + + // Other methods with appropriate caching... + } + ``` + +6. **Indexing**: Ensure appropriate database indexes for efficient querying, especially for fields used in filtering, sorting, or joining. + +7. **Bulk Operations**: Support bulk operations for better performance when processing multiple items, especially during event replay or system initialization. + +8. **Pagination**: Implement pagination for large result sets to avoid memory issues and improve response times. + + ```csharp + public class PagedResult + { + public IEnumerable Items { get; } + public int TotalCount { get; } + public int PageNumber { get; } + public int PageSize { get; } + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + + public PagedResult(IEnumerable items, int totalCount, int pageNumber, int pageSize) + { + Items = items; + TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + } + } + + // In your repository interface + Task> GetPagedAsync( + Expression> filter, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); + ``` + +### Robustness and Maintainability + +9. **Error Handling and Resilience**: Implement proper error handling, retry logic, and circuit breakers for database operations. + + ```csharp + // Using Polly for resilience + var retryPolicy = Policy + .Handle() + .Or() + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), + onRetry: (exception, timeSpan, retryCount, context) => + { + _logger.LogWarning( + exception, + "Retry {RetryCount} after {RetryDelay}ms due to {ExceptionType}", + retryCount, + timeSpan.TotalMilliseconds, + exception.GetType().Name); + }); + + // Usage in repository method + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await retryPolicy.ExecuteAsync(() => + _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken)); + } + ``` + +10. **Comprehensive Logging**: Include detailed logging for troubleshooting, performance monitoring, and auditing. + +11. **Testing**: Create mock implementations and test fixtures for testing event handlers and query services. + + ```csharp + public class TestReadModelRepository : IAsyncReadModelRepository where T : ReadModelBase + { + private readonly ConcurrentDictionary _items = new ConcurrentDictionary(); + + // Repository methods implementation... + + // Test-specific methods + public void Reset() + { + _items.Clear(); + } + + public void SetupTestData(IEnumerable testData) + { + foreach (var item in testData) + { + _items[item.Id] = item; + } + } + } + ``` + +12. **Versioning Strategy**: Include support for schema versioning to handle read model evolution over time. + +13. **Monitoring and Observability**: Add metrics collection for repository operations to track performance and detect issues. + + ```csharp + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + using var activity = _activitySource.StartActivity("ReadModel.GetById"); + activity?.SetTag("readModel.type", typeof(T).Name); + activity?.SetTag("readModel.id", id); + + var stopwatch = Stopwatch.StartNew(); + try + { + var result = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken); + + _metrics.RecordRepositoryOperation( + "GetById", + typeof(T).Name, + stopwatch.ElapsedMilliseconds, + result != null); + + return result; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + _metrics.RecordRepositoryError("GetById", typeof(T).Name, ex.GetType().Name); + throw; + } + } + ``` ## Common Pitfalls -1. **N+1 Query Problem**: Avoid making multiple database queries when a single query would suffice -2. **Over-normalization**: Read models should be denormalized for query efficiency -3. **Ignoring Indexes**: Missing indexes can lead to poor query performance -4. **Tight Coupling**: Avoid coupling read models to specific storage technologies -5. **Synchronous I/O**: Be cautious about making synchronous database calls in high-throughput systems -6. **Missing Error Handling**: Failing to handle database errors can lead to inconsistent read models -7. **Ignoring Eventual Consistency**: Remember that read models may be eventually consistent with the write model +### Performance Issues + +1. **N+1 Query Problem**: Avoid making multiple database queries when a single query would suffice, especially in event handlers processing multiple events. + + ```csharp + // Bad: N+1 query problem + foreach (var id in accountIds) + { + var account = await _repository.GetByIdAsync(id); + // Process account... + } + + // Good: Batch query + var accounts = await _repository.FindAsync(x => accountIds.Contains(x.Id)); + foreach (var account in accounts) + { + // Process account... + } + ``` + +2. **Inefficient Queries**: Avoid complex queries against read models. If you need complex queries, consider creating a specialized read model optimized for that query pattern. + +3. **Missing Indexes**: Failing to create appropriate indexes can lead to full table scans and poor query performance, especially as your data grows. + +4. **Synchronous I/O in High-Throughput Systems**: Using synchronous database calls in high-throughput systems can lead to thread pool starvation and reduced throughput. + + ```csharp + // Bad: Synchronous I/O + public void Handle(AccountCreated @event) + { + var account = _repository.GetById(@event.AccountId); // Blocks thread + // Process... + _repository.Save(account); // Blocks thread + } + + // Good: Asynchronous I/O + public async Task HandleAsync(AccountCreated @event) + { + var account = await _repository.GetByIdAsync(@event.AccountId); + // Process... + await _repository.SaveAsync(account); + } + ``` + +5. **Connection Management Issues**: Not properly managing database connections can lead to connection pool exhaustion. + +### Design Flaws + +6. **Over-normalization**: Read models should be denormalized for query efficiency. Don't try to maintain normal forms as you would in a traditional database design. + +7. **Tight Coupling to Storage Technology**: Avoid coupling your domain logic or projections directly to specific storage technologies. Use the repository abstraction to isolate these concerns. + +8. **One-Size-Fits-All Repositories**: Trying to create a single repository implementation that works efficiently for all read models and query patterns often leads to suboptimal performance. + +9. **Ignoring Eventual Consistency**: Failing to design your system to handle eventual consistency between write and read models can lead to confusing user experiences and bugs. + + ```csharp + // Bad: Assuming immediate consistency + public async Task CreateAccount(CreateAccountCommand command) + { + await _commandBus.SendAsync(command); + // Immediately trying to read the account might fail + var account = await _accountRepository.GetByIdAsync(command.AccountId); + return Ok(account); // Might return null if read model not yet updated + } + + // Good: Handling eventual consistency + public async Task CreateAccount(CreateAccountCommand command) + { + await _commandBus.SendAsync(command); + // Return the ID and let the client poll or use SignalR for updates + return Accepted(new { Id = command.AccountId }); + } + ``` + +### Operational Challenges + +10. **Missing Error Handling**: Failing to handle database errors can lead to inconsistent read models and hard-to-debug issues. + +11. **Inadequate Logging**: Without proper logging, it's difficult to troubleshoot issues with read model projections and queries. + +12. **No Rebuild Strategy**: Not having a way to rebuild read models from event streams makes it difficult to recover from data corruption or create new read model types. + +13. **Ignoring Database-Specific Behaviors**: Each database technology has its own quirks and best practices. Ignoring these can lead to suboptimal performance or unexpected behavior. + +14. **Lack of Monitoring**: Without proper monitoring, it's difficult to detect when read models are out of sync with the write model or when projections are failing. + +15. **Unbounded Result Sets**: Not implementing pagination or limits on query results can lead to out-of-memory exceptions or poor performance when dealing with large datasets. ## Related Components diff --git a/docs/api-reference/types/read-model-base.md b/docs/api-reference/types/read-model-base.md index a511ba47..26dd40ba 100644 --- a/docs/api-reference/types/read-model-base.md +++ b/docs/api-reference/types/read-model-base.md @@ -6,9 +6,19 @@ ## Overview -Read models in Reactive Domain represent the query side of the CQRS pattern. They are optimized for querying and provide a denormalized view of the domain data. The `ReadModelBase` class provides a common foundation for implementing read models with consistent behavior. +Read models in Reactive Domain represent the query side of the CQRS (Command Query Responsibility Segregation) pattern. They are specifically optimized for querying and provide a denormalized view of the domain data. The `ReadModelBase` class provides a common foundation for implementing read models with consistent behavior and identity management. -In a CQRS architecture, read models are separate from the write models (aggregates) and are specifically designed to efficiently answer queries. They typically contain denormalized data that is shaped according to the specific needs of the UI or API consumers. Read models are updated by event handlers in response to domain events raised by aggregates, creating an eventually consistent view of the domain state. +In a mature CQRS architecture, read models are completely separate from the write models (aggregates) and are designed to efficiently answer specific queries. Unlike aggregates that focus on maintaining consistency and enforcing business rules, read models focus on providing fast and efficient access to data in the exact shape needed by the UI or API consumers. + +Key characteristics of read models include: + +1. **Purpose-Built for Queries**: Designed specifically to answer particular questions or provide specific views of data +2. **Denormalized Structure**: Contains pre-computed, flattened data to eliminate the need for joins or complex transformations at query time +3. **Eventually Consistent**: Updated asynchronously in response to domain events, which means they may temporarily lag behind the write model +4. **Optimized for Reading**: Schema and storage mechanism chosen based on read patterns rather than normalization or write concerns +5. **Multiple Representations**: The same domain concept may be represented in multiple read models, each optimized for different query scenarios + +Read models are typically updated by event handlers that subscribe to domain events raised by aggregates. This creates a clear separation between the write and read sides of the application, allowing each to be optimized for its specific purpose. ## Class Definition @@ -30,185 +40,595 @@ public abstract class ReadModelBase ## Key Features -- **Identity Management**: Provides a standard `Id` property for uniquely identifying read models -- **Base Functionality**: Serves as a foundation for all read model implementations -- **Consistency**: Ensures consistent implementation patterns across different read models -- **Separation of Concerns**: Facilitates the separation between read and write models in CQRS -- **Optimized for Queries**: Designed to be efficient for read operations +- **Identity Management**: Provides a standard `Id` property for uniquely identifying read models, typically corresponding to an aggregate ID or other domain entity +- **Base Functionality**: Serves as a foundation for all read model implementations, providing common structure and behavior +- **Consistency**: Ensures consistent implementation patterns across different read models in the application +- **Separation of Concerns**: Facilitates the clear separation between read and write models in CQRS architecture +- **Optimized for Queries**: Designed to be efficient for read operations with a structure that matches query requirements +- **Extensibility**: Easily extended with additional properties and methods specific to each read model type +- **Serialization Support**: Simple structure makes serialization and deserialization straightforward for various storage mechanisms ## Usage ### Creating a Basic Read Model -To create a read model, inherit from `ReadModelBase` and add properties specific to your domain: +To create a read model, inherit from `ReadModelBase` and add properties specific to your domain and query requirements: ```csharp public class AccountSummary : ReadModelBase { + // Properties designed for efficient querying public string AccountNumber { get; private set; } public string CustomerName { get; private set; } public decimal Balance { get; private set; } + public AccountStatus Status { get; private set; } + public DateTime CreatedAt { get; private set; } public DateTime LastUpdated { get; private set; } + // Additional properties for filtering and sorting + public string CustomerEmail { get; private set; } + public AccountType AccountType { get; private set; } + public string BranchCode { get; private set; } + + // Constructor with required ID public AccountSummary(Guid id) : base(id) { + // Initialize collections or default values if needed } - public void Update(string accountNumber, string customerName, decimal balance) + // Update method with clear parameters + public void Update( + string accountNumber, + string customerName, + string customerEmail, + decimal balance, + AccountStatus status, + AccountType accountType, + string branchCode) { AccountNumber = accountNumber; CustomerName = customerName; + CustomerEmail = customerEmail; Balance = balance; + Status = status; + AccountType = accountType; + BranchCode = branchCode; + LastUpdated = DateTime.UtcNow; + } + + // Specialized update methods for specific changes + public void UpdateBalance(decimal newBalance) + { + Balance = newBalance; + LastUpdated = DateTime.UtcNow; + } + + public void UpdateStatus(AccountStatus newStatus) + { + Status = newStatus; LastUpdated = DateTime.UtcNow; } } ``` -### Creating a More Complex Read Model +### Creating a Specialized Query-Focused Read Model -For more complex scenarios, you can create read models that aggregate data from multiple sources: +Create read models that are specifically designed for particular query scenarios: + +```csharp +// Read model optimized for account search functionality +public class AccountSearchResult : ReadModelBase +{ + // Properties needed for search results + public string AccountNumber { get; private set; } + public string CustomerName { get; private set; } + public string CustomerEmail { get; private set; } + public AccountType AccountType { get; private set; } + public AccountStatus Status { get; private set; } + public decimal Balance { get; private set; } + public DateTime CreatedAt { get; private set; } + + // Searchable fields (indexed in the database) + public string SearchText { get; private set; } + + public AccountSearchResult(Guid id) : base(id) + { + } + + public void Update( + string accountNumber, + string customerName, + string customerEmail, + AccountType accountType, + AccountStatus status, + decimal balance, + DateTime createdAt) + { + AccountNumber = accountNumber; + CustomerName = customerName; + CustomerEmail = customerEmail; + AccountType = accountType; + Status = status; + Balance = balance; + CreatedAt = createdAt; + + // Create a combined search text for full-text search + SearchText = $"{accountNumber} {customerName} {customerEmail} {accountType} {status}"; + } +} +``` + +### Creating an Aggregated Dashboard Read Model + +For complex UI requirements, create read models that pre-aggregate data from multiple sources: ```csharp public class CustomerDashboard : ReadModelBase { + // Customer information public string CustomerName { get; private set; } public string Email { get; private set; } + public string PhoneNumber { get; private set; } + public CustomerStatus Status { get; private set; } + + // Pre-calculated aggregates public decimal TotalBalance { get; private set; } + public decimal TotalSavingsBalance { get; private set; } + public decimal TotalCheckingBalance { get; private set; } public int AccountCount { get; private set; } - public List Accounts { get; private set; } - public List RecentTransactions { get; private set; } + public DateTime LastActivity { get; private set; } + + // Related entities (denormalized) + public List Accounts { get; private set; } + public List RecentTransactions { get; private set; } + // Constructor public CustomerDashboard(Guid customerId) : base(customerId) { - Accounts = new List(); - RecentTransactions = new List(); + Accounts = new List(); + RecentTransactions = new List(); } - public void UpdateCustomerInfo(string name, string email) + // Update methods + public void UpdateCustomerInfo(string name, string email, string phoneNumber, CustomerStatus status) { CustomerName = name; Email = email; + PhoneNumber = phoneNumber; + Status = status; } - public void AddAccount(AccountSummary account) - { - Accounts.Add(account); - AccountCount = Accounts.Count; - RecalculateTotalBalance(); - } - - public void UpdateAccount(AccountSummary updatedAccount) + public void AddOrUpdateAccount(DashboardAccountSummary account) { - var existingAccount = Accounts.FirstOrDefault(a => a.Id == updatedAccount.Id); + var existingAccount = Accounts.FirstOrDefault(a => a.AccountId == account.AccountId); if (existingAccount != null) { var index = Accounts.IndexOf(existingAccount); - Accounts[index] = updatedAccount; - RecalculateTotalBalance(); + Accounts[index] = account; } + else + { + Accounts.Add(account); + } + + RecalculateBalances(); + AccountCount = Accounts.Count; } - public void AddTransaction(TransactionSummary transaction) + public void AddTransaction(DashboardTransactionSummary transaction) { + // Add the transaction and keep only the most recent ones RecentTransactions.Add(transaction); RecentTransactions = RecentTransactions .OrderByDescending(t => t.Timestamp) .Take(10) .ToList(); + + // Update the last activity timestamp + if (transaction.Timestamp > LastActivity) + { + LastActivity = transaction.Timestamp; + } } - private void RecalculateTotalBalance() + private void RecalculateBalances() { - TotalBalance = Accounts.Sum(a => a.Balance); + TotalCheckingBalance = Accounts + .Where(a => a.AccountType == AccountType.Checking) + .Sum(a => a.Balance); + + TotalSavingsBalance = Accounts + .Where(a => a.AccountType == AccountType.Savings) + .Sum(a => a.Balance); + + TotalBalance = TotalCheckingBalance + TotalSavingsBalance; } } + +// Simplified nested class for the dashboard +public class DashboardAccountSummary +{ + public Guid AccountId { get; set; } + public string AccountNumber { get; set; } + public AccountType AccountType { get; set; } + public decimal Balance { get; set; } + public AccountStatus Status { get; set; } +} + +// Simplified nested class for the dashboard +public class DashboardTransactionSummary +{ + public Guid TransactionId { get; set; } + public Guid AccountId { get; set; } + public string AccountNumber { get; set; } + public decimal Amount { get; set; } + public string Description { get; set; } + public TransactionType Type { get; set; } + public DateTime Timestamp { get; set; } +} ``` ## Integration with Event Handlers -In Reactive Domain, read models typically implement the event handler interfaces directly. This pattern allows the read model to handle its own updates in response to domain events. Here's how read models should be implemented to handle events: +There are several patterns for integrating read models with event handlers in Reactive Domain. Here are the most common approaches, each with its own advantages: + +### Pattern 1: Dedicated Projection Classes + +This pattern separates the read model (data container) from the event handling logic, following the Single Responsibility Principle: ```csharp -// The read model itself implements the event handler interfaces -public class AccountSummaryReadModel : ReadModelBase, +// The read model is just a data container +public class AccountSummary : ReadModelBase +{ + public string AccountNumber { get; private set; } + public string CustomerName { get; private set; } + public decimal Balance { get; private set; } + public AccountStatus Status { get; private set; } + public DateTime LastUpdated { get; private set; } + + public AccountSummary(Guid id) : base(id) { } + + // Update methods + public void Update(string accountNumber, string customerName, decimal balance, AccountStatus status) + { + AccountNumber = accountNumber; + CustomerName = customerName; + Balance = balance; + Status = status; + LastUpdated = DateTime.UtcNow; + } + + public void UpdateBalance(decimal newBalance) + { + Balance = newBalance; + LastUpdated = DateTime.UtcNow; + } + + public void UpdateStatus(AccountStatus newStatus) + { + Status = newStatus; + LastUpdated = DateTime.UtcNow; + } +} + +// Separate projection class handles the events +public class AccountSummaryProjection : IEventHandler, IEventHandler, IEventHandler, IEventHandler { - private readonly IReadModelRepository _repository; + private readonly IReadModelRepository _repository; + private readonly ILogger _logger; + + public AccountSummaryProjection( + IReadModelRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public void Handle(AccountCreated @event) + { + try + { + // Create a new read model + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update( + @event.AccountNumber, + @event.CustomerName, + @event.InitialBalance, + AccountStatus.Active); + + // Save the read model + _repository.Save(accountSummary); + + _logger.LogInformation( + "Created account summary for account {AccountId}", + @event.AccountId); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error creating account summary for account {AccountId}", + @event.AccountId); + throw; // Rethrow to allow the event processing system to handle it + } + } + + public void Handle(FundsDeposited @event) + { + try + { + // Get the existing read model + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary == null) + { + _logger.LogWarning( + "Account summary not found for account {AccountId} when processing FundsDeposited", + @event.AccountId); + return; + } + + // Update the balance + accountSummary.UpdateBalance(accountSummary.Balance + @event.Amount); + + // Save the updated read model + _repository.Save(accountSummary); + + _logger.LogDebug( + "Updated account summary balance for account {AccountId}, new balance: {Balance}", + @event.AccountId, + accountSummary.Balance); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error updating account summary for account {AccountId} when processing FundsDeposited", + @event.AccountId); + throw; + } + } + + public void Handle(FundsWithdrawn @event) + { + try + { + // Get the existing read model + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary == null) + { + _logger.LogWarning( + "Account summary not found for account {AccountId} when processing FundsWithdrawn", + @event.AccountId); + return; + } + + // Update the balance + accountSummary.UpdateBalance(accountSummary.Balance - @event.Amount); + + // Save the updated read model + _repository.Save(accountSummary); + + _logger.LogDebug( + "Updated account summary balance for account {AccountId}, new balance: {Balance}", + @event.AccountId, + accountSummary.Balance); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error updating account summary for account {AccountId} when processing FundsWithdrawn", + @event.AccountId); + throw; + } + } + + public void Handle(AccountClosed @event) + { + try + { + // Get the existing read model + var accountSummary = _repository.GetById(@event.AccountId); + if (accountSummary == null) + { + _logger.LogWarning( + "Account summary not found for account {AccountId} when processing AccountClosed", + @event.AccountId); + return; + } + + // Update the status + accountSummary.UpdateStatus(AccountStatus.Closed); + + // Save the updated read model + _repository.Save(accountSummary); + + _logger.LogInformation( + "Updated account summary status to Closed for account {AccountId}", + @event.AccountId); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error updating account summary for account {AccountId} when processing AccountClosed", + @event.AccountId); + throw; + } + } +} +``` + +### Pattern 2: Self-Handling Read Models + +In simpler scenarios, the read model can implement the event handler interfaces directly: + +```csharp +public class SimpleAccountSummary : ReadModelBase, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _repository; public string AccountNumber { get; private set; } public string CustomerName { get; private set; } public decimal Balance { get; private set; } - public bool IsActive { get; private set; } + public AccountStatus Status { get; private set; } public DateTime LastUpdated { get; private set; } // Constructor for creating a new read model instance - public AccountSummaryReadModel(Guid id, IReadModelRepository repository) : base(id) + public SimpleAccountSummary(Guid id, IReadModelRepository repository) : base(id) { _repository = repository; } - // Event handler for AccountCreated + // Event handlers public void Handle(AccountCreated @event) { - // Update the read model state AccountNumber = @event.AccountNumber; CustomerName = @event.CustomerName; Balance = @event.InitialBalance; - IsActive = true; + Status = AccountStatus.Active; LastUpdated = DateTime.UtcNow; - // Save the updated read model _repository.Save(this); } - // Event handler for FundsDeposited public void Handle(FundsDeposited @event) { - // Ensure this is the correct account - if (@event.AccountId == Id) - { - // Update the read model state - Balance += @event.Amount; - LastUpdated = DateTime.UtcNow; - - // Save the updated read model - _repository.Save(this); - } + Balance += @event.Amount; + LastUpdated = DateTime.UtcNow; + + _repository.Save(this); } - // Event handler for FundsWithdrawn public void Handle(FundsWithdrawn @event) { - // Ensure this is the correct account - if (@event.AccountId == Id) + Balance -= @event.Amount; + LastUpdated = DateTime.UtcNow; + + _repository.Save(this); + } + + public void Handle(AccountClosed @event) + { + Status = AccountStatus.Closed; + LastUpdated = DateTime.UtcNow; + + _repository.Save(this); + } +} +``` + +### Pattern 3: Generic Projection Infrastructure + +For more complex systems, a generic projection infrastructure can be beneficial: + +```csharp +// Generic projection base class +public abstract class Projection where TReadModel : ReadModelBase +{ + protected readonly IReadModelRepository Repository; + protected readonly ILogger Logger; + + protected Projection(IReadModelRepository repository, ILogger logger) + { + Repository = repository; + Logger = logger; + } + + protected TReadModel GetOrCreateReadModel(Guid id, Func factory = null) + { + var readModel = Repository.GetById(id); + if (readModel == null && factory != null) { - // Update the read model state - Balance -= @event.Amount; - LastUpdated = DateTime.UtcNow; - - // Save the updated read model - _repository.Save(this); + readModel = factory(); } + return readModel; } - // Event handler for AccountClosed - public void Handle(AccountClosed @event) + protected void SaveReadModel(TReadModel readModel, string operation) { - // Ensure this is the correct account - if (@event.AccountId == Id) + try { - // Update the read model state - IsActive = false; - LastUpdated = DateTime.UtcNow; - - // Save the updated read model - _repository.Save(this); + Repository.Save(readModel); + Logger.LogDebug( + "{Operation} read model {ReadModelType} with ID {ReadModelId}", + operation, + typeof(TReadModel).Name, + readModel.Id); + } + catch (Exception ex) + { + Logger.LogError( + ex, + "Error saving read model {ReadModelType} with ID {ReadModelId}", + typeof(TReadModel).Name, + readModel.Id); + throw; } } +} + +// Concrete projection implementation +public class AccountSummaryProjection : Projection, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + public AccountSummaryProjection( + IReadModelRepository repository, + ILogger logger) + : base(repository, logger) + { + } + + public void Handle(AccountCreated @event) + { + var readModel = new AccountSummary(@event.AccountId); + readModel.Update( + @event.AccountNumber, + @event.CustomerName, + @event.InitialBalance, + AccountStatus.Active); + + SaveReadModel(readModel, "Created"); + } + + public void Handle(FundsDeposited @event) + { + var readModel = GetOrCreateReadModel(@event.AccountId); + if (readModel == null) return; + + readModel.UpdateBalance(readModel.Balance + @event.Amount); + SaveReadModel(readModel, "Updated"); + } + public void Handle(FundsWithdrawn @event) + { + var readModel = GetOrCreateReadModel(@event.AccountId); + if (readModel == null) return; + + readModel.UpdateBalance(readModel.Balance - @event.Amount); + SaveReadModel(readModel, "Updated"); + } + + public void Handle(AccountClosed @event) + { + var readModel = GetOrCreateReadModel(@event.AccountId); + if (readModel == null) return; + + readModel.UpdateStatus(AccountStatus.Closed); + SaveReadModel(readModel, "Updated"); + } } ``` @@ -372,31 +792,178 @@ public class SqlReadModelRepository : IReadModelRepository where T : ReadM ## Best Practices -1. **Keep Read Models Focused**: Each read model should serve a specific query scenario -2. **Immutable Properties**: Make properties private set to ensure they are only modified through well-defined methods -3. **Denormalization**: Denormalize data to optimize for query performance -4. **Eventual Consistency**: Remember that read models are eventually consistent with the write model -5. **Versioning**: Consider adding version information to handle schema evolution -6. **Optimize for Reads**: Structure your read models to minimize the need for joins or complex queries -7. **Separate Storage**: Consider using different storage technologies for read and write models -8. **Rebuild Capability**: Design your system to be able to rebuild read models from event streams when needed -9. **Caching Strategy**: Implement appropriate caching for frequently accessed read models -10. **Monitoring**: Add monitoring to track the lag between write model updates and read model updates -11. **Idempotent Updates**: Ensure read model updates are idempotent, as events may be processed multiple times -12. **Event Handler Organization**: Organize event handlers by the read models they update rather than by event type +### Design Principles + +1. **Purpose-Driven Design**: Design read models based on specific query requirements rather than mirroring the domain model. Start by identifying the queries your application needs to support, then design read models optimized for those queries. + + ```csharp + // Good: Designed specifically for a dashboard view + public class CustomerDashboardReadModel : ReadModelBase + { + public string CustomerName { get; private set; } + public int TotalAccounts { get; private set; } + public decimal TotalBalance { get; private set; } + public DateTime LastActivity { get; private set; } + public List RecentTransactions { get; private set; } + } + ``` + +2. **Single Responsibility**: Each read model should serve a specific query scenario or related set of queries. Don't try to make a read model serve too many different purposes. + +3. **Immutable Properties**: Make properties with private setters to ensure they are only modified through well-defined methods. This helps maintain the integrity of the read model. + +4. **Denormalization for Performance**: Precompute and store derived data to eliminate the need for complex queries or joins at query time. This may include: + - Storing calculated totals and counts + - Duplicating data across multiple read models + - Storing data in the exact format needed for display + +5. **Explicit Update Methods**: Provide clear, well-named methods for updating the read model rather than directly modifying properties. + + ```csharp + // Good: Clear update methods with specific purposes + public void UpdateBalance(decimal newBalance) + { + Balance = newBalance; + LastUpdated = DateTime.UtcNow; + } + + public void UpdateStatus(AccountStatus newStatus) + { + Status = newStatus; + LastUpdated = DateTime.UtcNow; + } + ``` + +### Implementation Strategies + +6. **Separate Projection Classes**: Consider separating read models (data containers) from projection logic (event handlers) to maintain a clear separation of concerns. + +7. **Idempotent Updates**: Design read model updates to be idempotent, as events may be processed multiple times due to retries or replay. + + ```csharp + // Good: Idempotent update that works regardless of how many times it's called + public void Handle(AccountClosed @event) + { + var readModel = _repository.GetById(@event.AccountId); + if (readModel != null && readModel.Status != AccountStatus.Closed) + { + readModel.UpdateStatus(AccountStatus.Closed); + _repository.Save(readModel); + } + } + ``` + +8. **Error Handling**: Implement robust error handling in projections to prevent a single failed update from breaking the entire projection process. + +9. **Versioning Strategy**: Include version information in read models to handle schema evolution over time. + + ```csharp + public class VersionedReadModel : ReadModelBase + { + public int SchemaVersion { get; private set; } = 1; + + // Other properties... + + public void UpgradeSchema(int targetVersion) + { + if (SchemaVersion < targetVersion) + { + // Perform schema migration logic + SchemaVersion = targetVersion; + } + } + } + ``` + +### Infrastructure Considerations + +10. **Storage Technology Selection**: Choose storage technologies based on query patterns rather than write patterns. Different read models may use different storage technologies: + - Relational databases for complex queries with joins + - Document databases for hierarchical data + - Search engines for full-text search + - In-memory databases for high-performance needs + +11. **Asynchronous Projections**: Process events asynchronously to update read models, especially in high-throughput systems. + + ```csharp + public async Task HandleAsync(AccountCreated @event) + { + var readModel = new AccountSummary(@event.AccountId); + // Update properties... + await _repository.SaveAsync(readModel); + } + ``` + +12. **Rebuild Capability**: Design your system to be able to rebuild read models from event streams when needed. This is crucial for: + - Fixing corrupted read models + - Creating new read models from historical events + - Migrating to new storage technologies + +13. **Caching Strategy**: Implement appropriate caching for frequently accessed read models, with proper cache invalidation when updates occur. + +14. **Monitoring and Observability**: Add monitoring to track: + - Projection performance and throughput + - Lag between write model updates and read model updates + - Failed projections and error rates + - Read model query performance ## Common Pitfalls -1. **Business Logic in Read Models**: Avoid putting business logic in read models -2. **Complex Read Models**: Keep read models simple and focused on query requirements -3. **Missing Event Handlers**: Ensure all relevant events have handlers to update read models -4. **Ignoring Performance**: Design read models with query performance in mind -5. **Tight Coupling**: Avoid coupling read models to domain aggregates -6. **Overloading**: Don't try to make a single read model serve too many different query scenarios -7. **Inconsistent Naming**: Maintain consistent naming conventions between events, commands, and read models -8. **Neglecting Indexes**: Ensure appropriate database indexes for efficient querying -9. **Synchronous Updates**: Be cautious about synchronously updating read models in high-throughput systems -10. **Ignoring Eventual Consistency**: Design your UI and API consumers to handle eventual consistency +### Design Issues + +1. **Business Logic in Read Models**: Read models should not contain business rules or validation logic. Keep them focused on data representation for queries. + + ```csharp + // Bad: Business logic in read model + public class AccountReadModel : ReadModelBase + { + public decimal Balance { get; private set; } + + public void Withdraw(decimal amount) + { + // Business logic doesn't belong here + if (amount <= 0) + throw new ArgumentException("Amount must be positive"); + + if (Balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + Balance -= amount; + } + } + ``` + +2. **Mirror Image of Aggregates**: Avoid simply copying the structure of your domain aggregates. Read models should be designed for query efficiency, not domain modeling. + +3. **One-Size-Fits-All Models**: Don't try to create a single read model that serves all query needs. This leads to bloated models that are inefficient for any specific query. + +4. **Tight Coupling to Domain Model**: Read models should not depend on or reference domain aggregates. Keep them completely separate. + +5. **Ignoring Query Patterns**: Failing to design read models based on actual query requirements leads to inefficient queries and poor performance. + +### Implementation Pitfalls + +6. **Missing Event Handlers**: Ensure all relevant events have handlers to update read models. Missing handlers lead to inconsistent or incomplete read models. + +7. **Non-Idempotent Updates**: Event handlers that aren't idempotent can cause data corruption when events are processed multiple times. + +8. **Synchronous Updates in Request Pipeline**: Updating read models synchronously within the command handling process can slow down command processing and reduce system throughput. + +9. **Neglecting Database Indexes**: Failing to create appropriate indexes for query patterns can severely impact performance. + +10. **Inefficient Queries**: Using complex queries against read models defeats the purpose of having denormalized models optimized for reading. + +### Operational Challenges + +11. **Ignoring Eventual Consistency**: Failing to design your UI and API consumers to handle eventual consistency can lead to confusing user experiences. + +12. **No Rebuild Strategy**: Not having a way to rebuild read models from event streams makes it difficult to recover from data corruption or create new read model types. + +13. **Lack of Monitoring**: Without proper monitoring, it's difficult to detect when read models are out of sync with the write model or when projections are failing. + +14. **Inadequate Error Handling**: Poor error handling in projections can cause entire projection processes to fail, leading to stale or incomplete read models. + +15. **Unbounded Growth**: Not implementing strategies for managing the growth of read models (archiving, partitioning, etc.) can lead to performance degradation over time. ## Related Components diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index e48807da..b98f93d7 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -30,9 +30,9 @@ - [x] Update stream naming conventions if different ## 5. CQRS Implementation -- [ ] Verify separation of command and query models -- [ ] Update projection examples based on actual usage -- [ ] Check read model implementation patterns +- [x] Verify separation of command and query models +- [x] Update projection examples based on actual usage +- [x] Check read model implementation patterns - [ ] Ensure query handling examples match actual usage - [ ] Verify event subscription mechanisms From cdd0b8fc952fe51d2ab48f8ef7bcda20f1d32079 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 08:09:07 -0400 Subject: [PATCH 34/41] Complete CQRS Implementation documentation with query handling and event subscription patterns --- docs/api-reference/README.md | 6 + .../api-reference/types/event-subscription.md | 753 ++++++++++++++++++ docs/api-reference/types/query-handling.md | 474 +++++++++++ docs/documentation-update-checklist.md | 4 +- 4 files changed, 1235 insertions(+), 2 deletions(-) create mode 100644 docs/api-reference/types/event-subscription.md create mode 100644 docs/api-reference/types/query-handling.md diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md index d861498a..0cd1307b 100644 --- a/docs/api-reference/README.md +++ b/docs/api-reference/README.md @@ -67,12 +67,18 @@ For easier navigation through the API reference, use these resources: - [StreamStoreRepository](types/stream-store-repository.md) - Implementation of IRepository - [CorrelatedStreamStoreRepository](types/correlated-stream-store-repository.md) - Implementation of ICorrelatedRepository +- [IReadModelRepository](types/iread-model-repository.md) - Interface for read model repositories ### Event Store - [IStreamStoreConnection](types/istream-store-connection.md) - Interface for event store connections - [StreamStoreConnection](types/stream-store-connection.md) - Implementation of IStreamStoreConnection +### CQRS Components + +- [Query Handling](types/query-handling.md) - Patterns and best practices for handling queries +- [Event Subscription](types/event-subscription.md) - Patterns for subscribing to and processing events + ## Namespaces The Reactive Domain library is organized into the following namespaces: diff --git a/docs/api-reference/types/event-subscription.md b/docs/api-reference/types/event-subscription.md new file mode 100644 index 00000000..15be0d84 --- /dev/null +++ b/docs/api-reference/types/event-subscription.md @@ -0,0 +1,753 @@ +# Event Subscription + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +Event subscription is a critical component of event-driven architectures in Reactive Domain. This document outlines the patterns and best practices for subscribing to and processing events, particularly for updating read models in a CQRS architecture. + +## Overview + +In event-driven systems, components need to react to events as they occur. Event subscription provides the mechanism for components to register interest in specific events and receive notifications when those events occur. This is particularly important in CQRS architectures, where read models need to be updated based on domain events. + +## Subscription Patterns + +### 1. Direct Event Handler Registration + +The simplest approach is to register event handlers directly with an event bus or dispatcher. + +```csharp +// Event handler implementation +public class AccountSummaryProjection : + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _repository; + + public AccountSummaryProjection(IReadModelRepository repository) + { + _repository = repository; + } + + public void Handle(AccountCreated @event) + { + // Update read model + } + + public void Handle(FundsDeposited @event) + { + // Update read model + } + + public void Handle(FundsWithdrawn @event) + { + // Update read model + } +} + +// Registration in startup +public void ConfigureServices(IServiceCollection services) +{ + // Register the projection as a handler for specific events + services.AddScoped, AccountSummaryProjection>(); + services.AddScoped, AccountSummaryProjection>(); + services.AddScoped, AccountSummaryProjection>(); +} +``` + +### 2. Subscription Manager + +A more flexible approach uses a subscription manager to dynamically register and manage event subscriptions. + +```csharp +public interface IEventSubscriptionManager +{ + void Subscribe(IEventHandler handler) where TEvent : IEvent; + void Subscribe(Action handler) where TEvent : IEvent; + void Unsubscribe(IEventHandler handler) where TEvent : IEvent; + void Unsubscribe(Action handler) where TEvent : IEvent; +} + +public class EventSubscriptionManager : IEventSubscriptionManager +{ + private readonly Dictionary> _subscriptions = new Dictionary>(); + + public void Subscribe(IEventHandler handler) where TEvent : IEvent + { + var eventType = typeof(TEvent); + + if (!_subscriptions.ContainsKey(eventType)) + { + _subscriptions[eventType] = new List(); + } + + _subscriptions[eventType].Add(handler); + } + + public void Subscribe(Action handler) where TEvent : IEvent + { + var eventType = typeof(TEvent); + + if (!_subscriptions.ContainsKey(eventType)) + { + _subscriptions[eventType] = new List(); + } + + _subscriptions[eventType].Add(handler); + } + + public void Unsubscribe(IEventHandler handler) where TEvent : IEvent + { + var eventType = typeof(TEvent); + + if (_subscriptions.ContainsKey(eventType)) + { + _subscriptions[eventType].Remove(handler); + } + } + + public void Unsubscribe(Action handler) where TEvent : IEvent + { + var eventType = typeof(TEvent); + + if (_subscriptions.ContainsKey(eventType)) + { + _subscriptions[eventType].Remove(handler); + } + } + + public IEnumerable GetSubscriptionsForEvent() where TEvent : IEvent + { + var eventType = typeof(TEvent); + + if (_subscriptions.ContainsKey(eventType)) + { + return _subscriptions[eventType]; + } + + return new List(); + } +} +``` + +### 3. Event Processor with Catch-Up Capability + +For more robust systems, an event processor that can catch up on missed events is essential. + +```csharp +public interface IEventProcessor +{ + Task StartAsync(CancellationToken cancellationToken = default); + Task StopAsync(CancellationToken cancellationToken = default); + Task SubscribeAsync( + Func handler, + CancellationToken cancellationToken = default) + where TEvent : IEvent; +} + +public class EventStoreEventProcessor : IEventProcessor, IDisposable +{ + private readonly IEventStoreConnection _connection; + private readonly ILogger _logger; + private readonly Dictionary _subscriptions; + private readonly IEventSerializer _serializer; + private bool _disposed; + + public EventStoreEventProcessor( + IEventStoreConnection connection, + IEventSerializer serializer, + ILogger logger) + { + _connection = connection; + _serializer = serializer; + _logger = logger; + _subscriptions = new Dictionary(); + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + try + { + await _connection.ConnectAsync(); + _logger.LogInformation("Connected to Event Store"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect to Event Store"); + throw; + } + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + foreach (var subscription in _subscriptions.Values) + { + subscription.Stop(); + } + + _subscriptions.Clear(); + _logger.LogInformation("Stopped all Event Store subscriptions"); + + return Task.CompletedTask; + } + + public Task SubscribeAsync( + Func handler, + CancellationToken cancellationToken = default) + where TEvent : IEvent + { + var eventType = typeof(TEvent).Name; + var streamName = $"$ce-{eventType}"; + + if (_subscriptions.ContainsKey(streamName)) + { + _logger.LogWarning("Already subscribed to event type {EventType}", eventType); + return Task.CompletedTask; + } + + var settings = new CatchUpSubscriptionSettings( + maxLiveQueueSize: 10000, + readBatchSize: 500, + verboseLogging: false, + resolveLinkTos: true, + subscriptionName: $"Projection-{eventType}"); + + _subscriptions[streamName] = _connection.SubscribeToStreamFrom( + streamName, + StreamCheckpoint.StreamStart, + settings, + eventAppeared: async (subscription, resolvedEvent, cancellationToken) => + { + try + { + if (resolvedEvent.Event.EventType != eventType) + return; + + var eventData = _serializer.Deserialize( + resolvedEvent.Event.Data); + + await handler(eventData); + + _logger.LogDebug( + "Processed event {EventType} with ID {EventId}", + eventType, + resolvedEvent.Event.EventId); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error processing event {EventType} with ID {EventId}", + eventType, + resolvedEvent.Event.EventId); + } + }, + liveProcessingStarted: subscription => + { + _logger.LogInformation( + "Caught up and processing live events for {EventType}", + eventType); + }, + subscriptionDropped: (subscription, reason, exception) => + { + _logger.LogWarning( + exception, + "Subscription dropped for {EventType}: {Reason}", + eventType, + reason); + + // Attempt to reconnect after a delay + Task.Delay(TimeSpan.FromSeconds(5)) + .ContinueWith(_ => SubscribeAsync(handler, cancellationToken)); + }); + + _logger.LogInformation("Subscribed to event type {EventType}", eventType); + return Task.CompletedTask; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + foreach (var subscription in _subscriptions.Values) + { + subscription.Stop(); + } + + _subscriptions.Clear(); + _connection?.Dispose(); + } + + _disposed = true; + } +} +``` + +### 4. Projection Manager + +A projection manager coordinates multiple projections and their event subscriptions. + +```csharp +public interface IProjectionManager +{ + Task StartAllProjectionsAsync(CancellationToken cancellationToken = default); + Task StopAllProjectionsAsync(CancellationToken cancellationToken = default); + Task RegisterProjectionAsync(TProjection projection) where TProjection : class; +} + +public class ProjectionManager : IProjectionManager +{ + private readonly IEventProcessor _eventProcessor; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly List _registeredProjections = new List(); + + public ProjectionManager( + IEventProcessor eventProcessor, + IServiceProvider serviceProvider, + ILogger logger) + { + _eventProcessor = eventProcessor; + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task StartAllProjectionsAsync(CancellationToken cancellationToken = default) + { + await _eventProcessor.StartAsync(cancellationToken); + + foreach (var projection in _registeredProjections) + { + await RegisterEventHandlersAsync(projection, cancellationToken); + } + + _logger.LogInformation("Started all projections"); + } + + public async Task StopAllProjectionsAsync(CancellationToken cancellationToken = default) + { + await _eventProcessor.StopAsync(cancellationToken); + _logger.LogInformation("Stopped all projections"); + } + + public async Task RegisterProjectionAsync(TProjection projection) + where TProjection : class + { + _registeredProjections.Add(projection); + + // If the event processor is already running, register the handlers immediately + await RegisterEventHandlersAsync(projection, CancellationToken.None); + + _logger.LogInformation( + "Registered projection of type {ProjectionType}", + typeof(TProjection).Name); + } + + private async Task RegisterEventHandlersAsync( + object projection, + CancellationToken cancellationToken) + { + var projectionType = projection.GetType(); + + // Find all event handler interfaces implemented by the projection + var handlerInterfaces = projectionType + .GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEventHandler<>)); + + foreach (var handlerInterface in handlerInterfaces) + { + var eventType = handlerInterface.GetGenericArguments()[0]; + var handleMethod = handlerInterface.GetMethod("Handle"); + + if (handleMethod == null) + continue; + + // Create a generic method to subscribe to the event + var subscribeMethod = typeof(ProjectionManager) + .GetMethod(nameof(SubscribeToEvent), BindingFlags.NonPublic | BindingFlags.Instance) + .MakeGenericMethod(eventType); + + await (Task)subscribeMethod.Invoke( + this, + new[] { projection, handleMethod, cancellationToken }); + } + } + + private async Task SubscribeToEvent( + object projection, + MethodInfo handleMethod, + CancellationToken cancellationToken) + where TEvent : IEvent + { + await _eventProcessor.SubscribeAsync( + async @event => + { + try + { + // Convert synchronous handlers to async if needed + var result = handleMethod.Invoke(projection, new object[] { @event }); + + if (result is Task task) + { + await task; + } + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error handling event {EventType} in projection {ProjectionType}", + typeof(TEvent).Name, + projection.GetType().Name); + } + }, + cancellationToken); + } +} +``` + +## Subscription Lifecycle Management + +### Starting Subscriptions on Application Startup + +```csharp +public class ProjectionHostedService : IHostedService +{ + private readonly IProjectionManager _projectionManager; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public ProjectionHostedService( + IProjectionManager projectionManager, + IServiceProvider serviceProvider, + ILogger logger) + { + _projectionManager = projectionManager; + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting projection hosted service"); + + // Register all projections + RegisterProjections(); + + // Start all projections + await _projectionManager.StartAllProjectionsAsync(cancellationToken); + + _logger.LogInformation("Projection hosted service started"); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping projection hosted service"); + + await _projectionManager.StopAllProjectionsAsync(cancellationToken); + + _logger.LogInformation("Projection hosted service stopped"); + } + + private void RegisterProjections() + { + // Register all projections from the service provider + var accountProjection = _serviceProvider.GetRequiredService(); + _projectionManager.RegisterProjectionAsync(accountProjection).GetAwaiter().GetResult(); + + var transactionProjection = _serviceProvider.GetRequiredService(); + _projectionManager.RegisterProjectionAsync(transactionProjection).GetAwaiter().GetResult(); + + // Register other projections as needed + } +} + +// In Startup.cs or Program.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register event processor and projection manager + services.AddSingleton(); + services.AddSingleton(); + + // Register projections + services.AddScoped(); + services.AddScoped(); + + // Register hosted service to manage projection lifecycle + services.AddHostedService(); +} +``` + +## Handling Subscription Failures + +Robust event subscription systems need to handle various failure scenarios: + +### 1. Connection Failures + +```csharp +public class ResilientEventProcessor : IEventProcessor +{ + private readonly IEventProcessor _innerProcessor; + private readonly ILogger _logger; + private readonly int _maxRetries; + private readonly TimeSpan _initialRetryDelay; + + public ResilientEventProcessor( + IEventProcessor innerProcessor, + ILogger logger, + int maxRetries = 5, + TimeSpan? initialRetryDelay = null) + { + _innerProcessor = innerProcessor; + _logger = logger; + _maxRetries = maxRetries; + _initialRetryDelay = initialRetryDelay ?? TimeSpan.FromSeconds(1); + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + int retryCount = 0; + TimeSpan delay = _initialRetryDelay; + + while (true) + { + try + { + await _innerProcessor.StartAsync(cancellationToken); + return; + } + catch (Exception ex) + { + retryCount++; + + if (retryCount > _maxRetries) + { + _logger.LogError( + ex, + "Failed to start event processor after {RetryCount} attempts", + retryCount); + throw; + } + + _logger.LogWarning( + ex, + "Failed to start event processor, retrying in {Delay}ms (attempt {RetryCount}/{MaxRetries})", + delay.TotalMilliseconds, + retryCount, + _maxRetries); + + await Task.Delay(delay, cancellationToken); + + // Exponential backoff + delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); + } + } + } + + // Implement other methods with similar resilience patterns +} +``` + +### 2. Event Processing Failures + +```csharp +// In the event processor +private async Task ProcessEventAsync( + TEvent @event, + Func handler, + string eventId) + where TEvent : IEvent +{ + int retryCount = 0; + TimeSpan delay = TimeSpan.FromMilliseconds(500); + + while (true) + { + try + { + await handler(@event); + return; + } + catch (Exception ex) + { + retryCount++; + + if (retryCount > 3) + { + _logger.LogError( + ex, + "Failed to process event {EventType} with ID {EventId} after {RetryCount} attempts", + typeof(TEvent).Name, + eventId, + retryCount); + + // Consider sending to a dead letter queue or error stream + await SendToDeadLetterQueueAsync(@event, ex); + return; + } + + _logger.LogWarning( + ex, + "Failed to process event {EventType} with ID {EventId}, retrying in {Delay}ms (attempt {RetryCount}/3)", + typeof(TEvent).Name, + eventId, + delay.TotalMilliseconds, + retryCount); + + await Task.Delay(delay); + + // Exponential backoff + delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); + } + } +} + +private async Task SendToDeadLetterQueueAsync(TEvent @event, Exception exception) + where TEvent : IEvent +{ + try + { + var deadLetterEvent = new DeadLetterEvent + { + OriginalEvent = @event, + ErrorMessage = exception.Message, + StackTrace = exception.StackTrace, + Timestamp = DateTime.UtcNow + }; + + // Store in a dead letter queue for later inspection or retry + await _deadLetterRepository.SaveAsync(deadLetterEvent); + + _logger.LogInformation( + "Sent event {EventType} to dead letter queue", + typeof(TEvent).Name); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to send event {EventType} to dead letter queue", + typeof(TEvent).Name); + } +} +``` + +## Monitoring and Observability + +Effective monitoring is essential for event subscription systems: + +```csharp +public class MonitoredEventProcessor : IEventProcessor +{ + private readonly IEventProcessor _innerProcessor; + private readonly IMetricsCollector _metrics; + private readonly ILogger _logger; + + public MonitoredEventProcessor( + IEventProcessor innerProcessor, + IMetricsCollector metrics, + ILogger logger) + { + _innerProcessor = innerProcessor; + _metrics = metrics; + _logger = logger; + } + + public async Task SubscribeAsync( + Func handler, + CancellationToken cancellationToken = default) + where TEvent : IEvent + { + var eventType = typeof(TEvent).Name; + + await _innerProcessor.SubscribeAsync( + async @event => + { + var stopwatch = Stopwatch.StartNew(); + bool success = false; + + try + { + using var activity = Activity.Current?.Source.StartActivity( + $"ProcessEvent.{eventType}"); + + activity?.SetTag("event.type", eventType); + activity?.SetTag("event.id", @event.Id); + + await handler(@event); + success = true; + } + catch (Exception) + { + success = false; + throw; + } + finally + { + stopwatch.Stop(); + + _metrics.RecordEventProcessingDuration( + eventType, + stopwatch.ElapsedMilliseconds); + + if (success) + { + _metrics.IncrementEventProcessedCounter(eventType); + } + else + { + _metrics.IncrementEventProcessingFailureCounter(eventType); + } + } + }, + cancellationToken); + } + + // Implement other methods with similar monitoring +} +``` + +## Best Practices + +1. **Use Asynchronous Event Handlers**: Prefer async event handlers to avoid blocking threads during I/O operations. +2. **Implement Idempotent Handlers**: Ensure event handlers are idempotent to handle duplicate events safely. +3. **Handle Failures Gracefully**: Implement proper error handling and retry logic for event processing failures. +4. **Monitor Subscription Health**: Track metrics like event processing latency, success rates, and queue depths. +5. **Support Catch-Up Subscriptions**: Ensure new or restarted subscribers can catch up on missed events. +6. **Use Dead Letter Queues**: Store failed events in a dead letter queue for later inspection or retry. +7. **Implement Circuit Breakers**: Use circuit breakers to prevent cascading failures when downstream services are unavailable. +8. **Maintain Subscription State**: Store subscription position to resume processing from the last known position after restarts. +9. **Scale Horizontally**: Design subscription systems to scale horizontally for high-throughput scenarios. +10. **Implement Backpressure**: Handle scenarios where events are produced faster than they can be processed. + +## Common Pitfalls + +1. **Ignoring Duplicate Events**: Failing to handle duplicate events can lead to inconsistent read models. +2. **Synchronous Processing**: Processing events synchronously can lead to thread pool starvation and reduced throughput. +3. **Missing Error Handling**: Inadequate error handling can cause subscription failures and data inconsistencies. +4. **Tight Coupling**: Coupling event handlers directly to specific event store implementations makes it difficult to change storage technologies. +5. **Ignoring Ordering**: In some cases, event order matters; failing to handle this can lead to inconsistent state. +6. **Resource Leaks**: Not properly managing subscriptions can lead to resource leaks and memory issues. +7. **Inadequate Monitoring**: Without proper monitoring, it's difficult to detect when subscriptions are falling behind or failing. + +## Related Components + +- [Event](./event.md): Base class for domain events processed by subscriptions +- [IEventHandler](./ievent-handler.md): Interface for event handlers registered with subscriptions +- [ReadModelBase](./read-model-base.md): Base class for read models updated by event handlers +- [IReadModelRepository](./iread-model-repository.md): Interface for repositories that store read models + +--- + +**Navigation**: +- [← Previous: Query Handling](./query-handling.md) +- [↑ Back to Top](#event-subscription) +- [→ Next: AggregateRoot](./aggregate-root.md) diff --git a/docs/api-reference/types/query-handling.md b/docs/api-reference/types/query-handling.md new file mode 100644 index 00000000..e8a6c1e8 --- /dev/null +++ b/docs/api-reference/types/query-handling.md @@ -0,0 +1,474 @@ +# Query Handling + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +Query handling is a critical part of the CQRS (Command Query Responsibility Segregation) pattern in Reactive Domain. This document outlines the best practices and implementation patterns for handling queries efficiently. + +## Overview + +In CQRS, queries are responsible for retrieving data from the system without causing any state changes. They are typically executed against read models that are optimized for specific query scenarios. This separation allows for: + +- Optimizing read and write operations independently +- Scaling read and write sides separately +- Using different storage technologies for reads and writes +- Implementing specialized query patterns without affecting the domain model + +## Query Types + +### 1. Direct Queries + +Direct queries retrieve data based on specific identifiers or simple criteria. + +```csharp +// Query definition +public class GetAccountByIdQuery +{ + public Guid AccountId { get; } + + public GetAccountByIdQuery(Guid accountId) + { + AccountId = accountId; + } +} + +// Query result +public class AccountDto +{ + public Guid Id { get; set; } + public string AccountNumber { get; set; } + public string CustomerName { get; set; } + public decimal Balance { get; set; } + public AccountStatus Status { get; set; } + public DateTime LastUpdated { get; set; } +} +``` + +### 2. List Queries + +List queries retrieve collections of items, often with filtering, sorting, and pagination. + +```csharp +// Query definition +public class ListAccountsQuery +{ + public string CustomerNameFilter { get; } + public AccountStatus? StatusFilter { get; } + public int PageNumber { get; } + public int PageSize { get; } + public string SortBy { get; } + public bool SortDescending { get; } + + public ListAccountsQuery( + string customerNameFilter = null, + AccountStatus? statusFilter = null, + int pageNumber = 1, + int pageSize = 20, + string sortBy = "LastUpdated", + bool sortDescending = true) + { + CustomerNameFilter = customerNameFilter; + StatusFilter = statusFilter; + PageNumber = pageNumber > 0 ? pageNumber : 1; + PageSize = pageSize > 0 && pageSize <= 100 ? pageSize : 20; + SortBy = sortBy; + SortDescending = sortDescending; + } +} + +// Query result +public class AccountListResult +{ + public IEnumerable Accounts { get; } + public int TotalCount { get; } + public int PageNumber { get; } + public int PageSize { get; } + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); + + public AccountListResult( + IEnumerable accounts, + int totalCount, + int pageNumber, + int pageSize) + { + Accounts = accounts; + TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + } +} +``` + +### 3. Aggregate Queries + +Aggregate queries perform calculations or aggregations on data. + +```csharp +// Query definition +public class GetAccountBalanceSummaryQuery +{ + public Guid CustomerId { get; } + + public GetAccountBalanceSummaryQuery(Guid customerId) + { + CustomerId = customerId; + } +} + +// Query result +public class CustomerBalanceSummary +{ + public Guid CustomerId { get; set; } + public string CustomerName { get; set; } + public int TotalAccounts { get; set; } + public decimal TotalBalance { get; set; } + public decimal HighestAccountBalance { get; set; } + public decimal AverageAccountBalance { get; set; } +} +``` + +## Query Handlers + +Query handlers are responsible for processing queries and returning results. They typically interact with read model repositories to retrieve data. + +### Interface Definition + +```csharp +// Synchronous query handler +public interface IQueryHandler +{ + TResult Handle(TQuery query); +} + +// Asynchronous query handler (recommended) +public interface IAsyncQueryHandler +{ + Task HandleAsync(TQuery query, CancellationToken cancellationToken = default); +} +``` + +### Implementation Example + +```csharp +public class GetAccountByIdQueryHandler : IAsyncQueryHandler +{ + private readonly IAsyncReadModelRepository _repository; + private readonly ILogger _logger; + + public GetAccountByIdQueryHandler( + IAsyncReadModelRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task HandleAsync( + GetAccountByIdQuery query, + CancellationToken cancellationToken = default) + { + try + { + var account = await _repository.GetByIdAsync(query.AccountId, cancellationToken); + + if (account == null) + { + _logger.LogInformation( + "Account with ID {AccountId} not found", + query.AccountId); + return null; + } + + return new AccountDto + { + Id = account.Id, + AccountNumber = account.AccountNumber, + CustomerName = account.CustomerName, + Balance = account.Balance, + Status = account.Status, + LastUpdated = account.LastUpdated + }; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error retrieving account with ID {AccountId}", + query.AccountId); + throw; + } + } +} +``` + +### List Query Handler Example + +```csharp +public class ListAccountsQueryHandler : IAsyncQueryHandler +{ + private readonly IQueryableReadModelRepository _repository; + private readonly ILogger _logger; + + public ListAccountsQueryHandler( + IQueryableReadModelRepository repository, + ILogger logger) + { + _repository = repository; + _logger = logger; + } + + public async Task HandleAsync( + ListAccountsQuery query, + CancellationToken cancellationToken = default) + { + try + { + // Build the filter expression + Expression> filter = account => true; + + if (!string.IsNullOrWhiteSpace(query.CustomerNameFilter)) + { + filter = filter.AndAlso(account => + account.CustomerName.Contains(query.CustomerNameFilter)); + } + + if (query.StatusFilter.HasValue) + { + filter = filter.AndAlso(account => + account.Status == query.StatusFilter.Value); + } + + // Get total count for pagination + var totalCount = await _repository.CountAsync(filter, cancellationToken); + + // Calculate skip and take values + var skip = (query.PageNumber - 1) * query.PageSize; + var take = query.PageSize; + + // Get the paged data + var accounts = await _repository.FindAsync( + filter, + skip, + take, + cancellationToken); + + // Map to DTOs + var accountDtos = accounts.Select(account => new AccountDto + { + Id = account.Id, + AccountNumber = account.AccountNumber, + CustomerName = account.CustomerName, + Balance = account.Balance, + Status = account.Status, + LastUpdated = account.LastUpdated + }); + + return new AccountListResult( + accountDtos, + totalCount, + query.PageNumber, + query.PageSize); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing accounts"); + throw; + } + } +} + +// Extension method for combining expressions +public static class ExpressionExtensions +{ + public static Expression> AndAlso( + this Expression> expr1, + Expression> expr2) + { + var parameter = Expression.Parameter(typeof(T)); + + var leftVisitor = new ReplaceExpressionVisitor( + expr1.Parameters[0], parameter); + var left = leftVisitor.Visit(expr1.Body); + + var rightVisitor = new ReplaceExpressionVisitor( + expr2.Parameters[0], parameter); + var right = rightVisitor.Visit(expr2.Body); + + return Expression.Lambda>( + Expression.AndAlso(left, right), parameter); + } + + private class ReplaceExpressionVisitor : ExpressionVisitor + { + private readonly Expression _oldValue; + private readonly Expression _newValue; + + public ReplaceExpressionVisitor(Expression oldValue, Expression newValue) + { + _oldValue = oldValue; + _newValue = newValue; + } + + public override Expression Visit(Expression node) + { + if (node == _oldValue) + return _newValue; + return base.Visit(node); + } + } +} +``` + +## Query Dispatcher + +A query dispatcher provides a centralized way to send queries to their appropriate handlers. + +```csharp +public interface IQueryDispatcher +{ + Task DispatchAsync( + TQuery query, + CancellationToken cancellationToken = default); +} + +public class QueryDispatcher : IQueryDispatcher +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public QueryDispatcher( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task DispatchAsync( + TQuery query, + CancellationToken cancellationToken = default) + { + var handlerType = typeof(IAsyncQueryHandler); + var handler = _serviceProvider.GetService(handlerType); + + if (handler == null) + { + _logger.LogError( + "No handler registered for query type {QueryType}", + typeof(TQuery).Name); + throw new InvalidOperationException( + $"No handler registered for query type {typeof(TQuery).Name}"); + } + + try + { + return await ((IAsyncQueryHandler)handler) + .HandleAsync(query, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error handling query of type {QueryType}", + typeof(TQuery).Name); + throw; + } + } +} +``` + +## Dependency Injection Setup + +```csharp +// In your startup or service configuration +services.AddScoped(); + +// Register query handlers +services.AddScoped, GetAccountByIdQueryHandler>(); +services.AddScoped, ListAccountsQueryHandler>(); +``` + +## Usage in API Controllers + +```csharp +[ApiController] +[Route("api/accounts")] +public class AccountsController : ControllerBase +{ + private readonly IQueryDispatcher _queryDispatcher; + + public AccountsController(IQueryDispatcher queryDispatcher) + { + _queryDispatcher = queryDispatcher; + } + + [HttpGet("{id}")] + public async Task> GetAccount(Guid id) + { + var query = new GetAccountByIdQuery(id); + var result = await _queryDispatcher.DispatchAsync(query); + + if (result == null) + return NotFound(); + + return Ok(result); + } + + [HttpGet] + public async Task> ListAccounts( + [FromQuery] string customerName = null, + [FromQuery] AccountStatus? status = null, + [FromQuery] int pageNumber = 1, + [FromQuery] int pageSize = 20, + [FromQuery] string sortBy = "LastUpdated", + [FromQuery] bool sortDescending = true) + { + var query = new ListAccountsQuery( + customerName, + status, + pageNumber, + pageSize, + sortBy, + sortDescending); + + var result = await _queryDispatcher.DispatchAsync(query); + return Ok(result); + } +} +``` + +## Best Practices + +1. **Keep Queries Simple**: Queries should be simple data transfer objects without complex logic. +2. **Optimize for Read Performance**: Design read models and queries to minimize database round trips and optimize for specific query patterns. +3. **Use Asynchronous Handlers**: Prefer async query handlers to avoid blocking threads during I/O operations. +4. **Implement Pagination**: Always implement pagination for list queries to avoid performance issues with large result sets. +5. **Include Proper Error Handling**: Implement robust error handling in query handlers to provide meaningful error messages. +6. **Use Caching Where Appropriate**: Consider caching frequently accessed or expensive query results. +7. **Return DTOs, Not Domain Objects**: Always return DTOs (Data Transfer Objects) from query handlers, not domain objects or read models. +8. **Validate Queries**: Validate query parameters to ensure they meet business rules and constraints. +9. **Use Dependency Injection**: Register query handlers and repositories with a dependency injection container. +10. **Log Query Performance**: Include logging to track query performance and identify bottlenecks. + +## Common Pitfalls + +1. **Mixing Commands and Queries**: Avoid mixing state changes (commands) with data retrieval (queries). +2. **Over-fetching Data**: Avoid retrieving more data than needed for a specific use case. +3. **N+1 Query Problem**: Be careful not to execute N+1 queries when retrieving related data. +4. **Ignoring Eventual Consistency**: Remember that read models may be eventually consistent with the write model. +5. **Complex Query Logic**: Avoid putting complex business logic in query handlers; keep them focused on data retrieval. +6. **Missing Indexes**: Ensure appropriate database indexes for query performance. +7. **Returning Domain Objects**: Avoid returning domain objects or read models directly to clients. + +## Related Components + +- [ReadModelBase](./read-model-base.md): Base class for read models queried by query handlers +- [IReadModelRepository](./iread-model-repository.md): Interface for repositories that store and retrieve read models +- [Event](./event.md): Base class for domain events that trigger read model updates +- [Command](./command.md): Base class for commands that trigger state changes + +--- + +**Navigation**: +- [← Previous: IReadModelRepository](./iread-model-repository.md) +- [↑ Back to Top](#query-handling) +- [→ Next: Event Subscription](./event-subscription.md) diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index b98f93d7..9ed0a767 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -33,8 +33,8 @@ - [x] Verify separation of command and query models - [x] Update projection examples based on actual usage - [x] Check read model implementation patterns -- [ ] Ensure query handling examples match actual usage -- [ ] Verify event subscription mechanisms +- [x] Ensure query handling examples match actual usage +- [x] Verify event subscription mechanisms ## 6. Event Sourcing Patterns - [ ] Update event replay and state reconstruction examples From 88b7e162121e505e87c067bc5919d44dc17067b8 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 10:07:59 -0400 Subject: [PATCH 35/41] Complete event sourcing patterns and saga implementation patterns documentation --- .../patterns/event-sourcing-patterns.md | 2068 +++++++++++++++++ .../patterns/saga-implementation-patterns.md | 1541 ++++++++++++ docs/documentation-update-checklist.md | 20 +- 3 files changed, 3619 insertions(+), 10 deletions(-) create mode 100644 docs/api-reference/patterns/event-sourcing-patterns.md create mode 100644 docs/api-reference/patterns/saga-implementation-patterns.md diff --git a/docs/api-reference/patterns/event-sourcing-patterns.md b/docs/api-reference/patterns/event-sourcing-patterns.md new file mode 100644 index 00000000..2bbd1a0f --- /dev/null +++ b/docs/api-reference/patterns/event-sourcing-patterns.md @@ -0,0 +1,2068 @@ +# Event Sourcing Patterns + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +This document outlines the key patterns and best practices for implementing event sourcing in Reactive Domain applications. Event sourcing is a powerful architectural pattern where all changes to application state are stored as a sequence of events, providing a complete audit trail and enabling advanced capabilities like temporal queries and event replay. + +## Table of Contents + +1. [Event Replay and State Reconstruction](#event-replay-and-state-reconstruction) +2. [Snapshot Implementation](#snapshot-implementation) +3. [Versioning Strategies for Events](#versioning-strategies-for-events) +4. [Stream Management](#stream-management) +5. [Event Serialization](#event-serialization) +6. [Best Practices](#best-practices) +7. [Common Pitfalls](#common-pitfalls) + +## Event Replay and State Reconstruction + +Event replay is the process of reconstructing the state of an entity by applying all historical events in sequence. This is a fundamental concept in event sourcing and is used both when loading entities and when rebuilding projections. + +### Basic Event Replay + +The most straightforward approach to event replay is to apply all events in sequence: + +```csharp +public class Account : AggregateRoot +{ + private decimal _balance; + private bool _isActive; + private string _accountNumber; + private string _customerName; + + public Account(Guid id) : base(id) + { + // Initialize default state + _isActive = false; + _balance = 0; + } + + // IEventSource implementation + public override void RestoreFromEvents(IEnumerable events) + { + if (events == null) + throw new ArgumentNullException(nameof(events)); + + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + // Event handlers + private void Apply(AccountCreated @event) + { + _isActive = true; + _accountNumber = @event.AccountNumber; + _customerName = @event.CustomerName; + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } +} +``` + +### Optimized Event Replay with Snapshots + +For entities with long event histories, snapshots can significantly improve loading performance: + +```csharp +public class Account : AggregateRoot, ISnapshotSource +{ + private decimal _balance; + private bool _isActive; + private string _accountNumber; + private string _customerName; + + public long SnapshotVersion { get; set; } + + public Account(Guid id) : base(id) + { + _isActive = false; + _balance = 0; + } + + // Snapshot methods + public object CreateSnapshot() + { + return new AccountSnapshot + { + Balance = _balance, + IsActive = _isActive, + AccountNumber = _accountNumber, + CustomerName = _customerName + }; + } + + public void RestoreFromSnapshot(object snapshot) + { + if (snapshot is AccountSnapshot accountSnapshot) + { + _balance = accountSnapshot.Balance; + _isActive = accountSnapshot.IsActive; + _accountNumber = accountSnapshot.AccountNumber; + _customerName = accountSnapshot.CustomerName; + } + else + { + throw new ArgumentException($"Expected AccountSnapshot but got {snapshot.GetType().Name}"); + } + } +} + +// Repository implementation +public T GetById(Guid id) where T : IEventSource +{ + // Try to get the latest snapshot + var snapshot = _snapshotStore.GetLatestSnapshot(id); + + // Create a new instance of the aggregate + var aggregate = Activator.CreateInstance(typeof(T), id) as T; + + if (snapshot != null && aggregate is ISnapshotSource snapshotSource) + { + // Restore from snapshot + snapshotSource.RestoreFromSnapshot(snapshot.State); + snapshotSource.SnapshotVersion = snapshot.Version; + + // Get events after the snapshot + var events = _eventStore.GetEventsAfterVersion( + id.ToString(), + snapshot.Version); + + // Apply events after the snapshot + aggregate.RestoreFromEvents(events); + } + else + { + // No snapshot available, load all events + var events = _eventStore.GetEvents(id.ToString()); + aggregate.RestoreFromEvents(events); + } + + return aggregate; +} +``` + +### Parallel Event Replay for Projections + +For read model projections that process large numbers of events, parallel processing can improve performance: + +```csharp +public class ParallelProjectionEngine +{ + private readonly IEventStore _eventStore; + private readonly IReadModelRepository _repository; + private readonly int _batchSize; + private readonly int _maxDegreeOfParallelism; + + public ParallelProjectionEngine( + IEventStore eventStore, + IReadModelRepository repository, + int batchSize = 1000, + int maxDegreeOfParallelism = 4) + { + _eventStore = eventStore; + _repository = repository; + _batchSize = batchSize; + _maxDegreeOfParallelism = maxDegreeOfParallelism; + } + + public async Task RebuildProjectionAsync(CancellationToken cancellationToken = default) + { + // Clear existing projection data + await _repository.ClearAllAsync(cancellationToken); + + // Get all event streams (one per aggregate) + var streams = await _eventStore.GetAllStreamIdsAsync(cancellationToken); + + // Process streams in parallel + await Parallel.ForEachAsync( + streams, + new ParallelOptions + { + MaxDegreeOfParallelism = _maxDegreeOfParallelism, + CancellationToken = cancellationToken + }, + async (streamId, ct) => + { + // Process each stream + await ProcessStreamAsync(streamId, ct); + }); + } + + private async Task ProcessStreamAsync(string streamId, CancellationToken cancellationToken) + { + long position = 0; + bool hasMoreEvents = true; + + while (hasMoreEvents && !cancellationToken.IsCancellationRequested) + { + // Get events in batches + var events = await _eventStore.GetEventsAsync( + streamId, + position, + _batchSize, + cancellationToken); + + if (events.Count == 0) + { + hasMoreEvents = false; + continue; + } + + // Process events + foreach (var @event in events) + { + await ProcessEventAsync(@event, cancellationToken); + position = @event.Position + 1; + } + } + } + + private async Task ProcessEventAsync(EventData eventData, CancellationToken cancellationToken) + { + // Deserialize and process the event based on its type + switch (eventData.EventType) + { + case "AccountCreated": + var accountCreated = _serializer.Deserialize(eventData.Data); + await HandleAccountCreatedAsync(accountCreated, cancellationToken); + break; + + case "FundsDeposited": + var fundsDeposited = _serializer.Deserialize(eventData.Data); + await HandleFundsDepositedAsync(fundsDeposited, cancellationToken); + break; + + case "FundsWithdrawn": + var fundsWithdrawn = _serializer.Deserialize(eventData.Data); + await HandleFundsWithdrawnAsync(fundsWithdrawn, cancellationToken); + break; + } + } + + private async Task HandleAccountCreatedAsync( + AccountCreated @event, + CancellationToken cancellationToken) + { + var accountSummary = new AccountSummary(@event.AccountId) + { + AccountNumber = @event.AccountNumber, + CustomerName = @event.CustomerName, + Balance = 0, + IsActive = true, + LastUpdated = DateTime.UtcNow + }; + + await _repository.SaveAsync(accountSummary, cancellationToken); + } + + private async Task HandleFundsDepositedAsync( + FundsDeposited @event, + CancellationToken cancellationToken) + { + var accountSummary = await _repository.GetByIdAsync(@event.AccountId, cancellationToken); + if (accountSummary != null) + { + accountSummary.Balance += @event.Amount; + accountSummary.LastUpdated = DateTime.UtcNow; + await _repository.SaveAsync(accountSummary, cancellationToken); + } + } + + private async Task HandleFundsWithdrawnAsync( + FundsWithdrawn @event, + CancellationToken cancellationToken) + { + var accountSummary = await _repository.GetByIdAsync(@event.AccountId, cancellationToken); + if (accountSummary != null) + { + accountSummary.Balance -= @event.Amount; + accountSummary.LastUpdated = DateTime.UtcNow; + await _repository.SaveAsync(accountSummary, cancellationToken); + } + } +} +``` + +### Temporal Queries + +Event sourcing enables temporal queries, allowing you to determine the state of an entity at any point in time: + +```csharp +public T GetByIdAtVersion(Guid id, long version) where T : IEventSource +{ + // Create a new instance of the aggregate + var aggregate = Activator.CreateInstance(typeof(T), id) as T; + + // Get events up to the specified version + var events = _eventStore.GetEventsUpToVersion(id.ToString(), version); + + // Apply events + aggregate.RestoreFromEvents(events); + + return aggregate; +} + +public T GetByIdAtTimestamp(Guid id, DateTime timestamp) where T : IEventSource +{ + // Create a new instance of the aggregate + var aggregate = Activator.CreateInstance(typeof(T), id) as T; + + // Get events up to the specified timestamp + var events = _eventStore.GetEventsUpToTimestamp(id.ToString(), timestamp); + + // Apply events + aggregate.RestoreFromEvents(events); + + return aggregate; +} +``` + +## Snapshot Implementation + +Snapshots are point-in-time captures of an entity's state that can be used to optimize loading performance for entities with long event histories. Instead of replaying all events from the beginning, the entity can be restored from the most recent snapshot and then only apply events that occurred after the snapshot was taken. + +### Snapshot Interface + +In Reactive Domain, snapshots are implemented through the `ISnapshotSource` interface: + +```csharp +public interface ISnapshotSource : IEventSource +{ + object CreateSnapshot(); + void RestoreFromSnapshot(object snapshot); + long SnapshotVersion { get; set; } +} +``` + +### Snapshot Creation Strategy + +Snapshots should be created at strategic points to balance storage and performance. A common approach is to create snapshots based on the number of events since the last snapshot: + +```csharp +public void Save(T aggregate) where T : IEventSource +{ + // Get new events + var newEvents = aggregate.TakeEvents(); + + if (newEvents.Length > 0) + { + // Save events + _connection.AppendToStream( + aggregate.Id.ToString(), + aggregate.ExpectedVersion, + newEvents); + + // Update expected version + aggregate.ExpectedVersion += newEvents.Length; + + // Check if a snapshot should be created + if (aggregate is ISnapshotSource snapshotSource) + { + // Calculate the number of events since the last snapshot + var eventsSinceSnapshot = aggregate.ExpectedVersion - snapshotSource.SnapshotVersion; + + // Create a snapshot if there are enough new events + if (eventsSinceSnapshot >= _snapshotFrequency) + { + var snapshot = snapshotSource.CreateSnapshot(); + _snapshotStore.SaveSnapshot( + aggregate.Id, + aggregate.ExpectedVersion, + snapshot); + + snapshotSource.SnapshotVersion = aggregate.ExpectedVersion; + } + } + } +} +``` + +### Snapshot Storage + +Snapshots need to be stored in a way that allows efficient retrieval by aggregate ID and version. Here's an example of a snapshot store implementation using a document database: + +```csharp +public class DocumentDbSnapshotStore : ISnapshotStore +{ + private readonly IMongoCollection _snapshots; + private readonly ILogger _logger; + + public DocumentDbSnapshotStore( + IMongoDatabase database, + ILogger logger) + { + _snapshots = database.GetCollection("snapshots"); + _logger = logger; + + // Create indexes for efficient retrieval + var indexKeysDefinition = Builders.IndexKeys + .Ascending(s => s.AggregateId) + .Descending(s => s.Version); + + _snapshots.Indexes.CreateOne(new CreateIndexModel(indexKeysDefinition)); + } + + public async Task GetLatestSnapshotAsync( + Guid aggregateId, + CancellationToken cancellationToken = default) + { + try + { + var filter = Builders.Filter.Eq(s => s.AggregateId, aggregateId); + var sort = Builders.Sort.Descending(s => s.Version); + + var snapshot = await _snapshots + .Find(filter) + .Sort(sort) + .FirstOrDefaultAsync(cancellationToken); + + if (snapshot == null) + return null; + + return new SnapshotInfo + { + AggregateId = snapshot.AggregateId, + Version = snapshot.Version, + Timestamp = snapshot.Timestamp, + State = snapshot.State + }; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error retrieving latest snapshot for aggregate {AggregateId}", + aggregateId); + throw; + } + } + + public async Task SaveSnapshotAsync( + Guid aggregateId, + long version, + object state, + CancellationToken cancellationToken = default) + { + try + { + var document = new SnapshotDocument + { + AggregateId = aggregateId, + Version = version, + Timestamp = DateTime.UtcNow, + State = state + }; + + await _snapshots.InsertOneAsync(document, cancellationToken: cancellationToken); + + _logger.LogInformation( + "Saved snapshot for aggregate {AggregateId} at version {Version}", + aggregateId, + version); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error saving snapshot for aggregate {AggregateId} at version {Version}", + aggregateId, + version); + throw; + } + } + + // Optional: Cleanup old snapshots + public async Task CleanupSnapshotsAsync( + Guid aggregateId, + int keepLatest = 5, + CancellationToken cancellationToken = default) + { + try + { + var filter = Builders.Filter.Eq(s => s.AggregateId, aggregateId); + var sort = Builders.Sort.Descending(s => s.Version); + + // Get the versions of the snapshots to keep + var versionsToKeep = await _snapshots + .Find(filter) + .Sort(sort) + .Limit(keepLatest) + .Project(s => s.Version) + .ToListAsync(cancellationToken); + + if (versionsToKeep.Count < keepLatest) + return; // Not enough snapshots to clean up + + // Delete older snapshots + var deleteFilter = Builders.Filter.And( + Builders.Filter.Eq(s => s.AggregateId, aggregateId), + Builders.Filter.Lt(s => s.Version, versionsToKeep.Min())); + + var result = await _snapshots.DeleteManyAsync(deleteFilter, cancellationToken); + + _logger.LogInformation( + "Cleaned up {DeletedCount} old snapshots for aggregate {AggregateId}", + result.DeletedCount, + aggregateId); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error cleaning up snapshots for aggregate {AggregateId}", + aggregateId); + throw; + } + } +} + +public class SnapshotDocument +{ + [BsonId] + public ObjectId Id { get; set; } + + public Guid AggregateId { get; set; } + + public long Version { get; set; } + + public DateTime Timestamp { get; set; } + + public object State { get; set; } +} +``` + +### Snapshot Serialization + +Snapshots need to be serializable for storage. It's important to design snapshot classes with serialization in mind: + +```csharp +[Serializable] +public class AccountSnapshot +{ + public decimal Balance { get; set; } + public bool IsActive { get; set; } + public string AccountNumber { get; set; } + public string CustomerName { get; set; } + public List RecentTransactions { get; set; } = new List(); +} + +[Serializable] +public class TransactionSummary +{ + public TransactionType Type { get; set; } + public decimal Amount { get; set; } + public DateTime Timestamp { get; set; } +} + +public enum TransactionType +{ + Deposit, + Withdrawal +} +``` + +## Versioning Strategies for Events + +As systems evolve, event schemas may need to change. Proper versioning strategies ensure that older events can still be processed by newer versions of the system. + +### Event Versioning Approaches + +#### 1. Explicit Versioning + +Include a version number in the event class: + +```csharp +public class AccountCreated : Event +{ + public int Version { get; } = 2; + public Guid AccountId { get; } + public string AccountNumber { get; } + public string CustomerName { get; } + public string Email { get; } // Added in version 2 + + public AccountCreated(Guid accountId, string accountNumber, string customerName, string email = null) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + Email = email; // Optional in version 2, not present in version 1 + } +} +``` + +#### 2. Event Upcasting + +Transform older event versions to newer versions during deserialization: + +```csharp +public class EventUpcastingSerializer : IEventSerializer +{ + private readonly IEventSerializer _innerSerializer; + private readonly Dictionary>> _upcasters = + new Dictionary>>(); + + public EventUpcastingSerializer(IEventSerializer innerSerializer) + { + _innerSerializer = innerSerializer; + } + + public void RegisterUpcaster(Func upcaster) + { + var eventType = typeof(TEvent); + + if (!_upcasters.ContainsKey(eventType)) + { + _upcasters[eventType] = new List>(); + } + + _upcasters[eventType].Add(e => upcaster((TEvent)e)); + } + + public byte[] Serialize(T @event) + { + return _innerSerializer.Serialize(@event); + } + + public T Deserialize(byte[] data) + { + var deserialized = _innerSerializer.Deserialize(data); + + if (_upcasters.TryGetValue(typeof(T), out var upcasters)) + { + var result = deserialized; + + foreach (var upcaster in upcasters) + { + result = (T)upcaster(result); + } + + return (T)result; + } + + return deserialized; + } +} + +// Usage +var serializer = new EventUpcastingSerializer(new JsonEventSerializer()); + +// Register upcasters +serializer.RegisterUpcaster(oldEvent => + new AccountCreatedV2 + { + AccountId = oldEvent.AccountId, + AccountNumber = oldEvent.AccountNumber, + CustomerName = oldEvent.CustomerName, + Email = null // Default value for new field + }); +``` + +#### 3. Polymorphic Event Handlers + +Implement event handlers that can handle multiple versions of an event: + +```csharp +public class Account : AggregateRoot +{ + private void Apply(object @event) + { + switch (@event) + { + case AccountCreatedV1 e: + ApplyAccountCreated(e.AccountId, e.AccountNumber, e.CustomerName, null); + break; + + case AccountCreatedV2 e: + ApplyAccountCreated(e.AccountId, e.AccountNumber, e.CustomerName, e.Email); + break; + + // Other event handlers... + } + } + + private void ApplyAccountCreated(Guid accountId, string accountNumber, string customerName, string email) + { + _accountNumber = accountNumber; + _customerName = customerName; + _email = email ?? "unknown@example.com"; // Default value if email is null + _isActive = true; + } +} +``` + +### Handling Breaking Changes + +For more significant changes, consider these strategies: + +#### 1. Side-by-Side Versioning + +Maintain multiple versions of event handlers: + +```csharp +public class AccountProjection : + IEventHandler, + IEventHandler +{ + private readonly IReadModelRepository _repository; + + public AccountProjection(IReadModelRepository repository) + { + _repository = repository; + } + + public void Handle(AccountCreatedV1 @event) + { + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update( + @event.AccountNumber, + @event.CustomerName, + "unknown@example.com", // Default value for missing field + 0, + true); + + _repository.Save(accountSummary); + } + + public void Handle(AccountCreatedV2 @event) + { + var accountSummary = new AccountSummary(@event.AccountId); + accountSummary.Update( + @event.AccountNumber, + @event.CustomerName, + @event.Email, + 0, + true); + + _repository.Save(accountSummary); + } +} +``` + +#### 2. Event Transformation + +Transform events during replay: + +```csharp +public class EventTransformationPipeline +{ + private readonly List _transformers = new List(); + + public void RegisterTransformer(IEventTransformer transformer) + { + _transformers.Add(transformer); + } + + public IEnumerable TransformEvents(IEnumerable events) + { + var result = events; + + foreach (var transformer in _transformers) + { + result = transformer.Transform(result); + } + + return result; + } +} + +public interface IEventTransformer +{ + IEnumerable Transform(IEnumerable events); +} + +public class AccountCreatedTransformer : IEventTransformer +{ + public IEnumerable Transform(IEnumerable events) + { + foreach (var @event in events) + { + if (@event is AccountCreatedV1 oldEvent) + { + yield return new AccountCreatedV2 + { + AccountId = oldEvent.AccountId, + AccountNumber = oldEvent.AccountNumber, + CustomerName = oldEvent.CustomerName, + Email = "unknown@example.com" // Default value + }; + } + else + { + yield return @event; + } + } + } +} +``` + +## Stream Management + +Event streams are the core data structure in event sourcing. Proper stream management is essential for scalability and performance. + +### Stream Naming Conventions + +Consistent stream naming conventions make it easier to organize and query events: + +```csharp +public static class StreamNamingConventions +{ + // Individual aggregate streams + public static string GetAggregateStreamName(Guid aggregateId) where T : IEventSource + { + return $"{typeof(T).Name}-{aggregateId}"; + } + + // Category streams (all events of a specific type) + public static string GetCategoryStreamName() where T : IEventSource + { + return $"$ce-{typeof(T).Name}"; + } + + // All events stream + public static string GetAllEventsStreamName() + { + return "$all"; + } +} +``` + +### Stream Partitioning + +For high-throughput systems, partitioning streams can improve scalability: + +```csharp +public static class PartitionedStreamNamingConventions +{ + public static string GetPartitionedStreamName(Guid aggregateId, int partitionCount) where T : IEventSource + { + // Use the least significant bits of the GUID to determine the partition + var partition = Math.Abs(aggregateId.GetHashCode()) % partitionCount; + return $"{typeof(T).Name}-P{partition}-{aggregateId}"; + } + + public static string GetPartitionCategoryStreamName(int partition) where T : IEventSource + { + return $"$ce-{typeof(T).Name}-P{partition}"; + } +} +``` + +### Stream Metadata + +Stream metadata can store additional information about a stream: + +```csharp +public class StreamMetadata +{ + public string AggregateType { get; set; } + public int MaxCount { get; set; } + public TimeSpan MaxAge { get; set; } + public bool Truncated { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } +} + +public class EventStoreExtensions +{ + public static async Task SetStreamMetadataAsync( + this IEventStoreConnection connection, + string streamName, + StreamMetadata metadata, + CancellationToken cancellationToken = default) + { + var metadataJson = JsonConvert.SerializeObject(metadata); + var metadataBytes = Encoding.UTF8.GetBytes(metadataJson); + + await connection.SetStreamMetadataAsync( + streamName, + ExpectedVersion.Any, + metadataBytes, + cancellationToken); + } + + public static async Task GetStreamMetadataAsync( + this IEventStoreConnection connection, + string streamName, + CancellationToken cancellationToken = default) + { + var metadata = await connection.GetStreamMetadataAsync(streamName, cancellationToken); + + if (metadata.StreamMetadata.Length == 0) + return new StreamMetadata + { + CreatedUtc = DateTime.UtcNow, + LastModifiedUtc = DateTime.UtcNow + }; + + var metadataJson = Encoding.UTF8.GetString(metadata.StreamMetadata); + return JsonConvert.DeserializeObject(metadataJson); + } +} +``` + +### Stream Lifecycle Management + +Managing the lifecycle of streams is important for long-lived systems: + +```csharp +public class StreamLifecycleManager +{ + private readonly IEventStoreConnection _connection; + private readonly ILogger _logger; + + public StreamLifecycleManager( + IEventStoreConnection connection, + ILogger logger) + { + _connection = connection; + _logger = logger; + } + + public async Task SetStreamTruncationPolicyAsync( + string streamName, + int? maxCount = null, + TimeSpan? maxAge = null, + CancellationToken cancellationToken = default) + { + try + { + var metadata = await _connection.GetStreamMetadataAsync(streamName, cancellationToken); + var streamMetadata = new StreamMetadata(); + + if (metadata.StreamMetadata.Length > 0) + { + var metadataJson = Encoding.UTF8.GetString(metadata.StreamMetadata); + streamMetadata = JsonConvert.DeserializeObject(metadataJson); + } + else + { + streamMetadata.CreatedUtc = DateTime.UtcNow; + } + + if (maxCount.HasValue) + streamMetadata.MaxCount = maxCount.Value; + + if (maxAge.HasValue) + streamMetadata.MaxAge = maxAge.Value; + + streamMetadata.LastModifiedUtc = DateTime.UtcNow; + + await _connection.SetStreamMetadataAsync( + streamName, + metadata.MetastreamVersion, + Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(streamMetadata)), + cancellationToken); + + _logger.LogInformation( + "Set truncation policy for stream {StreamName}: MaxCount={MaxCount}, MaxAge={MaxAge}", + streamName, + maxCount, + maxAge); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error setting truncation policy for stream {StreamName}", + streamName); + throw; + } + } + + public async Task ArchiveStreamAsync( + string streamName, + string archivePrefix = "Archive-", + CancellationToken cancellationToken = default) + { + try + { + // Read all events from the source stream + var events = await _connection.ReadStreamEventsForwardAsync( + streamName, + 0, + 1000, + false, + cancellationToken); + + if (events.Status == SliceReadStatus.StreamNotFound) + { + _logger.LogWarning( + "Stream {StreamName} not found for archiving", + streamName); + return; + } + + // Create archive stream name + var archiveStreamName = $"{archivePrefix}{streamName}-{DateTime.UtcNow:yyyyMMddHHmmss}"; + + // Write events to archive stream + var eventsToWrite = events.Events.Select(e => new EventData( + e.Event.EventId, + e.Event.EventType, + e.Event.IsJson, + e.Event.Data, + e.Event.Metadata)); + + await _connection.AppendToStreamAsync( + archiveStreamName, + ExpectedVersion.NoStream, + eventsToWrite, + cancellationToken); + + // Mark original stream as archived in metadata + var metadata = await _connection.GetStreamMetadataAsync(streamName, cancellationToken); + var streamMetadata = new StreamMetadata(); + + if (metadata.StreamMetadata.Length > 0) + { + var metadataJson = Encoding.UTF8.GetString(metadata.StreamMetadata); + streamMetadata = JsonConvert.DeserializeObject(metadataJson); + } + + streamMetadata.Truncated = true; + streamMetadata.LastModifiedUtc = DateTime.UtcNow; + + await _connection.SetStreamMetadataAsync( + streamName, + metadata.MetastreamVersion, + Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(streamMetadata)), + cancellationToken); + + _logger.LogInformation( + "Archived stream {StreamName} to {ArchiveStreamName}", + streamName, + archiveStreamName); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error archiving stream {StreamName}", + streamName); + throw; + } + } +} +``` + +## Event Serialization + +Event serialization is a critical aspect of event sourcing, as it determines how events are stored and retrieved. The serialization strategy must ensure that events can be correctly deserialized even as the system evolves over time. + +### JSON Serialization + +JSON is a common format for event serialization due to its readability and flexibility: + +```csharp +public class JsonEventSerializer : IEventSerializer +{ + private readonly JsonSerializerSettings _serializerSettings; + + public JsonEventSerializer() + { + _serializerSettings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.All, + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore, + PreserveReferencesHandling = PreserveReferencesHandling.Objects, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + } + + public byte[] Serialize(T @event) + { + var json = JsonConvert.SerializeObject(@event, _serializerSettings); + return Encoding.UTF8.GetBytes(json); + } + + public T Deserialize(byte[] data) + { + var json = Encoding.UTF8.GetString(data); + return JsonConvert.DeserializeObject(json, _serializerSettings); + } + + public object Deserialize(byte[] data, Type type) + { + var json = Encoding.UTF8.GetString(data); + return JsonConvert.DeserializeObject(json, type, _serializerSettings); + } +} +``` + +### Event Type Resolution + +To correctly deserialize events, the system needs to know the event type. There are several approaches to this: + +#### 1. Type Information in the Event Data + +```csharp +public class EventData +{ + public Guid EventId { get; } + public string EventType { get; } + public byte[] Data { get; } + public byte[] Metadata { get; } + + public EventData(Guid eventId, string eventType, byte[] data, byte[] metadata) + { + EventId = eventId; + EventType = eventType; + Data = data; + Metadata = metadata; + } +} + +public class EventTypeResolver +{ + private readonly Dictionary _typeMap = new Dictionary(); + + public void RegisterType(string typeName = null) + { + var type = typeof(T); + var name = typeName ?? type.Name; + + _typeMap[name] = type; + } + + public Type ResolveType(string typeName) + { + if (_typeMap.TryGetValue(typeName, out var type)) + return type; + + throw new InvalidOperationException($"Unknown event type: {typeName}"); + } +} +``` + +#### 2. Type Information in the Serialized Data + +```csharp +public class TypeAwareJsonEventSerializer : IEventSerializer +{ + private readonly JsonSerializerSettings _serializerSettings; + + public TypeAwareJsonEventSerializer() + { + _serializerSettings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.All, + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore + }; + } + + public byte[] Serialize(T @event) + { + var json = JsonConvert.SerializeObject(@event, _serializerSettings); + return Encoding.UTF8.GetBytes(json); + } + + public T Deserialize(byte[] data) + { + var json = Encoding.UTF8.GetString(data); + return JsonConvert.DeserializeObject(json, _serializerSettings); + } + + public object Deserialize(byte[] data) + { + var json = Encoding.UTF8.GetString(data); + return JsonConvert.DeserializeObject(json, _serializerSettings); + } +} +``` + +### Event Metadata + +Metadata provides additional context for events without affecting the event data itself: + +```csharp +public class EventMetadata +{ + public Guid CorrelationId { get; set; } + public Guid CausationId { get; set; } + public string UserId { get; set; } + public DateTime Timestamp { get; set; } + public string EventType { get; set; } + public int EventVersion { get; set; } + public Dictionary AdditionalData { get; set; } = new Dictionary(); +} + +public class EventWithMetadata +{ + public T Event { get; } + public EventMetadata Metadata { get; } + + public EventWithMetadata(T @event, EventMetadata metadata) + { + Event = @event; + Metadata = metadata; + } +} + +public class MetadataSerializer +{ + private readonly JsonSerializerSettings _serializerSettings; + + public MetadataSerializer() + { + _serializerSettings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore + }; + } + + public byte[] SerializeMetadata(EventMetadata metadata) + { + var json = JsonConvert.SerializeObject(metadata, _serializerSettings); + return Encoding.UTF8.GetBytes(json); + } + + public EventMetadata DeserializeMetadata(byte[] data) + { + if (data == null || data.Length == 0) + return new EventMetadata(); + + var json = Encoding.UTF8.GetString(data); + return JsonConvert.DeserializeObject(json, _serializerSettings); + } +} +``` + +### Serialization Compatibility + +To ensure backward and forward compatibility, consider these strategies: + +#### 1. Schema Evolution + +```csharp +// Version 1 +public class AccountCreatedV1 +{ + public Guid AccountId { get; set; } + public string AccountNumber { get; set; } + public string CustomerName { get; set; } +} + +// Version 2 - Added Email field +public class AccountCreatedV2 +{ + public Guid AccountId { get; set; } + public string AccountNumber { get; set; } + public string CustomerName { get; set; } + public string Email { get; set; } +} + +// Version 3 - Added Phone field, removed AccountNumber +public class AccountCreatedV3 +{ + public Guid AccountId { get; set; } + public string CustomerName { get; set; } + public string Email { get; set; } + public string Phone { get; set; } +} +``` + +#### 2. Serialization Versioning + +```csharp +public class VersionedJsonEventSerializer : IEventSerializer +{ + private readonly Dictionary>> _upcasters = + new Dictionary>>(); + private readonly Dictionary _currentVersions = new Dictionary(); + + public void RegisterEventType(int currentVersion) + { + _currentVersions[typeof(T)] = currentVersion; + } + + public void RegisterUpcaster(int fromVersion, int toVersion, Func upcaster) + { + var type = typeof(T); + + if (!_upcasters.ContainsKey(type)) + _upcasters[type] = new Dictionary>(); + + _upcasters[type][fromVersion] = upcaster; + } + + public byte[] Serialize(T @event) + { + var type = typeof(T); + var version = _currentVersions.TryGetValue(type, out var v) ? v : 1; + + var wrapper = new EventWrapper + { + EventType = type.AssemblyQualifiedName, + EventVersion = version, + EventData = JsonConvert.SerializeObject(@event) + }; + + return Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(wrapper)); + } + + public T Deserialize(byte[] data) + { + var json = Encoding.UTF8.GetString(data); + var wrapper = JsonConvert.DeserializeObject(json); + + var eventType = Type.GetType(wrapper.EventType); + var eventVersion = wrapper.EventVersion; + var eventData = wrapper.EventData; + + var deserialized = JsonConvert.DeserializeObject(eventData, eventType); + + // Apply upcasters if needed + if (_upcasters.TryGetValue(eventType, out var typeUpcasters)) + { + var currentVersion = _currentVersions.TryGetValue(eventType, out var v) ? v : 1; + + for (int version = eventVersion; version < currentVersion; version++) + { + if (typeUpcasters.TryGetValue(version, out var upcaster)) + { + deserialized = upcaster(deserialized); + } + } + } + + return (T)deserialized; + } +} + +public class EventWrapper +{ + public string EventType { get; set; } + public int EventVersion { get; set; } + public string EventData { get; set; } +} + +## Best Practices + +### 1. Design Events Carefully + +Events should be designed to capture business intent and be as self-contained as possible: + +```csharp +// Good: Captures business intent clearly +public class AccountCreated +{ + public Guid AccountId { get; } + public string AccountNumber { get; } + public string CustomerName { get; } + public decimal InitialBalance { get; } + public DateTime CreatedAt { get; } + + public AccountCreated(Guid accountId, string accountNumber, string customerName, decimal initialBalance, DateTime createdAt) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + InitialBalance = initialBalance; + CreatedAt = createdAt; + } +} + +// Bad: Missing important context +public class AccountCreated +{ + public Guid AccountId { get; set; } + public string AccountNumber { get; set; } +} +``` + +### 2. Immutable Events + +Events should be immutable to maintain the integrity of the event log: + +```csharp +// Good: Immutable event with readonly properties +public class FundsDeposited +{ + public Guid AccountId { get; } + public decimal Amount { get; } + public string Description { get; } + public DateTime DepositedAt { get; } + + public FundsDeposited(Guid accountId, decimal amount, string description, DateTime depositedAt) + { + AccountId = accountId; + Amount = amount; + Description = description; + DepositedAt = depositedAt; + } +} + +// Bad: Mutable event +public class FundsDeposited +{ + public Guid AccountId { get; set; } + public decimal Amount { get; set; } + public string Description { get; set; } + public DateTime DepositedAt { get; set; } +} +``` + +### 3. Consistent Naming + +Use consistent naming conventions for events, typically past tense to indicate something that has happened: + +```csharp +// Good: Past tense naming +public class AccountCreated { /* ... */ } +public class FundsDeposited { /* ... */ } +public class CustomerAddressChanged { /* ... */ } +public class AccountClosed { /* ... */ } + +// Bad: Inconsistent naming +public class CreateAccount { /* ... */ } +public class DepositingFunds { /* ... */ } +public class ChangeAddress { /* ... */ } +public class AccountWasClosed { /* ... */ } +``` + +### 4. Use Event Metadata + +Capture important metadata with each event: + +```csharp +public interface IEventStore +{ + Task> GetEventsAsync(Guid aggregateId, long fromVersion = 0); + Task SaveEventsAsync(Guid aggregateId, IEnumerable events, long expectedVersion, EventMetadata metadata); +} + +public class EventStoreRepository where T : AggregateRoot, new() +{ + private readonly IEventStore _eventStore; + private readonly IEventPublisher _eventPublisher; + + public EventStoreRepository(IEventStore eventStore, IEventPublisher eventPublisher) + { + _eventStore = eventStore; + _eventPublisher = eventPublisher; + } + + public async Task GetByIdAsync(Guid id) + { + var events = await _eventStore.GetEventsAsync(id); + + if (!events.Any()) + return null; + + var aggregate = new T(); + aggregate.LoadFromHistory(events.Select(e => e.EventData)); + + return aggregate; + } + + public async Task SaveAsync(T aggregate, EventMetadata metadata) + { + var uncommittedEvents = aggregate.GetUncommittedEvents().ToList(); + + if (!uncommittedEvents.Any()) + return; + + await _eventStore.SaveEventsAsync( + aggregate.Id, + uncommittedEvents, + aggregate.Version, + metadata); + + foreach (var @event in uncommittedEvents) + { + await _eventPublisher.PublishAsync(@event); + } + + aggregate.ClearUncommittedEvents(); + } +} +``` + +### 5. Optimize Read Models + +Build specialized read models for different query needs: + +```csharp +public class AccountSummaryReadModel : ReadModelBase, + IHandle, + IHandle, + IHandle +{ + public Guid AccountId { get; private set; } + public string AccountNumber { get; private set; } + public string CustomerName { get; private set; } + public decimal CurrentBalance { get; private set; } + public DateTime LastActivityDate { get; private set; } + + public void Handle(AccountCreated @event) + { + AccountId = @event.AccountId; + AccountNumber = @event.AccountNumber; + CustomerName = @event.CustomerName; + CurrentBalance = @event.InitialBalance; + LastActivityDate = @event.CreatedAt; + } + + public void Handle(FundsDeposited @event) + { + CurrentBalance += @event.Amount; + LastActivityDate = @event.DepositedAt; + } + + public void Handle(FundsWithdrawn @event) + { + CurrentBalance -= @event.Amount; + LastActivityDate = @event.WithdrawnAt; + } +} + +public class AccountTransactionReadModel : ReadModelBase, + IHandle, + IHandle, + IHandle +{ + private readonly List _transactions = new List(); + + public Guid AccountId { get; private set; } + public string AccountNumber { get; private set; } + public IReadOnlyList Transactions => _transactions.AsReadOnly(); + + public void Handle(AccountCreated @event) + { + AccountId = @event.AccountId; + AccountNumber = @event.AccountNumber; + + if (@event.InitialBalance > 0) + { + _transactions.Add(new TransactionItem + { + TransactionId = Guid.NewGuid(), + Amount = @event.InitialBalance, + Balance = @event.InitialBalance, + Description = "Initial deposit", + TransactionDate = @event.CreatedAt, + Type = TransactionType.Credit + }); + } + } + + public void Handle(FundsDeposited @event) + { + var currentBalance = _transactions.Any() + ? _transactions.Last().Balance + : 0; + + var newBalance = currentBalance + @event.Amount; + + _transactions.Add(new TransactionItem + { + TransactionId = Guid.NewGuid(), + Amount = @event.Amount, + Balance = newBalance, + Description = @event.Description, + TransactionDate = @event.DepositedAt, + Type = TransactionType.Credit + }); + } + + public void Handle(FundsWithdrawn @event) + { + var currentBalance = _transactions.Any() + ? _transactions.Last().Balance + : 0; + + var newBalance = currentBalance - @event.Amount; + + _transactions.Add(new TransactionItem + { + TransactionId = Guid.NewGuid(), + Amount = @event.Amount, + Balance = newBalance, + Description = @event.Description, + TransactionDate = @event.WithdrawnAt, + Type = TransactionType.Debit + }); + } +} + +public class TransactionItem +{ + public Guid TransactionId { get; set; } + public decimal Amount { get; set; } + public decimal Balance { get; set; } + public string Description { get; set; } + public DateTime TransactionDate { get; set; } + public TransactionType Type { get; set; } +} + +public enum TransactionType +{ + Credit, + Debit +} +``` + +### 6. Use Correlation and Causation IDs + +Track event relationships with correlation and causation IDs: + +```csharp +public class EventMetadata +{ + public Guid CorrelationId { get; } + public Guid CausationId { get; } + public string UserId { get; } + public DateTime Timestamp { get; } + + public EventMetadata(Guid correlationId, Guid causationId, string userId) + { + CorrelationId = correlationId; + CausationId = causationId; + UserId = userId; + Timestamp = DateTime.UtcNow; + } +} + +public class CommandHandler +{ + private readonly IRepository _repository; + private readonly IEventBus _eventBus; + + public CommandHandler(IRepository repository, IEventBus eventBus) + { + _repository = repository; + _eventBus = eventBus; + } + + public async Task Handle(DepositFunds command, IMessageContext context) + { + var account = await _repository.GetByIdAsync(command.AccountId); + + if (account == null) + throw new AccountNotFoundException(command.AccountId); + + account.Deposit(command.Amount, command.Description); + + var metadata = new EventMetadata( + correlationId: context.CorrelationId, + causationId: context.MessageId, + userId: context.UserId); + + await _repository.SaveAsync(account, metadata); + } +} +``` + +### 7. Implement Idempotent Event Handlers + +Ensure event handlers can safely process the same event multiple times: + +```csharp +public class AccountTransactionProjection : IProjection, + IHandle, + IHandle +{ + private readonly ITransactionRepository _repository; + + public AccountTransactionProjection(ITransactionRepository repository) + { + _repository = repository; + } + + public async Task Handle(FundsDeposited @event, EventMetadata metadata) + { + // Check if we've already processed this event + if (await _repository.HasProcessedEventAsync(metadata.EventId)) + return; + + var transaction = new TransactionRecord + { + TransactionId = Guid.NewGuid(), + AccountId = @event.AccountId, + Amount = @event.Amount, + Description = @event.Description, + TransactionDate = @event.DepositedAt, + Type = TransactionType.Credit, + EventId = metadata.EventId + }; + + await _repository.SaveTransactionAsync(transaction); + await _repository.MarkEventAsProcessedAsync(metadata.EventId); + } + + public async Task Handle(FundsWithdrawn @event, EventMetadata metadata) + { + // Check if we've already processed this event + if (await _repository.HasProcessedEventAsync(metadata.EventId)) + return; + + var transaction = new TransactionRecord + { + TransactionId = Guid.NewGuid(), + AccountId = @event.AccountId, + Amount = @event.Amount, + Description = @event.Description, + TransactionDate = @event.WithdrawnAt, + Type = TransactionType.Debit, + EventId = metadata.EventId + }; + + await _repository.SaveTransactionAsync(transaction); + await _repository.MarkEventAsProcessedAsync(metadata.EventId); + } +} + +public interface ITransactionRepository +{ + Task HasProcessedEventAsync(Guid eventId); + Task MarkEventAsProcessedAsync(Guid eventId); + Task SaveTransactionAsync(TransactionRecord transaction); +} +``` + +## Common Pitfalls + +### 1. Mutable Events + +One of the most common mistakes in event sourcing is creating mutable events, which can lead to data inconsistency and loss of audit trail integrity. + +```csharp +// Problematic: Mutable event +public class AccountCreated +{ + public Guid AccountId { get; set; } // Setter allows modification after creation + public string AccountNumber { get; set; } + public string CustomerName { get; set; } +} + +// Solution: Immutable event +public class AccountCreated +{ + public Guid AccountId { get; } // Read-only property + public string AccountNumber { get; } + public string CustomerName { get; } + + public AccountCreated(Guid accountId, string accountNumber, string customerName) + { + AccountId = accountId; + AccountNumber = accountNumber; + CustomerName = customerName; + } +} +``` + +### 2. Large Events + +Creating excessively large events can impact performance and scalability. + +```csharp +// Problematic: Large event with unnecessary data +public class OrderPlaced +{ + public Guid OrderId { get; } + public Customer Customer { get; } // Entire customer object + public List Items { get; } // All order items with full details + public ShippingDetails ShippingDetails { get; } + public BillingDetails BillingDetails { get; } + public List AppliedDiscounts { get; } + public byte[] CustomerProfilePicture { get; } // Unnecessary binary data + // ... many more properties +} + +// Solution: Focused event with essential data +public class OrderPlaced +{ + public Guid OrderId { get; } + public Guid CustomerId { get; } // Reference instead of embedding + public List Items { get; } // Simplified summary + public decimal TotalAmount { get; } + public string ShippingAddress { get; } + public DateTime OrderDate { get; } +} + +// Additional events for specific aspects if needed +public class OrderDiscountsApplied +{ + public Guid OrderId { get; } + public List Discounts { get; } + public decimal TotalDiscountAmount { get; } +} +``` + +### 3. Missing Version Control + +Not implementing proper version control for events can make it difficult to evolve your system over time. + +```csharp +// Problematic: No versioning strategy +public class CustomerAddressChanged +{ + public Guid CustomerId { get; } + public string Address { get; } // What if we need to split into multiple fields later? +} + +// Solution: Explicit versioning +public class CustomerAddressChangedV1 +{ + public Guid CustomerId { get; } + public string Address { get; } +} + +public class CustomerAddressChangedV2 +{ + public Guid CustomerId { get; } + public string StreetAddress { get; } + public string City { get; } + public string State { get; } + public string PostalCode { get; } + public string Country { get; } +} + +// Event upcaster +public class CustomerAddressChangedUpcaster +{ + public CustomerAddressChangedV2 Upcast(CustomerAddressChangedV1 oldEvent) + { + // Parse the old address format and extract components + var addressParts = ParseAddress(oldEvent.Address); + + return new CustomerAddressChangedV2 + { + CustomerId = oldEvent.CustomerId, + StreetAddress = addressParts.StreetAddress, + City = addressParts.City, + State = addressParts.State, + PostalCode = addressParts.PostalCode, + Country = addressParts.Country + }; + } + + private AddressParts ParseAddress(string address) + { + // Logic to parse a single address string into components + // ... + } +} +``` + +### 4. Inefficient Event Replay + +Not optimizing event replay can lead to performance issues as your event store grows. + +```csharp +// Problematic: Loading all events for every query +public class AccountRepository +{ + private readonly IEventStore _eventStore; + + public AccountRepository(IEventStore eventStore) + { + _eventStore = eventStore; + } + + public Account GetById(Guid id) + { + // Always load all events from the beginning + var events = _eventStore.GetEvents(id); + var account = new Account(id); + account.RestoreFromEvents(events); + return account; + } +} + +// Solution: Using snapshots +public class OptimizedAccountRepository +{ + private readonly IEventStore _eventStore; + private readonly ISnapshotStore _snapshotStore; + + public OptimizedAccountRepository( + IEventStore eventStore, + ISnapshotStore snapshotStore) + { + _eventStore = eventStore; + _snapshotStore = snapshotStore; + } + + public Account GetById(Guid id) + { + // Try to get the latest snapshot + var snapshot = _snapshotStore.GetLatestSnapshot(id); + var account = new Account(id); + + if (snapshot != null) + { + // Restore from snapshot + account.RestoreFromSnapshot(snapshot.State); + + // Only load events after the snapshot version + var events = _eventStore.GetEventsAfterVersion(id, snapshot.Version); + account.RestoreFromEvents(events); + } + else + { + // No snapshot, load all events + var events = _eventStore.GetEvents(id); + account.RestoreFromEvents(events); + } + + return account; + } +} +``` + +### 5. Ignoring Concurrency Control + +Not implementing proper concurrency control can lead to lost events and data inconsistency. + +```csharp +// Problematic: No concurrency control +public class EventStore +{ + public void SaveEvents(Guid aggregateId, IEnumerable events) + { + // Directly save events without version check + foreach (var @event in events) + { + // Save event to database + } + } +} + +// Solution: Optimistic concurrency control +public class ConcurrencyAwareEventStore +{ + public void SaveEvents(Guid aggregateId, IEnumerable events, long expectedVersion) + { + // Get the current version from the database + var currentVersion = GetCurrentVersion(aggregateId); + + // Check if the expected version matches the current version + if (currentVersion != expectedVersion) + { + throw new ConcurrencyException( + $"Expected version {expectedVersion} but got {currentVersion}"); + } + + // Save events with incrementing versions + long version = expectedVersion; + foreach (var @event in events) + { + SaveEvent(aggregateId, @event, ++version); + } + } +} +``` + +### 6. Non-Deterministic Event Handlers + +Event handlers should be deterministic to ensure consistent state reconstruction. + +```csharp +// Problematic: Non-deterministic event handler +public class Account : AggregateRoot +{ + private decimal _balance; + + private void Apply(FundsDeposited @event) + { + // Using current time makes the handler non-deterministic + var interestRate = DateTime.Now.Hour > 12 ? 0.05m : 0.03m; + _balance += @event.Amount * (1 + interestRate); + } +} + +// Solution: Deterministic event handler +public class Account : AggregateRoot +{ + private decimal _balance; + + private void Apply(FundsDeposited @event) + { + // Use data from the event itself + _balance += @event.Amount; + + // If interest calculation is needed, it should be a separate event + // with the interest rate determined at command handling time + } + + private void Apply(InterestAccrued @event) + { + _balance += @event.InterestAmount; + } +} +``` + +### 7. Tight Coupling Between Events and Handlers + +Tightly coupling events to their handlers can make it difficult to evolve your system. + +```csharp +// Problematic: Tight coupling with concrete event types +public class AccountProjection +{ + public void Handle(AccountCreated @event) + { + // Directly coupled to AccountCreated event + } + + public void Handle(FundsDeposited @event) + { + // Directly coupled to FundsDeposited event + } +} + +// Solution: More flexible event handling +public class AccountProjection +{ + private readonly Dictionary> _handlers = + new Dictionary>(); + + public AccountProjection() + { + RegisterHandler(HandleAccountCreated); + RegisterHandler(HandleFundsDeposited); + // New event types can be registered without changing the core handler logic + } + + private void RegisterHandler(Action handler) + { + _handlers[typeof(T)] = @event => handler((T)@event); + } + + public void Handle(object @event) + { + var eventType = @event.GetType(); + + if (_handlers.TryGetValue(eventType, out var handler)) + { + handler(@event); + } + } + + private void HandleAccountCreated(AccountCreated @event) + { + // Handle account creation + } + + private void HandleFundsDeposited(FundsDeposited @event) + { + // Handle funds deposit + } +} +``` diff --git a/docs/api-reference/patterns/saga-implementation-patterns.md b/docs/api-reference/patterns/saga-implementation-patterns.md new file mode 100644 index 00000000..d3a45c77 --- /dev/null +++ b/docs/api-reference/patterns/saga-implementation-patterns.md @@ -0,0 +1,1541 @@ +# Saga/Process Manager Implementation Patterns + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +This document outlines the key patterns and best practices for implementing sagas (also known as process managers) in Reactive Domain applications. Sagas coordinate complex business processes that span multiple aggregates or bounded contexts, ensuring that the overall process completes correctly despite being distributed across different components. + +## Table of Contents + +1. [Saga Concepts and Principles](#saga-concepts-and-principles) +2. [Types of Sagas](#types-of-sagas) +3. [State Management](#state-management) +4. [Event Handling Patterns](#event-handling-patterns) +5. [Persistence Mechanisms](#persistence-mechanisms) +6. [Correlation and Tracking](#correlation-and-tracking) +7. [Error Handling and Recovery](#error-handling-and-recovery) +8. [Timeout Management](#timeout-management) +9. [Compensating Actions](#compensating-actions) +10. [Testing Sagas](#testing-sagas) +11. [Best Practices](#best-practices) +12. [Common Pitfalls](#common-pitfalls) + +## Saga Concepts and Principles + +A saga is a long-lived transaction that can be written as a sequence of transactions that can be interleaved with other transactions. In event-driven architectures, sagas solve the problem of maintaining process integrity across aggregate boundaries. + +### Key Characteristics + +1. **Coordination**: Sagas coordinate activities across multiple aggregates or bounded contexts +2. **Reactivity**: They react to events and issue commands to drive the process forward +3. **Stateful**: They maintain state to track the progress of the business process +4. **Long-running**: They can span extended periods, from seconds to days or longer +5. **Eventual Consistency**: They ensure eventual consistency across multiple aggregates + +### Saga vs. Process Manager + +While the terms "saga" and "process manager" are often used interchangeably, there are subtle differences: + +- **Saga**: Originally described as a sequence of local transactions where each transaction updates data within a single service, with compensating transactions to undo changes if a step fails +- **Process Manager**: A more general term for a component that coordinates multiple aggregates, reacting to events and issuing commands + +In Reactive Domain, the `ProcessManager` base class provides the foundation for implementing both concepts. + +## Types of Sagas + +Sagas can be categorized based on their implementation approach and behavior patterns. + +### 1. Choreography-based Sagas + +In a choreography-based saga, there is no central coordinator. Instead, each participant in the process knows what to do based on events published by other participants. + +```csharp +// Event handler in the Order service +public class OrderEventHandler : + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + + public OrderEventHandler(ICommandBus commandBus) + { + _commandBus = commandBus; + } + + public void Handle(OrderPlaced @event) + { + // When an order is placed, request payment + _commandBus.Send(new ProcessPayment( + @event.OrderId, + @event.CustomerId, + @event.TotalAmount)); + } + + public void Handle(PaymentCompleted @event) + { + // When payment is completed, ship the order + _commandBus.Send(new ShipOrder(@event.OrderId)); + } + + public void Handle(PaymentFailed @event) + { + // When payment fails, cancel the order + _commandBus.Send(new CancelOrder( + @event.OrderId, + "Payment failed")); + } +} + +// Event handler in the Payment service +public class PaymentEventHandler : IEventHandler +{ + private readonly IPaymentGateway _paymentGateway; + private readonly IEventBus _eventBus; + + public PaymentEventHandler( + IPaymentGateway paymentGateway, + IEventBus eventBus) + { + _paymentGateway = paymentGateway; + _eventBus = eventBus; + } + + public void Handle(PaymentRequested @event) + { + try + { + var result = _paymentGateway.ProcessPayment( + @event.CustomerId, + @event.Amount); + + if (result.Success) + { + _eventBus.Publish(new PaymentCompleted( + @event.OrderId, + @event.Amount, + result.TransactionId)); + } + else + { + _eventBus.Publish(new PaymentFailed( + @event.OrderId, + result.FailureReason)); + } + } + catch (Exception ex) + { + _eventBus.Publish(new PaymentFailed( + @event.OrderId, + ex.Message)); + } + } +} +``` + +**Advantages**: +- Simpler to implement initially +- No need for a central coordinator +- Naturally distributed + +**Disadvantages**: +- Process flow is implicit and distributed across services +- Harder to track the overall process state +- More difficult to implement complex flows and compensating actions + +### 2. Orchestration-based Sagas + +In an orchestration-based saga, a central coordinator (the saga/process manager) manages the entire process flow, reacting to events and issuing commands. + +```csharp +public class OrderProcessManager : ProcessManager, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + + // Process state + private bool _orderPlaced; + private bool _paymentCompleted; + private bool _orderShipped; + private Guid _orderId; + private Guid _customerId; + private decimal _orderAmount; + + public OrderProcessManager(Guid processId, ICommandBus commandBus) + : base(processId) + { + _commandBus = commandBus; + } + + public void Handle(OrderPlaced @event) + { + if (_orderPlaced) return; // Idempotency check + + // Update process state + RaiseEvent(new OrderProcessStarted( + Id, + @event.OrderId, + @event.CustomerId, + @event.TotalAmount)); + + // Request payment + _commandBus.Send(MessageBuilder.From(@event, () => new ProcessPayment( + @event.OrderId, + @event.CustomerId, + @event.TotalAmount))); + } + + public void Handle(PaymentCompleted @event) + { + if (_paymentCompleted || !_orderPlaced) return; // Idempotency and sequence check + + // Update process state + RaiseEvent(new PaymentCompletedForOrder( + Id, + @event.OrderId)); + + // Ship the order + _commandBus.Send(MessageBuilder.From(@event, () => new ShipOrder(@event.OrderId))); + } + + public void Handle(PaymentFailed @event) + { + if (!_orderPlaced) return; // Sequence check + + // Update process state + RaiseEvent(new OrderProcessFailed( + Id, + @event.OrderId, + "Payment failed: " + @event.FailureReason)); + + // Cancel the order + _commandBus.Send(MessageBuilder.From(@event, () => new CancelOrder( + @event.OrderId, + "Payment failed: " + @event.FailureReason))); + } + + public void Handle(OrderShipped @event) + { + if (_orderShipped || !_paymentCompleted) return; // Idempotency and sequence check + + // Update process state + RaiseEvent(new OrderProcessCompleted( + Id, + @event.OrderId)); + } + + public void Handle(ShippingFailed @event) + { + if (!_paymentCompleted) return; // Sequence check + + // Update process state + RaiseEvent(new OrderProcessFailed( + Id, + @event.OrderId, + "Shipping failed: " + @event.FailureReason)); + + // Refund the payment + _commandBus.Send(MessageBuilder.From(@event, () => new RefundPayment( + @event.OrderId, + _customerId, + _orderAmount, + "Shipping failed: " + @event.FailureReason))); + } + + // Event handlers for the process manager's own events + private void Apply(OrderProcessStarted @event) + { + _orderPlaced = true; + _orderId = @event.OrderId; + _customerId = @event.CustomerId; + _orderAmount = @event.OrderAmount; + } + + private void Apply(PaymentCompletedForOrder @event) + { + _paymentCompleted = true; + } + + private void Apply(OrderProcessCompleted @event) + { + _orderShipped = true; + } +} +``` + +**Advantages**: +- Explicit process flow centralized in one component +- Easier to track and monitor the process state +- Better suited for complex flows with many steps +- Simpler to implement compensating actions + +**Disadvantages**: +- Introduces a central component that could become a bottleneck +- More complex initial implementation +- Can introduce coupling between services + +### 3. Event-Sourced Sagas + +Event-sourced sagas store their state as a sequence of events, allowing for complete reconstruction of the saga's state. + +```csharp +public class EventSourcedOrderProcessManager : ProcessManager, + IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly ICommandBus _commandBus; + private readonly ISagaRepository _repository; + + // Process state + private OrderProcessState _state; + + public EventSourcedOrderProcessManager( + Guid processId, + ICommandBus commandBus, + ISagaRepository repository) + : base(processId) + { + _commandBus = commandBus; + _repository = repository; + _state = new OrderProcessState(); + } + + public async Task Handle(OrderPlaced @event) + { + if (_state.OrderPlaced) return; // Idempotency check + + // Update process state + RaiseEvent(new OrderProcessStarted( + Id, + @event.OrderId, + @event.CustomerId, + @event.TotalAmount)); + + // Save the updated state + await _repository.SaveAsync(this); + + // Request payment + _commandBus.Send(MessageBuilder.From(@event, () => new ProcessPayment( + @event.OrderId, + @event.CustomerId, + @event.TotalAmount))); + } + + // Additional event handlers... + + // Event handlers for the process manager's own events + private void Apply(OrderProcessStarted @event) + { + _state.OrderPlaced = true; + _state.OrderId = @event.OrderId; + _state.CustomerId = @event.CustomerId; + _state.OrderAmount = @event.OrderAmount; + } + + // Additional apply methods... + + // State class to encapsulate the process state + private class OrderProcessState + { + public bool OrderPlaced { get; set; } + public bool PaymentCompleted { get; set; } + public bool OrderShipped { get; set; } + public Guid OrderId { get; set; } + public Guid CustomerId { get; set; } + public decimal OrderAmount { get; set; } + } +} +``` + +**Advantages**: +- Complete audit trail of the saga's execution +- Ability to reconstruct the saga's state at any point in time +- Natural fit with event-sourced aggregates + +**Disadvantages**: +- More complex implementation +- Potential performance overhead for long-running sagas with many events + +## State Management + +Effective state management is crucial for sagas to track their progress and make decisions based on the current state of the process. + +### State Representation Approaches + +#### 1. Boolean Flags + +The simplest approach is to use boolean flags to track the completion of each step in the process. + +```csharp +public class OrderProcessManager : ProcessManager +{ + // State represented as boolean flags + private bool _orderPlaced; + private bool _paymentProcessed; + private bool _inventoryReserved; + private bool _orderShipped; + + // Additional state data + private Guid _orderId; + private Guid _customerId; + private decimal _orderAmount; + + // Event handlers and business logic... +} +``` + +**Advantages**: +- Simple and easy to understand +- Minimal overhead + +**Disadvantages**: +- Limited expressiveness for complex state transitions +- Can become unwieldy for processes with many steps +- Difficult to visualize the overall process state + +#### 2. Enum-based State + +Using enums to represent the current state of the process provides a more explicit representation of the process flow. + +```csharp +public class OrderProcessManager : ProcessManager +{ + // State represented as an enum + private enum OrderProcessState + { + New, + OrderPlaced, + PaymentProcessing, + PaymentCompleted, + PaymentFailed, + InventoryReserving, + InventoryReserved, + InventoryReservationFailed, + Shipping, + Shipped, + ShippingFailed, + Completed, + Failed, + Cancelled + } + + private OrderProcessState _currentState; + + // Additional state data + private Guid _orderId; + private Guid _customerId; + private decimal _orderAmount; + private string _failureReason; + + public void Handle(OrderPlaced @event) + { + if (_currentState != OrderProcessState.New) return; + + // Update state + _currentState = OrderProcessState.OrderPlaced; + _orderId = @event.OrderId; + _customerId = @event.CustomerId; + _orderAmount = @event.TotalAmount; + + // Issue commands... + } + + public void Handle(PaymentCompleted @event) + { + if (_currentState != OrderProcessState.PaymentProcessing) return; + + // Update state + _currentState = OrderProcessState.PaymentCompleted; + + // Issue commands... + } + + // Additional event handlers... +} +``` + +**Advantages**: +- More explicit representation of the process state +- Easier to visualize and understand the process flow +- Better for processes with well-defined state transitions + +**Disadvantages**: +- Less flexible for processes with parallel steps +- Can require frequent enum updates as the process evolves + +#### 3. State Object Pattern + +Encapsulating the state in a dedicated object provides a more structured approach to state management. + +```csharp +public class OrderProcessManager : ProcessManager +{ + // State represented as a dedicated object + private class OrderProcessState + { + public bool OrderPlaced { get; set; } + public bool PaymentProcessed { get; set; } + public bool InventoryReserved { get; set; } + public bool OrderShipped { get; set; } + public Guid OrderId { get; set; } + public Guid CustomerId { get; set; } + public decimal OrderAmount { get; set; } + public string FailureReason { get; set; } + public DateTime StartTime { get; set; } + public DateTime? CompletionTime { get; set; } + } + + private OrderProcessState _state; + + public OrderProcessManager(Guid id) : base(id) + { + _state = new OrderProcessState + { + StartTime = DateTime.UtcNow + }; + } + + // Event handlers that update the state object... +} +``` + +**Advantages**: +- Better encapsulation of state +- Easier to persist and reconstruct +- More maintainable for complex processes + +**Disadvantages**: +- Slightly more complex implementation +- Can lead to anemic state objects without proper design + +### State Persistence + +Sagas need to persist their state to survive process restarts and failures. There are several approaches to state persistence: + +#### 1. Event Sourcing + +With event sourcing, the saga's state is stored as a sequence of events that can be replayed to reconstruct the state. + +```csharp +public class EventSourcedOrderProcessManager : ProcessManager +{ + // State is reconstructed from events + private OrderProcessState _state = new OrderProcessState(); + + // IEventSource implementation + public override void RestoreFromEvents(IEnumerable events) + { + foreach (var @event in events) + { + Apply(@event); + ExpectedVersion++; + } + } + + // Event handlers for the saga's own events + private void Apply(OrderProcessStarted @event) + { + _state.OrderPlaced = true; + _state.OrderId = @event.OrderId; + _state.CustomerId = @event.CustomerId; + _state.OrderAmount = @event.OrderAmount; + } + + private void Apply(PaymentCompletedForOrder @event) + { + _state.PaymentProcessed = true; + } + + // Additional apply methods... +} +``` + +#### 2. Snapshot-based Persistence + +For long-running sagas with many events, snapshot-based persistence can improve performance. + +```csharp +public class SnapshotOrderProcessManager : ProcessManager, ISnapshotSource +{ + private OrderProcessState _state = new OrderProcessState(); + + public long SnapshotVersion { get; set; } + + public object CreateSnapshot() + { + return _state.Clone(); // Deep copy of the state + } + + public void RestoreFromSnapshot(object snapshot) + { + if (snapshot is OrderProcessState state) + { + _state = state; + } + } + + // Additional implementation... +} +``` + +#### 3. Direct State Persistence + +For simpler scenarios, the saga's state can be directly persisted to a data store. + +```csharp +public class OrderProcessManager : ProcessManager +{ + private OrderProcessState _state; + private readonly ISagaStateRepository _stateRepository; + + public async Task Persist() + { + await _stateRepository.SaveStateAsync(Id, _state); + } + + public static async Task LoadAsync( + Guid id, + ISagaStateRepository stateRepository, + ICommandBus commandBus) + { + var state = await stateRepository.GetStateAsync(id); + return new OrderProcessManager(id, state, commandBus, stateRepository); + } + + // Additional implementation... +} + +public interface ISagaStateRepository +{ + Task SaveStateAsync(Guid sagaId, T state); + Task GetStateAsync(Guid sagaId) where T : class, new(); +} +``` + +## Event Handling Patterns + +Sagas need to handle events efficiently and reliably to drive the business process forward. Here are key patterns for event handling in sagas: + +### 1. Saga Router Pattern + +The Saga Router pattern routes events to the appropriate saga instance based on correlation information in the event. + +```csharp +public class ProcessManagerRouter : IEventHandler + where TSaga : ProcessManager + where TEvent : class, IEvent +{ + private readonly ISagaRepository _repository; + private readonly Func _factory; + private readonly Func _correlationIdSelector; + private readonly Action _handler; + + public ProcessManagerRouter( + ISagaRepository repository, + Func factory, + Func correlationIdSelector, + Action handler) + { + _repository = repository; + _factory = factory; + _correlationIdSelector = correlationIdSelector; + _handler = handler; + } + + public async Task Handle(TEvent @event) + { + // Extract correlation ID from the event + var correlationId = _correlationIdSelector(@event); + + // Try to load existing saga instance + var saga = await _repository.GetByIdAsync(correlationId); + + // If not found, create a new instance + if (saga == null) + { + saga = _factory(correlationId); + } + + // Handle the event + _handler(saga, @event); + + // Save the updated saga + await _repository.SaveAsync(saga); + } +} + +// Usage in configuration +public void ConfigureProcessManagers( + IEventBus eventBus, + ICommandBus commandBus, + ISagaRepository repository) +{ + // Create a factory for the process manager + Func factory = + id => new OrderProcessManager(id, commandBus); + + // Register event handlers that will route events to the appropriate process manager instance + eventBus.Subscribe(new ProcessManagerRouter( + repository, + factory, + e => e.OrderId, // Use OrderId to find or create process manager instances + (pm, e) => pm.Handle(e) + )); + + // Register other event handlers similarly +} +``` + +### 2. Idempotent Event Handling + +Idempotent event handling ensures that the same event can be processed multiple times without causing duplicate effects. + +```csharp +public class OrderProcessManager : ProcessManager, + IEventHandler, + IEventHandler +{ + // State tracking for idempotency + private HashSet _processedEventIds = new HashSet(); + + public void Handle(OrderPlaced @event) + { + // Check if we've already processed this event + if (_processedEventIds.Contains(@event.MessageId)) + return; + + // Process the event + // ... + + // Mark the event as processed + _processedEventIds.Add(@event.MessageId); + } + + // Alternative approach using state flags + public void Handle(PaymentCompleted @event) + { + // Check if we've already processed this event based on state + if (_paymentProcessed) + return; + + // Process the event + // ... + + // Update state to indicate this step is complete + _paymentProcessed = true; + } +} +``` + +### 3. Event Correlation + +Event correlation ensures that events are routed to the correct saga instance. + +```csharp +public interface ICorrelatedEvent +{ + Guid CorrelationId { get; } +} + +public class OrderPlaced : Event, ICorrelatedEvent +{ + public Guid OrderId { get; } + public Guid CustomerId { get; } + public decimal TotalAmount { get; } + + // Implement correlation based on OrderId + public Guid CorrelationId => OrderId; + + public OrderPlaced(Guid orderId, Guid customerId, decimal totalAmount) + { + OrderId = orderId; + CustomerId = customerId; + TotalAmount = totalAmount; + } +} + +// Saga repository that uses correlation +public class SagaRepository : ISagaRepository + where TSaga : ProcessManager +{ + private readonly IEventStore _eventStore; + + public SagaRepository(IEventStore eventStore) + { + _eventStore = eventStore; + } + + public async Task GetByCorrelationIdAsync(Guid correlationId) + { + // Load events for the saga with the given correlation ID + var events = await _eventStore.GetEventsAsync(correlationId); + + if (!events.Any()) + return null; + + // Create and restore saga instance + var saga = Activator.CreateInstance(typeof(TSaga), correlationId) as TSaga; + saga.RestoreFromEvents(events.Select(e => e.Data)); + + return saga; + } + + // Additional implementation... +} +``` + +### 4. Event Filtering + +Event filtering ensures that sagas only process events that are relevant to their current state. + +```csharp +public class OrderProcessManager : ProcessManager, + IEventHandler, + IEventHandler, + IEventHandler +{ + private enum ProcessState + { + New, + OrderPlaced, + PaymentProcessing, + PaymentCompleted, + PaymentFailed, + Completed, + Failed + } + + private ProcessState _currentState = ProcessState.New; + + public void Handle(OrderPlaced @event) + { + // Only process if in the correct state + if (_currentState != ProcessState.New) + return; + + // Process the event + // ... + + _currentState = ProcessState.OrderPlaced; + } + + public void Handle(PaymentCompleted @event) + { + // Only process if in the correct state + if (_currentState != ProcessState.PaymentProcessing) + return; + + // Process the event + // ... + + _currentState = ProcessState.PaymentCompleted; + } + + public void Handle(PaymentFailed @event) + { + // Only process if in the correct state + if (_currentState != ProcessState.PaymentProcessing) + return; + + // Process the event + // ... + + _currentState = ProcessState.PaymentFailed; + } +} +``` + +### 5. Event Versioning + +Event versioning ensures that sagas can handle different versions of events as the system evolves. + +```csharp +// Version 1 of the event +public class OrderPlacedV1 : Event +{ + public Guid OrderId { get; } + public Guid CustomerId { get; } + public decimal TotalAmount { get; } + + public OrderPlacedV1(Guid orderId, Guid customerId, decimal totalAmount) + { + OrderId = orderId; + CustomerId = customerId; + TotalAmount = totalAmount; + } +} + +// Version 2 of the event with additional fields +public class OrderPlacedV2 : Event +{ + public Guid OrderId { get; } + public Guid CustomerId { get; } + public decimal TotalAmount { get; } + public List Items { get; } + public string ShippingAddress { get; } + + public OrderPlacedV2( + Guid orderId, + Guid customerId, + decimal totalAmount, + List items, + string shippingAddress) + { + OrderId = orderId; + CustomerId = customerId; + TotalAmount = totalAmount; + Items = items; + ShippingAddress = shippingAddress; + } +} + +// Saga that handles both versions +public class OrderProcessManager : ProcessManager, + IEventHandler, + IEventHandler +{ + public void Handle(OrderPlacedV1 @event) + { + // Handle version 1 of the event + ProcessOrderPlaced( + @event.OrderId, + @event.CustomerId, + @event.TotalAmount, + null, + null); + } + + public void Handle(OrderPlacedV2 @event) + { + // Handle version 2 of the event + ProcessOrderPlaced( + @event.OrderId, + @event.CustomerId, + @event.TotalAmount, + @event.Items, + @event.ShippingAddress); + } + + private void ProcessOrderPlaced( + Guid orderId, + Guid customerId, + decimal totalAmount, + List items, + string shippingAddress) + { + // Common processing logic + // ... + } +} +``` + +## Persistence Mechanisms + +Persistence is crucial for sagas to maintain their state across process restarts and failures. Here are key patterns for persisting sagas: + +### 1. Event Sourcing-based Persistence + +Event sourcing is a natural fit for saga persistence, as it aligns with the event-driven nature of sagas. + +```csharp +public class EventSourcedSagaRepository : ISagaRepository + where TSaga : ProcessManager +{ + private readonly IEventStore _eventStore; + private readonly Func _factory; + + public EventSourcedSagaRepository(IEventStore eventStore, Func factory) + { + _eventStore = eventStore; + _factory = factory; + } + + public async Task GetByIdAsync(Guid sagaId) + { + // Load all events for the saga + var events = await _eventStore.GetEventsAsync(sagaId); + + if (!events.Any()) + return null; + + // Create a new saga instance + var saga = _factory(sagaId); + + // Restore the saga state from events + saga.RestoreFromEvents(events.Select(e => e.Data)); + + return saga; + } + + public async Task SaveAsync(TSaga saga) + { + // Get uncommitted events from the saga + var uncommittedEvents = saga.TakeEvents(); + + if (uncommittedEvents.Length > 0) + { + // Save the events to the event store + await _eventStore.SaveEventsAsync( + saga.Id, + uncommittedEvents, + saga.ExpectedVersion); + } + } +} +``` + +### 2. Snapshot-based Persistence + +For long-running sagas with many events, snapshot-based persistence can improve performance. + +```csharp +public class SnapshotSagaRepository : ISagaRepository + where TSaga : ProcessManager, ISnapshotSource +{ + private readonly IEventStore _eventStore; + private readonly ISnapshotStore _snapshotStore; + private readonly Func _factory; + + public SnapshotSagaRepository( + IEventStore eventStore, + ISnapshotStore snapshotStore, + Func factory) + { + _eventStore = eventStore; + _snapshotStore = snapshotStore; + _factory = factory; + } + + public async Task GetByIdAsync(Guid sagaId) + { + // Try to get the latest snapshot + var snapshot = await _snapshotStore.GetLatestSnapshotAsync(sagaId); + + // Create a new saga instance + var saga = _factory(sagaId); + + if (snapshot != null) + { + // Restore from snapshot + saga.RestoreFromSnapshot(snapshot.State); + saga.SnapshotVersion = snapshot.Version; + + // Load events after the snapshot + var events = await _eventStore.GetEventsAfterVersionAsync(sagaId, snapshot.Version); + + if (events.Any()) + { + // Apply events after the snapshot + saga.RestoreFromEvents(events.Select(e => e.Data)); + } + } + else + { + // No snapshot, load all events + var events = await _eventStore.GetEventsAsync(sagaId); + + if (!events.Any()) + return null; + + // Restore from events + saga.RestoreFromEvents(events.Select(e => e.Data)); + } + + return saga; + } + + public async Task SaveAsync(TSaga saga) + { + // Get uncommitted events from the saga + var uncommittedEvents = saga.TakeEvents(); + + if (uncommittedEvents.Length > 0) + { + // Save the events to the event store + await _eventStore.SaveEventsAsync( + saga.Id, + uncommittedEvents, + saga.ExpectedVersion); + + // Check if we need to create a snapshot + if (ShouldCreateSnapshot(saga)) + { + var snapshot = saga.CreateSnapshot(); + await _snapshotStore.SaveSnapshotAsync( + saga.Id, + snapshot, + saga.ExpectedVersion); + saga.SnapshotVersion = saga.ExpectedVersion; + } + } + } + + private bool ShouldCreateSnapshot(TSaga saga) + { + // Create a snapshot every 100 events + return (saga.ExpectedVersion - saga.SnapshotVersion) >= 100; + } +} +``` + +### 3. Document Database Persistence + +Document databases are well-suited for storing saga state directly. + +```csharp +public class DocumentDbSagaRepository : ISagaRepository + where TSaga : ProcessManager +{ + private readonly IMongoCollection> _collection; + private readonly Func _factory; + + public DocumentDbSagaRepository( + IMongoDatabase database, + Func factory) + { + _collection = database.GetCollection>("Sagas"); + _factory = factory; + + // Create indexes + var indexKeysDefinition = Builders>.IndexKeys.Ascending(x => x.Id); + _collection.Indexes.CreateOne(new CreateIndexModel>(indexKeysDefinition)); + } + + public async Task GetByIdAsync(Guid sagaId) + { + var filter = Builders>.Filter.Eq(x => x.Id, sagaId); + var document = await _collection.Find(filter).FirstOrDefaultAsync(); + + if (document == null) + return null; + + return document.State; + } + + public async Task SaveAsync(TSaga saga) + { + var filter = Builders>.Filter.Eq(x => x.Id, saga.Id); + var document = new SagaDocument + { + Id = saga.Id, + State = saga, + Version = saga.ExpectedVersion, + LastUpdated = DateTime.UtcNow + }; + + await _collection.ReplaceOneAsync( + filter, + document, + new ReplaceOptions { IsUpsert = true }); + } + + private class SagaDocument + { + public Guid Id { get; set; } + public T State { get; set; } + public long Version { get; set; } + public DateTime LastUpdated { get; set; } + } +} +``` + +### 4. Relational Database Persistence + +Relational databases can also be used for saga persistence, especially for simpler sagas. + +```csharp +public class SqlSagaRepository : ISagaRepository + where TSaga : ProcessManager +{ + private readonly string _connectionString; + private readonly Func _factory; + private readonly JsonSerializerSettings _serializerSettings; + + public SqlSagaRepository( + string connectionString, + Func factory) + { + _connectionString = connectionString; + _factory = factory; + _serializerSettings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.All, + PreserveReferencesHandling = PreserveReferencesHandling.Objects + }; + } + + public async Task GetByIdAsync(Guid sagaId) + { + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + var sql = "SELECT Data, Version FROM Sagas WHERE Id = @Id AND Type = @Type"; + + var parameters = new DynamicParameters(); + parameters.Add("@Id", sagaId, DbType.Guid); + parameters.Add("@Type", typeof(TSaga).FullName, DbType.String); + + var result = await connection.QueryFirstOrDefaultAsync(sql, parameters); + + if (result == null) + return null; + + var sagaData = result.Data.ToString(); + var version = (long)result.Version; + + var saga = JsonConvert.DeserializeObject(sagaData, _serializerSettings); + saga.ExpectedVersion = version; + + return saga; + } + } + + public async Task SaveAsync(TSaga saga) + { + var sagaData = JsonConvert.SerializeObject(saga, _serializerSettings); + + using (var connection = new SqlConnection(_connectionString)) + { + await connection.OpenAsync(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + // Check optimistic concurrency + var currentVersion = await GetCurrentVersionAsync(connection, transaction, saga.Id); + + if (currentVersion.HasValue && currentVersion.Value != saga.ExpectedVersion) + { + throw new ConcurrencyException( + $"Expected version {saga.ExpectedVersion} but got {currentVersion.Value}"); + } + + // Insert or update the saga + var sql = currentVersion.HasValue + ? "UPDATE Sagas SET Data = @Data, Version = @NewVersion, LastUpdated = @LastUpdated WHERE Id = @Id AND Type = @Type" + : "INSERT INTO Sagas (Id, Type, Data, Version, LastUpdated) VALUES (@Id, @Type, @Data, @NewVersion, @LastUpdated)"; + + var parameters = new DynamicParameters(); + parameters.Add("@Id", saga.Id, DbType.Guid); + parameters.Add("@Type", typeof(TSaga).FullName, DbType.String); + parameters.Add("@Data", sagaData, DbType.String); + parameters.Add("@NewVersion", saga.ExpectedVersion + 1, DbType.Int64); + parameters.Add("@LastUpdated", DateTime.UtcNow, DbType.DateTime2); + + await connection.ExecuteAsync(sql, parameters, transaction); + + transaction.Commit(); + + // Update the expected version + saga.ExpectedVersion++; + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + + private async Task GetCurrentVersionAsync( + SqlConnection connection, + SqlTransaction transaction, + Guid sagaId) + { + var sql = "SELECT Version FROM Sagas WHERE Id = @Id AND Type = @Type"; + + var parameters = new DynamicParameters(); + parameters.Add("@Id", sagaId, DbType.Guid); + parameters.Add("@Type", typeof(TSaga).FullName, DbType.String); + + return await connection.QueryFirstOrDefaultAsync(sql, parameters, transaction); + } +} + +## Correlation and Tracking + +Correlation and tracking are essential for managing the flow of messages in saga-based systems. They ensure that events and commands are properly associated with the correct saga instances and business processes. + +### 1. Correlation IDs + +Correlation IDs are unique identifiers that link related messages together across different services and components. + +```csharp +public interface ICorrelatedMessage +{ + Guid CorrelationId { get; } + Guid CausationId { get; } + Guid MessageId { get; } +} + +public abstract class CorrelatedCommand : Command, ICorrelatedMessage +{ + public Guid CorrelationId { get; private set; } + public Guid CausationId { get; private set; } + + protected CorrelatedCommand() + { + // Default initialization + CorrelationId = Guid.NewGuid(); + CausationId = Guid.NewGuid(); + } + + // Constructor for creating a correlated command from another message + protected CorrelatedCommand(ICorrelatedMessage sourceMessage) + { + // Maintain correlation chain + CorrelationId = sourceMessage.CorrelationId; + CausationId = sourceMessage.MessageId; + } +} + +public abstract class CorrelatedEvent : Event, ICorrelatedMessage +{ + public Guid CorrelationId { get; private set; } + public Guid CausationId { get; private set; } + + protected CorrelatedEvent() + { + // Default initialization + CorrelationId = Guid.NewGuid(); + CausationId = Guid.NewGuid(); + } + + // Constructor for creating a correlated event from another message + protected CorrelatedEvent(ICorrelatedMessage sourceMessage) + { + // Maintain correlation chain + CorrelationId = sourceMessage.CorrelationId; + CausationId = sourceMessage.MessageId; + } +} +``` + +### 2. Message Builder Pattern + +The Message Builder pattern simplifies the creation of correlated messages. + +```csharp +public static class MessageBuilder +{ + public static TResult From(TSource source, Func factory) + where TSource : ICorrelatedMessage + where TResult : ICorrelatedMessage + { + var result = factory(); + + // Set correlation properties via reflection + typeof(TResult).GetProperty("CorrelationId") + .SetValue(result, source.CorrelationId); + + typeof(TResult).GetProperty("CausationId") + .SetValue(result, source.MessageId); + + return result; + } +} + +// Usage in a saga +public class OrderProcessManager : ProcessManager, + IEventHandler +{ + private readonly ICommandBus _commandBus; + + public void Handle(OrderPlaced @event) + { + // Create a correlated command + var processPaymentCommand = MessageBuilder.From(@event, () => new ProcessPayment( + @event.OrderId, + @event.CustomerId, + @event.TotalAmount)); + + _commandBus.Send(processPaymentCommand); + } +} +``` + +### 3. Saga Instance Tracking + +Tracking active saga instances is important for monitoring and management purposes. + +```csharp +public class SagaTracker : ISagaTracker +{ + private readonly IDocumentStore _documentStore; + + public SagaTracker(IDocumentStore documentStore) + { + _documentStore = documentStore; + } + + public async Task RegisterSagaStartedAsync(Guid sagaId, string sagaType, object correlationData) + where TSaga : ProcessManager + { + var sagaInfo = new SagaInfo + { + Id = sagaId, + Type = sagaType, + Status = SagaStatus.Active, + StartTime = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow, + CorrelationData = correlationData + }; + + await _documentStore.StoreAsync(sagaInfo); + } + + public async Task UpdateSagaStatusAsync(Guid sagaId, SagaStatus status, string statusReason = null) + { + var sagaInfo = await _documentStore.LoadAsync(sagaId); + + if (sagaInfo != null) + { + sagaInfo.Status = status; + sagaInfo.StatusReason = statusReason; + sagaInfo.LastUpdated = DateTime.UtcNow; + + if (status == SagaStatus.Completed || status == SagaStatus.Failed) + { + sagaInfo.EndTime = DateTime.UtcNow; + } + + await _documentStore.StoreAsync(sagaInfo); + } + } + + public async Task> GetActiveSagasAsync() + { + return await _documentStore.Query() + .Where(s => s.Status == SagaStatus.Active) + .ToListAsync(); + } + + public async Task> GetStalledSagasAsync(TimeSpan threshold) + { + var cutoffTime = DateTime.UtcNow.Subtract(threshold); + + return await _documentStore.Query() + .Where(s => s.Status == SagaStatus.Active && s.LastUpdated < cutoffTime) + .ToListAsync(); + } +} + +public enum SagaStatus +{ + Active, + Completed, + Failed, + Compensating +} + +public class SagaInfo +{ + public Guid Id { get; set; } + public string Type { get; set; } + public SagaStatus Status { get; set; } + public string StatusReason { get; set; } + public DateTime StartTime { get; set; } + public DateTime? EndTime { get; set; } + public DateTime LastUpdated { get; set; } + public object CorrelationData { get; set; } +} + +public interface ISagaTracker +{ + Task RegisterSagaStartedAsync(Guid sagaId, string sagaType, object correlationData) + where TSaga : ProcessManager; + Task UpdateSagaStatusAsync(Guid sagaId, SagaStatus status, string statusReason = null); + Task> GetActiveSagasAsync(); + Task> GetStalledSagasAsync(TimeSpan threshold); +} +``` + +### 4. Correlation Context + +Correlation context provides a way to track correlation information across service boundaries. + +```csharp +public class CorrelationContext +{ + private static readonly AsyncLocal _current = new AsyncLocal(); + + public static CorrelationContext Current + { + get => _current.Value; + set => _current.Value = value; + } + + public Guid CorrelationId { get; } + public Guid CausationId { get; } + public string UserId { get; } + + public CorrelationContext(Guid correlationId, Guid causationId, string userId) + { + CorrelationId = correlationId; + CausationId = causationId; + UserId = userId; + } + + public static void CreateFromMessage(ICorrelatedMessage message, string userId) + { + Current = new CorrelationContext(message.CorrelationId, message.MessageId, userId); + } + + public static void Clear() + { + Current = null; + } +} + +// Middleware to capture correlation context +public class CorrelationMiddleware +{ + private readonly RequestDelegate _next; + + public CorrelationMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + // Extract correlation IDs from headers + var correlationId = GetHeaderGuid(context, "X-Correlation-ID") ?? Guid.NewGuid(); + var causationId = GetHeaderGuid(context, "X-Causation-ID") ?? Guid.NewGuid(); + var userId = context.User?.Identity?.Name; + + // Set correlation context + CorrelationContext.Current = new CorrelationContext(correlationId, causationId, userId); + + // Add correlation headers to response + context.Response.Headers["X-Correlation-ID"] = correlationId.ToString(); + + await _next(context); + } + finally + { + // Clear correlation context + CorrelationContext.Clear(); + } + } + + private Guid? GetHeaderGuid(HttpContext context, string headerName) + { + if (context.Request.Headers.TryGetValue(headerName, out var value) && + Guid.TryParse(value, out var guid)) + { + return guid; + } + + return null; + } +} diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index 9ed0a767..db8fb1ca 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -37,18 +37,18 @@ - [x] Verify event subscription mechanisms ## 6. Event Sourcing Patterns -- [ ] Update event replay and state reconstruction examples -- [ ] Verify snapshot implementation if used -- [ ] Check versioning strategies for events -- [ ] Update stream management examples -- [ ] Verify event serialization approaches +- [x] Update event replay and state reconstruction examples +- [x] Verify snapshot implementation if used +- [x] Check versioning strategies for events +- [x] Update stream management examples +- [x] Verify event serialization approaches ## 7. Saga/Process Manager Implementation -- [ ] Compare saga implementation with PowerModels examples -- [ ] Update saga state management documentation -- [ ] Verify saga event handling patterns -- [ ] Check saga persistence mechanisms -- [ ] Update saga correlation tracking examples +- [x] Compare saga implementation with PowerModels examples +- [x] Update saga state management documentation +- [x] Verify saga event handling patterns +- [x] Check saga persistence mechanisms +- [x] Update saga correlation tracking examples ## 8. Error Handling and Recovery - [ ] Verify error handling patterns in PowerModels From 221eaf62cb6805ed81687ed274cbc9d4acfb1acb Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 10:22:25 -0400 Subject: [PATCH 36/41] Add comprehensive error handling patterns documentation - Added retry patterns with examples - Added circuit breaker pattern implementations - Added bulkhead pattern for isolation - Added timeout pattern strategies - Updated documentation checklist --- .../patterns/error-handling-patterns.md | 2657 +++++++++++++++++ docs/documentation-update-checklist.md | 12 +- 2 files changed, 2663 insertions(+), 6 deletions(-) create mode 100644 docs/api-reference/patterns/error-handling-patterns.md diff --git a/docs/api-reference/patterns/error-handling-patterns.md b/docs/api-reference/patterns/error-handling-patterns.md new file mode 100644 index 00000000..641fe1b7 --- /dev/null +++ b/docs/api-reference/patterns/error-handling-patterns.md @@ -0,0 +1,2657 @@ +# Error Handling and Recovery Patterns + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +This document outlines the key patterns and best practices for implementing error handling and recovery mechanisms in Reactive Domain applications. Proper error handling is essential for building resilient event-driven systems that can recover from failures and maintain data consistency. + +## Table of Contents + +1. [Error Handling Principles](#error-handling-principles) +2. [Command Validation](#command-validation) +3. [Exception Handling Strategies](#exception-handling-strategies) +4. [Retry Patterns](#retry-patterns) +5. [Circuit Breaker Pattern](#circuit-breaker-pattern) +6. [Compensating Transactions](#compensating-transactions) +7. [Dead Letter Queues](#dead-letter-queues) +8. [Error Logging and Monitoring](#error-logging-and-monitoring) +9. [Testing Error Scenarios](#testing-error-scenarios) +10. [Best Practices](#best-practices) +11. [Common Pitfalls](#common-pitfalls) + +## Error Handling Principles + +In event-driven systems, error handling requires special consideration due to the asynchronous and distributed nature of these systems. The following principles should guide your error handling strategy: + +1. **Fail Fast**: Detect and report errors as early as possible +2. **Fail Safely**: Ensure that failures don't leave the system in an inconsistent state +3. **Isolate Failures**: Prevent failures in one component from cascading to others +4. **Recover Automatically**: Implement mechanisms for automatic recovery where possible +5. **Track and Log**: Maintain comprehensive error logs for troubleshooting +6. **Design for Resilience**: Anticipate failures and design systems to handle them gracefully + +### Error Categories in Event-Driven Systems + +In Reactive Domain applications, errors typically fall into the following categories: + +1. **Validation Errors**: Invalid commands or data that fail business rules +2. **Technical Errors**: Infrastructure failures, timeouts, or connectivity issues +3. **Concurrency Errors**: Conflicts due to concurrent modifications +4. **Business Process Errors**: Failures in multi-step business processes +5. **Integration Errors**: Issues when interacting with external systems + +## Command Validation + +Command validation is the first line of defense against errors. By validating commands before they're processed, you can catch many issues early and provide immediate feedback to users. + +### 1. Validator Pattern + +The Validator pattern separates validation logic from command handling: + +```csharp +public interface IValidator +{ + ValidationResult Validate(T command); +} + +public class ValidationResult +{ + private readonly List _errors = new List(); + + public bool IsValid => !_errors.Any(); + public IReadOnlyList Errors => _errors.AsReadOnly(); + + public void AddError(string error) + { + _errors.Add(error); + } + + public static ValidationResult Success() + { + return new ValidationResult(); + } + + public static ValidationResult Failure(string error) + { + var result = new ValidationResult(); + result.AddError(error); + return result; + } + + public static ValidationResult Failure(IEnumerable errors) + { + var result = new ValidationResult(); + foreach (var error in errors) + { + result.AddError(error); + } + return result; + } +} + +public class CreateAccountValidator : IValidator +{ + private readonly IAccountRepository _repository; + + public CreateAccountValidator(IAccountRepository repository) + { + _repository = repository; + } + + public ValidationResult Validate(CreateAccount command) + { + var result = new ValidationResult(); + + // Validate required fields + if (string.IsNullOrWhiteSpace(command.AccountNumber)) + { + result.AddError("Account number is required"); + } + + if (string.IsNullOrWhiteSpace(command.CustomerName)) + { + result.AddError("Customer name is required"); + } + + if (command.InitialDeposit < 0) + { + result.AddError("Initial deposit cannot be negative"); + } + + // Validate business rules + if (!result.IsValid) + { + return result; // Don't check database if basic validation fails + } + + // Check for duplicate account number + if (_repository.ExistsByAccountNumber(command.AccountNumber)) + { + result.AddError($"Account number '{command.AccountNumber}' is already in use"); + } + + return result; + } +} +``` + +### 2. Validation Middleware + +Validation middleware intercepts commands and validates them before they reach the command handlers: + +```csharp +public class ValidationMiddleware +{ + private readonly IValidator _validator; + private readonly ICommandHandler _innerHandler; + + public ValidationMiddleware( + IValidator validator, + ICommandHandler innerHandler) + { + _validator = validator; + _innerHandler = innerHandler; + } + + public void Handle(TCommand command) + { + // Validate the command + var validationResult = _validator.Validate(command); + + if (!validationResult.IsValid) + { + // Throw a validation exception with all validation errors + throw new ValidationException(validationResult.Errors); + } + + // Command is valid, proceed with handling + _innerHandler.Handle(command); + } +} + +// Registration in DI container +public void ConfigureServices(IServiceCollection services) +{ + // Register validators + services.AddTransient, CreateAccountValidator>(); + services.AddTransient, DepositFundsValidator>(); + + // Register command handlers with validation + services.AddTransient>( + sp => new ValidationMiddleware( + sp.GetRequiredService>(), + new CreateAccountHandler( + sp.GetRequiredService>(), + sp.GetRequiredService()))); + + // Register other handlers similarly +} +``` + +### 3. Fluent Validation + +Fluent validation provides a more expressive and readable way to define validation rules: + +```csharp +public class CreateAccountValidator : AbstractValidator +{ + public CreateAccountValidator(IAccountRepository repository) + { + RuleFor(x => x.AccountNumber) + .NotEmpty().WithMessage("Account number is required") + .MaximumLength(20).WithMessage("Account number cannot exceed 20 characters") + .Must(accountNumber => !repository.ExistsByAccountNumber(accountNumber)) + .WithMessage(x => $"Account number '{x.AccountNumber}' is already in use"); + + RuleFor(x => x.CustomerName) + .NotEmpty().WithMessage("Customer name is required") + .MaximumLength(100).WithMessage("Customer name cannot exceed 100 characters"); + + RuleFor(x => x.InitialDeposit) + .GreaterThanOrEqualTo(0).WithMessage("Initial deposit cannot be negative"); + } +} + +// Usage with command handler +public class CreateAccountHandler : ICommandHandler +{ + private readonly IValidator _validator; + private readonly IRepository _repository; + private readonly IEventBus _eventBus; + + public CreateAccountHandler( + IValidator validator, + IRepository repository, + IEventBus eventBus) + { + _validator = validator; + _repository = repository; + _eventBus = eventBus; + } + + public void Handle(CreateAccount command) + { + // Validate the command + var validationResult = _validator.Validate(command); + validationResult.ThrowIfInvalid(); + + // Process the command + var account = new Account(Guid.NewGuid()); + account.Initialize( + command.AccountNumber, + command.CustomerName, + command.InitialDeposit); + + _repository.Save(account); + } +} + +// Extension method for validation results +public static class ValidationResultExtensions +{ + public static void ThrowIfInvalid(this ValidationResult result) + { + if (!result.IsValid) + { + throw new ValidationException(result.Errors); + } + } +} +``` + +### 4. Domain Validation vs. Application Validation + +Distinguish between domain validation (business rules) and application validation (input validation): + +```csharp +// Application-level validation (input validation) +public class DepositFundsValidator : IValidator +{ + public ValidationResult Validate(DepositFunds command) + { + var result = new ValidationResult(); + + if (command.AccountId == Guid.Empty) + { + result.AddError("Account ID is required"); + } + + if (command.Amount <= 0) + { + result.AddError("Deposit amount must be greater than zero"); + } + + return result; + } +} + +// Domain-level validation (business rules) +public class Account : AggregateRoot +{ + private bool _isActive; + private decimal _balance; + + public void Deposit(decimal amount, string description) + { + // Domain validation + if (!_isActive) + { + throw new DomainException("Cannot deposit to an inactive account"); + } + + if (amount <= 0) + { + throw new DomainException("Deposit amount must be greater than zero"); + } + + // Apply the deposit + RaiseEvent(new FundsDeposited( + Id, + amount, + description, + DateTime.UtcNow)); + } + + private void Apply(FundsDeposited @event) + { + _balance += @event.Amount; + } +} +``` + +### 5. Validation Response Pattern + +The Validation Response pattern provides structured feedback for validation failures: + +```csharp +public class CommandResponse +{ + public bool Success { get; } + public IReadOnlyList Errors { get; } + public Guid? AggregateId { get; } + + private CommandResponse(bool success, IEnumerable errors = null, Guid? aggregateId = null) + { + Success = success; + Errors = errors?.ToList().AsReadOnly() ?? new List().AsReadOnly(); + AggregateId = aggregateId; + } + + public static CommandResponse Successful(Guid aggregateId) + { + return new CommandResponse(true, aggregateId: aggregateId); + } + + public static CommandResponse Failed(IEnumerable errors) + { + return new CommandResponse(false, errors); + } + + public static CommandResponse Failed(string error) + { + return new CommandResponse(false, new[] { error }); + } +} + +// Command handler with response +public class CreateAccountHandler : ICommandHandler +{ + private readonly IValidator _validator; + private readonly IRepository _repository; + + public CommandResponse Handle(CreateAccount command) + { + // Validate the command + var validationResult = _validator.Validate(command); + + if (!validationResult.IsValid) + { + return CommandResponse.Failed(validationResult.Errors); + } + + // Process the command + var accountId = Guid.NewGuid(); + var account = new Account(accountId); + account.Initialize( + command.AccountNumber, + command.CustomerName, + command.InitialDeposit); + + _repository.Save(account); + + return CommandResponse.Successful(accountId); + } +} + +## Exception Handling Strategies + +Effective exception handling is crucial for maintaining system stability and providing meaningful feedback when errors occur. Here are key exception handling strategies for Reactive Domain applications: + +### 1. Exception Hierarchy + +Implement a well-structured exception hierarchy to categorize different types of errors: + +```csharp +// Base exception for all application exceptions +public abstract class ApplicationException : Exception +{ + protected ApplicationException(string message) : base(message) { } + protected ApplicationException(string message, Exception innerException) + : base(message, innerException) { } +} + +// Validation exceptions +public class ValidationException : ApplicationException +{ + public IReadOnlyList ValidationErrors { get; } + + public ValidationException(IEnumerable errors) + : base("Validation failed: " + string.Join(", ", errors)) + { + ValidationErrors = errors.ToList().AsReadOnly(); + } + + public ValidationException(string error) + : this(new[] { error }) + { + } +} + +// Domain exceptions for business rule violations +public class DomainException : ApplicationException +{ + public DomainException(string message) : base(message) { } +} + +// Concurrency exceptions +public class ConcurrencyException : ApplicationException +{ + public ConcurrencyException(string message) : base(message) { } +} + +// Infrastructure exceptions +public class InfrastructureException : ApplicationException +{ + public InfrastructureException(string message) : base(message) { } + public InfrastructureException(string message, Exception innerException) + : base(message, innerException) { } +} + +// Not found exceptions +public class NotFoundException : ApplicationException +{ + public NotFoundException(string message) : base(message) { } + + public static NotFoundException For(Guid id) + { + return new NotFoundException($"{typeof(T).Name} with ID {id} was not found"); + } +} +``` + +### 2. Global Exception Handling + +Implement global exception handling to ensure consistent error responses: + +```csharp +// ASP.NET Core global exception handler middleware +public class GlobalExceptionHandlerMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public GlobalExceptionHandlerMiddleware( + RequestDelegate next, + ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "An unhandled exception occurred"); + await HandleExceptionAsync(context, ex); + } + } + + private static async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + context.Response.ContentType = "application/json"; + + var response = new ErrorResponse + { + TraceId = context.TraceIdentifier + }; + + switch (exception) + { + case ValidationException validationEx: + context.Response.StatusCode = StatusCodes.Status400BadRequest; + response.Message = "Validation failed"; + response.Errors = validationEx.ValidationErrors; + break; + + case DomainException domainEx: + context.Response.StatusCode = StatusCodes.Status400BadRequest; + response.Message = domainEx.Message; + break; + + case NotFoundException notFoundEx: + context.Response.StatusCode = StatusCodes.Status404NotFound; + response.Message = notFoundEx.Message; + break; + + case ConcurrencyException concurrencyEx: + context.Response.StatusCode = StatusCodes.Status409Conflict; + response.Message = concurrencyEx.Message; + break; + + case InfrastructureException infraEx: + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + response.Message = "A system error occurred. Please try again later."; + break; + + default: + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + response.Message = "An unexpected error occurred. Please try again later."; + break; + } + + await context.Response.WriteAsync(JsonConvert.SerializeObject(response)); + } + + private class ErrorResponse + { + public string Message { get; set; } + public IEnumerable Errors { get; set; } + public string TraceId { get; set; } + } +} + +// Extension method to register the middleware +public static class GlobalExceptionHandlerMiddlewareExtensions +{ + public static IApplicationBuilder UseGlobalExceptionHandler( + this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} + +// Usage in Startup.cs +public void Configure(IApplicationBuilder app, IWebHostEnvironment env) +{ + // Add global exception handler + app.UseGlobalExceptionHandler(); + + // Other middleware... +} +``` + +### 3. Try-Operation Pattern + +The Try-Operation pattern provides a consistent way to handle exceptions in service methods: + +```csharp +public class OperationResult +{ + public bool Success { get; } + public T Data { get; } + public string ErrorMessage { get; } + public Exception Exception { get; } + + private OperationResult(bool success, T data, string errorMessage, Exception exception) + { + Success = success; + Data = data; + ErrorMessage = errorMessage; + Exception = exception; + } + + public static OperationResult Successful(T data) + { + return new OperationResult(true, data, null, null); + } + + public static OperationResult Failed(string errorMessage) + { + return new OperationResult(false, default, errorMessage, null); + } + + public static OperationResult Failed(Exception exception) + { + return new OperationResult(false, default, exception.Message, exception); + } +} + +public static class Try +{ + public static OperationResult Run(Func operation) + { + try + { + var result = operation(); + return OperationResult.Successful(result); + } + catch (ValidationException ex) + { + return OperationResult.Failed(ex); + } + catch (DomainException ex) + { + return OperationResult.Failed(ex); + } + catch (NotFoundException ex) + { + return OperationResult.Failed(ex); + } + catch (Exception ex) + { + // Log unexpected exceptions + // logger.LogError(ex, "Unexpected error occurred"); + return OperationResult.Failed(ex); + } + } + + public static async Task> RunAsync(Func> operation) + { + try + { + var result = await operation(); + return OperationResult.Successful(result); + } + catch (ValidationException ex) + { + return OperationResult.Failed(ex); + } + catch (DomainException ex) + { + return OperationResult.Failed(ex); + } + catch (NotFoundException ex) + { + return OperationResult.Failed(ex); + } + catch (Exception ex) + { + // Log unexpected exceptions + // logger.LogError(ex, "Unexpected error occurred"); + return OperationResult.Failed(ex); + } + } +} + +// Usage in a service +public class AccountService +{ + private readonly IRepository _repository; + + public AccountService(IRepository repository) + { + _repository = repository; + } + + public async Task> GetAccountAsync(Guid id) + { + return await Try.RunAsync(async () => + { + var account = await _repository.GetByIdAsync(id); + + if (account == null) + throw NotFoundException.For(id); + + return new AccountDto + { + Id = account.Id, + AccountNumber = account.AccountNumber, + Balance = account.Balance, + IsActive = account.IsActive + }; + }); + } +} +``` + +### 4. Exception Filters + +Exception filters in ASP.NET Core provide a way to handle exceptions at the controller or action level: + +```csharp +public class DomainExceptionFilter : IExceptionFilter +{ + private readonly ILogger _logger; + + public DomainExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public void OnException(ExceptionContext context) + { + if (context.Exception is DomainException domainException) + { + _logger.LogWarning(domainException, "Domain exception occurred"); + + var result = new ObjectResult(new + { + error = domainException.Message + }) + { + StatusCode = StatusCodes.Status400BadRequest + }; + + context.Result = result; + context.ExceptionHandled = true; + } + } +} + +// Usage in controller +[ApiController] +[Route("api/[controller]")] +[TypeFilter(typeof(DomainExceptionFilter))] +public class AccountsController : ControllerBase +{ + private readonly ICommandBus _commandBus; + + public AccountsController(ICommandBus commandBus) + { + _commandBus = commandBus; + } + + [HttpPost] + public IActionResult CreateAccount([FromBody] CreateAccountRequest request) + { + var command = new CreateAccount( + request.AccountNumber, + request.CustomerName, + request.InitialDeposit); + + var result = _commandBus.Send(command); + + return CreatedAtAction( + nameof(GetAccount), + new { id = result.AggregateId }, + null); + } + + // Other actions... +} +``` + +### 5. Domain-Specific Exception Handling + +Implement domain-specific exception handling for business rule violations: + +```csharp +// Specific domain exceptions +public class InsufficientFundsException : DomainException +{ + public Guid AccountId { get; } + public decimal AttemptedAmount { get; } + public decimal AvailableBalance { get; } + + public InsufficientFundsException( + Guid accountId, + decimal attemptedAmount, + decimal availableBalance) + : base($"Insufficient funds to withdraw {attemptedAmount:C}. Available balance: {availableBalance:C}") + { + AccountId = accountId; + AttemptedAmount = attemptedAmount; + AvailableBalance = availableBalance; + } +} + +public class AccountClosedException : DomainException +{ + public Guid AccountId { get; } + + public AccountClosedException(Guid accountId) + : base($"Cannot perform operation on closed account {accountId}") + { + AccountId = accountId; + } +} + +// Usage in domain model +public class Account : AggregateRoot +{ + private bool _isActive; + private decimal _balance; + + public void Withdraw(decimal amount, string description) + { + if (!_isActive) + throw new AccountClosedException(Id); + + if (amount <= 0) + throw new DomainException("Withdrawal amount must be greater than zero"); + + if (_balance < amount) + throw new InsufficientFundsException(Id, amount, _balance); + + RaiseEvent(new FundsWithdrawn( + Id, + amount, + description, + DateTime.UtcNow)); + } + + private void Apply(FundsWithdrawn @event) + { + _balance -= @event.Amount; + } +} +``` + +## Retry Patterns + +Retry patterns help handle transient failures in distributed systems by automatically retrying operations that might succeed if attempted again after a delay. + +### 1. Basic Retry Pattern + +A simple retry pattern with a fixed number of attempts and delay: + +```csharp +public static class RetryHelper +{ + public static async Task RetryAsync( + Func> operation, + int maxAttempts = 3, + TimeSpan? delay = null) + { + var attempts = 0; + var actualDelay = delay ?? TimeSpan.FromSeconds(1); + + while (true) + { + try + { + attempts++; + return await operation(); + } + catch (Exception ex) when (ShouldRetry(ex) && attempts < maxAttempts) + { + // Log retry attempt + // logger.LogWarning(ex, $"Retry attempt {attempts} of {maxAttempts} failed. Retrying in {actualDelay.TotalMilliseconds}ms"); + + await Task.Delay(actualDelay); + } + } + } + + private static bool ShouldRetry(Exception ex) + { + // Determine which exceptions should trigger a retry + return ex is TimeoutException || + ex is HttpRequestException || + ex is SocketException || + ex is IOException || + (ex is SqlException sqlEx && IsTransientSqlException(sqlEx)); + } + + private static bool IsTransientSqlException(SqlException ex) + { + // SQL Server transient error codes + int[] transientErrorCodes = { 4060, 40197, 40501, 40613, 49918, 49919, 49920 }; + return transientErrorCodes.Contains(ex.Number); + } +} + +// Usage +public class EventStoreRepository : IEventStoreRepository +{ + private readonly IEventStoreConnection _connection; + + public EventStoreRepository(IEventStoreConnection connection) + { + _connection = connection; + } + + public async Task> GetEventsAsync(Guid aggregateId) + { + return await RetryHelper.RetryAsync(async () => + { + var streamName = $"aggregate-{aggregateId}"; + var events = new List(); + + var sliceStart = 0L; + const int sliceCount = 100; + StreamEventsSlice slice; + + do + { + slice = await _connection.ReadStreamEventsForwardAsync( + streamName, sliceStart, sliceCount, false); + + events.AddRange(slice.Events.Select(e => new EventData + { + EventId = e.Event.EventId, + EventType = e.Event.EventType, + Data = Encoding.UTF8.GetString(e.Event.Data.ToArray()), + Metadata = Encoding.UTF8.GetString(e.Event.Metadata.ToArray()), + StreamPosition = e.Event.EventNumber + })); + + sliceStart = slice.NextEventNumber; + } while (!slice.IsEndOfStream); + + return events; + }); + } +} +``` + +### 2. Exponential Backoff + +Exponential backoff increases the delay between retry attempts to reduce system load during recovery: + +```csharp +public static class RetryWithExponentialBackoff +{ + public static async Task ExecuteAsync( + Func> operation, + int maxAttempts = 5, + TimeSpan? initialDelay = null, + double backoffFactor = 2.0, + TimeSpan? maxDelay = null) + { + var attempts = 0; + var delay = initialDelay ?? TimeSpan.FromMilliseconds(200); + var maxDelayValue = maxDelay ?? TimeSpan.FromSeconds(30); + + while (true) + { + try + { + attempts++; + return await operation(); + } + catch (Exception ex) when (ShouldRetry(ex) && attempts < maxAttempts) + { + // Calculate next delay with exponential backoff + var nextDelay = TimeSpan.FromMilliseconds( + Math.Min(delay.TotalMilliseconds * backoffFactor, maxDelayValue.TotalMilliseconds)); + + // Add jitter to avoid thundering herd problem + var jitteredDelay = AddJitter(delay); + + // Log retry attempt + // logger.LogWarning(ex, $"Retry attempt {attempts} of {maxAttempts} failed. Retrying in {jitteredDelay.TotalMilliseconds}ms"); + + await Task.Delay(jitteredDelay); + delay = nextDelay; + } + } + } + + private static TimeSpan AddJitter(TimeSpan delay) + { + var random = new Random(); + var jitter = random.NextDouble() * 0.3 - 0.15; // -15% to +15% + var jitteredDelayMs = delay.TotalMilliseconds * (1 + jitter); + return TimeSpan.FromMilliseconds(jitteredDelayMs); + } + + private static bool ShouldRetry(Exception ex) + { + // Same implementation as before + return ex is TimeoutException || + ex is HttpRequestException || + ex is SocketException || + ex is IOException || + (ex is SqlException sqlEx && IsTransientSqlException(sqlEx)); + } + + private static bool IsTransientSqlException(SqlException ex) + { + // Same implementation as before + int[] transientErrorCodes = { 4060, 40197, 40501, 40613, 49918, 49919, 49920 }; + return transientErrorCodes.Contains(ex.Number); + } +} +``` + +### 3. Polly for Resilience Policies + +Polly is a .NET resilience and transient-fault-handling library that provides a fluent API for defining retry policies: + +```csharp +public class ResilientEventStore : IEventStore +{ + private readonly IEventStore _innerEventStore; + private readonly ILogger _logger; + private readonly AsyncPolicy _retryPolicy; + + public ResilientEventStore( + IEventStore innerEventStore, + ILogger logger) + { + _innerEventStore = innerEventStore; + _logger = logger; + + // Define retry policy with Polly + _retryPolicy = Policy + .Handle() + .Or() + .Or() + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)), + onRetry: (ex, timeSpan, retryCount, context) => + { + _logger.LogWarning(ex, + "Error accessing event store. Retry attempt {RetryCount} after {RetryDelay}ms", + retryCount, timeSpan.TotalMilliseconds); + }); + } + + public async Task> GetEventsAsync(Guid aggregateId) + { + return await _retryPolicy.ExecuteAsync(() => + _innerEventStore.GetEventsAsync(aggregateId)); + } + + public async Task SaveEventsAsync(Guid aggregateId, IEnumerable events, long expectedVersion) + { + await _retryPolicy.ExecuteAsync(() => + _innerEventStore.SaveEventsAsync(aggregateId, events, expectedVersion)); + } +} + +// Registration in DI container +public void ConfigureServices(IServiceCollection services) +{ + // Register the inner event store + services.AddSingleton(provider => + { + var connectionSettings = ConnectionSettings.Create() + .EnableVerboseLogging() + .UseConsoleLogger() + .Build(); + + var connection = EventStoreConnection.Create( + connectionSettings, + new Uri("tcp://admin:changeit@localhost:1113")); + + connection.ConnectAsync().Wait(); + return connection; + }); + + services.AddSingleton(); + + // Register the resilient decorator + services.Decorate((inner, provider) => + new ResilientEventStore( + inner, + provider.GetRequiredService>())); +} +``` + +### 4. Retry with Circuit Breaker + +Combine retry with circuit breaker to prevent repeated retries when a service is unavailable: + +```csharp +public class ResilientCommandBus : ICommandBus +{ + private readonly ICommandBus _innerCommandBus; + private readonly ILogger _logger; + private readonly AsyncPolicyWrap _resilientPolicy; + + public ResilientCommandBus( + ICommandBus innerCommandBus, + ILogger logger) + { + _innerCommandBus = innerCommandBus; + _logger = logger; + + // Define retry policy + var retryPolicy = Policy + .Handle(ex => ShouldRetry(ex)) + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)), + onRetry: (ex, timeSpan, retryCount, context) => + { + _logger.LogWarning(ex, + "Error executing command. Retry attempt {RetryCount} after {RetryDelay}ms", + retryCount, timeSpan.TotalMilliseconds); + }); + + // Define circuit breaker policy + var circuitBreakerPolicy = Policy + .Handle(ex => ShouldRetry(ex)) + .CircuitBreakerAsync( + exceptionsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromMinutes(1), + onBreak: (ex, breakDelay) => + { + _logger.LogError(ex, + "Circuit breaker opened for {BreakDelay}ms due to: {ExceptionMessage}", + breakDelay.TotalMilliseconds, ex.Message); + }, + onReset: () => + { + _logger.LogInformation("Circuit breaker reset"); + }, + onHalfOpen: () => + { + _logger.LogInformation("Circuit breaker half-open, next call is a trial"); + }); + + // Combine policies + _resilientPolicy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy); + } + + public async Task SendAsync(TCommand command) where TCommand : ICommand + { + await _resilientPolicy.ExecuteAsync(() => + _innerCommandBus.SendAsync(command)); + } + + private bool ShouldRetry(Exception ex) + { + // Determine which exceptions should trigger a retry + return !(ex is ValidationException || ex is DomainException); + } +} +``` + +## Circuit Breaker Pattern + +The Circuit Breaker pattern prevents an application from repeatedly trying to execute an operation that's likely to fail, allowing it to continue without waiting for the fault to be fixed or wasting CPU cycles. + +### 1. Basic Circuit Breaker + +A simple implementation of the Circuit Breaker pattern: + +```csharp +public enum CircuitState +{ + Closed, // Normal operation - requests go through + Open, // Circuit is broken - requests fail fast + HalfOpen // Testing if the circuit can be closed again +} + +public class CircuitBreaker +{ + private readonly int _failureThreshold; + private readonly TimeSpan _resetTimeout; + private readonly ILogger _logger; + + private CircuitState _state = CircuitState.Closed; + private int _failureCount; + private DateTime _lastFailureTime; + + public CircuitBreaker( + int failureThreshold = 5, + TimeSpan? resetTimeout = null, + ILogger logger = null) + { + _failureThreshold = failureThreshold; + _resetTimeout = resetTimeout ?? TimeSpan.FromSeconds(30); + _logger = logger; + } + + public async Task ExecuteAsync(Func> operation) + { + await CheckStateAsync(); + + try + { + var result = await operation(); + Reset(); // Success, reset the circuit + return result; + } + catch (Exception ex) + { + TrackFailure(ex); + throw; // Re-throw the original exception + } + } + + private async Task CheckStateAsync() + { + if (_state == CircuitState.Open) + { + // Check if the timeout has expired + if (DateTime.UtcNow - _lastFailureTime > _resetTimeout) + { + // Move to half-open state + _state = CircuitState.HalfOpen; + _logger?.LogInformation("Circuit breaker state changed from Open to Half-Open"); + } + else + { + // Circuit is still open, fail fast + _logger?.LogWarning("Circuit breaker is Open - failing fast"); + throw new CircuitBreakerOpenException("Circuit breaker is open"); + } + } + } + + private void TrackFailure(Exception ex) + { + _lastFailureTime = DateTime.UtcNow; + + if (_state == CircuitState.HalfOpen) + { + // If we're testing the circuit and it failed, open the circuit again + _state = CircuitState.Open; + _logger?.LogWarning(ex, "Circuit breaker trial call failed, resetting to Open state"); + } + else if (_state == CircuitState.Closed) + { + // Increment the failure counter + _failureCount++; + + if (_failureCount >= _failureThreshold) + { + // Too many failures, open the circuit + _state = CircuitState.Open; + _logger?.LogWarning(ex, "Circuit breaker threshold reached ({FailureCount}/{FailureThreshold}), changing to Open state", + _failureCount, _failureThreshold); + } + } + } + + private void Reset() + { + if (_state != CircuitState.Closed) + { + _logger?.LogInformation("Circuit breaker reset to Closed state"); + } + + _failureCount = 0; + _state = CircuitState.Closed; + } +} + +public class CircuitBreakerOpenException : Exception +{ + public CircuitBreakerOpenException(string message) : base(message) { } +} + +// Usage +public class ExternalServiceClient +{ + private readonly HttpClient _httpClient; + private readonly CircuitBreaker _circuitBreaker; + + public ExternalServiceClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _circuitBreaker = new CircuitBreaker( + failureThreshold: 3, + resetTimeout: TimeSpan.FromMinutes(1), + logger: logger); + } + + public async Task GetDataAsync(string endpoint) + { + return await _circuitBreaker.ExecuteAsync(async () => + { + var response = await _httpClient.GetAsync(endpoint); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + }); + } +} +``` + +### 2. Advanced Circuit Breaker with Monitoring + +An advanced circuit breaker with monitoring capabilities: + +```csharp +public class MonitoredCircuitBreaker +{ + private readonly int _failureThreshold; + private readonly TimeSpan _resetTimeout; + private readonly ILogger _logger; + private readonly IMetricsReporter _metrics; + + private CircuitState _state = CircuitState.Closed; + private int _failureCount; + private DateTime _lastFailureTime; + private readonly string _circuitName; + + // Metrics + private long _totalRequests; + private long _successfulRequests; + private long _failedRequests; + private long _shortCircuitedRequests; + + public MonitoredCircuitBreaker( + string circuitName, + int failureThreshold = 5, + TimeSpan? resetTimeout = null, + ILogger logger = null, + IMetricsReporter metrics = null) + { + _circuitName = circuitName; + _failureThreshold = failureThreshold; + _resetTimeout = resetTimeout ?? TimeSpan.FromSeconds(30); + _logger = logger; + _metrics = metrics; + } + + public async Task ExecuteAsync(Func> operation) + { + Interlocked.Increment(ref _totalRequests); + + await CheckStateAsync(); + + try + { + var result = await operation(); + + Interlocked.Increment(ref _successfulRequests); + Reset(); // Success, reset the circuit + + return result; + } + catch (Exception ex) + { + Interlocked.Increment(ref _failedRequests); + TrackFailure(ex); + + // Report metrics + _metrics?.IncrementCounter($"{_circuitName}.failures"); + + throw; // Re-throw the original exception + } + } + + private async Task CheckStateAsync() + { + if (_state == CircuitState.Open) + { + // Check if the timeout has expired + if (DateTime.UtcNow - _lastFailureTime > _resetTimeout) + { + // Move to half-open state + _state = CircuitState.HalfOpen; + _logger?.LogInformation("Circuit {CircuitName} state changed from Open to Half-Open", _circuitName); + + // Report state change + _metrics?.SetGauge($"{_circuitName}.state", 1); // 0=Closed, 1=HalfOpen, 2=Open + } + else + { + // Circuit is still open, fail fast + Interlocked.Increment(ref _shortCircuitedRequests); + + // Report metrics + _metrics?.IncrementCounter($"{_circuitName}.short_circuits"); + + _logger?.LogWarning("Circuit {CircuitName} is Open - failing fast", _circuitName); + throw new CircuitBreakerOpenException($"Circuit {_circuitName} is open"); + } + } + } + + private void TrackFailure(Exception ex) + { + _lastFailureTime = DateTime.UtcNow; + + if (_state == CircuitState.HalfOpen) + { + // If we're testing the circuit and it failed, open the circuit again + _state = CircuitState.Open; + _logger?.LogWarning(ex, "Circuit {CircuitName} trial call failed, resetting to Open state", _circuitName); + + // Report state change + _metrics?.SetGauge($"{_circuitName}.state", 2); // 0=Closed, 1=HalfOpen, 2=Open + } + else if (_state == CircuitState.Closed) + { + // Increment the failure counter + _failureCount++; + + if (_failureCount >= _failureThreshold) + { + // Too many failures, open the circuit + _state = CircuitState.Open; + _logger?.LogWarning(ex, "Circuit {CircuitName} threshold reached ({FailureCount}/{FailureThreshold}), changing to Open state", + _circuitName, _failureCount, _failureThreshold); + + // Report state change + _metrics?.SetGauge($"{_circuitName}.state", 2); // 0=Closed, 1=HalfOpen, 2=Open + } + } + } + + private void Reset() + { + if (_state != CircuitState.Closed) + { + _logger?.LogInformation("Circuit {CircuitName} reset to Closed state", _circuitName); + + // Report state change + _metrics?.SetGauge($"{_circuitName}.state", 0); // 0=Closed, 1=HalfOpen, 2=Open + } + + _failureCount = 0; + _state = CircuitState.Closed; + } + + public CircuitBreakerMetrics GetMetrics() + { + return new CircuitBreakerMetrics + { + CircuitName = _circuitName, + State = _state, + FailureCount = _failureCount, + TotalRequests = _totalRequests, + SuccessfulRequests = _successfulRequests, + FailedRequests = _failedRequests, + ShortCircuitedRequests = _shortCircuitedRequests, + LastFailureTime = _lastFailureTime, + SuccessRate = _totalRequests > 0 + ? (double)_successfulRequests / _totalRequests + : 0 + }; + } +} + +public class CircuitBreakerMetrics +{ + public string CircuitName { get; set; } + public CircuitState State { get; set; } + public int FailureCount { get; set; } + public long TotalRequests { get; set; } + public long SuccessfulRequests { get; set; } + public long FailedRequests { get; set; } + public long ShortCircuitedRequests { get; set; } + public DateTime LastFailureTime { get; set; } + public double SuccessRate { get; set; } +} + +public interface IMetricsReporter +{ + void IncrementCounter(string name, double value = 1); + void SetGauge(string name, double value); + void RecordTiming(string name, TimeSpan duration); +} +``` + +### 3. Circuit Breaker Factory + +A factory for creating and managing circuit breakers: + +```csharp +public class CircuitBreakerFactory +{ + private readonly ConcurrentDictionary _circuitBreakers = + new ConcurrentDictionary(); + private readonly ILogger _logger; + private readonly IMetricsReporter _metrics; + + public CircuitBreakerFactory( + ILogger logger, + IMetricsReporter metrics) + { + _logger = logger; + _metrics = metrics; + } + + public MonitoredCircuitBreaker GetOrCreate( + string circuitName, + int failureThreshold = 5, + TimeSpan? resetTimeout = null) + { + return _circuitBreakers.GetOrAdd( + circuitName, + name => new MonitoredCircuitBreaker( + name, + failureThreshold, + resetTimeout, + _logger, + _metrics)); + } + + public IReadOnlyDictionary GetAllMetrics() + { + return _circuitBreakers.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.GetMetrics()); + } +} + +// Usage with dependency injection +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + // Register metrics reporter + services.AddSingleton(); + + // Register circuit breaker factory + services.AddSingleton(); + + // Register services that use circuit breakers + services.AddHttpClient(); + } +} + +public class ExternalServiceClient : IExternalServiceClient +{ + private readonly HttpClient _httpClient; + private readonly MonitoredCircuitBreaker _circuitBreaker; + + public ExternalServiceClient( + HttpClient httpClient, + CircuitBreakerFactory circuitBreakerFactory) + { + _httpClient = httpClient; + _circuitBreaker = circuitBreakerFactory.GetOrCreate( + "external-service", + failureThreshold: 3, + resetTimeout: TimeSpan.FromMinutes(1)); + } + + public async Task GetDataAsync(string endpoint) + { + return await _circuitBreaker.ExecuteAsync(async () => + { + var response = await _httpClient.GetAsync(endpoint); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + }); + } +} +``` + +### 4. Circuit Breaker with Fallback + +Combine circuit breaker with fallback for graceful degradation: + +```csharp +public class ResilientService +{ + private readonly Func> _primaryOperation; + private readonly Func> _fallbackOperation; + private readonly CircuitBreaker _circuitBreaker; + private readonly ILogger _logger; + + public ResilientService( + Func> primaryOperation, + Func> fallbackOperation, + CircuitBreaker circuitBreaker, + ILogger logger) + { + _primaryOperation = primaryOperation; + _fallbackOperation = fallbackOperation; + _circuitBreaker = circuitBreaker; + _logger = logger; + } + + public async Task ExecuteAsync() + { + try + { + // Try the primary operation with circuit breaker + return await _circuitBreaker.ExecuteAsync(_primaryOperation); + } + catch (CircuitBreakerOpenException) + { + // Circuit is open, use fallback immediately + _logger.LogWarning("Circuit breaker open, using fallback"); + return await _fallbackOperation(); + } + catch (Exception ex) + { + // Primary operation failed, try fallback + _logger.LogWarning(ex, "Primary operation failed, using fallback"); + return await _fallbackOperation(); + } + } +} + +// Usage +public class ProductService +{ + private readonly ResilientService> _productListingService; + + public ProductService( + IExternalServiceClient externalServiceClient, + ILocalCacheService cacheService, + CircuitBreakerFactory circuitBreakerFactory, + ILogger logger) + { + // Primary operation - get from external service + Func>> primaryOperation = async () => + { + var data = await externalServiceClient.GetDataAsync("/api/products"); + var products = JsonConvert.DeserializeObject>(data); + + // Update cache with fresh data + await cacheService.SetAsync("products", products, TimeSpan.FromHours(1)); + + return products; + }; + + // Fallback operation - get from cache or return empty list + Func>> fallbackOperation = async () => + { + var cachedProducts = await cacheService.GetAsync>("products"); + return cachedProducts ?? new List(); + }; + + // Create circuit breaker + var circuitBreaker = circuitBreakerFactory.GetOrCreate("product-service"); + + // Create resilient service + _productListingService = new ResilientService>( + primaryOperation, + fallbackOperation, + circuitBreaker, + logger); + } + + public async Task> GetProductsAsync() + { + return await _productListingService.ExecuteAsync(); + } +} +``` + +## Bulkhead Pattern + +The Bulkhead pattern isolates elements of an application into pools so that if one fails, the others will continue to function. It's named after the sectioned partitions (bulkheads) of a ship's hull that prevent the entire ship from flooding when one section is compromised. + +### 1. Thread Pool Isolation + +Isolate operations by using dedicated thread pools: + +```csharp +public class BulkheadThreadPool +{ + private readonly string _name; + private readonly int _maxConcurrency; + private readonly int _queueSize; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _semaphore; + private readonly ConcurrentQueue> _queue; + + public BulkheadThreadPool( + string name, + int maxConcurrency, + int queueSize, + ILogger logger = null) + { + _name = name; + _maxConcurrency = maxConcurrency; + _queueSize = queueSize; + _logger = logger; + + _semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); + _queue = new ConcurrentQueue>(); + } + + public async Task ExecuteAsync(Func> operation, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + + // Try to enter the semaphore immediately + if (await _semaphore.WaitAsync(0, cancellationToken)) + { + try + { + _logger?.LogDebug("Executing operation in bulkhead {BulkheadName} (direct execution)", _name); + return await operation(); + } + finally + { + // Process the next queued item if any + ProcessQueuedOperation(); + + // Release the semaphore + _semaphore.Release(); + } + } + + // If we can't enter the semaphore immediately, try to queue + if (_queue.Count >= _queueSize) + { + _logger?.LogWarning("Bulkhead {BulkheadName} rejected execution - queue full", _name); + throw new BulkheadRejectedException($"Bulkhead {_name} rejected execution - queue full"); + } + + // Add to queue + _queue.Enqueue(tcs); + _logger?.LogDebug("Operation queued in bulkhead {BulkheadName}, queue size: {QueueSize}", _name, _queue.Count); + + // Wait for our turn or cancellation + using var registration = cancellationToken.Register(() => + { + tcs.TrySetCanceled(); + }); + + try + { + await tcs.Task; // Wait until we're allowed to proceed + _logger?.LogDebug("Executing operation in bulkhead {BulkheadName} (from queue)", _name); + return await operation(); + } + finally + { + // Process the next queued item if any + ProcessQueuedOperation(); + + // Release the semaphore + _semaphore.Release(); + } + } + + private void ProcessQueuedOperation() + { + if (_queue.TryDequeue(out var nextTcs)) + { + nextTcs.TrySetResult(true); + } + } + + public BulkheadStatistics GetStatistics() + { + return new BulkheadStatistics + { + Name = _name, + MaxConcurrency = _maxConcurrency, + AvailableConcurrency = _semaphore.CurrentCount, + QueueSize = _queueSize, + QueuedOperations = _queue.Count + }; + } +} + +public class BulkheadStatistics +{ + public string Name { get; set; } + public int MaxConcurrency { get; set; } + public int AvailableConcurrency { get; set; } + public int QueueSize { get; set; } + public int QueuedOperations { get; set; } +} + +public class BulkheadRejectedException : Exception +{ + public BulkheadRejectedException(string message) : base(message) { } +} + +// Usage +public class ExternalServiceClient +{ + private readonly HttpClient _httpClient; + private readonly BulkheadThreadPool _bulkhead; + + public ExternalServiceClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _bulkhead = new BulkheadThreadPool( + "external-service", + maxConcurrency: 10, // Max concurrent requests + queueSize: 20, // Max queued requests + logger: logger); + } + + public async Task GetDataAsync(string endpoint, CancellationToken cancellationToken = default) + { + return await _bulkhead.ExecuteAsync(async () => + { + var response = await _httpClient.GetAsync(endpoint, cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + }, cancellationToken); + } +} +``` + +### 2. Service Isolation with Dependency Injection + +Isolate services using dependency injection and dedicated resources: + +```csharp +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + // Configure main database connection + services.AddDbContext(options => + options.UseSqlServer(Configuration.GetConnectionString("MainDatabase"))); + + // Configure read-only database connection for reporting + services.AddDbContext(options => + options.UseSqlServer(Configuration.GetConnectionString("ReportingReadOnlyDatabase"))); + + // Configure HTTP clients with different connection pools + services.AddHttpClient("critical-service", client => + { + client.BaseAddress = new Uri("https://critical-service.example.com"); + }).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + MaxConnectionsPerServer = 100, // Dedicated connection pool + PooledConnectionLifetime = TimeSpan.FromMinutes(10) + }); + + services.AddHttpClient("non-critical-service", client => + { + client.BaseAddress = new Uri("https://non-critical-service.example.com"); + }).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + MaxConnectionsPerServer = 20, // Smaller connection pool + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }); + + // Register bulkhead factories + services.AddSingleton(); + + // Register services + services.AddScoped(); + services.AddScoped(); + } +} + +public class BulkheadFactory +{ + private readonly ConcurrentDictionary _bulkheads = + new ConcurrentDictionary(); + private readonly ILogger _logger; + + public BulkheadFactory(ILogger logger) + { + _logger = logger; + } + + public BulkheadThreadPool GetOrCreate( + string name, + int maxConcurrency, + int queueSize) + { + return _bulkheads.GetOrAdd( + name, + key => new BulkheadThreadPool( + key, + maxConcurrency, + queueSize, + _logger)); + } + + public IReadOnlyDictionary GetAllStatistics() + { + return _bulkheads.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.GetStatistics()); + } +} + +public class CriticalService : ICriticalService +{ + private readonly HttpClient _httpClient; + private readonly BulkheadThreadPool _bulkhead; + + public CriticalService( + IHttpClientFactory httpClientFactory, + BulkheadFactory bulkheadFactory) + { + _httpClient = httpClientFactory.CreateClient("critical-service"); + _bulkhead = bulkheadFactory.GetOrCreate( + "critical-service", + maxConcurrency: 50, + queueSize: 100); + } + + public async Task GetCriticalDataAsync(string endpoint, CancellationToken cancellationToken = default) + { + return await _bulkhead.ExecuteAsync(async () => + { + var response = await _httpClient.GetAsync(endpoint, cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + }, cancellationToken); + } +} + +public class NonCriticalService : INonCriticalService +{ + private readonly HttpClient _httpClient; + private readonly BulkheadThreadPool _bulkhead; + + public NonCriticalService( + IHttpClientFactory httpClientFactory, + BulkheadFactory bulkheadFactory) + { + _httpClient = httpClientFactory.CreateClient("non-critical-service"); + _bulkhead = bulkheadFactory.GetOrCreate( + "non-critical-service", + maxConcurrency: 10, + queueSize: 20); + } + + public async Task GetNonCriticalDataAsync(string endpoint, CancellationToken cancellationToken = default) + { + return await _bulkhead.ExecuteAsync(async () => + { + var response = await _httpClient.GetAsync(endpoint, cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + }, cancellationToken); + } +} +``` + +### 3. Task Scheduler Isolation + +Use custom task schedulers to isolate different workloads: + +```csharp +public class LimitedConcurrencyTaskScheduler : TaskScheduler +{ + private readonly int _maxDegreeOfParallelism; + private readonly SemaphoreSlim _semaphore; + private readonly ConcurrentQueue _tasks = new ConcurrentQueue(); + private readonly string _name; + private readonly ILogger _logger; + + public LimitedConcurrencyTaskScheduler( + string name, + int maxDegreeOfParallelism, + ILogger logger = null) + { + _name = name; + _maxDegreeOfParallelism = maxDegreeOfParallelism; + _semaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism); + _logger = logger; + } + + protected override IEnumerable GetScheduledTasks() + { + return _tasks.ToArray(); + } + + protected override void QueueTask(Task task) + { + _tasks.Enqueue(task); + _logger?.LogDebug("Task queued in scheduler {SchedulerName}", _name); + + ThreadPool.QueueUserWorkItem(_ => TryExecuteTask()); + } + + private void TryExecuteTask() + { + if (_semaphore.Wait(0)) + { + try + { + if (_tasks.TryDequeue(out var task)) + { + _logger?.LogDebug("Executing task in scheduler {SchedulerName}", _name); + TryExecuteTask(task); + } + } + finally + { + _semaphore.Release(); + } + + // Check if there are more tasks to process + if (!_tasks.IsEmpty) + { + ThreadPool.QueueUserWorkItem(_ => TryExecuteTask()); + } + } + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) + { + // Don't execute tasks inline to maintain concurrency control + return false; + } + + public TaskFactory CreateTaskFactory() + { + return new TaskFactory(this); + } + + public int CurrentlyExecutingTaskCount => _maxDegreeOfParallelism - _semaphore.CurrentCount; + + public int QueuedTaskCount => _tasks.Count; +} + +// Usage with task factory +public class IsolatedTaskService +{ + private readonly TaskFactory _criticalTaskFactory; + private readonly TaskFactory _nonCriticalTaskFactory; + private readonly ILogger _logger; + + public IsolatedTaskService(ILogger logger) + { + _logger = logger; + + var criticalScheduler = new LimitedConcurrencyTaskScheduler( + "critical-tasks", + maxDegreeOfParallelism: 4, + logger: logger); + + var nonCriticalScheduler = new LimitedConcurrencyTaskScheduler( + "non-critical-tasks", + maxDegreeOfParallelism: 2, + logger: logger); + + _criticalTaskFactory = criticalScheduler.CreateTaskFactory(); + _nonCriticalTaskFactory = nonCriticalScheduler.CreateTaskFactory(); + } + + public async Task ProcessCriticalWorkAsync(Func workItem) + { + await _criticalTaskFactory.StartNew(async () => + { + try + { + _logger.LogInformation("Processing critical work item"); + await workItem(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing critical work item"); + throw; + } + }).Unwrap(); + } + + public async Task ProcessNonCriticalWorkAsync(Func workItem) + { + await _nonCriticalTaskFactory.StartNew(async () => + { + try + { + _logger.LogInformation("Processing non-critical work item"); + await workItem(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing non-critical work item"); + // Swallow exception for non-critical work + } + }).Unwrap(); + } +} +``` + +### 4. Resource Isolation with Timeouts + +Combine bulkheads with timeouts to prevent resource exhaustion: + +```csharp +public class ResourceIsolationService +{ + private readonly BulkheadThreadPool _bulkhead; + private readonly ILogger _logger; + + public ResourceIsolationService( + BulkheadFactory bulkheadFactory, + ILogger logger) + { + _logger = logger; + _bulkhead = bulkheadFactory.GetOrCreate( + "resource-service", + maxConcurrency: 5, + queueSize: 10); + } + + public async Task ExecuteWithTimeoutAsync( + Func> operation, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + // Create a new token source that will timeout after the specified duration + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, cancellationToken); + + try + { + return await _bulkhead.ExecuteAsync(async () => + { + try + { + return await operation(linkedCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + _logger.LogWarning("Operation timed out after {Timeout}ms", timeout.TotalMilliseconds); + throw new TimeoutException($"Operation timed out after {timeout.TotalMilliseconds}ms"); + } + }, linkedCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + _logger.LogWarning("Operation timed out after {Timeout}ms", timeout.TotalMilliseconds); + throw new TimeoutException($"Operation timed out after {timeout.TotalMilliseconds}ms"); + } + } +} + +// Usage with dependency injection +public class DataService +{ + private readonly ResourceIsolationService _isolationService; + private readonly HttpClient _httpClient; + + public DataService( + ResourceIsolationService isolationService, + IHttpClientFactory httpClientFactory) + { + _isolationService = isolationService; + _httpClient = httpClientFactory.CreateClient(); + } + + public async Task GetDataWithTimeoutAsync(string url, CancellationToken cancellationToken = default) + { + return await _isolationService.ExecuteWithTimeoutAsync(async (token) => + { + var response = await _httpClient.GetAsync(url, token); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + }, TimeSpan.FromSeconds(5), cancellationToken); + } +} +``` + +## Timeout Pattern + +The Timeout pattern prevents an operation from blocking indefinitely by setting a maximum time limit for its completion. This is essential in distributed systems where external services may be slow or unresponsive. + +### 1. Basic Timeout with CancellationToken + +Implement a simple timeout using `CancellationToken`: + +public static class TimeoutExtensions +{ + public static async Task WithTimeout( + this Task task, + TimeSpan timeout, + string operationName = null, + CancellationToken cancellationToken = default) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, cancellationToken); + + try + { + return await task.WaitAsync(linkedCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + throw new TimeoutException( + $"Operation {operationName ?? "unknown"} timed out after {timeout.TotalMilliseconds}ms"); + } + } + + public static async Task WithTimeout( + this Task task, + TimeSpan timeout, + string operationName = null, + CancellationToken cancellationToken = default) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, cancellationToken); + + try + { + await task.WaitAsync(linkedCts.Token); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + throw new TimeoutException( + $"Operation {operationName ?? "unknown"} timed out after {timeout.TotalMilliseconds}ms"); + } + } +} + +// Usage +public class ExternalServiceClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ExternalServiceClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task GetDataWithTimeoutAsync( + string endpoint, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(10); + + try + { + return await _httpClient.GetStringAsync(endpoint, cancellationToken) + .WithTimeout(effectiveTimeout, $"HTTP GET {endpoint}", cancellationToken); + } + catch (TimeoutException ex) + { + _logger.LogWarning(ex, "Request to {Endpoint} timed out after {Timeout}ms", + endpoint, effectiveTimeout.TotalMilliseconds); + throw; + } + } +} +``` + +### 2. Timeout with Graceful Cancellation + +Implement a timeout that attempts to gracefully cancel the operation: + +```csharp +public class GracefulTimeoutHandler +{ + private readonly ILogger _logger; + + public GracefulTimeoutHandler(ILogger logger = null) + { + _logger = logger; + } + + public async Task<(bool Success, T Result, Exception Error)> ExecuteWithTimeoutAsync( + Func> operation, + TimeSpan timeout, + Func cleanupAction = null, + CancellationToken cancellationToken = default) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, cancellationToken); + + var operationTask = operation(linkedCts.Token); + + try + { + var result = await operationTask; + return (true, result, null); + } + catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested) + { + _logger?.LogWarning("Operation timed out after {Timeout}ms", timeout.TotalMilliseconds); + + // Try to execute cleanup action if provided + if (cleanupAction != null) + { + try + { + _logger?.LogDebug("Executing cleanup action after timeout"); + await cleanupAction(); + } + catch (Exception cleanupEx) + { + _logger?.LogError(cleanupEx, "Error executing cleanup action after timeout"); + } + } + + return (false, default, new TimeoutException( + $"Operation timed out after {timeout.TotalMilliseconds}ms", ex)); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Operation failed with exception"); + return (false, default, ex); + } + } +} + +// Usage with database transaction +public class DatabaseService +{ + private readonly IDbConnection _connection; + private readonly GracefulTimeoutHandler _timeoutHandler; + private readonly ILogger _logger; + + public DatabaseService( + IDbConnection connection, + ILogger logger) + { + _connection = connection; + _logger = logger; + _timeoutHandler = new GracefulTimeoutHandler(logger); + } + + public async Task<(bool Success, IEnumerable Customers)> GetCustomersWithTimeoutAsync( + CancellationToken cancellationToken = default) + { + IDbTransaction transaction = null; + + var result = await _timeoutHandler.ExecuteWithTimeoutAsync( + async (token) => + { + await _connection.OpenAsync(token); + transaction = _connection.BeginTransaction(); + + var customers = await _connection.QueryAsync( + "SELECT * FROM Customers", + transaction: transaction, + commandTimeout: 30); + + transaction.Commit(); + return customers.ToList(); + }, + timeout: TimeSpan.FromSeconds(5), + cleanupAction: async () => + { + // Cleanup: rollback transaction if it exists + transaction?.Rollback(); + + if (_connection.State != ConnectionState.Closed) + { + _connection.Close(); + } + }, + cancellationToken); + + if (result.Success) + { + return (true, result.Result); + } + else + { + _logger.LogError(result.Error, "Failed to get customers"); + return (false, Enumerable.Empty()); + } + } +} +``` + +### 3. Progressive Timeout Strategy + +Implement a timeout strategy that adjusts based on system conditions: + +```csharp +public class AdaptiveTimeoutStrategy +{ + private readonly ILogger _logger; + private readonly object _lock = new object(); + + // Default timeout values + private readonly TimeSpan _minTimeout; + private readonly TimeSpan _maxTimeout; + private readonly TimeSpan _defaultTimeout; + private readonly double _timeoutIncreaseFactor; + private readonly double _timeoutDecreaseFactor; + + // Current timeout and statistics + private TimeSpan _currentTimeout; + private int _successCount; + private int _timeoutCount; + private int _adjustmentThreshold; + + public AdaptiveTimeoutStrategy( + TimeSpan? minTimeout = null, + TimeSpan? maxTimeout = null, + TimeSpan? defaultTimeout = null, + double timeoutIncreaseFactor = 1.5, + double timeoutDecreaseFactor = 0.9, + int adjustmentThreshold = 10, + ILogger logger = null) + { + _minTimeout = minTimeout ?? TimeSpan.FromSeconds(1); + _maxTimeout = maxTimeout ?? TimeSpan.FromSeconds(30); + _defaultTimeout = defaultTimeout ?? TimeSpan.FromSeconds(5); + _timeoutIncreaseFactor = timeoutIncreaseFactor; + _timeoutDecreaseFactor = timeoutDecreaseFactor; + _adjustmentThreshold = adjustmentThreshold; + _logger = logger; + + _currentTimeout = _defaultTimeout; + } + + public TimeSpan GetCurrentTimeout() + { + lock (_lock) + { + return _currentTimeout; + } + } + + public void RecordSuccess() + { + lock (_lock) + { + _successCount++; + + // If we've had several successes, we might decrease the timeout + if (_successCount >= _adjustmentThreshold) + { + DecreaseTimeout(); + _successCount = 0; + } + } + } + + public void RecordTimeout() + { + lock (_lock) + { + _timeoutCount++; + _successCount = 0; // Reset success count + + // Immediately increase timeout after a failure + IncreaseTimeout(); + } + } + + private void IncreaseTimeout() + { + var newTimeout = TimeSpan.FromMilliseconds( + _currentTimeout.TotalMilliseconds * _timeoutIncreaseFactor); + + if (newTimeout > _maxTimeout) + { + newTimeout = _maxTimeout; + } + + if (newTimeout != _currentTimeout) + { + _logger?.LogInformation( + "Increasing timeout from {CurrentTimeout}ms to {NewTimeout}ms", + _currentTimeout.TotalMilliseconds, newTimeout.TotalMilliseconds); + + _currentTimeout = newTimeout; + } + } + + private void DecreaseTimeout() + { + var newTimeout = TimeSpan.FromMilliseconds( + _currentTimeout.TotalMilliseconds * _timeoutDecreaseFactor); + + if (newTimeout < _minTimeout) + { + newTimeout = _minTimeout; + } + + if (newTimeout != _currentTimeout) + { + _logger?.LogInformation( + "Decreasing timeout from {CurrentTimeout}ms to {NewTimeout}ms", + _currentTimeout.TotalMilliseconds, newTimeout.TotalMilliseconds); + + _currentTimeout = newTimeout; + } + } +} + +// Usage +public class AdaptiveTimeoutClient +{ + private readonly HttpClient _httpClient; + private readonly AdaptiveTimeoutStrategy _timeoutStrategy; + private readonly ILogger _logger; + + public AdaptiveTimeoutClient( + HttpClient httpClient, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + _timeoutStrategy = new AdaptiveTimeoutStrategy( + minTimeout: TimeSpan.FromSeconds(1), + maxTimeout: TimeSpan.FromSeconds(20), + defaultTimeout: TimeSpan.FromSeconds(5), + logger: logger); + } + + public async Task GetDataAsync( + string endpoint, + CancellationToken cancellationToken = default) + { + var timeout = _timeoutStrategy.GetCurrentTimeout(); + + try + { + var result = await _httpClient.GetStringAsync(endpoint, cancellationToken) + .WithTimeout(timeout, $"HTTP GET {endpoint}", cancellationToken); + + // Record success + _timeoutStrategy.RecordSuccess(); + + return result; + } + catch (TimeoutException) + { + // Record timeout + _timeoutStrategy.RecordTimeout(); + throw; + } + } +} +``` + +### 4. Timeout with Fallback + +Implement a timeout with a fallback mechanism for graceful degradation: + +```csharp +public class TimeoutWithFallback +{ + private readonly ILogger _logger; + + public TimeoutWithFallback(ILogger logger = null) + { + _logger = logger; + } + + public async Task ExecuteWithFallbackAsync( + Func> primaryOperation, + Func> fallbackOperation, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, cancellationToken); + + try + { + // Try primary operation with timeout + return await primaryOperation(linkedCts.Token); + } + catch (Exception ex) when (ex is OperationCanceledException && timeoutCts.IsCancellationRequested) + { + _logger?.LogWarning("Primary operation timed out after {Timeout}ms, using fallback", + timeout.TotalMilliseconds); + + // Use fallback for timeout + return await fallbackOperation(new TimeoutException( + $"Operation timed out after {timeout.TotalMilliseconds}ms", ex)); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Primary operation failed with exception, using fallback"); + + // Use fallback for other exceptions + return await fallbackOperation(ex); + } + } +} + +// Usage with caching fallback +public class ProductService +{ + private readonly HttpClient _httpClient; + private readonly IDistributedCache _cache; + private readonly TimeoutWithFallback _timeoutHandler; + private readonly ILogger _logger; + + public ProductService( + HttpClient httpClient, + IDistributedCache cache, + ILogger logger) + { + _httpClient = httpClient; + _cache = cache; + _logger = logger; + _timeoutHandler = new TimeoutWithFallback(logger); + } + + public async Task> GetProductsAsync(CancellationToken cancellationToken = default) + { + return await _timeoutHandler.ExecuteWithFallbackAsync( + // Primary operation - get from API + async (token) => + { + var response = await _httpClient.GetAsync("/api/products", token); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var products = JsonConvert.DeserializeObject>(content); + + // Update cache with fresh data + await UpdateCacheAsync(products); + + return products; + }, + // Fallback operation - get from cache + async (ex) => + { + _logger.LogWarning(ex, "Using cached products due to API failure"); + + var cachedData = await _cache.GetStringAsync("products", cancellationToken); + if (!string.IsNullOrEmpty(cachedData)) + { + return JsonConvert.DeserializeObject>(cachedData); + } + + // If cache is empty, return empty list + return new List(); + }, + timeout: TimeSpan.FromSeconds(3), + cancellationToken); + } + + private async Task UpdateCacheAsync(List products) + { + var serializedData = JsonConvert.SerializeObject(products); + await _cache.SetStringAsync( + "products", + serializedData, + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) + }); + } +} +``` + +## Conclusion + +Effective error handling is a critical aspect of building robust, resilient event-sourced systems. By implementing the patterns described in this document, you can create applications that gracefully handle failures, provide meaningful error information, and maintain system integrity even under adverse conditions. + +### Key Takeaways + +1. **Defense in Depth**: Implement multiple layers of error handling, from input validation to global exception handling, to create a comprehensive error management strategy. + +2. **Fail Fast, Fail Safely**: Detect errors as early as possible in the processing pipeline, but ensure that when failures occur, they don't compromise system integrity or data consistency. + +3. **Resilience Patterns**: Use patterns like Circuit Breaker, Retry, Bulkhead, and Timeout to build systems that can withstand transient failures and gracefully degrade when necessary. + +4. **Domain-Specific Error Handling**: Create domain-specific exceptions and error responses that provide meaningful context about what went wrong from a business perspective. + +5. **Observability**: Ensure errors are properly logged, monitored, and tracked to facilitate troubleshooting and system improvement. + +### Best Practices + +- **Be Specific**: Use specific exception types rather than generic ones to provide clear information about what went wrong. + +- **Don't Swallow Exceptions**: Always handle exceptions appropriately; never catch exceptions without proper handling or logging. + +- **Centralize Error Handling Logic**: Use middleware, decorators, or aspects to centralize error handling logic and ensure consistent error responses. + +- **Provide Context**: Include relevant context information in error messages and logs to aid in troubleshooting. + +- **Design for Recovery**: Implement mechanisms for automatic recovery from transient failures and graceful degradation during more severe issues. + +- **Test Error Scenarios**: Explicitly test error scenarios, including edge cases and failure modes, to ensure your error handling works as expected. + +By applying these patterns and practices, you can build event-sourced systems that are not only functionally correct but also resilient in the face of the inevitable errors and failures that occur in distributed systems. diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index db8fb1ca..3b739df1 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -51,11 +51,11 @@ - [x] Update saga correlation tracking examples ## 8. Error Handling and Recovery -- [ ] Verify error handling patterns in PowerModels -- [ ] Update exception handling examples -- [ ] Check retry strategies -- [ ] Verify compensation patterns for failed operations -- [ ] Update error logging examples +- [x] Verify error handling patterns in PowerModels +- [x] Update exception handling examples +- [x] Check retry strategies +- [x] Verify compensation patterns for failed operations +- [x] Update error logging examples ## 9. Testing Approaches - [ ] Review testing patterns used in PowerModels @@ -86,7 +86,7 @@ - [ ] Verify that all examples compile and work correctly ## 13. Documentation Structure -- [ ] Reorganize documentation to better reflect actual usage +- [ ] Reorganize documentation to better reflect actual usage remove what is not used or useful - [ ] Ensure consistent terminology throughout - [ ] Add more diagrams to illustrate actual patterns - [ ] Create a "best practices" section based on PowerModels From cbf60f85696aa821911d444909bedefe1848105b Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 10:25:18 -0400 Subject: [PATCH 37/41] Add comprehensive testing patterns documentation - Added unit testing approaches for aggregates - Added event handler testing patterns - Added saga and process manager testing - Added integration testing approaches - Added event store testing patterns - Added snapshot and versioning testing - Updated documentation checklist - Updated API reference README.md --- docs/api-reference/README.md | 7 + .../patterns/testing-patterns.md | 863 ++++++++++++++++++ docs/documentation-update-checklist.md | 8 +- 3 files changed, 874 insertions(+), 4 deletions(-) create mode 100644 docs/api-reference/patterns/testing-patterns.md diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md index 0cd1307b..d9c479f1 100644 --- a/docs/api-reference/README.md +++ b/docs/api-reference/README.md @@ -79,6 +79,13 @@ For easier navigation through the API reference, use these resources: - [Query Handling](types/query-handling.md) - Patterns and best practices for handling queries - [Event Subscription](types/event-subscription.md) - Patterns for subscribing to and processing events +### Design Patterns + +- [Event Sourcing Patterns](patterns/event-sourcing-patterns.md) - Patterns and best practices for event sourcing +- [Saga Implementation Patterns](patterns/saga-implementation-patterns.md) - Patterns for implementing sagas and process managers +- [Error Handling Patterns](patterns/error-handling-patterns.md) - Patterns for error handling and recovery +- [Testing Patterns](patterns/testing-patterns.md) - Patterns for testing event-sourced systems + ## Namespaces The Reactive Domain library is organized into the following namespaces: diff --git a/docs/api-reference/patterns/testing-patterns.md b/docs/api-reference/patterns/testing-patterns.md new file mode 100644 index 00000000..d0d6b775 --- /dev/null +++ b/docs/api-reference/patterns/testing-patterns.md @@ -0,0 +1,863 @@ +# Testing Patterns for Event-Sourced Systems + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +This document outlines the key patterns and best practices for testing event-sourced systems built with Reactive Domain. Effective testing is crucial for ensuring the correctness, reliability, and maintainability of event-driven applications. + +## Table of Contents + +1. [Testing Principles for Event-Sourced Systems](#testing-principles-for-event-sourced-systems) +2. [Unit Testing Aggregates](#unit-testing-aggregates) +3. [Testing Event Handlers](#testing-event-handlers) +4. [Testing Sagas and Process Managers](#testing-sagas-and-process-managers) +5. [Integration Testing](#integration-testing) +6. [Event Store Testing](#event-store-testing) +7. [Snapshot Testing](#snapshot-testing) +8. [Testing Event Versioning](#testing-event-versioning) +9. [Performance Testing](#performance-testing) +10. [Best Practices](#best-practices) + +## Testing Principles for Event-Sourced Systems + +Testing event-sourced systems requires a different approach compared to traditional CRUD applications. Here are the key principles to guide your testing strategy: + +1. **Command-Event-State**: Test that commands produce the expected events and that events correctly modify state. +2. **Historical Invariants**: Test that past events are correctly applied to rebuild the current state. +3. **Behavior Over Implementation**: Focus tests on the behavior of aggregates rather than internal implementation details. +4. **Event-Centric**: Center tests around the events that are produced, as they represent the facts about what happened. +5. **Deterministic**: Tests should be deterministic and repeatable, avoiding dependencies on external systems when possible. + +### Testing Pyramid for Event-Sourced Systems + +A balanced testing strategy for event-sourced systems typically includes: + +1. **Unit Tests**: Testing individual aggregates, commands, and event handlers in isolation. +2. **Integration Tests**: Testing the interaction between components, such as aggregates and repositories. +3. **System Tests**: Testing the entire system, including event store, projections, and external interfaces. +4. **Acceptance Tests**: Testing from the user's perspective to ensure the system meets requirements. + +## Unit Testing Aggregates + +Unit testing aggregates is the foundation of testing event-sourced systems. These tests focus on ensuring that commands produce the expected events and that events correctly modify state. + +### 1. Given-When-Then Pattern + +The Given-When-Then pattern is particularly well-suited for testing event-sourced systems: + +- **Given**: The historical events that have occurred +- **When**: The command being executed +- **Then**: The expected events that should be produced + +```csharp +[Fact] +public void WithdrawFunds_WithSufficientBalance_ShouldProduceFundsWithdrawnEvent() +{ + // Given + var accountId = Guid.NewGuid(); + var events = new List + { + new AccountCreated(accountId, "Test Account", "12345"), + new FundsDeposited(accountId, 1000.00m) + }; + + var account = new Account(); + account.LoadFromHistory(events); + + // When + account.WithdrawFunds(500.00m); + + // Then + var uncommittedEvents = account.GetUncommittedEvents().ToList(); + Assert.Single(uncommittedEvents); + + var withdrawalEvent = uncommittedEvents[0] as FundsWithdrawn; + Assert.NotNull(withdrawalEvent); + Assert.Equal(accountId, withdrawalEvent.AccountId); + Assert.Equal(500.00m, withdrawalEvent.Amount); +} +``` + +### 2. Testing Command Validation + +Test that commands are properly validated and rejected when invalid: + +```csharp +[Fact] +public void WithdrawFunds_WithInsufficientBalance_ShouldThrowInsufficientFundsException() +{ + // Given + var accountId = Guid.NewGuid(); + var events = new List + { + new AccountCreated(accountId, "Test Account", "12345"), + new FundsDeposited(accountId, 100.00m) + }; + + var account = new Account(); + account.LoadFromHistory(events); + + // When/Then + var exception = Assert.Throws(() => + account.WithdrawFunds(500.00m)); + + Assert.Equal(accountId, exception.AccountId); + Assert.Equal(100.00m, exception.CurrentBalance); + Assert.Equal(500.00m, exception.WithdrawalAmount); +} +``` + +### 3. Testing Event Application + +Test that events correctly modify the aggregate's state: + +```csharp +[Fact] +public void ApplyFundsDeposited_ShouldIncreaseBalance() +{ + // Given + var account = new Account(); + account.LoadFromHistory(new List + { + new AccountCreated(Guid.NewGuid(), "Test Account", "12345") + }); + + // Initial balance should be zero + Assert.Equal(0m, account.Balance); + + // When + var depositEvent = new FundsDeposited(account.Id, 100.00m); + account.ApplyEvent(depositEvent); + + // Then + Assert.Equal(100.00m, account.Balance); +} +``` + +### 4. Testing Aggregate Reconstruction + +Test that an aggregate can be correctly reconstructed from its event history: + +```csharp +[Fact] +public void LoadFromHistory_ShouldReconstructAggregateState() +{ + // Given + var accountId = Guid.NewGuid(); + var events = new List + { + new AccountCreated(accountId, "Test Account", "12345"), + new FundsDeposited(accountId, 100.00m), + new FundsDeposited(accountId, 50.00m), + new FundsWithdrawn(accountId, 30.00m) + }; + + // When + var account = new Account(); + account.LoadFromHistory(events); + + // Then + Assert.Equal(accountId, account.Id); + Assert.Equal("Test Account", account.Name); + Assert.Equal("12345", account.AccountNumber); + Assert.Equal(120.00m, account.Balance); + Assert.Empty(account.GetUncommittedEvents()); +} +``` + +### 5. Test Fixture for Aggregates + +Create a test fixture to simplify aggregate testing: + +```csharp +public class AggregateTestFixture + where TAggregate : AggregateRoot, new() +{ + private readonly TAggregate _aggregate; + private readonly List _uncommittedEvents; + + public AggregateTestFixture() + { + _aggregate = new TAggregate(); + _uncommittedEvents = new List(); + } + + public AggregateTestFixture Given(params object[] events) + { + _aggregate.LoadFromHistory(events); + return this; + } + + public AggregateTestFixture When(Action action) + { + action(_aggregate); + _uncommittedEvents.AddRange(_aggregate.GetUncommittedEvents()); + return this; + } + + public void Then(Action> assertion) + { + assertion(_uncommittedEvents); + } + + public void ThenState(Action assertion) + { + assertion(_aggregate); + } + + public void ThenException(Action action) where TException : Exception + { + Assert.Throws(() => action(_aggregate)); + } +} + +// Usage +[Fact] +public void WithdrawFunds_WithSufficientBalance_ShouldProduceFundsWithdrawnEvent() +{ + var accountId = Guid.NewGuid(); + + new AggregateTestFixture() + .Given( + new AccountCreated(accountId, "Test Account", "12345"), + new FundsDeposited(accountId, 1000.00m)) + .When(account => account.WithdrawFunds(500.00m)) + .Then(events => + { + Assert.Single(events); + var withdrawalEvent = events.Single() as FundsWithdrawn; + Assert.NotNull(withdrawalEvent); + Assert.Equal(accountId, withdrawalEvent.AccountId); + Assert.Equal(500.00m, withdrawalEvent.Amount); + }); +} +``` + +## Testing Event Handlers + +Event handlers are responsible for updating read models and triggering side effects in response to events. Testing them requires verifying that they correctly process events and produce the expected outcomes. + +### 1. Testing Read Model Projections + +Test that event handlers correctly update read models: + +```csharp +[Fact] +public void AccountSummaryProjection_ShouldUpdateReadModel() +{ + // Given + var accountId = Guid.NewGuid(); + var readModelStore = new InMemoryReadModelStore(); + var projection = new AccountSummaryProjection(readModelStore); + + // When + projection.Handle(new AccountCreated(accountId, "Test Account", "12345")); + projection.Handle(new FundsDeposited(accountId, 100.00m)); + + // Then + var readModel = readModelStore.Get(accountId.ToString()); + Assert.NotNull(readModel); + Assert.Equal("Test Account", readModel.AccountName); + Assert.Equal("12345", readModel.AccountNumber); + Assert.Equal(100.00m, readModel.CurrentBalance); +} +``` + +### 2. Testing Side Effects + +Test that event handlers correctly trigger side effects: + +```csharp +[Fact] +public void LargeDepositHandler_ShouldNotifyComplianceForLargeDeposits() +{ + // Given + var mockComplianceService = new Mock(); + var handler = new LargeDepositHandler(mockComplianceService.Object); + var accountId = Guid.NewGuid(); + + // When + handler.Handle(new FundsDeposited(accountId, 10000.00m)); + + // Then + mockComplianceService.Verify(s => + s.ReportLargeDeposit(accountId, 10000.00m), Times.Once); +} +``` + +### 3. Testing Event Processor + +Test the event processor that dispatches events to handlers: + +```csharp +[Fact] +public void EventProcessor_ShouldDispatchEventsToHandlers() +{ + // Given + var mockHandler1 = new Mock>(); + var mockHandler2 = new Mock>(); + + var eventProcessor = new EventProcessor(); + eventProcessor.RegisterHandler(mockHandler1.Object); + eventProcessor.RegisterHandler(mockHandler2.Object); + + var accountCreatedEvent = new AccountCreated(Guid.NewGuid(), "Test Account", "12345"); + var fundsDepositedEvent = new FundsDeposited(Guid.NewGuid(), 100.00m); + + // When + eventProcessor.Process(accountCreatedEvent); + eventProcessor.Process(fundsDepositedEvent); + + // Then + mockHandler1.Verify(h => h.Handle(accountCreatedEvent), Times.Once); + mockHandler2.Verify(h => h.Handle(fundsDepositedEvent), Times.Once); +} +``` + +## Testing Sagas and Process Managers + +Sagas and process managers coordinate complex business processes across multiple aggregates. Testing them requires verifying that they correctly respond to events and issue the appropriate commands. + +### 1. Testing Saga State Changes + +Test that events correctly update the saga's state: + +```csharp +[Fact] +public void OrderProcessSaga_ShouldUpdateStateWhenOrderPlaced() +{ + // Given + var sagaId = Guid.NewGuid(); + var orderId = Guid.NewGuid(); + var customerId = Guid.NewGuid(); + + var saga = new OrderProcessSaga(sagaId); + + // When + saga.Apply(new OrderPlaced(orderId, customerId, 100.00m)); + + // Then + Assert.Equal(sagaId, saga.Id); + Assert.Equal(orderId, saga.OrderId); + Assert.Equal(customerId, saga.CustomerId); + Assert.Equal(100.00m, saga.OrderAmount); + Assert.Equal(OrderProcessSagaState.OrderPlaced, saga.State); +} +``` + +### 2. Testing Saga Command Dispatch + +Test that sagas dispatch the correct commands in response to events: + +```csharp +[Fact] +public void OrderProcessSaga_ShouldDispatchPaymentCommandWhenOrderPlaced() +{ + // Given + var mockCommandBus = new Mock(); + var sagaRepository = new InMemorySagaRepository(); + + var sagaManager = new SagaManager( + sagaRepository, + mockCommandBus.Object); + + var orderId = Guid.NewGuid(); + var customerId = Guid.NewGuid(); + var orderPlacedEvent = new OrderPlaced(orderId, customerId, 100.00m); + + // When + sagaManager.Handle(orderPlacedEvent); + + // Then + mockCommandBus.Verify(cb => + cb.Send(It.Is(cmd => + cmd.OrderId == orderId && + cmd.Amount == 100.00m)), + Times.Once); +} +``` + +### 3. Testing Saga Completion + +Test that sagas complete correctly when all steps are done: + +```csharp +[Fact] +public void OrderProcessSaga_ShouldCompleteWhenOrderFulfilled() +{ + // Given + var sagaId = Guid.NewGuid(); + var orderId = Guid.NewGuid(); + var customerId = Guid.NewGuid(); + + var saga = new OrderProcessSaga(sagaId); + saga.Apply(new OrderPlaced(orderId, customerId, 100.00m)); + saga.Apply(new PaymentProcessed(orderId, 100.00m)); + saga.Apply(new OrderShipped(orderId, "123456789")); + + // When + saga.Apply(new OrderDelivered(orderId)); + + // Then + Assert.Equal(OrderProcessSagaState.Completed, saga.State); + Assert.True(saga.IsCompleted); +} +``` + +## Integration Testing + +Integration tests verify that components work correctly together, including the event store, repositories, and command/event handlers. + +### 1. Testing Repository Operations + +Test that repositories correctly store and retrieve aggregates: + +```csharp +[Fact] +public async Task Repository_ShouldSaveAndLoadAggregate() +{ + // Given + var eventStore = new InMemoryEventStore(); + var repository = new Repository(eventStore); + + var accountId = Guid.NewGuid(); + var account = new Account(); + account.Create(accountId, "Test Account", "12345"); + account.DepositFunds(100.00m); + + // When + await repository.SaveAsync(account); + var loadedAccount = await repository.GetByIdAsync(accountId); + + // Then + Assert.NotNull(loadedAccount); + Assert.Equal(accountId, loadedAccount.Id); + Assert.Equal("Test Account", loadedAccount.Name); + Assert.Equal("12345", loadedAccount.AccountNumber); + Assert.Equal(100.00m, loadedAccount.Balance); +} +``` + +### 2. Testing Command Handling Pipeline + +Test the entire command handling pipeline: + +```csharp +[Fact] +public async Task CommandHandlingPipeline_ShouldProcessCommandAndUpdateReadModel() +{ + // Given + var eventStore = new InMemoryEventStore(); + var repository = new Repository(eventStore); + var readModelStore = new InMemoryReadModelStore(); + var projection = new AccountSummaryProjection(readModelStore); + + var eventBus = new EventBus(); + eventBus.Subscribe(projection.Handle); + eventBus.Subscribe(projection.Handle); + + var commandHandler = new CreateAccountHandler(repository, eventBus); + var commandBus = new CommandBus(); + commandBus.Register(commandHandler.Handle); + + var accountId = Guid.NewGuid(); + var command = new CreateAccount(accountId, "Test Account", "12345"); + + // When + await commandBus.SendAsync(command); + + // Then + var account = await repository.GetByIdAsync(accountId); + Assert.NotNull(account); + Assert.Equal("Test Account", account.Name); + + var readModel = readModelStore.Get(accountId.ToString()); + Assert.NotNull(readModel); + Assert.Equal("Test Account", readModel.AccountName); + Assert.Equal("12345", readModel.AccountNumber); +} +``` + +### 3. Testing Event Replay + +Test that events can be replayed to rebuild read models: + +```csharp +[Fact] +public async Task EventReplay_ShouldRebuildReadModel() +{ + // Given + var eventStore = new InMemoryEventStore(); + var repository = new Repository(eventStore); + + var accountId = Guid.NewGuid(); + var account = new Account(); + account.Create(accountId, "Test Account", "12345"); + account.DepositFunds(100.00m); + account.WithdrawFunds(50.00m); + await repository.SaveAsync(account); + + // When + var readModelStore = new InMemoryReadModelStore(); + var projection = new AccountSummaryProjection(readModelStore); + + var events = await eventStore.GetEventsForAggregateAsync(accountId); + foreach (var @event in events) + { + switch (@event) + { + case AccountCreated e: projection.Handle(e); break; + case FundsDeposited e: projection.Handle(e); break; + case FundsWithdrawn e: projection.Handle(e); break; + } + } + + // Then + var readModel = readModelStore.Get(accountId.ToString()); + Assert.NotNull(readModel); + Assert.Equal("Test Account", readModel.AccountName); + Assert.Equal("12345", readModel.AccountNumber); + Assert.Equal(50.00m, readModel.CurrentBalance); +} +``` + +## Event Store Testing + +Testing the event store ensures that events are correctly persisted and retrieved. + +### 1. Testing Event Persistence + +Test that events are correctly persisted to the event store: + +```csharp +[Fact] +public async Task EventStore_ShouldPersistEvents() +{ + // Given + var eventStore = new InMemoryEventStore(); + var aggregateId = Guid.NewGuid(); + var events = new List + { + new AccountCreated(aggregateId, "Test Account", "12345"), + new FundsDeposited(aggregateId, 100.00m) + }; + + // When + await eventStore.SaveEventsAsync(aggregateId, events, 0); + + // Then + var storedEvents = await eventStore.GetEventsForAggregateAsync(aggregateId); + Assert.Equal(2, storedEvents.Count()); + Assert.IsType(storedEvents.First()); + Assert.IsType(storedEvents.Skip(1).First()); +} +``` + +### 2. Testing Concurrency Control + +Test that the event store correctly handles concurrency conflicts: + +```csharp +[Fact] +public async Task EventStore_ShouldDetectConcurrencyConflicts() +{ + // Given + var eventStore = new InMemoryEventStore(); + var aggregateId = Guid.NewGuid(); + var events1 = new List { new AccountCreated(aggregateId, "Test Account", "12345") }; + + // Save the first batch of events + await eventStore.SaveEventsAsync(aggregateId, events1, 0); + + // When/Then + var events2 = new List { new FundsDeposited(aggregateId, 100.00m) }; + + // Try to save with wrong expected version + await Assert.ThrowsAsync(() => + eventStore.SaveEventsAsync(aggregateId, events2, 0)); + + // Should succeed with correct expected version + await eventStore.SaveEventsAsync(aggregateId, events2, 1); +} +``` + +### 3. Testing Event Serialization + +Test that events can be correctly serialized and deserialized: + +```csharp +[Fact] +public void EventSerializer_ShouldSerializeAndDeserializeEvents() +{ + // Given + var serializer = new JsonEventSerializer(); + var aggregateId = Guid.NewGuid(); + var originalEvent = new AccountCreated(aggregateId, "Test Account", "12345"); + + // When + var serialized = serializer.Serialize(originalEvent); + var deserialized = serializer.Deserialize(serialized, typeof(AccountCreated)) as AccountCreated; + + // Then + Assert.NotNull(deserialized); + Assert.Equal(aggregateId, deserialized.AccountId); + Assert.Equal("Test Account", deserialized.AccountName); + Assert.Equal("12345", deserialized.AccountNumber); +} +``` + +## Snapshot Testing + +Testing snapshot functionality ensures that aggregates can be efficiently loaded from snapshots. + +### 1. Testing Snapshot Creation + +Test that snapshots are correctly created: + +```csharp +[Fact] +public async Task SnapshotRepository_ShouldCreateSnapshot() +{ + // Given + var eventStore = new InMemoryEventStore(); + var snapshotStore = new InMemorySnapshotStore(); + var repository = new SnapshotRepository( + eventStore, snapshotStore, 2); + + var accountId = Guid.NewGuid(); + var account = new Account(); + account.Create(accountId, "Test Account", "12345"); + account.DepositFunds(100.00m); + account.DepositFunds(50.00m); // This should trigger a snapshot + + // When + await repository.SaveAsync(account); + + // Then + var snapshot = await snapshotStore.GetSnapshotAsync(accountId); + Assert.NotNull(snapshot); + Assert.Equal(accountId, snapshot.AggregateId); + Assert.Equal(3, snapshot.Version); + + var accountState = snapshot.State as AccountState; + Assert.NotNull(accountState); + Assert.Equal("Test Account", accountState.Name); + Assert.Equal("12345", accountState.AccountNumber); + Assert.Equal(150.00m, accountState.Balance); +} +``` + +### 2. Testing Aggregate Loading from Snapshot + +Test that aggregates can be correctly loaded from snapshots: + +```csharp +[Fact] +public async Task SnapshotRepository_ShouldLoadAggregateFromSnapshot() +{ + // Given + var eventStore = new InMemoryEventStore(); + var snapshotStore = new InMemorySnapshotStore(); + var repository = new SnapshotRepository( + eventStore, snapshotStore, 2); + + var accountId = Guid.NewGuid(); + var account = new Account(); + account.Create(accountId, "Test Account", "12345"); + account.DepositFunds(100.00m); + account.DepositFunds(50.00m); // This should trigger a snapshot + await repository.SaveAsync(account); + + // When + var loadedAccount = await repository.GetByIdAsync(accountId); + + // Then + Assert.NotNull(loadedAccount); + Assert.Equal(accountId, loadedAccount.Id); + Assert.Equal("Test Account", loadedAccount.Name); + Assert.Equal("12345", loadedAccount.AccountNumber); + Assert.Equal(150.00m, loadedAccount.Balance); + Assert.Equal(3, loadedAccount.Version); +} +``` + +## Testing Event Versioning + +Testing event versioning ensures that the system can handle changes to event schemas over time. + +### 1. Testing Event Upcasting + +Test that old event versions can be upcasted to new versions: + +```csharp +[Fact] +public void EventUpcaster_ShouldUpcastOldEventVersions() +{ + // Given + var upcaster = new AccountCreatedEventUpcaster(); + var oldEvent = new AccountCreatedV1 + { + AccountId = Guid.NewGuid(), + Name = "Test Account", + AccountNumber = "12345" + }; + + // When + var newEvent = upcaster.Upcast(oldEvent) as AccountCreatedV2; + + // Then + Assert.NotNull(newEvent); + Assert.Equal(oldEvent.AccountId, newEvent.AccountId); + Assert.Equal(oldEvent.Name, newEvent.AccountName); + Assert.Equal(oldEvent.AccountNumber, newEvent.AccountNumber); + Assert.Equal(DateTime.UtcNow.Date, newEvent.CreatedDate.Date); +} +``` + +### 2. Testing Backward Compatibility + +Test that new event handlers can process old event versions: + +```csharp +[Fact] +public void EventHandler_ShouldHandleOldAndNewEventVersions() +{ + // Given + var readModelStore = new InMemoryReadModelStore(); + var projection = new AccountSummaryProjection(readModelStore); + + var accountId = Guid.NewGuid(); + var oldEvent = new AccountCreatedV1 + { + AccountId = accountId, + Name = "Test Account", + AccountNumber = "12345" + }; + + var newEvent = new AccountCreatedV2 + { + AccountId = Guid.NewGuid(), + AccountName = "New Account", + AccountNumber = "67890", + CreatedDate = DateTime.UtcNow + }; + + // When + projection.Handle(oldEvent); + projection.Handle(newEvent); + + // Then + var oldAccountModel = readModelStore.Get(accountId.ToString()); + Assert.NotNull(oldAccountModel); + Assert.Equal("Test Account", oldAccountModel.AccountName); + + var newAccountModel = readModelStore.Get(newEvent.AccountId.ToString()); + Assert.NotNull(newAccountModel); + Assert.Equal("New Account", newAccountModel.AccountName); +} +``` + +## Performance Testing + +Performance testing ensures that the event-sourced system can handle the expected load and scale appropriately. + +### 1. Testing Event Store Performance + +Test the performance of event store operations: + +```csharp +[Fact] +public async Task EventStore_ShouldHandleHighVolumeOfEvents() +{ + // Given + var eventStore = new InMemoryEventStore(); + var aggregateId = Guid.NewGuid(); + const int eventCount = 1000; + + // When + var stopwatch = Stopwatch.StartNew(); + + for (int i = 0; i < eventCount; i++) + { + var events = new List { new FundsDeposited(aggregateId, 1.00m) }; + await eventStore.SaveEventsAsync(aggregateId, events, i); + } + + stopwatch.Stop(); + + // Then + var storedEvents = await eventStore.GetEventsForAggregateAsync(aggregateId); + Assert.Equal(eventCount, storedEvents.Count()); + + // Performance assertion - adjust threshold as needed + Assert.True(stopwatch.ElapsedMilliseconds < 5000, + $"Saving {eventCount} events took {stopwatch.ElapsedMilliseconds}ms"); +} +``` + +### 2. Testing Snapshot Performance + +Test the performance improvement from using snapshots: + +```csharp +[Fact] +public async Task SnapshotRepository_ShouldImproveLoadPerformance() +{ + // Given + var eventStore = new InMemoryEventStore(); + var snapshotStore = new InMemorySnapshotStore(); + var repository = new SnapshotRepository( + eventStore, snapshotStore, 100); + + var accountId = Guid.NewGuid(); + var account = new Account(); + account.Create(accountId, "Test Account", "12345"); + + // Add many events + for (int i = 0; i < 200; i++) + { + account.DepositFunds(1.00m); + } + + await repository.SaveAsync(account); + + // When - Load without using snapshot + var regularRepository = new Repository(eventStore); + + var stopwatchWithoutSnapshot = Stopwatch.StartNew(); + await regularRepository.GetByIdAsync(accountId); + stopwatchWithoutSnapshot.Stop(); + + // When - Load using snapshot + var stopwatchWithSnapshot = Stopwatch.StartNew(); + await repository.GetByIdAsync(accountId); + stopwatchWithSnapshot.Stop(); + + // Then + Assert.True(stopwatchWithSnapshot.ElapsedMilliseconds < stopwatchWithoutSnapshot.ElapsedMilliseconds, + $"Loading with snapshot ({stopwatchWithSnapshot.ElapsedMilliseconds}ms) should be faster than without ({stopwatchWithoutSnapshot.ElapsedMilliseconds}ms)"); +} +``` + +## Best Practices + +Here are some best practices for testing event-sourced systems: + +1. **Use the Given-When-Then Pattern**: This pattern is particularly well-suited for testing event-sourced systems. + +2. **Test Both Commands and Events**: Ensure that commands produce the expected events and that events correctly modify state. + +3. **Create Test Fixtures**: Build reusable test fixtures to simplify testing and reduce boilerplate code. + +4. **Use In-Memory Implementations**: Use in-memory implementations of the event store and repositories for unit and integration tests. + +5. **Test Event Replay**: Verify that replaying events produces the correct state. + +6. **Test Concurrency Handling**: Ensure that concurrent modifications are correctly handled. + +7. **Test Event Versioning**: Verify that the system can handle changes to event schemas over time. + +8. **Test Performance**: Ensure that the system can handle the expected load and scale appropriately. + +9. **Automate Testing**: Use continuous integration to run tests automatically. + +10. **Test Edge Cases**: Test boundary conditions, error scenarios, and edge cases to ensure robust behavior. diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index 3b739df1..a007a05f 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -58,10 +58,10 @@ - [x] Update error logging examples ## 9. Testing Approaches -- [ ] Review testing patterns used in PowerModels -- [ ] Update unit testing examples for aggregates -- [ ] Check integration testing approaches -- [ ] Verify event testing methodologies +- [x] Review testing patterns used in PowerModels +- [x] Update unit testing examples for aggregates +- [x] Check integration testing approaches +- [x] Verify event testing methodologies - [ ] Update test fixture examples ## 10. Infrastructure Setup From f768353ee452fc80adffa636a1100396f633d2d1 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 10:27:58 -0400 Subject: [PATCH 38/41] Enhance test fixture examples in testing patterns documentation - Added advanced test fixtures with exception handling - Added event-specific test fixture with type-safe assertions - Added scenario-based test fixture for complex test cases - Added factory pattern for test fixtures - Added integration with Reactive Domain test framework - Updated documentation checklist --- .../patterns/testing-patterns.md | 496 +++++++++++++++++- docs/documentation-update-checklist.md | 2 +- 2 files changed, 491 insertions(+), 7 deletions(-) diff --git a/docs/api-reference/patterns/testing-patterns.md b/docs/api-reference/patterns/testing-patterns.md index d0d6b775..b04d7e66 100644 --- a/docs/api-reference/patterns/testing-patterns.md +++ b/docs/api-reference/patterns/testing-patterns.md @@ -164,9 +164,13 @@ public void LoadFromHistory_ShouldReconstructAggregateState() } ``` -### 5. Test Fixture for Aggregates +### 5. Advanced Test Fixtures for Aggregates -Create a test fixture to simplify aggregate testing: +Test fixtures provide a powerful way to simplify and standardize your aggregate tests. Here are several approaches to creating effective test fixtures for event-sourced systems. + +#### 5.1 Basic Given-When-Then Test Fixture + +A fluent test fixture that follows the Given-When-Then pattern: ```csharp public class AggregateTestFixture @@ -174,6 +178,7 @@ public class AggregateTestFixture { private readonly TAggregate _aggregate; private readonly List _uncommittedEvents; + private Exception _caughtException; public AggregateTestFixture() { @@ -189,24 +194,45 @@ public class AggregateTestFixture public AggregateTestFixture When(Action action) { - action(_aggregate); - _uncommittedEvents.AddRange(_aggregate.GetUncommittedEvents()); + try + { + action(_aggregate); + _uncommittedEvents.AddRange(_aggregate.GetUncommittedEvents()); + } + catch (Exception ex) + { + _caughtException = ex; + } return this; } public void Then(Action> assertion) { + if (_caughtException != null) + { + Assert.Fail($"Expected events but got exception: {_caughtException}"); + } assertion(_uncommittedEvents); } public void ThenState(Action assertion) { + if (_caughtException != null) + { + Assert.Fail($"Expected state check but got exception: {_caughtException}"); + } assertion(_aggregate); } - public void ThenException(Action action) where TException : Exception + public void ThenException(Action assertion = null) where TException : Exception { - Assert.Throws(() => action(_aggregate)); + Assert.NotNull(_caughtException); + Assert.IsType(_caughtException); + + if (assertion != null) + { + assertion((TException)_caughtException); + } } } @@ -230,6 +256,464 @@ public void WithdrawFunds_WithSufficientBalance_ShouldProduceFundsWithdrawnEvent Assert.Equal(500.00m, withdrawalEvent.Amount); }); } + +// Testing exceptions with detailed assertions +[Fact] +public void WithdrawFunds_WithInsufficientBalance_ShouldThrowInsufficientFundsException() +{ + var accountId = Guid.NewGuid(); + + new AggregateTestFixture() + .Given( + new AccountCreated(accountId, "Test Account", "12345"), + new FundsDeposited(accountId, 100.00m)) + .When(account => account.WithdrawFunds(500.00m)) + .ThenException(ex => + { + Assert.Equal(accountId, ex.AccountId); + Assert.Equal(100.00m, ex.CurrentBalance); + Assert.Equal(500.00m, ex.WithdrawalAmount); + }); +} +``` + +#### 5.2 Event-Specific Test Fixture + +A more specialized test fixture that provides type-safe event assertions: + +```csharp +public class EventTestFixture + where TAggregate : AggregateRoot, new() +{ + private readonly TAggregate _aggregate; + private readonly List _uncommittedEvents; + private Exception _caughtException; + + public EventTestFixture() + { + _aggregate = new TAggregate(); + _uncommittedEvents = new List(); + } + + public EventTestFixture Given(params object[] events) + { + _aggregate.LoadFromHistory(events); + return this; + } + + public EventTestFixture When(Action action) + { + try + { + action(_aggregate); + _uncommittedEvents.AddRange(_aggregate.GetUncommittedEvents()); + } + catch (Exception ex) + { + _caughtException = ex; + } + return this; + } + + public EventTestFixture ThenEventCount(int expectedCount) + { + if (_caughtException != null) + { + Assert.Fail($"Expected {expectedCount} events but got exception: {_caughtException}"); + } + + Assert.Equal(expectedCount, _uncommittedEvents.Count); + return this; + } + + public EventTestFixture ThenContainsEvent(Action assertion = null) + where TEvent : class + { + if (_caughtException != null) + { + Assert.Fail($"Expected event of type {typeof(TEvent).Name} but got exception: {_caughtException}"); + } + + var matchingEvent = _uncommittedEvents.OfType().FirstOrDefault(); + Assert.NotNull(matchingEvent); + + assertion?.Invoke(matchingEvent); + return this; + } + + public EventTestFixture ThenNoEvents() + { + if (_caughtException != null) + { + Assert.Fail($"Expected no events but got exception: {_caughtException}"); + } + + Assert.Empty(_uncommittedEvents); + return this; + } + + public EventTestFixture ThenState(Action assertion) + { + if (_caughtException != null) + { + Assert.Fail($"Expected state check but got exception: {_caughtException}"); + } + + assertion(_aggregate); + return this; + } + + public void ThenException(Action assertion = null) where TException : Exception + { + Assert.NotNull(_caughtException); + Assert.IsType(_caughtException); + + assertion?.Invoke((TException)_caughtException); + } +} + +// Usage with fluent assertions +[Fact] +public void Account_WithMultipleOperations_ShouldHaveCorrectEvents() +{ + var accountId = Guid.NewGuid(); + + new EventTestFixture() + .Given( + new AccountCreated(accountId, "Test Account", "12345")) + .When(account => + { + account.DepositFunds(1000.00m); + account.WithdrawFunds(300.00m); + account.UpdateAccountName("Updated Account"); + }) + .ThenEventCount(3) + .ThenContainsEvent(e => + { + Assert.Equal(accountId, e.AccountId); + Assert.Equal(1000.00m, e.Amount); + }) + .ThenContainsEvent(e => + { + Assert.Equal(accountId, e.AccountId); + Assert.Equal(300.00m, e.Amount); + }) + .ThenContainsEvent(e => + { + Assert.Equal(accountId, e.AccountId); + Assert.Equal("Updated Account", e.NewName); + }) + .ThenState(account => + { + Assert.Equal("Updated Account", account.Name); + Assert.Equal(700.00m, account.Balance); + }); +} +``` + +#### 5.3 Scenario-Based Test Fixture + +A test fixture that supports complex test scenarios with multiple commands and events: + +```csharp +public class ScenarioTestFixture + where TAggregate : AggregateRoot, new() +{ + private readonly TAggregate _aggregate; + private readonly List _historicalEvents; + private readonly List<(string Description, Action Command, List Events)> _steps; + private Exception _lastException; + + public ScenarioTestFixture() + { + _aggregate = new TAggregate(); + _historicalEvents = new List(); + _steps = new List<(string, Action, List)>(); + } + + public ScenarioTestFixture Given(params object[] events) + { + _historicalEvents.AddRange(events); + return this; + } + + public ScenarioTestFixture WhenCommand(string description, Action command) + { + _steps.Add((description, command, new List())); + return this; + } + + public void Execute() + { + // Load historical events + _aggregate.LoadFromHistory(_historicalEvents); + + // Execute each command and collect events + foreach (var step in _steps) + { + try + { + // Clear uncommitted events before executing command + _aggregate.ClearUncommittedEvents(); + + // Execute command + step.Command(_aggregate); + + // Collect events + step.Events.AddRange(_aggregate.GetUncommittedEvents()); + } + catch (Exception ex) + { + _lastException = ex; + break; + } + } + } + + public void AssertEventsAt(int stepIndex, Action> assertion) + { + if (_lastException != null) + { + Assert.Fail($"Expected events at step {stepIndex} but got exception: {_lastException}"); + } + + if (stepIndex >= _steps.Count) + { + Assert.Fail($"Step index {stepIndex} is out of range. Only {_steps.Count} steps were executed."); + } + + assertion(_steps[stepIndex].Events); + } + + public void AssertFinalState(Action assertion) + { + if (_lastException != null) + { + Assert.Fail($"Expected final state check but got exception: {_lastException}"); + } + + assertion(_aggregate); + } + + public void AssertException(Action assertion = null) where TException : Exception + { + Assert.NotNull(_lastException); + Assert.IsType(_lastException); + + assertion?.Invoke((TException)_lastException); + } + + public void PrintScenario() + { + Console.WriteLine("Scenario:"); + Console.WriteLine("Given the following events:"); + foreach (var evt in _historicalEvents) + { + Console.WriteLine($" - {evt.GetType().Name}"); + } + + Console.WriteLine("When the following commands are executed:"); + for (int i = 0; i < _steps.Count; i++) + { + Console.WriteLine($" {i+1}. {_steps[i].Description}"); + Console.WriteLine($" Resulting in events:"); + foreach (var evt in _steps[i].Events) + { + Console.WriteLine($" - {evt.GetType().Name}"); + } + } + } +} + +// Usage for complex scenarios +[Fact] +public void Account_ComplexScenario_ShouldBehaveCorrectly() +{ + var accountId = Guid.NewGuid(); + var fixture = new ScenarioTestFixture() + .Given( + new AccountCreated(accountId, "Initial Account", "12345")) + .WhenCommand("Deposit $1000", account => account.DepositFunds(1000.00m)) + .WhenCommand("Withdraw $300", account => account.WithdrawFunds(300.00m)) + .WhenCommand("Update account name", account => account.UpdateAccountName("Updated Account")) + .WhenCommand("Deposit $200", account => account.DepositFunds(200.00m)); + + fixture.Execute(); + + // Assert events from specific steps + fixture.AssertEventsAt(0, events => + { + Assert.Single(events); + var depositEvent = events.Single() as FundsDeposited; + Assert.Equal(1000.00m, depositEvent.Amount); + }); + + fixture.AssertEventsAt(2, events => + { + Assert.Single(events); + var nameUpdateEvent = events.Single() as AccountNameUpdated; + Assert.Equal("Updated Account", nameUpdateEvent.NewName); + }); + + // Assert final state + fixture.AssertFinalState(account => + { + Assert.Equal("Updated Account", account.Name); + Assert.Equal(900.00m, account.Balance); + }); + + // Print scenario for documentation + fixture.PrintScenario(); +} +``` + +#### 5.4 Factory for Test Fixtures + +A factory approach for creating test fixtures with common setup: + +```csharp +public class TestFixtureFactory +{ + public static AggregateTestFixture CreateAccountFixture(Guid? accountId = null) + { + var id = accountId ?? Guid.NewGuid(); + return new AggregateTestFixture() + .Given(new AccountCreated(id, "Test Account", "12345")); + } + + public static AggregateTestFixture CreateAccountWithBalanceFixture( + decimal initialBalance, + Guid? accountId = null) + { + var id = accountId ?? Guid.NewGuid(); + return new AggregateTestFixture() + .Given( + new AccountCreated(id, "Test Account", "12345"), + new FundsDeposited(id, initialBalance)); + } + + public static AggregateTestFixture CreateOrderFixture( + Guid? orderId = null, + Guid? customerId = null) + { + var id = orderId ?? Guid.NewGuid(); + var custId = customerId ?? Guid.NewGuid(); + return new AggregateTestFixture() + .Given(new OrderCreated(id, custId, DateTime.UtcNow)); + } +} + +// Usage +[Fact] +public void WithdrawFunds_WithSufficientBalance_ShouldProduceFundsWithdrawnEvent() +{ + TestFixtureFactory + .CreateAccountWithBalanceFixture(1000.00m) + .When(account => account.WithdrawFunds(500.00m)) + .Then(events => + { + Assert.Single(events); + var withdrawalEvent = events.Single() as FundsWithdrawn; + Assert.Equal(500.00m, withdrawalEvent.Amount); + }); +} +``` + +#### 5.5 Integration with Reactive Domain Test Framework + +Integrating with Reactive Domain's built-in test framework: + +```csharp +public class ReactiveDomainTestFixture + where TAggregate : AggregateRoot, new() +{ + private readonly TestRepository _repository; + private readonly TestEventBus _eventBus; + private TAggregate _aggregate; + private Exception _caughtException; + + public ReactiveDomainTestFixture() + { + _eventBus = new TestEventBus(); + _repository = new TestRepository(_eventBus); + } + + public ReactiveDomainTestFixture Given(TId id, params object[] events) + { + _repository.AddEvents(id, events); + return this; + } + + public async Task> WhenAsync(Func action) + { + try + { + _aggregate = await _repository.GetByIdAsync((dynamic)_repository.LastId); + await action(_aggregate); + await _repository.SaveAsync(_aggregate); + } + catch (Exception ex) + { + _caughtException = ex; + } + return this; + } + + public void ThenEvents(Action> assertion) + { + if (_caughtException != null) + { + Assert.Fail($"Expected events but got exception: {_caughtException}"); + } + + assertion(_eventBus.PublishedEvents); + } + + public void ThenState(Action assertion) + { + if (_caughtException != null) + { + Assert.Fail($"Expected state check but got exception: {_caughtException}"); + } + + assertion(_aggregate); + } + + public void ThenException(Action assertion = null) where TException : Exception + { + Assert.NotNull(_caughtException); + Assert.IsType(_caughtException); + + assertion?.Invoke((TException)_caughtException); + } +} + +// Usage with async operations +[Fact] +public async Task Account_AsyncOperations_ShouldWorkCorrectly() +{ + var accountId = Guid.NewGuid(); + var fixture = new ReactiveDomainTestFixture(); + + await fixture + .Given(accountId, + new AccountCreated(accountId, "Test Account", "12345"), + new FundsDeposited(accountId, 1000.00m)) + .WhenAsync(async account => + { + // Simulate async operation + await Task.Delay(10); + account.WithdrawFunds(500.00m); + }); + + fixture.ThenEvents(events => + { + var withdrawalEvent = events.OfType().Single(); + Assert.Equal(accountId, withdrawalEvent.AccountId); + Assert.Equal(500.00m, withdrawalEvent.Amount); + }); +} +``` ``` ## Testing Event Handlers diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index a007a05f..498280ad 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -62,7 +62,7 @@ - [x] Update unit testing examples for aggregates - [x] Check integration testing approaches - [x] Verify event testing methodologies -- [ ] Update test fixture examples +- [x] Update test fixture examples ## 10. Infrastructure Setup - [ ] Verify EventStoreDB connection setup From 5d4728a5f95492cf859aa6135799cbf34a9f3ce5 Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 11:18:54 -0400 Subject: [PATCH 39/41] Update Reactive Domain documentation with modern best practices - Updated implementing-projections.md with modern async patterns and DI support - Updated saving-retrieving-aggregates.md with proper repository patterns - Updated handling-commands-events.md with improved command handling - Updated creating-aggregate-root.md with best practices - Added infrastructure-setup.md with modern configuration patterns - Added performance-considerations.md with optimization techniques - Updated documentation checklist to reflect completed sections - Updated API reference README with new links --- docs/api-reference/README.md | 2 + .../patterns/infrastructure-setup.md | 2105 +++++++++++++++++ .../patterns/performance-considerations.md | 869 +++++++ docs/code-examples/creating-aggregate-root.md | 153 +- .../code-examples/handling-commands-events.md | 832 ++++++- .../code-examples/implementing-projections.md | 1770 +++++++++++--- .../saving-retrieving-aggregates.md | 796 +++++-- docs/documentation-update-checklist.md | 32 +- 8 files changed, 5921 insertions(+), 638 deletions(-) create mode 100644 docs/api-reference/patterns/infrastructure-setup.md create mode 100644 docs/api-reference/patterns/performance-considerations.md diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md index d9c479f1..6d99e5bc 100644 --- a/docs/api-reference/README.md +++ b/docs/api-reference/README.md @@ -85,6 +85,8 @@ For easier navigation through the API reference, use these resources: - [Saga Implementation Patterns](patterns/saga-implementation-patterns.md) - Patterns for implementing sagas and process managers - [Error Handling Patterns](patterns/error-handling-patterns.md) - Patterns for error handling and recovery - [Testing Patterns](patterns/testing-patterns.md) - Patterns for testing event-sourced systems +- [Infrastructure Setup](patterns/infrastructure-setup.md) - Comprehensive guide for setting up event-sourced infrastructure +- [Performance Considerations](patterns/performance-considerations.md) - Optimization techniques for event-sourced systems ## Namespaces diff --git a/docs/api-reference/patterns/infrastructure-setup.md b/docs/api-reference/patterns/infrastructure-setup.md new file mode 100644 index 00000000..928154a5 --- /dev/null +++ b/docs/api-reference/patterns/infrastructure-setup.md @@ -0,0 +1,2105 @@ +# Infrastructure Setup for Reactive Domain Applications + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +This document provides comprehensive guidance on setting up the infrastructure for Reactive Domain applications, including configuring event stores, message buses, repositories, and read models. + +## Table of Contents + +1. [Event Store Configuration](#event-store-configuration) +2. [Message Bus Setup](#message-bus-setup) +3. [Repository Configuration](#repository-configuration) +4. [Read Model Implementation](#read-model-implementation) +5. [Dependency Injection](#dependency-injection) +6. [Logging and Monitoring](#logging-and-monitoring) +7. [Scaling Considerations](#scaling-considerations) +8. [Production Deployment](#production-deployment) +9. [Best Practices](#best-practices) + +## Event Store Configuration + +The event store is the heart of an event-sourced system, responsible for persisting and retrieving events. Reactive Domain supports multiple event store implementations, with EventStoreDB being the primary choice for production systems. + +### 1. EventStoreDB Configuration + +EventStoreDB is a purpose-built database for event sourcing that provides high performance, reliability, and specialized features for event-sourced systems. + +#### Basic Connection Setup + +```csharp +public static class EventStoreFactory +{ + public static IStreamStoreConnection CreateConnection(string connectionString) + { + var settings = ConnectionSettings.Create() + .EnableVerboseLogging() + .UseConsoleLogger() + .KeepReconnecting() + .KeepRetrying() + .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")) + .Build(); + + var connection = EventStoreConnection.Create( + settings, + new Uri(connectionString)); + + connection.ConnectAsync().Wait(); + return new StreamStoreConnection(connection); + } +} + +// Usage in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register EventStore connection + services.AddSingleton(provider => + { + var connectionString = Configuration.GetConnectionString("EventStore"); + return EventStoreFactory.CreateConnection(connectionString); + }); + + // Register other dependencies + services.AddSingleton, StreamStoreRepository>(); + // ... +} +``` + +#### Connection Settings for Production + +For production environments, you should configure more robust connection settings: + +```csharp +public static IStreamStoreConnection CreateProductionConnection(string connectionString) +{ + var settings = ConnectionSettings.Create() + .SetDefaultUserCredentials(new UserCredentials( + Environment.GetEnvironmentVariable("EVENTSTORE_USERNAME"), + Environment.GetEnvironmentVariable("EVENTSTORE_PASSWORD"))) + .SetHeartbeatInterval(TimeSpan.FromSeconds(30)) + .SetHeartbeatTimeout(TimeSpan.FromSeconds(120)) + .SetOperationTimeout(TimeSpan.FromSeconds(60)) + .SetTimerPeriod(TimeSpan.FromMilliseconds(500)) + .SetReconnectionDelayTo(TimeSpan.FromSeconds(1)) + .SetMaxReconnections(10) + .SetMaxOperationRetries(10) + .SetMaxDiscoverAttempts(10) + .SetGossipTimeout(TimeSpan.FromSeconds(5)) + .UseCustomLogger(new SerilogEventStoreLogger()) + .EnableConnectionTimeoutCheck() + .Build(); + + var clusterSettings = ClusterSettings.Create() + .DiscoverClusterViaGossipSeeds() + .SetGossipSeedEndPoints(ParseGossipSeeds(connectionString)) + .SetGossipTimeout(TimeSpan.FromSeconds(5)) + .Build(); + + var connection = EventStoreConnection.Create(settings, clusterSettings); + connection.ConnectAsync().Wait(); + return new StreamStoreConnection(connection); +} + +private static IPEndPoint[] ParseGossipSeeds(string connectionString) +{ + // Parse connection string to extract gossip seed endpoints + // Format: "gossip://node1:2113,node2:2113,node3:2113" + var uri = new Uri(connectionString); + var hostsAndPorts = uri.Host.Split(','); + + return hostsAndPorts.Select(hostAndPort => + { + var parts = hostAndPort.Split(':'); + var host = parts[0]; + var port = int.Parse(parts[1]); + return new IPEndPoint(Dns.GetHostAddresses(host)[0], port); + }).ToArray(); +} +``` + +#### Connection Health Monitoring + +Implement health checks to monitor the EventStore connection: + +```csharp +public class EventStoreHealthCheck : IHealthCheck +{ + private readonly IStreamStoreConnection _connection; + + public EventStoreHealthCheck(IStreamStoreConnection connection) + { + _connection = connection; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + // Ping the event store by reading a small stream + var streamName = "$stats-0-0"; + var slice = await _connection.ReadStreamEventsForwardAsync( + streamName, 0, 1, false); + + return HealthCheckResult.Healthy("EventStore connection is healthy"); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("EventStore connection failed", ex); + } + } +} + +// Register in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register health checks + services.AddHealthChecks() + .AddCheck("eventstore_connection"); +} +``` + +### 2. In-Memory Event Store for Testing + +For testing purposes, you can use an in-memory event store implementation: + +```csharp +public class InMemoryEventStore : IStreamStoreConnection +{ + private readonly Dictionary> _streams = + new Dictionary>(); + private readonly object _lock = new object(); + + public Task ReadStreamEventsForwardAsync( + string stream, long start, int count, bool resolveLinks) + { + lock (_lock) + { + if (!_streams.TryGetValue(stream, out var events)) + { + return Task.FromResult(new StreamEventsSlice( + SliceReadStatus.StreamNotFound, + stream, + start, + ReadDirection.Forward, + new List(), + start, + true, + 0)); + } + + var slice = events + .Skip((int)start) + .Take(count) + .ToList(); + + return Task.FromResult(new StreamEventsSlice( + SliceReadStatus.Success, + stream, + start, + ReadDirection.Forward, + slice, + start + slice.Count, + slice.Count < count || (start + slice.Count) >= events.Count, + events.Count)); + } + } + + public Task AppendToStreamAsync( + string stream, long expectedVersion, IEnumerable events) + { + lock (_lock) + { + if (!_streams.TryGetValue(stream, out var streamEvents)) + { + if (expectedVersion > -1) + { + throw new WrongExpectedVersionException( + $"Stream {stream} does not exist but expected version {expectedVersion}"); + } + + streamEvents = new List(); + _streams.Add(stream, streamEvents); + } + else if (expectedVersion != -1 && streamEvents.Count != expectedVersion) + { + throw new WrongExpectedVersionException( + $"Expected version {expectedVersion} but got {streamEvents.Count}"); + } + + var position = streamEvents.Count; + var resolvedEvents = events.Select((e, i) => new ResolvedEvent( + new EventRecord( + position + i, + DateTime.UtcNow, + Guid.Parse(e.EventId), + e.Type, + true, + e.Data, + e.Metadata), + null, + null)).ToList(); + + streamEvents.AddRange(resolvedEvents); + + return Task.FromResult(new WriteResult( + position + resolvedEvents.Count - 1, + position)); + } + } + + // Implement other interface methods... +} + +// Register for testing +public void ConfigureServices(IServiceCollection services) +{ + // Use in-memory event store for testing + services.AddSingleton(); + services.AddSingleton, StreamStoreRepository>(); +} +``` + +### 3. Event Store Subscription Configuration + +Configure event subscriptions to process events for read models and other event handlers: + +```csharp +public class EventStoreSubscriptionManager : IEventSubscriptionManager, IDisposable +{ + private readonly IStreamStoreConnection _connection; + private readonly ILogger _logger; + private readonly Dictionary _subscriptions = + new Dictionary(); + + public EventStoreSubscriptionManager( + IStreamStoreConnection connection, + ILogger logger) + { + _connection = connection; + _logger = logger; + } + + public async Task SubscribeToStreamAsync( + string streamName, + Action eventAppeared, + Action subscriptionDropped = null, + long? lastCheckpoint = null) + { + if (_subscriptions.ContainsKey(streamName)) + { + throw new InvalidOperationException($"Already subscribed to stream {streamName}"); + } + + var settings = new CatchUpSubscriptionSettings( + maxLiveQueueSize: 10000, + readBatchSize: 500, + verboseLogging: false, + resolveLinkTos: true, + subscriptionName: $"Subscription-{streamName}"); + + var subscription = _connection.SubscribeToStreamFrom( + streamName, + lastCheckpoint, + settings, + eventAppeared, + liveProcessingStarted: null, + subscriptionDropped: subscriptionDropped ?? DefaultSubscriptionDropped); + + _subscriptions.Add(streamName, subscription); + _logger.LogInformation("Subscribed to stream {StreamName}", streamName); + } + + public void Dispose() + { + foreach (var subscription in _subscriptions.Values) + { + subscription.Stop(); + } + + _subscriptions.Clear(); + } + + private void DefaultSubscriptionDropped(SubscriptionDropReason reason, Exception ex) + { + _logger.LogError(ex, "Subscription dropped: {Reason}", reason); + } +} + +// Register in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + services.AddSingleton(); +} + +// Usage in application startup +public class ApplicationStartup +{ + private readonly IEventSubscriptionManager _subscriptionManager; + private readonly IReadModelProjection _accountProjection; + private readonly ILogger _logger; + + public ApplicationStartup( + IEventSubscriptionManager subscriptionManager, + IReadModelProjection accountProjection, + ILogger logger) + { + _subscriptionManager = subscriptionManager; + _accountProjection = accountProjection; + _logger = logger; + } + + public async Task StartAsync() + { + // Subscribe to category streams + await _subscriptionManager.SubscribeToStreamAsync( + "$ce-account", + eventAppeared: HandleAccountEvent, + subscriptionDropped: HandleSubscriptionDropped); + } + + private void HandleAccountEvent(ResolvedEvent resolvedEvent) + { + try + { + // Deserialize and process event + var eventType = Type.GetType(resolvedEvent.Event.EventType); + var eventData = Encoding.UTF8.GetString(resolvedEvent.Event.Data); + var @event = JsonConvert.DeserializeObject(eventData, eventType); + + // Project event to read model + _accountProjection.Project(@event); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing event {EventId}", resolvedEvent.Event.EventId); + } + } + + private void HandleSubscriptionDropped(SubscriptionDropReason reason, Exception ex) + { + _logger.LogError(ex, "Account subscription dropped: {Reason}", reason); + + // Implement retry logic + Task.Delay(TimeSpan.FromSeconds(5)) + .ContinueWith(_ => StartAsync()) + .ConfigureAwait(false); + } +} +``` + +### 4. Stream Naming Conventions + +Establish consistent stream naming conventions for your event-sourced system: + +```csharp +public static class StreamNamingConventions +{ + // Stream for a specific aggregate instance + public static string GetAggregateStreamName(TId id) + where TAggregate : AggregateRoot + { + return $"{typeof(TAggregate).Name.ToLower()}-{id}"; + } + + // Category stream for all aggregates of a type + public static string GetCategoryStreamName() + where TAggregate : AggregateRoot + { + return $"$ce-{typeof(TAggregate).Name.ToLower()}"; + } + + // Stream for all events of a specific type + public static string GetEventTypeStreamName() + where TEvent : IEvent + { + return $"$et-{typeof(TEvent).Name.ToLower()}"; + } + + // Stream for a specific process manager + public static string GetProcessManagerStreamName(TId id) + where TProcessManager : ProcessManager + { + return $"processmanager-{typeof(TProcessManager).Name.ToLower()}-{id}"; + } + + // Stream for a specific read model + public static string GetReadModelStreamName(TId id) + where TReadModel : ReadModelBase + { + return $"readmodel-{typeof(TReadModel).Name.ToLower()}-{id}"; + } +} + +// Usage in repository +public class StreamStoreRepository : IRepository + where TAggregate : AggregateRoot, new() +{ + private readonly IStreamStoreConnection _connection; + + public StreamStoreRepository(IStreamStoreConnection connection) + { + _connection = connection; + } + + public async Task GetByIdAsync(TId id) + { + var streamName = StreamNamingConventions.GetAggregateStreamName(id); + // Implementation... + } + + public async Task SaveAsync(TAggregate aggregate) + { + var streamName = StreamNamingConventions.GetAggregateStreamName(aggregate.Id); + // Implementation... + } +} +``` + +## Message Bus Setup + +The message bus is a critical component in CQRS and event-sourced systems, responsible for routing commands and events to their appropriate handlers. Reactive Domain provides a robust implementation that supports both synchronous and asynchronous messaging patterns. + +### 1. Command Bus Configuration + +The command bus routes commands to their handlers and enforces the one-command-one-handler principle: + +```csharp +public class CommandBus : ICommandBus +{ + private readonly Dictionary> _handlers = + new Dictionary>(); + private readonly ILogger _logger; + + public CommandBus(ILogger logger) + { + _logger = logger; + } + + public void Register(Func handler) where TCommand : ICommand + { + var commandType = typeof(TCommand); + if (_handlers.ContainsKey(commandType)) + { + throw new InvalidOperationException( + $"Handler for command type {commandType.Name} is already registered"); + } + + _handlers[commandType] = cmd => handler((TCommand)cmd); + _logger.LogInformation("Registered handler for command type {CommandType}", commandType.Name); + } + + public async Task SendAsync(TCommand command) where TCommand : ICommand + { + var commandType = command.GetType(); + if (!_handlers.TryGetValue(commandType, out var handler)) + { + throw new InvalidOperationException( + $"No handler registered for command type {commandType.Name}"); + } + + try + { + _logger.LogDebug("Executing command {CommandType} with ID {CommandId}", + commandType.Name, command.CommandId); + + await handler(command); + + _logger.LogDebug("Command {CommandType} with ID {CommandId} executed successfully", + commandType.Name, command.CommandId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing command {CommandType} with ID {CommandId}", + commandType.Name, command.CommandId); + throw; + } + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register command bus + services.AddSingleton(); + + // Register command handlers + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Register command handler registrar + services.AddSingleton(provider => + { + var commandBus = provider.GetRequiredService(); + var registrar = new CommandHandlerRegistrar(commandBus); + + // Register handlers + registrar.Register(provider.GetRequiredService().Handle); + registrar.Register(provider.GetRequiredService().Handle); + registrar.Register(provider.GetRequiredService().Handle); + + return registrar; + }); +} +``` + +### 2. Event Bus Configuration + +The event bus distributes events to multiple subscribers, enabling the implementation of the Observer pattern: + +```csharp +public class EventBus : IEventBus +{ + private readonly Dictionary>> _handlers = + new Dictionary>>(); + private readonly ILogger _logger; + + public EventBus(ILogger logger) + { + _logger = logger; + } + + public void Subscribe(Func handler) where TEvent : class, IEvent + { + var eventType = typeof(TEvent); + if (!_handlers.TryGetValue(eventType, out var handlers)) + { + handlers = new List>(); + _handlers[eventType] = handlers; + } + + handlers.Add(evt => handler((TEvent)evt)); + _logger.LogInformation("Subscribed handler for event type {EventType}", eventType.Name); + } + + public async Task PublishAsync(TEvent @event) where TEvent : class, IEvent + { + var eventType = @event.GetType(); + if (!_handlers.TryGetValue(eventType, out var handlers)) + { + _logger.LogDebug("No handlers registered for event type {EventType}", eventType.Name); + return; + } + + _logger.LogDebug("Publishing event {EventType} with ID {EventId} to {HandlerCount} handlers", + eventType.Name, @event.EventId, handlers.Count); + + var tasks = handlers.Select(handler => + ExecuteHandlerSafely(handler, @event, eventType.Name)); + + await Task.WhenAll(tasks); + } + + private async Task ExecuteHandlerSafely(Func handler, IEvent @event, string eventTypeName) + { + try + { + await handler(@event); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling event {EventType} with ID {EventId}", + eventTypeName, @event.EventId); + + // Consider adding a dead letter queue or retry mechanism here + } + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register event bus + services.AddSingleton(); + + // Register event handlers + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Register event handler registrar + services.AddSingleton(provider => + { + var eventBus = provider.GetRequiredService(); + var registrar = new EventHandlerRegistrar(eventBus); + + // Register handlers + registrar.Register(provider.GetRequiredService().Handle); + registrar.Register(provider.GetRequiredService().Handle); + registrar.Register(provider.GetRequiredService().Handle); + + return registrar; + }); +} +``` + +### 3. Asynchronous Message Processing + +Implement asynchronous message processing for better scalability and responsiveness: + +```csharp +public class AsyncEventProcessor : IEventProcessor, IDisposable +{ + private readonly IEventBus _eventBus; + private readonly IStreamStoreConnection _connection; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _subscriptions = + new ConcurrentDictionary(); + private readonly ConcurrentDictionary _eventTypeToStreamName = + new ConcurrentDictionary(); + + public AsyncEventProcessor( + IEventBus eventBus, + IStreamStoreConnection connection, + ILogger logger) + { + _eventBus = eventBus; + _connection = connection; + _logger = logger; + } + + public void Subscribe(Func handler) where TEvent : class, IEvent + { + // Register with the event bus + _eventBus.Subscribe(handler); + + // Map event type to stream name + var eventType = typeof(TEvent); + var streamName = StreamNamingConventions.GetEventTypeStreamName(); + _eventTypeToStreamName[eventType] = streamName; + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + // Subscribe to all registered event streams + foreach (var kvp in _eventTypeToStreamName) + { + var streamName = kvp.Value; + if (_subscriptions.ContainsKey(streamName)) + { + continue; + } + + var settings = new CatchUpSubscriptionSettings( + maxLiveQueueSize: 10000, + readBatchSize: 500, + verboseLogging: false, + resolveLinkTos: true, + subscriptionName: $"AsyncProcessor-{streamName}"); + + var subscription = _connection.SubscribeToStreamFrom( + streamName, + null, // Start from beginning + settings, + EventAppeared, + liveProcessingStarted: null, + subscriptionDropped: SubscriptionDropped); + + _subscriptions[streamName] = subscription; + _logger.LogInformation("Subscribed to stream {StreamName}", streamName); + } + } + + private async Task EventAppeared(ResolvedEvent resolvedEvent) + { + try + { + // Deserialize event + var eventType = Type.GetType(resolvedEvent.Event.EventType); + if (eventType == null) + { + _logger.LogWarning("Unknown event type: {EventType}", resolvedEvent.Event.EventType); + return; + } + + var eventData = Encoding.UTF8.GetString(resolvedEvent.Event.Data); + var @event = JsonConvert.DeserializeObject(eventData, eventType) as IEvent; + + if (@event == null) + { + _logger.LogWarning("Failed to deserialize event: {EventId}", resolvedEvent.Event.EventId); + return; + } + + // Publish to event bus + await _eventBus.PublishAsync(@event); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing event: {EventId}", resolvedEvent.Event.EventId); + } + } + + private void SubscriptionDropped(SubscriptionDropReason reason, Exception ex) + { + _logger.LogError(ex, "Subscription dropped: {Reason}", reason); + + // Implement retry logic + Task.Delay(TimeSpan.FromSeconds(5)) + .ContinueWith(_ => StartAsync()) + .ConfigureAwait(false); + } + + public void Dispose() + { + foreach (var subscription in _subscriptions.Values) + { + subscription.Stop(); + } + + _subscriptions.Clear(); + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register async event processor + services.AddSingleton(); + + // Register hosted service to start the processor + services.AddHostedService(); +} + +public class EventProcessorHostedService : IHostedService +{ + private readonly IEventProcessor _eventProcessor; + private readonly ILogger _logger; + + public EventProcessorHostedService( + IEventProcessor eventProcessor, + ILogger logger) + { + _eventProcessor = eventProcessor; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting event processor"); + await _eventProcessor.StartAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping event processor"); + + if (_eventProcessor is IDisposable disposable) + { + disposable.Dispose(); + } + + return Task.CompletedTask; + } +} +``` + +### 4. Message Correlation and Tracing + +Implement message correlation for tracking related commands and events across the system: + +```csharp +public class CorrelationMiddleware : ICommandMiddleware +{ + private readonly ICorrelationIdProvider _correlationIdProvider; + private readonly ILogger _logger; + + public CorrelationMiddleware( + ICorrelationIdProvider correlationIdProvider, + ILogger logger) + { + _correlationIdProvider = correlationIdProvider; + _logger = logger; + } + + public async Task HandleAsync(TCommand command, Func next) + where TCommand : ICommand + { + if (command is ICorrelatedMessage correlatedMessage) + { + // If correlation ID is not set, set it + if (string.IsNullOrEmpty(correlatedMessage.CorrelationId)) + { + correlatedMessage.CorrelationId = _correlationIdProvider.GetCorrelationId(); + _logger.LogDebug("Set correlation ID {CorrelationId} for command {CommandType}", + correlatedMessage.CorrelationId, command.GetType().Name); + } + + // Set correlation ID in ambient context + using (_correlationIdProvider.SetCorrelationId(correlatedMessage.CorrelationId)) + { + await next(command); + } + } + else + { + await next(command); + } + } +} + +public class CorrelationIdProvider : ICorrelationIdProvider +{ + private static readonly AsyncLocal _currentCorrelationId = new AsyncLocal(); + + public string GetCorrelationId() + { + return _currentCorrelationId.Value ?? Guid.NewGuid().ToString(); + } + + public IDisposable SetCorrelationId(string correlationId) + { + var previousCorrelationId = _currentCorrelationId.Value; + _currentCorrelationId.Value = correlationId; + + return new CorrelationIdScope(() => _currentCorrelationId.Value = previousCorrelationId); + } + + private class CorrelationIdScope : IDisposable + { + private readonly Action _onDispose; + + public CorrelationIdScope(Action onDispose) + { + _onDispose = onDispose; + } + + public void Dispose() + { + _onDispose(); + } + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register correlation ID provider + services.AddSingleton(); + + // Register command middleware + services.AddSingleton(); + + // Register decorated command bus + services.AddSingleton(provider => + { + var innerBus = new CommandBus(provider.GetRequiredService>()); + var middleware = provider.GetRequiredService(); + + return new CommandBusWithMiddleware(innerBus, middleware); + }); +} + +public class CommandBusWithMiddleware : ICommandBus +{ + private readonly ICommandBus _innerBus; + private readonly ICommandMiddleware _middleware; + + public CommandBusWithMiddleware( + ICommandBus innerBus, + ICommandMiddleware middleware) + { + _innerBus = innerBus; + _middleware = middleware; + } + + public void Register(Func handler) where TCommand : ICommand + { + _innerBus.Register(handler); + } + + public async Task SendAsync(TCommand command) where TCommand : ICommand + { + await _middleware.HandleAsync(command, cmd => _innerBus.SendAsync(cmd)); + } +} +``` + +## Repository Configuration + +Repositories in Reactive Domain provide a clean abstraction over the event store, handling the loading and saving of aggregates. Proper repository configuration is essential for efficient event sourcing. + +### 1. Basic Repository Implementation + +Implement a repository that loads and saves aggregates to the event store: + +```csharp +public class StreamStoreRepository : IRepository + where TAggregate : AggregateRoot, new() +{ + private readonly IStreamStoreConnection _connection; + private readonly IEventSerializer _serializer; + private readonly ILogger> _logger; + + public StreamStoreRepository( + IStreamStoreConnection connection, + IEventSerializer serializer, + ILogger> logger) + { + _connection = connection; + _serializer = serializer; + _logger = logger; + } + + public async Task GetByIdAsync(TId id) + { + var streamName = StreamNamingConventions.GetAggregateStreamName(id); + _logger.LogDebug("Loading aggregate {AggregateType} with ID {AggregateId} from stream {StreamName}", + typeof(TAggregate).Name, id, streamName); + + var aggregate = new TAggregate(); + + try + { + var sliceStart = 0L; + const int sliceCount = 200; + StreamEventsSlice slice; + var events = new List(); + + do + { + slice = await _connection.ReadStreamEventsForwardAsync( + streamName, sliceStart, sliceCount, false); + + if (slice.Status == SliceReadStatus.StreamNotFound) + { + _logger.LogDebug("Stream {StreamName} not found", streamName); + break; + } + + foreach (var resolvedEvent in slice.Events) + { + var eventType = Type.GetType(resolvedEvent.Event.EventType); + if (eventType == null) + { + _logger.LogWarning("Unknown event type: {EventType}", resolvedEvent.Event.EventType); + continue; + } + + var eventData = Encoding.UTF8.GetString(resolvedEvent.Event.Data); + var @event = _serializer.Deserialize(eventData, eventType); + + events.Add(@event); + } + + sliceStart = slice.NextEventNumber; + } while (!slice.IsEndOfStream); + + // Load events into aggregate + aggregate.LoadFromHistory(events); + + _logger.LogDebug("Loaded aggregate {AggregateType} with ID {AggregateId} from {EventCount} events", + typeof(TAggregate).Name, id, events.Count); + + return aggregate; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading aggregate {AggregateType} with ID {AggregateId}", + typeof(TAggregate).Name, id); + throw; + } + } + + public async Task SaveAsync(TAggregate aggregate) + { + var streamName = StreamNamingConventions.GetAggregateStreamName(aggregate.Id); + var uncommittedEvents = aggregate.GetUncommittedEvents().ToList(); + + if (!uncommittedEvents.Any()) + { + _logger.LogDebug("No uncommitted events to save for aggregate {AggregateType} with ID {AggregateId}", + typeof(TAggregate).Name, aggregate.Id); + return; + } + + _logger.LogDebug("Saving {EventCount} events for aggregate {AggregateType} with ID {AggregateId} to stream {StreamName}", + uncommittedEvents.Count, typeof(TAggregate).Name, aggregate.Id, streamName); + + try + { + var expectedVersion = aggregate.Version - uncommittedEvents.Count; + var eventData = uncommittedEvents.Select(e => + { + var eventType = e.GetType(); + var data = _serializer.Serialize(e); + var metadata = _serializer.Serialize(new EventMetadata + { + AggregateType = typeof(TAggregate).AssemblyQualifiedName, + AggregateId = aggregate.Id.ToString(), + Timestamp = DateTime.UtcNow + }); + + return new EventData( + Guid.NewGuid(), + eventType.AssemblyQualifiedName, + true, + Encoding.UTF8.GetBytes(data), + Encoding.UTF8.GetBytes(metadata)); + }).ToList(); + + await _connection.AppendToStreamAsync(streamName, expectedVersion, eventData); + + // Clear uncommitted events after successful save + aggregate.ClearUncommittedEvents(); + + _logger.LogDebug("Successfully saved {EventCount} events for aggregate {AggregateType} with ID {AggregateId}", + uncommittedEvents.Count, typeof(TAggregate).Name, aggregate.Id); + } + catch (WrongExpectedVersionException ex) + { + _logger.LogError(ex, "Concurrency conflict when saving aggregate {AggregateType} with ID {AggregateId}", + typeof(TAggregate).Name, aggregate.Id); + throw new ConcurrencyException( + $"Concurrency conflict when saving aggregate {typeof(TAggregate).Name} with ID {aggregate.Id}", + ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving aggregate {AggregateType} with ID {AggregateId}", + typeof(TAggregate).Name, aggregate.Id); + throw; + } + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register event serializer + services.AddSingleton(); + + // Register repositories + services.AddSingleton, StreamStoreRepository>(); + services.AddSingleton, StreamStoreRepository>(); + services.AddSingleton, StreamStoreRepository>(); +} +``` + +### 2. Snapshot Repository Implementation + +Implement a repository that supports snapshots for more efficient loading of aggregates with many events: + +```csharp +public class SnapshotRepository : IRepository + where TAggregate : AggregateRoot, ISnapshotable, new() +{ + private readonly IStreamStoreConnection _connection; + private readonly ISnapshotStore _snapshotStore; + private readonly IEventSerializer _serializer; + private readonly ILogger> _logger; + private readonly int _snapshotFrequency; + + public SnapshotRepository( + IStreamStoreConnection connection, + ISnapshotStore snapshotStore, + IEventSerializer serializer, + ILogger> logger, + int snapshotFrequency = 100) + { + _connection = connection; + _snapshotStore = snapshotStore; + _serializer = serializer; + _logger = logger; + _snapshotFrequency = snapshotFrequency; + } + + public async Task GetByIdAsync(TId id) + { + var streamName = StreamNamingConventions.GetAggregateStreamName(id); + var aggregate = new TAggregate(); + + try + { + // Try to load snapshot first + var snapshot = await _snapshotStore.GetSnapshotAsync(id.ToString()); + long sliceStart = 0; + + if (snapshot != null) + { + _logger.LogDebug("Found snapshot for aggregate {AggregateType} with ID {AggregateId} at version {Version}", + typeof(TAggregate).Name, id, snapshot.Version); + + aggregate.RestoreFromSnapshot(snapshot.State); + sliceStart = snapshot.Version + 1; + } + + // Load events from snapshot version onwards + const int sliceCount = 200; + StreamEventsSlice slice; + var events = new List(); + + do + { + slice = await _connection.ReadStreamEventsForwardAsync( + streamName, sliceStart, sliceCount, false); + + if (slice.Status == SliceReadStatus.StreamNotFound) + { + if (snapshot == null) + { + _logger.LogDebug("Stream {StreamName} not found", streamName); + } + break; + } + + foreach (var resolvedEvent in slice.Events) + { + var eventType = Type.GetType(resolvedEvent.Event.EventType); + if (eventType == null) + { + _logger.LogWarning("Unknown event type: {EventType}", resolvedEvent.Event.EventType); + continue; + } + + var eventData = Encoding.UTF8.GetString(resolvedEvent.Event.Data); + var @event = _serializer.Deserialize(eventData, eventType); + + events.Add(@event); + } + + sliceStart = slice.NextEventNumber; + } while (!slice.IsEndOfStream); + + // Apply events after snapshot + if (events.Any()) + { + aggregate.LoadFromHistory(events); + + _logger.LogDebug("Loaded {EventCount} events for aggregate {AggregateType} with ID {AggregateId} after snapshot", + events.Count, typeof(TAggregate).Name, id); + } + + return aggregate; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading aggregate {AggregateType} with ID {AggregateId}", + typeof(TAggregate).Name, id); + throw; + } + } + + public async Task SaveAsync(TAggregate aggregate) + { + var streamName = StreamNamingConventions.GetAggregateStreamName(aggregate.Id); + var uncommittedEvents = aggregate.GetUncommittedEvents().ToList(); + + if (!uncommittedEvents.Any()) + { + _logger.LogDebug("No uncommitted events to save for aggregate {AggregateType} with ID {AggregateId}", + typeof(TAggregate).Name, aggregate.Id); + return; + } + + _logger.LogDebug("Saving {EventCount} events for aggregate {AggregateType} with ID {AggregateId} to stream {StreamName}", + uncommittedEvents.Count, typeof(TAggregate).Name, aggregate.Id, streamName); + + try + { + var expectedVersion = aggregate.Version - uncommittedEvents.Count; + var eventData = uncommittedEvents.Select(e => + { + var eventType = e.GetType(); + var data = _serializer.Serialize(e); + var metadata = _serializer.Serialize(new EventMetadata + { + AggregateType = typeof(TAggregate).AssemblyQualifiedName, + AggregateId = aggregate.Id.ToString(), + Timestamp = DateTime.UtcNow + }); + + return new EventData( + Guid.NewGuid(), + eventType.AssemblyQualifiedName, + true, + Encoding.UTF8.GetBytes(data), + Encoding.UTF8.GetBytes(metadata)); + }).ToList(); + + await _connection.AppendToStreamAsync(streamName, expectedVersion, eventData); + + // Check if we need to create a snapshot + if (aggregate.Version % _snapshotFrequency == 0) + { + var snapshot = new Snapshot + { + AggregateId = aggregate.Id.ToString(), + Version = aggregate.Version, + State = aggregate.CreateSnapshot(), + Timestamp = DateTime.UtcNow + }; + + await _snapshotStore.SaveSnapshotAsync(snapshot); + + _logger.LogDebug("Created snapshot for aggregate {AggregateType} with ID {AggregateId} at version {Version}", + typeof(TAggregate).Name, aggregate.Id, aggregate.Version); + } + + // Clear uncommitted events after successful save + aggregate.ClearUncommittedEvents(); + + _logger.LogDebug("Successfully saved {EventCount} events for aggregate {AggregateType} with ID {AggregateId}", + uncommittedEvents.Count, typeof(TAggregate).Name, aggregate.Id); + } + catch (WrongExpectedVersionException ex) + { + _logger.LogError(ex, "Concurrency conflict when saving aggregate {AggregateType} with ID {AggregateId}", + typeof(TAggregate).Name, aggregate.Id); + throw new ConcurrencyException( + $"Concurrency conflict when saving aggregate {typeof(TAggregate).Name} with ID {aggregate.Id}", + ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving aggregate {AggregateType} with ID {AggregateId}", + typeof(TAggregate).Name, aggregate.Id); + throw; + } + } +} + +// Snapshot store implementation +public class SqlSnapshotStore : ISnapshotStore +{ + private readonly string _connectionString; + private readonly IEventSerializer _serializer; + private readonly ILogger _logger; + + public SqlSnapshotStore( + string connectionString, + IEventSerializer serializer, + ILogger logger) + { + _connectionString = connectionString; + _serializer = serializer; + _logger = logger; + } + + public async Task GetSnapshotAsync(string aggregateId) + { + using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(); + + var sql = @" + SELECT TOP 1 AggregateId, Version, State, Timestamp + FROM Snapshots + WHERE AggregateId = @AggregateId + ORDER BY Version DESC"; + + var snapshot = await connection.QueryFirstOrDefaultAsync(sql, new { AggregateId = aggregateId }); + + if (snapshot == null) + { + return null; + } + + var state = _serializer.Deserialize(snapshot.State, Type.GetType(snapshot.StateType)); + + return new Snapshot + { + AggregateId = snapshot.AggregateId, + Version = snapshot.Version, + State = state, + Timestamp = snapshot.Timestamp + }; + } + + public async Task SaveSnapshotAsync(Snapshot snapshot) + { + using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(); + + var sql = @" + INSERT INTO Snapshots (AggregateId, Version, StateType, State, Timestamp) + VALUES (@AggregateId, @Version, @StateType, @State, @Timestamp)"; + + var stateType = snapshot.State.GetType(); + var serializedState = _serializer.Serialize(snapshot.State); + + await connection.ExecuteAsync(sql, new + { + snapshot.AggregateId, + snapshot.Version, + StateType = stateType.AssemblyQualifiedName, + State = serializedState, + snapshot.Timestamp + }); + } + + private class SnapshotRecord + { + public string AggregateId { get; set; } + public long Version { get; set; } + public string StateType { get; set; } + public string State { get; set; } + public DateTime Timestamp { get; set; } + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register snapshot store + services.AddSingleton(provider => + { + var connectionString = Configuration.GetConnectionString("SnapshotStore"); + var serializer = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + + return new SqlSnapshotStore(connectionString, serializer, logger); + }); + + // Register repositories with snapshots + services.AddSingleton>(provider => + { + var connection = provider.GetRequiredService(); + var snapshotStore = provider.GetRequiredService(); + var serializer = provider.GetRequiredService(); + var logger = provider.GetRequiredService>>(); + + return new SnapshotRepository(connection, snapshotStore, serializer, logger, 50); + }); +} +``` + +### 3. Correlated Repository Implementation + +Implement a repository that supports correlation for tracking related aggregates: + +```csharp +public class CorrelatedRepository : ICorrelatedRepository + where TAggregate : AggregateRoot, new() +{ + private readonly IRepository _innerRepository; + private readonly ICorrelationIdProvider _correlationIdProvider; + private readonly ILogger> _logger; + + public CorrelatedRepository( + IRepository innerRepository, + ICorrelationIdProvider correlationIdProvider, + ILogger> logger) + { + _innerRepository = innerRepository; + _correlationIdProvider = correlationIdProvider; + _logger = logger; + } + + public async Task GetByIdAsync(TId id) + { + return await _innerRepository.GetByIdAsync(id); + } + + public async Task SaveAsync(TAggregate aggregate) + { + var correlationId = _correlationIdProvider.GetCorrelationId(); + + // Set correlation ID on uncommitted events + var uncommittedEvents = aggregate.GetUncommittedEvents().ToList(); + foreach (var @event in uncommittedEvents.OfType()) + { + if (string.IsNullOrEmpty(@event.CorrelationId)) + { + @event.CorrelationId = correlationId; + } + } + + _logger.LogDebug("Saving aggregate {AggregateType} with ID {AggregateId} with correlation ID {CorrelationId}", + typeof(TAggregate).Name, aggregate.Id, correlationId); + + await _innerRepository.SaveAsync(aggregate); + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register correlation ID provider + services.AddSingleton(); + + // Register inner repositories + services.AddSingleton, StreamStoreRepository>(); + + // Register correlated repositories as decorators + services.AddSingleton>(provider => + { + var innerRepository = provider.GetRequiredService>(); + var correlationIdProvider = provider.GetRequiredService(); + var logger = provider.GetRequiredService>>(); + + return new CorrelatedRepository(innerRepository, correlationIdProvider, logger); + }); +} +``` + +### 4. Repository Factory + +Implement a factory for creating repositories dynamically: + +```csharp +public class RepositoryFactory : IRepositoryFactory +{ + private readonly IServiceProvider _serviceProvider; + + public RepositoryFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public IRepository CreateRepository() + where TAggregate : AggregateRoot, new() + { + return _serviceProvider.GetRequiredService>(); + } + + public ICorrelatedRepository CreateCorrelatedRepository() + where TAggregate : AggregateRoot, new() + { + return _serviceProvider.GetRequiredService>(); + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register repository factory + services.AddSingleton(); +} + +// Usage in application code +public class AccountService +{ + private readonly IRepositoryFactory _repositoryFactory; + + public AccountService(IRepositoryFactory repositoryFactory) + { + _repositoryFactory = repositoryFactory; + } + + public async Task GetAccountAsync(Guid id) + { + var repository = _repositoryFactory.CreateRepository(); + return await repository.GetByIdAsync(id); + } + + public async Task CreateAccountAsync(string name, string accountNumber) + { + var repository = _repositoryFactory.CreateCorrelatedRepository(); + var account = new Account(); + account.Create(Guid.NewGuid(), name, accountNumber); + await repository.SaveAsync(account); + } +} +``` + +## Read Model Implementation + +Read models in event-sourced systems provide optimized views of the data for querying. They are updated in response to events and are designed for efficient reads. + +### 1. Basic Read Model Projector + +Implement a projector that updates read models in response to domain events: + +```csharp +public class AccountSummaryProjector : IEventHandler, + IEventHandler, + IEventHandler +{ + private readonly IReadModelDbContext _dbContext; + private readonly ILogger _logger; + + public AccountSummaryProjector( + IReadModelDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task HandleAsync(AccountCreated @event) + { + _logger.LogDebug("Projecting AccountCreated event for account {AccountId}", @event.AccountId); + + var accountSummary = new AccountSummary + { + Id = @event.AccountId, + AccountNumber = @event.AccountNumber, + Name = @event.Name, + Balance = 0, + CreatedAt = @event.Timestamp, + UpdatedAt = @event.Timestamp, + Version = 1 + }; + + _dbContext.AccountSummaries.Add(accountSummary); + await _dbContext.SaveChangesAsync(); + } + + public async Task HandleAsync(DepositMade @event) + { + _logger.LogDebug("Projecting DepositMade event for account {AccountId}", @event.AccountId); + + var accountSummary = await _dbContext.AccountSummaries + .FindAsync(@event.AccountId); + + if (accountSummary == null) + { + _logger.LogWarning("Account summary not found for account {AccountId}", @event.AccountId); + return; + } + + accountSummary.Balance += @event.Amount; + accountSummary.UpdatedAt = @event.Timestamp; + accountSummary.Version++; + + await _dbContext.SaveChangesAsync(); + } + + public async Task HandleAsync(WithdrawalMade @event) + { + _logger.LogDebug("Projecting WithdrawalMade event for account {AccountId}", @event.AccountId); + + var accountSummary = await _dbContext.AccountSummaries + .FindAsync(@event.AccountId); + + if (accountSummary == null) + { + _logger.LogWarning("Account summary not found for account {AccountId}", @event.AccountId); + return; + } + + accountSummary.Balance -= @event.Amount; + accountSummary.UpdatedAt = @event.Timestamp; + accountSummary.Version++; + + await _dbContext.SaveChangesAsync(); + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register read model projector + services.AddScoped(); + + // Register event handlers + services.AddScoped>(provider => + provider.GetRequiredService()); + services.AddScoped>(provider => + provider.GetRequiredService()); + services.AddScoped>(provider => + provider.GetRequiredService()); +} +``` + +### 2. Database Context for Read Models + +Implement a database context for read models using Entity Framework Core: + +```csharp +public class ReadModelDbContext : DbContext, IReadModelDbContext +{ + public ReadModelDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet AccountSummaries { get; set; } + public DbSet TransactionSummaries { get; set; } + public DbSet CustomerSummaries { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.AccountNumber).IsRequired().HasMaxLength(50); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Balance).HasColumnType("decimal(18, 2)"); + entity.Property(e => e.Version).IsConcurrencyToken(); + entity.HasIndex(e => e.AccountNumber).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.AccountId).IsRequired(); + entity.Property(e => e.Type).IsRequired().HasMaxLength(50); + entity.Property(e => e.Amount).HasColumnType("decimal(18, 2)"); + entity.Property(e => e.Description).HasMaxLength(500); + entity.HasIndex(e => e.AccountId); + entity.HasIndex(e => e.Timestamp); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Email).HasMaxLength(255); + entity.HasIndex(e => e.Email).IsUnique(); + }); + } +} + +public interface IReadModelDbContext +{ + DbSet AccountSummaries { get; } + DbSet TransactionSummaries { get; } + DbSet CustomerSummaries { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register read model database context + services.AddDbContext(options => + options.UseSqlServer(Configuration.GetConnectionString("ReadModelDb"))); + services.AddScoped(provider => + provider.GetRequiredService()); +} +``` + +### 3. Catch-Up Subscription Manager + +Implement a service that manages catch-up subscriptions to the event store for updating read models: + +```csharp +public class CatchUpSubscriptionManager : BackgroundService +{ + private readonly IStreamStoreConnection _connection; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger _logger; + private readonly ICheckpointStore _checkpointStore; + private readonly Dictionary _subscriptions; + + public CatchUpSubscriptionManager( + IStreamStoreConnection connection, + IServiceScopeFactory serviceScopeFactory, + ICheckpointStore checkpointStore, + ILogger logger) + { + _connection = connection; + _serviceScopeFactory = serviceScopeFactory; + _checkpointStore = checkpointStore; + _logger = logger; + _subscriptions = new Dictionary(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting catch-up subscription manager"); + + // Create subscriptions for each projection group + await CreateSubscription("AccountProjections", HandleAccountEvent, stoppingToken); + await CreateSubscription("CustomerProjections", HandleCustomerEvent, stoppingToken); + await CreateSubscription("TransactionProjections", HandleTransactionEvent, stoppingToken); + + // Keep the service running + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + } + + // Stop all subscriptions when the service is stopping + foreach (var subscription in _subscriptions.Values) + { + subscription.Stop(); + } + + _logger.LogInformation("Catch-up subscription manager stopped"); + } + + private async Task CreateSubscription( + string subscriptionName, + Action eventHandler, + CancellationToken stoppingToken) + { + var checkpoint = await _checkpointStore.GetCheckpointAsync(subscriptionName); + var position = checkpoint != null ? new Position(checkpoint.Value, checkpoint.Value) : Position.Start; + + _logger.LogInformation("Starting {SubscriptionName} subscription from position {Position}", + subscriptionName, position); + + var settings = new CatchUpSubscriptionSettings( + maxLiveQueueSize: 10000, + readBatchSize: 500, + verboseLogging: false, + resolveLinkTos: true, + subscriptionName: subscriptionName); + + var subscription = _connection.SubscribeToAllFrom( + position, + settings, + eventAppeared: (sub, evt) => { + try + { + eventHandler(sub, evt); + + // Update checkpoint + var position = evt.OriginalPosition?.CommitPosition ?? 0; + _checkpointStore.StoreCheckpointAsync(subscriptionName, position).Wait(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling event in subscription {SubscriptionName}", + subscriptionName); + } + + return Task.CompletedTask; + }, + liveProcessingStarted: _ => { + _logger.LogInformation("{SubscriptionName} subscription caught up to live events", + subscriptionName); + return Task.CompletedTask; + }, + subscriptionDropped: (sub, reason, ex) => { + _logger.LogWarning(ex, "{SubscriptionName} subscription dropped: {Reason}", + subscriptionName, reason); + + if (reason != SubscriptionDropReason.UserInitiated && !stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Restarting {SubscriptionName} subscription", subscriptionName); + Task.Delay(TimeSpan.FromSeconds(5), stoppingToken) + .ContinueWith(_ => CreateSubscription(subscriptionName, eventHandler, stoppingToken)); + } + + return Task.CompletedTask; + }); + + _subscriptions[subscriptionName] = subscription; + } + + private void HandleAccountEvent(EventStoreCatchUpSubscription subscription, ResolvedEvent resolvedEvent) + { + if (resolvedEvent.Event.EventType.StartsWith("Account")) + { + ProcessEvent(resolvedEvent); + } + } + + private void HandleCustomerEvent(EventStoreCatchUpSubscription subscription, ResolvedEvent resolvedEvent) + { + if (resolvedEvent.Event.EventType.StartsWith("Customer")) + { + ProcessEvent(resolvedEvent); + } + } + + private void HandleTransactionEvent(EventStoreCatchUpSubscription subscription, ResolvedEvent resolvedEvent) + { + if (resolvedEvent.Event.EventType.StartsWith("Transaction") || + resolvedEvent.Event.EventType == "DepositMade" || + resolvedEvent.Event.EventType == "WithdrawalMade") + { + ProcessEvent(resolvedEvent); + } + } + + private void ProcessEvent(ResolvedEvent resolvedEvent) + { + var eventType = Type.GetType(resolvedEvent.Event.EventType); + if (eventType == null) + { + _logger.LogWarning("Unknown event type: {EventType}", resolvedEvent.Event.EventType); + return; + } + + using var scope = _serviceScopeFactory.CreateScope(); + var eventBus = scope.ServiceProvider.GetRequiredService(); + var serializer = scope.ServiceProvider.GetRequiredService(); + + var eventData = Encoding.UTF8.GetString(resolvedEvent.Event.Data); + var @event = serializer.Deserialize(eventData, eventType); + + eventBus.PublishAsync(@event).Wait(); + } +} + +// Checkpoint store implementation +public class SqlCheckpointStore : ICheckpointStore +{ + private readonly string _connectionString; + private readonly ILogger _logger; + + public SqlCheckpointStore(string connectionString, ILogger logger) + { + _connectionString = connectionString; + _logger = logger; + } + + public async Task GetCheckpointAsync(string subscriptionName) + { + using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(); + + var sql = "SELECT Position FROM Checkpoints WHERE SubscriptionName = @SubscriptionName"; + var position = await connection.QueryFirstOrDefaultAsync(sql, new { SubscriptionName = subscriptionName }); + + _logger.LogDebug("Retrieved checkpoint for {SubscriptionName}: {Position}", + subscriptionName, position); + + return position; + } + + public async Task StoreCheckpointAsync(string subscriptionName, long position) + { + using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(); + + var sql = @" + MERGE INTO Checkpoints WITH (HOLDLOCK) AS target + USING (SELECT @SubscriptionName AS SubscriptionName) AS source + ON target.SubscriptionName = source.SubscriptionName + WHEN MATCHED AND target.Position < @Position THEN + UPDATE SET Position = @Position, UpdatedAt = GETUTCDATE() + WHEN NOT MATCHED THEN + INSERT (SubscriptionName, Position, UpdatedAt) + VALUES (@SubscriptionName, @Position, GETUTCDATE()); + "; + + await connection.ExecuteAsync(sql, new + { + SubscriptionName = subscriptionName, + Position = position + }); + + _logger.LogDebug("Stored checkpoint for {SubscriptionName} at position {Position}", + subscriptionName, position); + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register checkpoint store + services.AddSingleton(provider => + { + var connectionString = Configuration.GetConnectionString("ReadModelDb"); + var logger = provider.GetRequiredService>(); + + return new SqlCheckpointStore(connectionString, logger); + }); + + // Register catch-up subscription manager as a hosted service + services.AddHostedService(); +} +``` + +### 4. Query Service Implementation + +Implement query services that provide optimized access to read models: + +```csharp +public class AccountQueryService : IAccountQueryService +{ + private readonly IReadModelDbContext _dbContext; + private readonly ILogger _logger; + + public AccountQueryService( + IReadModelDbContext dbContext, + ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task GetAccountByIdAsync(Guid id) + { + _logger.LogDebug("Getting account summary for account {AccountId}", id); + + var account = await _dbContext.AccountSummaries + .AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == id); + + if (account == null) + { + _logger.LogWarning("Account summary not found for account {AccountId}", id); + return null; + } + + return new AccountSummaryDto + { + Id = account.Id, + AccountNumber = account.AccountNumber, + Name = account.Name, + Balance = account.Balance, + CreatedAt = account.CreatedAt, + UpdatedAt = account.UpdatedAt + }; + } + + public async Task> GetAllAccountsAsync(int page = 1, int pageSize = 10) + { + _logger.LogDebug("Getting all account summaries, page {Page}, pageSize {PageSize}", page, pageSize); + + var accounts = await _dbContext.AccountSummaries + .AsNoTracking() + .OrderBy(a => a.Name) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return accounts.Select(account => new AccountSummaryDto + { + Id = account.Id, + AccountNumber = account.AccountNumber, + Name = account.Name, + Balance = account.Balance, + CreatedAt = account.CreatedAt, + UpdatedAt = account.UpdatedAt + }); + } + + public async Task> GetAccountTransactionsAsync( + Guid accountId, int page = 1, int pageSize = 20) + { + _logger.LogDebug("Getting transactions for account {AccountId}, page {Page}, pageSize {PageSize}", + accountId, page, pageSize); + + var transactions = await _dbContext.TransactionSummaries + .AsNoTracking() + .Where(t => t.AccountId == accountId) + .OrderByDescending(t => t.Timestamp) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return transactions.Select(transaction => new TransactionSummaryDto + { + Id = transaction.Id, + AccountId = transaction.AccountId, + Type = transaction.Type, + Amount = transaction.Amount, + Description = transaction.Description, + Timestamp = transaction.Timestamp + }); + } +} + +// Registration in Startup.cs +public void ConfigureServices(IServiceCollection services) +{ + // Register query services + services.AddScoped(); + services.AddScoped(); +} +``` + +### 5. Handling Eventual Consistency + +Implement a service that helps clients deal with eventual consistency between write and read models: + +```csharp +public class EventualConsistencyService : IEventualConsistencyService +{ + private readonly IStreamStoreConnection _connection; + private readonly IEventSerializer _serializer; + private readonly ILogger _logger; + + public EventualConsistencyService( + IStreamStoreConnection connection, + IEventSerializer serializer, + ILogger logger) + { + _connection = connection; + _serializer = serializer; + _logger = logger; + } + + public async Task WaitForProjectionAsync( + Guid aggregateId, + Func> readModelCheck, + TimeSpan timeout, + int maxAttempts = 10) + { + var streamName = StreamNamingConventions.GetAggregateStreamName(aggregateId); + var stopwatch = Stopwatch.StartNew(); + var attempts = 0; + + while (stopwatch.Elapsed < timeout && attempts < maxAttempts) + { + attempts++; + + // Check if the read model has been updated + if (await readModelCheck()) + { + _logger.LogDebug("Read model for {AggregateId} is consistent after {ElapsedMs}ms and {Attempts} attempts", + aggregateId, stopwatch.ElapsedMilliseconds, attempts); + return true; + } + + // Wait before trying again + var delay = CalculateExponentialBackoff(attempts); + await Task.Delay(delay); + } + + _logger.LogWarning("Read model for {AggregateId} did not become consistent after {ElapsedMs}ms and {Attempts} attempts", + aggregateId, stopwatch.ElapsedMilliseconds, attempts); + return false; + } + + private TimeSpan CalculateExponentialBackoff(int attempt) + { + // Start with 50ms, then exponentially increase (50, 100, 200, 400, 800, etc.) + var delayMs = Math.Min(50 * Math.Pow(2, attempt - 1), 2000); + return TimeSpan.FromMilliseconds(delayMs); + } +} + +// Usage in application code +public class AccountController : ControllerBase +{ + private readonly ICommandBus _commandBus; + private readonly IAccountQueryService _accountQueryService; + private readonly IEventualConsistencyService _consistencyService; + + public AccountController( + ICommandBus commandBus, + IAccountQueryService accountQueryService, + IEventualConsistencyService consistencyService) + { + _commandBus = commandBus; + _accountQueryService = accountQueryService; + _consistencyService = consistencyService; + } + + [HttpPost] + public async Task CreateAccount([FromBody] CreateAccountRequest request) + { + var accountId = Guid.NewGuid(); + var command = new CreateAccount(accountId, request.Name, request.InitialDeposit); + + await _commandBus.SendAsync(command); + + // Wait for the read model to be updated + var isConsistent = await _consistencyService.WaitForProjectionAsync( + accountId, + async () => await _accountQueryService.GetAccountByIdAsync(accountId) != null, + TimeSpan.FromSeconds(5)); + + if (isConsistent) + { + var account = await _accountQueryService.GetAccountByIdAsync(accountId); + return CreatedAtAction(nameof(GetAccount), new { id = accountId }, account); + } + + // Return a 202 Accepted if the read model is not yet updated + return AcceptedAtAction(nameof(GetAccount), new { id = accountId }); + } + + [HttpGet("{id}")] + public async Task GetAccount(Guid id) + { + var account = await _accountQueryService.GetAccountByIdAsync(id); + + if (account == null) + { + return NotFound(); + } + + return Ok(account); + } +} +``` diff --git a/docs/api-reference/patterns/performance-considerations.md b/docs/api-reference/patterns/performance-considerations.md new file mode 100644 index 00000000..d2682168 --- /dev/null +++ b/docs/api-reference/patterns/performance-considerations.md @@ -0,0 +1,869 @@ +# Performance Considerations for Reactive Domain Applications + +[← Back to API Reference](../README.md) | [← Back to Table of Contents](../../README.md) + +This document outlines key performance considerations and optimization techniques for Reactive Domain applications, focusing on event sourcing, CQRS, and read model optimization. + +## Table of Contents + +1. [Event Stream Optimization](#event-stream-optimization) +2. [Snapshot Strategies](#snapshot-strategies) +3. [Read Model Performance](#read-model-performance) +4. [Caching Strategies](#caching-strategies) +5. [Scaling Event-Sourced Systems](#scaling-event-sourced-systems) +6. [Monitoring and Profiling](#monitoring-and-profiling) +7. [Best Practices](#best-practices) + +## Event Stream Optimization + +Efficient handling of event streams is critical for performance in event-sourced systems, especially as streams grow larger over time. + +### 1. Batch Loading of Events + +When loading events from the event store, use batching to avoid memory pressure: + +```csharp +public async Task GetByIdAsync(TId id) +{ + var streamName = StreamNamingConventions.GetAggregateStreamName(id); + var aggregate = new TAggregate(); + + // Load events in batches + var sliceStart = 0L; + const int sliceCount = 200; // Optimal batch size + StreamEventsSlice slice; + var events = new List(); + + do + { + slice = await _connection.ReadStreamEventsForwardAsync( + streamName, sliceStart, sliceCount, false); + + // Process batch... + foreach (var resolvedEvent in slice.Events) + { + // Deserialize and add to events list + // ... + } + + sliceStart = slice.NextEventNumber; + } while (!slice.IsEndOfStream); + + // Apply events to aggregate + aggregate.LoadFromHistory(events); + + return aggregate; +} +``` + +### 2. Event Stream Partitioning + +For high-volume systems, consider partitioning event streams by logical boundaries: + +```csharp +// Stream naming convention with partitioning +public static string GetPartitionedStreamName(TId id, string partition) +{ + return $"{typeof(TAggregate).Name}-{partition}-{id}"; +} +``` + +### 3. Optimizing Event Size + +Keep events small and focused to improve serialization/deserialization performance: + +```csharp +// Good: Small, focused event +public class ItemAddedToCart : Event +{ + public Guid CartId { get; } + public Guid ProductId { get; } + public int Quantity { get; } + public decimal UnitPrice { get; } + + public ItemAddedToCart(Guid cartId, Guid productId, int quantity, decimal unitPrice) + { + CartId = cartId; + ProductId = productId; + Quantity = quantity; + UnitPrice = unitPrice; + } +} + +// Avoid: Large, unfocused event +public class CartUpdated : Event +{ + public Guid CartId { get; } + public List Items { get; } // Potentially large collection + public CustomerInfo CustomerDetails { get; } // Complex nested object + public Dictionary Metadata { get; } // Unstructured data + + // Constructor... +} +``` + +## Snapshot Strategies + +Snapshots can significantly improve performance for aggregates with many events by reducing the number of events that need to be loaded and applied. + +### 1. Frequency-Based Snapshots + +Create snapshots based on the number of events since the last snapshot: + +```csharp +public async Task SaveAsync(TAggregate aggregate) +{ + // Save events... + + // Check if we need to create a snapshot + if (aggregate.Version % _snapshotFrequency == 0) + { + var snapshot = new Snapshot + { + AggregateId = aggregate.Id.ToString(), + Version = aggregate.Version, + State = aggregate.CreateSnapshot(), + Timestamp = DateTime.UtcNow + }; + + await _snapshotStore.SaveSnapshotAsync(snapshot); + } +} +``` + +### 2. Time-Based Snapshots + +For aggregates that change frequently, consider time-based snapshot strategies: + +```csharp +public async Task SaveAsync(TAggregate aggregate) +{ + // Save events... + + // Check if we need to create a snapshot based on time + var lastSnapshot = await _snapshotStore.GetLastSnapshotTimeAsync(aggregate.Id.ToString()); + var timeSinceLastSnapshot = DateTime.UtcNow - (lastSnapshot ?? DateTime.MinValue); + + if (timeSinceLastSnapshot > TimeSpan.FromHours(24)) + { + var snapshot = new Snapshot + { + AggregateId = aggregate.Id.ToString(), + Version = aggregate.Version, + State = aggregate.CreateSnapshot(), + Timestamp = DateTime.UtcNow + }; + + await _snapshotStore.SaveSnapshotAsync(snapshot); + } +} +``` + +### 3. Snapshot Storage Optimization + +Optimize snapshot storage for fast retrieval: + +```csharp +public class SqlSnapshotStore : ISnapshotStore +{ + // ... + + public async Task GetSnapshotAsync(string aggregateId) + { + using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(); + + // Use indexed query for fast retrieval + var sql = @" + SELECT TOP 1 AggregateId, Version, StateType, State, Timestamp + FROM Snapshots WITH (INDEX(IX_Snapshots_AggregateId_Version)) + WHERE AggregateId = @AggregateId + ORDER BY Version DESC"; + + // ... + } +} +``` + +## Read Model Performance + +Optimizing read models is essential for query performance in CQRS architectures. + +### 1. Denormalized Read Models + +Design read models specifically for query patterns to avoid joins: + +```csharp +// Denormalized read model for order details +public class OrderDetailReadModel +{ + public Guid OrderId { get; set; } + public string OrderNumber { get; set; } + public DateTime OrderDate { get; set; } + public decimal TotalAmount { get; set; } + + // Denormalized customer data + public Guid CustomerId { get; set; } + public string CustomerName { get; set; } + public string CustomerEmail { get; set; } + + // Denormalized shipping data + public string ShippingAddress { get; set; } + public string ShippingCity { get; set; } + public string ShippingPostalCode { get; set; } + + // Denormalized payment data + public string PaymentMethod { get; set; } + public string PaymentStatus { get; set; } + + // Items in a JSON column for flexible querying + public string ItemsJson { get; set; } +} +``` + +### 2. Optimized Indexing + +Create appropriate indexes for common query patterns: + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.OrderId); + + // Index for customer queries + entity.HasIndex(e => e.CustomerId); + + // Composite index for date-based queries + entity.HasIndex(e => new { e.OrderDate, e.TotalAmount }); + + // Full-text search index for order items + entity.HasIndex(e => e.ItemsJson).ForFullTextSearch(); + + // Filtered index for specific query patterns + entity.HasIndex(e => e.PaymentStatus) + .HasFilter("PaymentStatus = 'Pending'") + .HasName("IX_OrderDetail_PendingPayments"); + }); +} +``` + +### 3. Asynchronous Projections + +Process projections asynchronously to avoid blocking the command path: + +```csharp +public class CatchUpSubscriptionManager : BackgroundService +{ + // ... + + private void ProcessEvent(ResolvedEvent resolvedEvent) + { + // Deserialize event + var eventType = Type.GetType(resolvedEvent.Event.EventType); + var eventData = Encoding.UTF8.GetString(resolvedEvent.Event.Data); + var @event = _serializer.Deserialize(eventData, eventType); + + // Queue projection work to avoid blocking + _backgroundTaskQueue.QueueBackgroundWorkItem(async token => + { + using var scope = _serviceScopeFactory.CreateScope(); + var eventBus = scope.ServiceProvider.GetRequiredService(); + await eventBus.PublishAsync(@event); + }); + } +} +``` + +## Caching Strategies + +Implement caching to reduce database load and improve response times. + +### 1. Aggregate Caching + +Cache frequently accessed aggregates to reduce event store load: + +```csharp +public class CachingRepository : IRepository + where TAggregate : AggregateRoot, new() +{ + private readonly IRepository _innerRepository; + private readonly IMemoryCache _cache; + private readonly TimeSpan _cacheExpiration; + + public CachingRepository( + IRepository innerRepository, + IMemoryCache cache, + TimeSpan? cacheExpiration = null) + { + _innerRepository = innerRepository; + _cache = cache; + _cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(5); + } + + public async Task GetByIdAsync(TId id) + { + var cacheKey = $"{typeof(TAggregate).Name}:{id}"; + + if (_cache.TryGetValue(cacheKey, out TAggregate cachedAggregate)) + { + return cachedAggregate; + } + + var aggregate = await _innerRepository.GetByIdAsync(id); + + var cacheOptions = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(_cacheExpiration); + + _cache.Set(cacheKey, aggregate, cacheOptions); + + return aggregate; + } + + public async Task SaveAsync(TAggregate aggregate) + { + await _innerRepository.SaveAsync(aggregate); + + // Update cache after saving + var cacheKey = $"{typeof(TAggregate).Name}:{aggregate.Id}"; + var cacheOptions = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(_cacheExpiration); + + _cache.Set(cacheKey, aggregate, cacheOptions); + } +} +``` + +### 2. Read Model Caching + +Cache query results to improve read performance: + +```csharp +public class CachingAccountQueryService : IAccountQueryService +{ + private readonly IAccountQueryService _innerService; + private readonly IMemoryCache _cache; + private readonly TimeSpan _cacheExpiration; + + public CachingAccountQueryService( + IAccountQueryService innerService, + IMemoryCache cache, + TimeSpan? cacheExpiration = null) + { + _innerService = innerService; + _cache = cache; + _cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(1); + } + + public async Task GetAccountByIdAsync(Guid id) + { + var cacheKey = $"Account:{id}"; + + return await _cache.GetOrCreateAsync(cacheKey, async entry => + { + entry.SetAbsoluteExpiration(_cacheExpiration); + return await _innerService.GetAccountByIdAsync(id); + }); + } + + public async Task> GetAllAccountsAsync(int page = 1, int pageSize = 10) + { + var cacheKey = $"Accounts:Page:{page}:Size:{pageSize}"; + + return await _cache.GetOrCreateAsync(cacheKey, async entry => + { + entry.SetAbsoluteExpiration(_cacheExpiration); + return await _innerService.GetAllAccountsAsync(page, pageSize); + }); + } +} +``` + +### 3. Distributed Caching + +For scaled-out applications, use distributed caching: + +```csharp +public class DistributedCachingRepository : IRepository + where TAggregate : AggregateRoot, new() +{ + private readonly IRepository _innerRepository; + private readonly IDistributedCache _cache; + private readonly IEventSerializer _serializer; + private readonly TimeSpan _cacheExpiration; + + public DistributedCachingRepository( + IRepository innerRepository, + IDistributedCache cache, + IEventSerializer serializer, + TimeSpan? cacheExpiration = null) + { + _innerRepository = innerRepository; + _cache = cache; + _serializer = serializer; + _cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(5); + } + + public async Task GetByIdAsync(TId id) + { + var cacheKey = $"{typeof(TAggregate).Name}:{id}"; + var cachedData = await _cache.GetStringAsync(cacheKey); + + if (!string.IsNullOrEmpty(cachedData)) + { + return _serializer.Deserialize(cachedData); + } + + var aggregate = await _innerRepository.GetByIdAsync(id); + + var options = new DistributedCacheEntryOptions() + .SetAbsoluteExpiration(_cacheExpiration); + + await _cache.SetStringAsync( + cacheKey, + _serializer.Serialize(aggregate), + options); + + return aggregate; + } + + public async Task SaveAsync(TAggregate aggregate) + { + await _innerRepository.SaveAsync(aggregate); + + // Update cache after saving + var cacheKey = $"{typeof(TAggregate).Name}:{aggregate.Id}"; + var options = new DistributedCacheEntryOptions() + .SetAbsoluteExpiration(_cacheExpiration); + + await _cache.SetStringAsync( + cacheKey, + _serializer.Serialize(aggregate), + options); + } +} +``` + +## Scaling Event-Sourced Systems + +Strategies for scaling event-sourced systems to handle increased load. + +### 1. Read Model Sharding + +Shard read models by tenant or other logical boundaries: + +```csharp +public class ShardedReadModelDbContext : IReadModelDbContext +{ + private readonly string _connectionStringTemplate; + private readonly ITenantProvider _tenantProvider; + private readonly Dictionary _contextCache = new(); + + public ShardedReadModelDbContext( + string connectionStringTemplate, + ITenantProvider tenantProvider) + { + _connectionStringTemplate = connectionStringTemplate; + _tenantProvider = tenantProvider; + } + + private ReadModelDbContext GetContextForCurrentTenant() + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + + if (!_contextCache.TryGetValue(tenantId, out var context)) + { + var connectionString = string.Format(_connectionStringTemplate, tenantId); + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + + context = new ReadModelDbContext(options); + _contextCache[tenantId] = context; + } + + return context; + } + + public DbSet AccountSummaries => + GetContextForCurrentTenant().AccountSummaries; + + public DbSet TransactionSummaries => + GetContextForCurrentTenant().TransactionSummaries; + + public DbSet CustomerSummaries => + GetContextForCurrentTenant().CustomerSummaries; + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await GetContextForCurrentTenant().SaveChangesAsync(cancellationToken); + } +} +``` + +### 2. Event Store Clustering + +Configure EventStoreDB in a clustered mode for high availability and throughput: + +```csharp +public static IStreamStoreConnection CreateClusteredConnection(string[] gossipSeeds) +{ + var settings = ConnectionSettings.Create() + .SetDefaultUserCredentials(new UserCredentials( + Environment.GetEnvironmentVariable("EVENTSTORE_USERNAME"), + Environment.GetEnvironmentVariable("EVENTSTORE_PASSWORD"))) + .SetHeartbeatInterval(TimeSpan.FromSeconds(30)) + .SetHeartbeatTimeout(TimeSpan.FromSeconds(120)) + .Build(); + + var clusterSettings = ClusterSettings.Create() + .DiscoverClusterViaGossipSeeds() + .SetGossipSeedEndPoints(ParseGossipSeeds(gossipSeeds)) + .SetGossipTimeout(TimeSpan.FromSeconds(5)) + .Build(); + + var connection = EventStoreConnection.Create(settings, clusterSettings); + connection.ConnectAsync().Wait(); + return new StreamStoreConnection(connection); +} +``` + +### 3. Projection Scaling + +Scale out projections using competing consumers pattern: + +```csharp +public class ProjectionWorker : BackgroundService +{ + private readonly IStreamStoreConnection _connection; + private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly ILogger _logger; + private readonly string _workerName; + private readonly string _subscriptionGroup; + + public ProjectionWorker( + IStreamStoreConnection connection, + IServiceScopeFactory serviceScopeFactory, + ILogger logger, + string workerName, + string subscriptionGroup) + { + _connection = connection; + _serviceScopeFactory = serviceScopeFactory; + _logger = logger; + _workerName = workerName; + _subscriptionGroup = subscriptionGroup; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting projection worker {WorkerName} for group {SubscriptionGroup}", + _workerName, _subscriptionGroup); + + var settings = new PersistentSubscriptionSettings( + resolveLinkTos: true, + startFrom: Position.Start, + messageTimeout: TimeSpan.FromMinutes(1), + maxRetryCount: 10, + liveBufferSize: 500, + readBatchSize: 20, + historyBufferSize: 500, + checkPointAfter: TimeSpan.FromSeconds(10), + minCheckPointCount: 10, + maxCheckPointCount: 1000, + maxSubscriberCount: 10, + namedConsumerStrategy: SystemConsumerStrategies.RoundRobin); + + try + { + await _connection.CreatePersistentSubscriptionAsync( + "$all", _subscriptionGroup, settings, stoppingToken); + } + catch (InvalidOperationException) + { + // Subscription already exists + } + + var subscription = await _connection.ConnectToPersistentSubscriptionAsync( + "$all", + _subscriptionGroup, + (_, evt) => ProcessEvent(evt), + (_, reason, ex) => HandleSubscriptionDropped(reason, ex), + _workerName, + bufferSize: 10, + autoAck: false); + + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + + subscription.Stop(); + } + + private Task ProcessEvent(ResolvedEvent evt) + { + // Process event... + return Task.CompletedTask; + } + + private Task HandleSubscriptionDropped(SubscriptionDropReason reason, Exception ex) + { + _logger.LogError(ex, "Subscription dropped: {Reason}", reason); + return Task.CompletedTask; + } +} +``` + +## Monitoring and Profiling + +Implement comprehensive monitoring to identify and address performance bottlenecks. + +### 1. Event Store Metrics + +Monitor EventStoreDB performance metrics: + +```csharp +public class EventStoreMetricsCollector : BackgroundService +{ + private readonly IStreamStoreConnection _connection; + private readonly IMetricsPublisher _metricsPublisher; + private readonly ILogger _logger; + + public EventStoreMetricsCollector( + IStreamStoreConnection connection, + IMetricsPublisher metricsPublisher, + ILogger logger) + { + _connection = connection; + _metricsPublisher = metricsPublisher; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + var stats = await _connection.GetStatsAsync(); + + _metricsPublisher.PublishGauge("eventstore.tcp.connections", stats.TcpConnections); + _metricsPublisher.PublishGauge("eventstore.http.connections", stats.HttpConnections); + _metricsPublisher.PublishGauge("eventstore.process.cpu", stats.ProcessCpu); + _metricsPublisher.PublishGauge("eventstore.process.memory", stats.ProcessMemory); + + // Publish other relevant metrics + } + catch (Exception ex) + { + _logger.LogError(ex, "Error collecting EventStore metrics"); + } + + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } +} +``` + +### 2. Repository Performance Tracking + +Track repository performance using metrics and logging: + +```csharp +public class MetricsRepository : IRepository + where TAggregate : AggregateRoot, new() +{ + private readonly IRepository _innerRepository; + private readonly IMetricsPublisher _metricsPublisher; + private readonly ILogger> _logger; + + public MetricsRepository( + IRepository innerRepository, + IMetricsPublisher metricsPublisher, + ILogger> logger) + { + _innerRepository = innerRepository; + _metricsPublisher = metricsPublisher; + _logger = logger; + } + + public async Task GetByIdAsync(TId id) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var aggregate = await _innerRepository.GetByIdAsync(id); + + stopwatch.Stop(); + _metricsPublisher.PublishTimer( + $"repository.{typeof(TAggregate).Name}.get", + stopwatch.ElapsedMilliseconds); + + _logger.LogDebug( + "Retrieved {AggregateType} with ID {AggregateId} in {ElapsedMs}ms", + typeof(TAggregate).Name, id, stopwatch.ElapsedMilliseconds); + + return aggregate; + } + catch (Exception) + { + stopwatch.Stop(); + _metricsPublisher.PublishTimer( + $"repository.{typeof(TAggregate).Name}.get.error", + stopwatch.ElapsedMilliseconds); + throw; + } + } + + public async Task SaveAsync(TAggregate aggregate) + { + var stopwatch = Stopwatch.StartNew(); + var uncommittedEvents = aggregate.GetUncommittedEvents().Count(); + + try + { + await _innerRepository.SaveAsync(aggregate); + + stopwatch.Stop(); + _metricsPublisher.PublishTimer( + $"repository.{typeof(TAggregate).Name}.save", + stopwatch.ElapsedMilliseconds); + + _metricsPublisher.PublishCounter( + $"repository.{typeof(TAggregate).Name}.events", + uncommittedEvents); + + _logger.LogDebug( + "Saved {AggregateType} with ID {AggregateId} and {EventCount} events in {ElapsedMs}ms", + typeof(TAggregate).Name, aggregate.Id, uncommittedEvents, stopwatch.ElapsedMilliseconds); + } + catch (Exception) + { + stopwatch.Stop(); + _metricsPublisher.PublishTimer( + $"repository.{typeof(TAggregate).Name}.save.error", + stopwatch.ElapsedMilliseconds); + throw; + } + } +} +``` + +### 3. Query Performance Monitoring + +Monitor query performance to identify slow queries: + +```csharp +public class MetricsQueryService : IAccountQueryService +{ + private readonly IAccountQueryService _innerService; + private readonly IMetricsPublisher _metricsPublisher; + private readonly ILogger _logger; + + public MetricsQueryService( + IAccountQueryService innerService, + IMetricsPublisher metricsPublisher, + ILogger logger) + { + _innerService = innerService; + _metricsPublisher = metricsPublisher; + _logger = logger; + } + + public async Task GetAccountByIdAsync(Guid id) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var result = await _innerService.GetAccountByIdAsync(id); + + stopwatch.Stop(); + _metricsPublisher.PublishTimer("query.account.byid", stopwatch.ElapsedMilliseconds); + + if (stopwatch.ElapsedMilliseconds > 100) + { + _logger.LogWarning( + "Slow query: GetAccountByIdAsync took {ElapsedMs}ms for account {AccountId}", + stopwatch.ElapsedMilliseconds, id); + } + + return result; + } + catch (Exception) + { + stopwatch.Stop(); + _metricsPublisher.PublishTimer("query.account.byid.error", stopwatch.ElapsedMilliseconds); + throw; + } + } + + public async Task> GetAllAccountsAsync(int page = 1, int pageSize = 10) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var result = await _innerService.GetAllAccountsAsync(page, pageSize); + + stopwatch.Stop(); + _metricsPublisher.PublishTimer("query.account.all", stopwatch.ElapsedMilliseconds); + + if (stopwatch.ElapsedMilliseconds > 200) + { + _logger.LogWarning( + "Slow query: GetAllAccountsAsync took {ElapsedMs}ms for page {Page}, size {PageSize}", + stopwatch.ElapsedMilliseconds, page, pageSize); + } + + return result; + } + catch (Exception) + { + stopwatch.Stop(); + _metricsPublisher.PublishTimer("query.account.all.error", stopwatch.ElapsedMilliseconds); + throw; + } + } +} +``` + +## Best Practices + +### 1. Event Design + +- Keep events small and focused +- Include only relevant data in events +- Use value objects for complex properties +- Consider versioning strategy for long-lived events + +### 2. Aggregate Design + +- Keep aggregates small and focused on a single responsibility +- Limit the number of events per aggregate +- Consider splitting large aggregates into smaller ones +- Use snapshots for aggregates with many events + +### 3. Read Model Design + +- Design read models for specific query patterns +- Denormalize data to avoid joins +- Create appropriate indexes for common queries +- Consider materialized views for complex aggregations + +### 4. Caching Strategy + +- Cache hot aggregates to reduce event store load +- Use distributed caching for scaled-out applications +- Implement cache invalidation strategies +- Consider read-through and write-through caching + +### 5. Scaling Considerations + +- Shard read models by tenant or other boundaries +- Use competing consumers for processing projections +- Configure event store clustering for high availability +- Implement backpressure mechanisms for high-volume systems diff --git a/docs/code-examples/creating-aggregate-root.md b/docs/code-examples/creating-aggregate-root.md index 83ad1a63..6dd9b27d 100644 --- a/docs/code-examples/creating-aggregate-root.md +++ b/docs/code-examples/creating-aggregate-root.md @@ -2,17 +2,20 @@ [← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) -This example demonstrates how to create a new aggregate root in Reactive Domain. +This example demonstrates how to create a new aggregate root in Reactive Domain, following current best practices and patterns. ## Basic Aggregate Root Structure ```csharp using System; +using System.Collections.Generic; using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Messages; namespace MyApp.Domain { - public class Account : AggregateRoot + public class Account : AggregateRoot { // Private state fields private decimal _balance; @@ -20,16 +23,16 @@ namespace MyApp.Domain private string _customerName; private bool _isClosed; - // Constructor for creating a new aggregate - public Account(Guid id) : base(id) + // Default constructor required for deserialization + public Account() : base() { - // Initialize with default state + // Required for deserialization } - // Constructor for loading from history - protected Account(Guid id, IEnumerable events) : base(id, events) + // Constructor for creating a new aggregate + public Account(Guid id) : base(id) { - // Base constructor will call RestoreFromEvents + // Initialize with default state } // Constructor with correlation @@ -45,8 +48,14 @@ namespace MyApp.Domain if (_accountNumber != null) throw new InvalidOperationException("Account already created"); + if (string.IsNullOrWhiteSpace(accountNumber)) + throw new ArgumentException("Account number cannot be empty", nameof(accountNumber)); + + if (string.IsNullOrWhiteSpace(customerName)) + throw new ArgumentException("Customer name cannot be empty", nameof(customerName)); + // Generate and apply event - RaiseEvent(new AccountCreated(Id, accountNumber, customerName)); + Apply(new AccountCreated(Id, accountNumber, customerName)); } public void Deposit(decimal amount) @@ -56,10 +65,10 @@ namespace MyApp.Domain throw new InvalidOperationException("Account is closed"); if (amount <= 0) - throw new ArgumentException("Amount must be positive"); + throw new ArgumentException("Amount must be positive", nameof(amount)); // Generate and apply event - RaiseEvent(new FundsDeposited(Id, amount)); + Apply(new FundsDeposited(Id, amount)); } public void Withdraw(decimal amount) @@ -69,13 +78,13 @@ namespace MyApp.Domain throw new InvalidOperationException("Account is closed"); if (amount <= 0) - throw new ArgumentException("Amount must be positive"); + throw new ArgumentException("Amount must be positive", nameof(amount)); if (_balance < amount) throw new InvalidOperationException("Insufficient funds"); // Generate and apply event - RaiseEvent(new FundsWithdrawn(Id, amount)); + Apply(new FundsWithdrawn(Id, amount)); } public void Close() @@ -88,7 +97,7 @@ namespace MyApp.Domain throw new InvalidOperationException("Cannot close account with positive balance"); // Generate and apply event - RaiseEvent(new AccountClosed(Id)); + Apply(new AccountClosed(Id)); } // Query methods @@ -102,7 +111,7 @@ namespace MyApp.Domain return _isClosed; } - // Event handlers + // Event handlers - these are called automatically by the base class private void Apply(AccountCreated @event) { _accountNumber = @event.AccountNumber; @@ -125,6 +134,40 @@ namespace MyApp.Domain { _isClosed = true; } + + // Override to handle snapshot restoration if needed + protected override void RestoreFromSnapshot(object snapshot) + { + if (snapshot is AccountSnapshot s) + { + _accountNumber = s.AccountNumber; + _customerName = s.CustomerName; + _balance = s.Balance; + _isClosed = s.IsClosed; + } + } + + // Override to create snapshots if needed + protected override object CreateSnapshot() + { + return new AccountSnapshot + { + AccountNumber = _accountNumber, + CustomerName = _customerName, + Balance = _balance, + IsClosed = _isClosed + }; + } + + // Snapshot class for serialization + [Serializable] + private class AccountSnapshot + { + public string AccountNumber { get; set; } + public string CustomerName { get; set; } + public decimal Balance { get; set; } + public bool IsClosed { get; set; } + } } } ``` @@ -136,13 +179,15 @@ using System; using ReactiveDomain.Messaging; using ReactiveDomain.Messaging.Messages; -namespace MyApp.Domain +namespace MyApp.Domain.Events { + [Serializable] public class AccountCreated : Event { public readonly Guid AccountId; public readonly string AccountNumber; public readonly string CustomerName; + public readonly DateTime Timestamp; public AccountCreated(Guid accountId, string accountNumber, string customerName) : base() @@ -150,74 +195,90 @@ namespace MyApp.Domain AccountId = accountId; AccountNumber = accountNumber; CustomerName = customerName; + Timestamp = DateTime.UtcNow; } - public AccountCreated(Guid accountId, string accountNumber, string customerName, - Guid correlationId, Guid causationId) - : base(correlationId, causationId) + // Constructor with explicit correlation + public AccountCreated(Guid accountId, string accountNumber, string customerName, + ICorrelatedMessage source) + : base(source) { AccountId = accountId; AccountNumber = accountNumber; CustomerName = customerName; + Timestamp = DateTime.UtcNow; } } + [Serializable] public class FundsDeposited : Event { public readonly Guid AccountId; public readonly decimal Amount; + public readonly DateTime Timestamp; public FundsDeposited(Guid accountId, decimal amount) : base() { AccountId = accountId; Amount = amount; + Timestamp = DateTime.UtcNow; } - public FundsDeposited(Guid accountId, decimal amount, - Guid correlationId, Guid causationId) - : base(correlationId, causationId) + // Constructor with explicit correlation + public FundsDeposited(Guid accountId, decimal amount, ICorrelatedMessage source) + : base(source) { AccountId = accountId; Amount = amount; + Timestamp = DateTime.UtcNow; } } + [Serializable] public class FundsWithdrawn : Event { public readonly Guid AccountId; public readonly decimal Amount; + public readonly DateTime Timestamp; public FundsWithdrawn(Guid accountId, decimal amount) : base() { AccountId = accountId; Amount = amount; + Timestamp = DateTime.UtcNow; } - public FundsWithdrawn(Guid accountId, decimal amount, - Guid correlationId, Guid causationId) - : base(correlationId, causationId) + // Constructor with explicit correlation + public FundsWithdrawn(Guid accountId, decimal amount, ICorrelatedMessage source) + : base(source) { AccountId = accountId; Amount = amount; + Timestamp = DateTime.UtcNow; } } + [Serializable] public class AccountClosed : Event { public readonly Guid AccountId; + public readonly DateTime Timestamp; public AccountClosed(Guid accountId) : base() { AccountId = accountId; + Timestamp = DateTime.UtcNow; } - public AccountClosed(Guid accountId, Guid correlationId, Guid causationId) - : base(correlationId, causationId) + // Constructor with explicit correlation + public AccountClosed(Guid accountId, ICorrelatedMessage source) + : base(source) { AccountId = accountId; + Timestamp = DateTime.UtcNow; } } } @@ -227,45 +288,53 @@ namespace MyApp.Domain ### Aggregate Structure +- **Type Parameter**: Modern Reactive Domain aggregates inherit from `AggregateRoot` where `TId` is the identifier type - **Private State**: Aggregates maintain their state in private fields - **Command Methods**: Public methods that validate commands and generate events - **Query Methods**: Public methods that return information about the aggregate state - **Event Handlers**: Private `Apply` methods that update the aggregate state +- **Snapshot Support**: Optional methods for creating and restoring from snapshots ### Constructors -- **Default Constructor**: Used when creating a new aggregate -- **History Constructor**: Used when loading an aggregate from its event history +- **Default Constructor**: Required for deserialization +- **ID Constructor**: Used when creating a new aggregate - **Correlated Constructor**: Used when creating an aggregate from a command with correlation information ### Command Validation - Commands are validated against the current state of the aggregate - Business rules are enforced before generating events -- Exceptions are thrown when commands are invalid +- Exceptions are thrown when commands are invalid, with proper parameter names +- Input validation ensures data integrity ### Event Application -- Events are generated using the `RaiseEvent` method +- Events are generated using the `Apply` method (not `RaiseEvent` in newer versions) - Each event type has a corresponding `Apply` method - The `Apply` method updates the aggregate state based on the event +- Events include timestamps for auditing and temporal queries ## Best Practices -1. **Keep Aggregates Small**: Focus on a single business concept -2. **Validate Commands**: Ensure all commands are valid before generating events -3. **Immutable Events**: Make all event properties read-only -4. **Private State**: Keep aggregate state private and expose it through controlled methods -5. **Descriptive Event Names**: Use past tense for event names (e.g., `AccountCreated`, `FundsDeposited`) -6. **Correlation Support**: Implement constructors that support correlation tracking +1. **Keep Aggregates Small**: Focus on a single business concept and its invariants +2. **Use Strong Typing**: Specify the ID type parameter in `AggregateRoot` +3. **Validate Commands Thoroughly**: Check all preconditions and input parameters +4. **Immutable Events**: Make all event properties read-only and mark events as `[Serializable]` +5. **Include Timestamps**: Add creation timestamps to events for auditing +6. **Implement Snapshots**: For aggregates with many events, implement snapshot support +7. **Proper Correlation**: Use `ICorrelatedMessage` for tracking message chains +8. **Descriptive Naming**: Use past tense for events and imperative for commands ## Common Pitfalls -1. **Large Aggregates**: Avoid creating aggregates that are too large or contain too many responsibilities -2. **Public State Modification**: Don't allow direct modification of aggregate state from outside -3. **Missing Business Rules**: Ensure all business rules are enforced in command methods -4. **Complex Apply Methods**: Keep event handlers simple and focused on updating state -5. **Side Effects in Apply Methods**: Avoid side effects like I/O operations in Apply methods +1. **Missing Default Constructor**: Forgetting the parameter-less constructor required for deserialization +2. **Mutable State**: Exposing setters for aggregate state +3. **Business Logic in Event Handlers**: Keep business logic in command methods, not in Apply methods +4. **Side Effects in Apply Methods**: Avoid I/O, external calls, or generating new events in Apply methods +5. **Overly Complex Aggregates**: Trying to model too many concepts in a single aggregate +6. **Inconsistent Validation**: Not validating all inputs or checking all business rules +7. **Ignoring Correlation**: Not properly maintaining correlation chains across messages --- diff --git a/docs/code-examples/handling-commands-events.md b/docs/code-examples/handling-commands-events.md index 594a4fd6..955ba5de 100644 --- a/docs/code-examples/handling-commands-events.md +++ b/docs/code-examples/handling-commands-events.md @@ -2,7 +2,7 @@ [← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) -This example demonstrates how to handle commands and generate events in Reactive Domain. +This example demonstrates how to handle commands and generate events in Reactive Domain, following current best practices. ## Command Definitions @@ -13,90 +13,128 @@ using ReactiveDomain.Messaging.Messages; namespace MyApp.Domain.Commands { - public class CreateAccount : Command + [Serializable] + public class CreateAccount : Command { public readonly Guid AccountId; public readonly string AccountNumber; public readonly string CustomerName; + public readonly DateTime Timestamp; + // Constructor for creating a new command public CreateAccount(Guid accountId, string accountNumber, string customerName) - : base() + : base(accountId) // Pass ID to base constructor { + if (string.IsNullOrWhiteSpace(accountNumber)) + throw new ArgumentException("Account number cannot be empty", nameof(accountNumber)); + + if (string.IsNullOrWhiteSpace(customerName)) + throw new ArgumentException("Customer name cannot be empty", nameof(customerName)); + AccountId = accountId; AccountNumber = accountNumber; CustomerName = customerName; + Timestamp = DateTime.UtcNow; } - // Constructor with explicit correlation - public CreateAccount(Guid accountId, string accountNumber, string customerName, - Guid correlationId, Guid causationId) - : base(correlationId, causationId) + // Constructor with correlation from source message + public CreateAccount(Guid accountId, string accountNumber, string customerName, ICorrelatedMessage source) + : base(accountId, source) // Pass ID and source for correlation { + if (string.IsNullOrWhiteSpace(accountNumber)) + throw new ArgumentException("Account number cannot be empty", nameof(accountNumber)); + + if (string.IsNullOrWhiteSpace(customerName)) + throw new ArgumentException("Customer name cannot be empty", nameof(customerName)); + AccountId = accountId; AccountNumber = accountNumber; CustomerName = customerName; + Timestamp = DateTime.UtcNow; } } - public class DepositFunds : Command + [Serializable] + public class DepositFunds : Command { public readonly Guid AccountId; public readonly decimal Amount; + public readonly DateTime Timestamp; public DepositFunds(Guid accountId, decimal amount) - : base() + : base(accountId) { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + AccountId = accountId; Amount = amount; + Timestamp = DateTime.UtcNow; } - // Constructor with explicit correlation - public DepositFunds(Guid accountId, decimal amount, - Guid correlationId, Guid causationId) - : base(correlationId, causationId) + // Constructor with correlation from source message + public DepositFunds(Guid accountId, decimal amount, ICorrelatedMessage source) + : base(accountId, source) { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + AccountId = accountId; Amount = amount; + Timestamp = DateTime.UtcNow; } } - public class WithdrawFunds : Command + [Serializable] + public class WithdrawFunds : Command { public readonly Guid AccountId; public readonly decimal Amount; + public readonly DateTime Timestamp; public WithdrawFunds(Guid accountId, decimal amount) - : base() + : base(accountId) { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + AccountId = accountId; Amount = amount; + Timestamp = DateTime.UtcNow; } - // Constructor with explicit correlation - public WithdrawFunds(Guid accountId, decimal amount, - Guid correlationId, Guid causationId) - : base(correlationId, causationId) + // Constructor with correlation from source message + public WithdrawFunds(Guid accountId, decimal amount, ICorrelatedMessage source) + : base(accountId, source) { + if (amount <= 0) + throw new ArgumentException("Amount must be positive", nameof(amount)); + AccountId = accountId; Amount = amount; + Timestamp = DateTime.UtcNow; } } - public class CloseAccount : Command + [Serializable] + public class CloseAccount : Command { public readonly Guid AccountId; + public readonly DateTime Timestamp; public CloseAccount(Guid accountId) - : base() + : base(accountId) { AccountId = accountId; + Timestamp = DateTime.UtcNow; } - // Constructor with explicit correlation - public CloseAccount(Guid accountId, Guid correlationId, Guid causationId) - : base(correlationId, causationId) + // Constructor with correlation from source message + public CloseAccount(Guid accountId, ICorrelatedMessage source) + : base(accountId, source) { AccountId = accountId; + Timestamp = DateTime.UtcNow; } } } @@ -106,6 +144,8 @@ namespace MyApp.Domain.Commands ```csharp using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using ReactiveDomain.Foundation; using ReactiveDomain.Messaging; using ReactiveDomain.Messaging.Bus; @@ -119,88 +159,146 @@ namespace MyApp.Domain.Handlers IHandleCommand, IHandleCommand { - private readonly IRepository _repository; + private readonly IRepository _repository; + private readonly ILogger _logger; - public AccountCommandHandler(IRepository repository) + public AccountCommandHandler( + IRepository repository, + ILogger logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public void Handle(CreateAccount command) + public async Task HandleAsync(CreateAccount command) { - // Create a new account with correlation - var account = new Account(command.AccountId, command); - - // Initialize the account - account.Create(command.AccountNumber, command.CustomerName); - - // Save the account - _repository.Save(account); + _logger.LogInformation("Creating account {AccountId} for customer {CustomerName}", + command.AccountId, command.CustomerName); + + try + { + // Create a new account with correlation + var account = new Account(command.AccountId, command); + + // Initialize the account + account.Create(command.AccountNumber, command.CustomerName); + + // Save the account + await _repository.SaveAsync(account); + + _logger.LogInformation("Successfully created account {AccountId}", command.AccountId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating account {AccountId}", command.AccountId); + throw; + } } - public void Handle(DepositFunds command) + public async Task HandleAsync(DepositFunds command) { + _logger.LogInformation("Depositing {Amount} to account {AccountId}", + command.Amount, command.AccountId); + try { // Load the account - var account = _repository.GetById(command.AccountId); + var account = await _repository.GetByIdAsync(command.AccountId); // Process the command account.Deposit(command.Amount); - // Save the changes - _repository.Save(account); + // Save the account + await _repository.SaveAsync(account); + + _logger.LogInformation("Successfully deposited {Amount} to account {AccountId}", + command.Amount, command.AccountId); } catch (AggregateNotFoundException) { - // Handle not found case + _logger.LogWarning("Account {AccountId} not found for deposit", command.AccountId); throw new InvalidOperationException($"Account {command.AccountId} not found"); } + catch (Exception ex) + { + _logger.LogError(ex, "Error depositing {Amount} to account {AccountId}", + command.Amount, command.AccountId); + throw; + } } - public void Handle(WithdrawFunds command) + public async Task HandleAsync(WithdrawFunds command) { + _logger.LogInformation("Withdrawing {Amount} from account {AccountId}", + command.Amount, command.AccountId); + try { // Load the account - var account = _repository.GetById(command.AccountId); + var account = await _repository.GetByIdAsync(command.AccountId); // Process the command account.Withdraw(command.Amount); - // Save the changes - _repository.Save(account); + // Save the account + await _repository.SaveAsync(account); + + _logger.LogInformation("Successfully withdrew {Amount} from account {AccountId}", + command.Amount, command.AccountId); } catch (AggregateNotFoundException) { - // Handle not found case + _logger.LogWarning("Account {AccountId} not found for withdrawal", command.AccountId); throw new InvalidOperationException($"Account {command.AccountId} not found"); } catch (InvalidOperationException ex) { - // Rethrow business rule violations + // Business rule violations (like insufficient funds) are expected exceptions + _logger.LogWarning(ex, "Business rule violation when withdrawing {Amount} from account {AccountId}", + command.Amount, command.AccountId); + throw; // Rethrow for proper handling upstream + } + catch (Exception ex) + { + _logger.LogError(ex, "Error withdrawing {Amount} from account {AccountId}", + command.Amount, command.AccountId); throw; } } - public void Handle(CloseAccount command) + public async Task HandleAsync(CloseAccount command) { + _logger.LogInformation("Closing account {AccountId}", command.AccountId); + try { // Load the account - var account = _repository.GetById(command.AccountId); + var account = await _repository.GetByIdAsync(command.AccountId); // Process the command account.Close(); - // Save the changes - _repository.Save(account); + // Save the account + await _repository.SaveAsync(account); + + _logger.LogInformation("Successfully closed account {AccountId}", command.AccountId); } catch (AggregateNotFoundException) { - // Handle not found case + _logger.LogWarning("Account {AccountId} not found for closing", command.AccountId); throw new InvalidOperationException($"Account {command.AccountId} not found"); } + catch (InvalidOperationException ex) + { + // Business rule violations are expected exceptions + _logger.LogWarning(ex, "Business rule violation when closing account {AccountId}", command.AccountId); + throw; // Rethrow for proper handling upstream + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing account {AccountId}", command.AccountId); + throw; + } } } } @@ -210,6 +308,8 @@ namespace MyApp.Domain.Handlers ```csharp using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using ReactiveDomain.Messaging; using MyApp.Domain.Commands; @@ -217,45 +317,77 @@ namespace MyApp.Domain.Examples { public class MessageBuilderExample { - public void DemonstrateMessageBuilder() + private readonly ICommandBus _commandBus; + private readonly ILogger _logger; + + public MessageBuilderExample( + ICommandBus commandBus, + ILogger logger) + { + _commandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task DemonstrateMessageBuilderAsync() { + // Generate a unique account ID + var accountId = Guid.NewGuid(); + // Create a new command that starts a correlation chain var createCommand = MessageBuilder.New(() => new CreateAccount( - Guid.NewGuid(), + accountId, "ACC-123", "John Doe" )); - // Create a command from an existing command (maintains correlation) + // Log correlation information + _logger.LogInformation("Starting correlation chain with ID {CorrelationId}", + createCommand.CorrelationId); + + // Send the command + await _commandBus.SendAsync(createCommand); + + // Create a new command that continues the correlation chain var depositCommand = MessageBuilder.From(createCommand, () => new DepositFunds( - ((CreateAccount)createCommand).AccountId, - 1000 + accountId, + 100.00m )); - // Create another command in the same chain + // Send the command + await _commandBus.SendAsync(depositCommand); + + // Create another command in the chain var withdrawCommand = MessageBuilder.From(depositCommand, () => new WithdrawFunds( - ((DepositFunds)depositCommand).AccountId, - 500 + accountId, + 50.00m )); - // Correlation IDs are maintained throughout the chain - Console.WriteLine($"Create Command Correlation ID: {createCommand.CorrelationId}"); - Console.WriteLine($"Deposit Command Correlation ID: {depositCommand.CorrelationId}"); - Console.WriteLine($"Withdraw Command Correlation ID: {withdrawCommand.CorrelationId}"); + // Send the command + await _commandBus.SendAsync(withdrawCommand); - // Causation IDs form a chain - Console.WriteLine($"Create Command Causation ID: {createCommand.CausationId}"); - Console.WriteLine($"Deposit Command Causation ID: {depositCommand.CausationId}"); - Console.WriteLine($"Withdraw Command Causation ID: {withdrawCommand.CausationId}"); + // Log correlation information + _logger.LogInformation("Command chain completed with correlation ID {CorrelationId}", + createCommand.CorrelationId); + + // Demonstrate correlation ID tracking + _logger.LogDebug("Correlation chain details:"); + _logger.LogDebug("Create Command: CorrelationId={CorrelationId}, CausationId={CausationId}", + createCommand.CorrelationId, createCommand.CausationId); + _logger.LogDebug("Deposit Command: CorrelationId={CorrelationId}, CausationId={CausationId}", + depositCommand.CorrelationId, depositCommand.CausationId); + _logger.LogDebug("Withdraw Command: CorrelationId={CorrelationId}, CausationId={CausationId}", + withdrawCommand.CorrelationId, withdrawCommand.CausationId); } } } ``` -## Registering Command Handlers +## Registering Command Handlers with Dependency Injection ```csharp using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using ReactiveDomain.Foundation; using ReactiveDomain.Messaging; using ReactiveDomain.Messaging.Bus; @@ -264,23 +396,161 @@ using MyApp.Domain.Handlers; namespace MyApp.Infrastructure { - public class CommandBusSetup + public static class CommandHandlerRegistration { - public ICommandBus ConfigureCommandBus(IRepository repository) + public static IServiceCollection AddCommandHandlers(this IServiceCollection services) { - // Create a command bus - var commandBus = new CommandBus(); - - // Create command handlers - var accountCommandHandler = new AccountCommandHandler(repository); + // Register command bus + services.AddSingleton(); // Register command handlers - commandBus.Subscribe(accountCommandHandler); - commandBus.Subscribe(accountCommandHandler); - commandBus.Subscribe(accountCommandHandler); - commandBus.Subscribe(accountCommandHandler); + services.AddScoped(); - return commandBus; + // Register handler registrations + services.AddSingleton(); + + return services; + } + } + + public class AccountCommandHandlerRegistration : ICommandHandlerRegistration + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public AccountCommandHandlerRegistration( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public void RegisterHandlers(ICommandBus commandBus) + { + _logger.LogInformation("Registering account command handlers"); + + // Use factory method to create handler with scoped lifetime + commandBus.Subscribe(cmd => { + using var scope = _serviceProvider.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + return handler.HandleAsync(cmd); + }); + + commandBus.Subscribe(cmd => { + using var scope = _serviceProvider.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + return handler.HandleAsync(cmd); + }); + + commandBus.Subscribe(cmd => { + using var scope = _serviceProvider.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + return handler.HandleAsync(cmd); + }); + + commandBus.Subscribe(cmd => { + using var scope = _serviceProvider.CreateScope(); + var handler = scope.ServiceProvider.GetRequiredService(); + return handler.HandleAsync(cmd); + }); + + _logger.LogInformation("Account command handlers registered successfully"); + } + } + + // Interface for command handler registration + public interface ICommandHandlerRegistration + { + void RegisterHandlers(ICommandBus commandBus); + } +} +``` + +## Application Startup with Dependency Injection + +```csharp +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ReactiveDomain.Foundation; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; +using MyApp.Domain; +using MyApp.Infrastructure; + +namespace MyApp +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + // Register event store + services.AddSingleton(provider => + { + var logger = provider.GetRequiredService>(); + logger.LogInformation("Configuring event store"); + + var connectionString = hostContext.Configuration.GetConnectionString("EventStore"); + return new EventStore(connectionString); + }); + + // Register repository + services.AddSingleton>(provider => + { + var eventStore = provider.GetRequiredService(); + return new Repository(eventStore); + }); + + // Register command handlers + services.AddCommandHandlers(); + + // Register command bus initialization + services.AddHostedService(); + }); + } + + public class CommandBusInitializer : IHostedService + { + private readonly ICommandBus _commandBus; + private readonly IEnumerable _registrations; + private readonly ILogger _logger; + + public CommandBusInitializer( + ICommandBus commandBus, + IEnumerable registrations, + ILogger logger) + { + _commandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus)); + _registrations = registrations ?? throw new ArgumentNullException(nameof(registrations)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Initializing command bus"); + + foreach (var registration in _registrations) + { + registration.RegisterHandlers(_commandBus); + } + + _logger.LogInformation("Command bus initialized successfully"); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; } } } @@ -290,50 +560,148 @@ namespace MyApp.Infrastructure ```csharp using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; using MyApp.Domain.Commands; -namespace MyApp.Application +namespace MyApp.Examples { public class AccountService { private readonly ICommandBus _commandBus; + private readonly ILogger _logger; - public AccountService(ICommandBus commandBus) + public AccountService( + ICommandBus commandBus, + ILogger logger) { _commandBus = commandBus ?? throw new ArgumentNullException(nameof(commandBus)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public Guid CreateAccount(string accountNumber, string customerName) + public async Task CreateNewAccountAsync(string accountNumber, string customerName) { + // Generate a unique ID for the account var accountId = Guid.NewGuid(); - var command = new CreateAccount(accountId, accountNumber, customerName); - _commandBus.Send(command); + _logger.LogInformation("Creating new account for customer {CustomerName}", customerName); + + // Create a new account command + var createCommand = new CreateAccount( + accountId, + accountNumber, + customerName + ); + + // Send the command and await the result + await _commandBus.SendAsync(createCommand); + + _logger.LogInformation("Account {AccountId} created successfully", accountId); return accountId; } - public void DepositFunds(Guid accountId, decimal amount) - { - var command = new DepositFunds(accountId, amount); - _commandBus.Send(command); + public async Task DepositFundsAsync(Guid accountId, decimal amount, ICorrelatedMessage sourceMessage = null) + { + _logger.LogInformation("Depositing {Amount} to account {AccountId}", amount, accountId); + + // Create deposit command with correlation if source message provided + DepositFunds depositCommand; + + if (sourceMessage != null) + { + depositCommand = new DepositFunds(accountId, amount, sourceMessage); + } + else + { + depositCommand = new DepositFunds(accountId, amount); + } + + // Send the command and await the result + await _commandBus.SendAsync(depositCommand); + + _logger.LogInformation("Successfully deposited {Amount} to account {AccountId}", amount, accountId); } - public void WithdrawFunds(Guid accountId, decimal amount) - { - var command = new WithdrawFunds(accountId, amount); - _commandBus.Send(command); + public async Task WithdrawFundsAsync(Guid accountId, decimal amount, ICorrelatedMessage sourceMessage = null) + { + _logger.LogInformation("Withdrawing {Amount} from account {AccountId}", amount, accountId); + + try + { + // Create withdraw command with correlation if source message provided + WithdrawFunds withdrawCommand; + + if (sourceMessage != null) + { + withdrawCommand = new WithdrawFunds(accountId, amount, sourceMessage); + } + else + { + withdrawCommand = new WithdrawFunds(accountId, amount); + } + + // Send the command and await the result + await _commandBus.SendAsync(withdrawCommand); + + _logger.LogInformation("Successfully withdrew {Amount} from account {AccountId}", amount, accountId); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Business rule violation when withdrawing from account {AccountId}", accountId); + throw; // Rethrow for proper handling upstream + } } - public void CloseAccount(Guid accountId) + public async Task CloseAccountAsync(Guid accountId, ICorrelatedMessage sourceMessage = null) + { + _logger.LogInformation("Closing account {AccountId}", accountId); + + try + { + // Create close command with correlation if source message provided + CloseAccount closeCommand; + + if (sourceMessage != null) + { + closeCommand = new CloseAccount(accountId, sourceMessage); + } + else + { + closeCommand = new CloseAccount(accountId); + } + + // Send the command and await the result + await _commandBus.SendAsync(closeCommand); + + _logger.LogInformation("Successfully closed account {AccountId}", accountId); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Business rule violation when closing account {AccountId}", accountId); + throw; // Rethrow for proper handling upstream + } + } + + // Example of a business operation that uses multiple commands with correlation + public async Task ProcessMonthlyFeeAsync(Guid accountId, decimal feeAmount) { - var command = new CloseAccount(accountId); - _commandBus.Send(command); + _logger.LogInformation("Processing monthly fee of {Amount} for account {AccountId}", + feeAmount, accountId); + + // Create the initial command + var withdrawCommand = new WithdrawFunds(accountId, feeAmount); + + // Send the command and maintain correlation + await _commandBus.SendAsync(withdrawCommand); + + // Log the successful fee processing + _logger.LogInformation("Monthly fee processed successfully for account {AccountId}", accountId); } } } -``` ## Key Concepts @@ -344,42 +712,268 @@ namespace MyApp.Application - Commands contain all the data needed to perform the operation - Commands implement the `ICommand` interface or inherit from the `Command` base class -### Command Handlers +### Command Design + +1. **Strongly Typed Commands**: Commands now inherit from `Command` where `TId` is the type of the aggregate ID. This provides type safety and makes the code more maintainable. + +2. **Immutability**: Commands are immutable data structures with readonly properties, ensuring they cannot be changed after creation. + +3. **Validation at Creation**: Commands validate their parameters at construction time, failing fast if invalid data is provided. + +4. **Timestamps**: Including timestamps in commands provides valuable metadata for auditing and debugging. + +5. **Serialization Attribute**: The `[Serializable]` attribute ensures commands can be properly serialized for storage or transmission across process boundaries. + +### Command Correlation + +1. **Source-Based Correlation**: Commands accept an `ICorrelatedMessage` source in their constructors, automatically propagating correlation and causation IDs. + +2. **MessageBuilder Pattern**: The `MessageBuilder` class simplifies creating correlated message chains, ensuring proper correlation ID propagation. + +3. **Correlation Chain**: Correlation IDs remain constant throughout a business transaction, while causation IDs form a chain showing the sequence of messages. + +### Command Handling + +1. **Asynchronous Processing**: Command handlers use `async/await` with `Task` return types for non-blocking I/O operations. + +2. **Strongly Typed Repository**: The repository is now generic (`IRepository`), providing type safety and eliminating casting. + +3. **Comprehensive Error Handling**: Command handlers include structured exception handling with specific handling for different error types. + +4. **Logging**: Extensive logging provides visibility into the command handling process, including information, warnings, and errors. + +### Dependency Injection + +1. **Service Registration**: Command handlers and dependencies are registered with the DI container, promoting loose coupling. -- Command handlers implement the `IHandleCommand` interface -- They load the appropriate aggregate from the repository -- They invoke the appropriate method on the aggregate -- They save the aggregate back to the repository +2. **Scoped Lifetime**: Command handlers use scoped lifetime, ensuring proper resource management and isolation. + +3. **Factory Pattern**: Command handler factories create handlers with the appropriate scope when needed. + +4. **Hosted Service**: The `CommandBusInitializer` runs at application startup to register all command handlers. + +### Application Services + +1. **Task-Based Interface**: Application services expose `async` methods returning `Task` or `Task`, allowing for non-blocking calls. + +2. **Correlation Support**: Services accept optional `ICorrelatedMessage` parameters to maintain correlation across operations. + +3. **Business Operations**: Higher-level methods encapsulate business operations that may involve multiple commands. + +4. **Structured Logging**: Services use structured logging with semantic logging patterns for better observability. + +### Best Practices + +1. **Fail Fast**: Validate inputs early and throw appropriate exceptions rather than allowing invalid state. + +2. **Separation of Concerns**: Commands, command handlers, and application services each have clear, distinct responsibilities. + +3. **Explicit Dependencies**: Dependencies are explicitly declared and injected, making the code more testable and maintainable. + +4. **Consistent Error Handling**: Exceptions are caught, logged, and rethrown at appropriate levels of the stack. + +5. **Async All the Way**: Asynchronous programming patterns are used consistently throughout the codebase. + +6. **Structured Logging**: Logging uses structured formats with semantic information rather than string concatenation. + +7. **Strong Typing**: Generic type parameters and strong typing are used throughout to catch errors at compile time rather than runtime. ### Correlation and Causation -- Commands can be correlated using `MessageBuilder` -- `MessageBuilder.New()` starts a new correlation chain -- `MessageBuilder.From()` continues an existing correlation chain +- **Correlation ID**: A unique identifier that remains constant throughout a business transaction or user request, allowing you to trace all related messages across system boundaries. + +- **Causation ID**: A unique identifier that creates a direct link between a message and the message that caused it, forming a chain of causality. + +- **Message ID**: Each message has its own unique identifier that can be used as the causation ID for subsequent messages. + +- **Correlation Flow**: + 1. The first message in a chain generates a new correlation ID (typically a GUID) + 2. Subsequent messages inherit the same correlation ID + 3. Each message's ID becomes the causation ID for the next message + 4. This creates a traceable path through the system + +- **ICorrelatedMessage Interface**: All commands and events implement this interface, which provides: + - `MessageId`: Unique identifier for this specific message + - `CorrelationId`: Identifier linking related messages in a transaction + - `CausationId`: Identifier of the message that caused this one + +- **MessageBuilder Class**: A utility that simplifies creating properly correlated message chains: + - `MessageBuilder.New()`: Creates a new message with fresh correlation information + - `MessageBuilder.From()`: Creates a new message that continues an existing correlation chain + +- **Benefits**: + - Distributed tracing across microservices + - Debugging complex workflows + - Auditing capabilities + - Performance monitoring + - Root cause analysis - Correlation IDs track related messages across the system ### Command Bus -- The command bus routes commands to their handlers -- Handlers are registered with the bus using the `Subscribe` method -- Commands are sent to the bus using the `Send` method +- **Purpose**: The command bus routes commands to their appropriate handlers, decoupling the command sender from the command handler implementation. + +- **Asynchronous API**: Modern command buses support asynchronous processing with `SendAsync` methods returning `Task` or `Task` for command results. + +- **Dependency Injection Integration**: Command buses are registered with the DI container and handlers are resolved at runtime. + +- **Scoped Handlers**: Command handlers are typically created with scoped lifetime to ensure proper resource management and isolation. + +- **Factory Pattern**: Handler factories create handlers with the appropriate scope when needed, allowing for proper disposal of resources. + +- **Middleware Support**: Command buses can support middleware for cross-cutting concerns such as: + - Logging + - Validation + - Authentication/Authorization + - Transaction management + - Exception handling + - Retry policies + - Circuit breakers + - Performance monitoring + +- **Error Handling**: Command buses provide structured error handling with specific handling for different error types. + +- **Correlation Propagation**: Command buses automatically propagate correlation and causation IDs through the message chain. + +- **Command Results**: Modern command buses can return results from command execution, useful for returning generated IDs or other information. ## Best Practices -1. **Single Responsibility**: Each command should represent a single operation -2. **Immutable Commands**: Make all command properties read-only -3. **Validation**: Validate commands before processing them -4. **Error Handling**: Implement proper error handling in command handlers -5. **Correlation**: Use `MessageBuilder` to maintain correlation chains -6. **Command Naming**: Use imperative verb phrases for command names +### Command Design + +1. **Strong Typing**: Use generic `Command` base classes to ensure type safety and make the code more maintainable. + +2. **Immutability**: Design commands as immutable data structures with readonly properties to prevent unexpected changes. + +3. **Validation at Creation**: Validate command parameters at construction time to fail fast if invalid data is provided. + +4. **Include Timestamps**: Add timestamps to commands for auditing, debugging, and time-based business rules. + +5. **Single Responsibility**: Each command should represent a single operation with a clear intent. + +6. **Serialization Support**: Ensure commands can be properly serialized for storage or transmission across process boundaries. + +### Command Handling + +1. **Asynchronous Processing**: Use `async/await` with `Task` return types for non-blocking I/O operations. + +2. **Comprehensive Error Handling**: Implement structured exception handling with specific handling for different error types. + +3. **Structured Logging**: Use structured logging with semantic information rather than string concatenation. + +4. **Separate Command Handling from Business Logic**: Command handlers should delegate to the domain model for business logic implementation. + +5. **Transactional Integrity**: Ensure that command handling is atomic - either all changes are applied or none. + +6. **Idempotency**: Design command handlers to be idempotent where possible, allowing safe retries. + +### Dependency Management + +1. **Explicit Dependencies**: Declare dependencies explicitly and inject them, making the code more testable and maintainable. + +2. **Appropriate Lifetimes**: Use the correct lifetime scope for each dependency (singleton, scoped, transient). + +3. **Factory Pattern**: Use factory methods when dependencies need to be created with specific scopes or configurations. + +4. **Interface-Based Design**: Program to interfaces rather than concrete implementations to support testability and flexibility. + +### Error Handling + +1. **Domain Exceptions**: Create specific exception types for domain rule violations to distinguish them from technical errors. + +2. **Fail Fast**: Validate inputs early and throw appropriate exceptions rather than allowing invalid state. + +3. **Consistent Approach**: Handle exceptions consistently across all command handlers. + +4. **Error Logging**: Log errors with appropriate context to aid debugging and monitoring. + +5. **Don't Swallow Exceptions**: Avoid catching exceptions without proper handling or re-throwing. + +### Performance Considerations + +1. **Async All the Way**: Use asynchronous programming patterns consistently throughout the codebase. + +2. **Minimize Database Calls**: Structure command handlers to minimize the number of database operations. + +3. **Consider Batching**: For high-volume scenarios, consider batching commands or using bulk operations. + +4. **Optimize Repository Access**: Use efficient repository implementations with appropriate caching strategies. + +5. **Monitor Performance**: Implement performance monitoring to identify bottlenecks. + +### Testing + +1. **Unit Test Command Validation**: Test that commands properly validate their inputs. + +2. **Unit Test Command Handlers**: Test command handlers with mocked dependencies. + +3. **Integration Test Command Flow**: Test the full command flow from sending to handling. + +4. **Test Error Scenarios**: Ensure error handling works as expected by testing failure scenarios. + +5. **Test Correlation**: Verify that correlation IDs are properly propagated. + +### Security + +1. **Authorization**: Implement proper authorization checks before processing commands. + +2. **Input Validation**: Validate all command inputs to prevent injection attacks. + +3. **Audit Logging**: Log command execution for audit purposes. + +4. **Principle of Least Privilege**: Ensure command handlers only have access to the resources they need. + +5. **Secure Communication**: Use secure channels for transmitting commands between system boundaries. ## Common Pitfalls -1. **Complex Commands**: Avoid commands that do too many things -2. **Missing Validation**: Ensure all commands are validated before processing -3. **Ignoring Errors**: Handle errors properly in command handlers -4. **Breaking Correlation**: Ensure correlation information is maintained throughout the system -5. **Business Logic in Handlers**: Keep business logic in aggregates, not in command handlers +### Design Pitfalls + +1. **Complex Commands**: Creating commands that do too many things or change multiple aggregates, violating the single responsibility principle. + +2. **Anemic Commands**: Commands that lack proper validation or don't include all necessary data, requiring additional lookups. + +3. **Mutable Commands**: Allowing commands to be modified after creation, leading to inconsistent state and race conditions. + +4. **Missing Strong Typing**: Using primitive types for IDs instead of strongly-typed IDs, reducing type safety and increasing the chance of errors. + +5. **Inconsistent Naming**: Using inconsistent naming conventions for commands, making the codebase harder to understand and maintain. + +### Implementation Pitfalls + +1. **Ignoring Correlation**: Failing to maintain correlation chains, making it difficult to trace related operations across the system. + +2. **Synchronous Processing**: Blocking I/O operations in command handlers, reducing system throughput and responsiveness. + +3. **Poor Error Handling**: Catching exceptions without proper logging or re-throwing, hiding errors and making debugging difficult. + +4. **Direct Repository Access**: Bypassing the repository pattern and accessing the data store directly, breaking encapsulation. + +5. **Forgetting to Save**: Modifying aggregates but forgetting to save them to the repository, losing changes. + +### Architectural Pitfalls + +1. **Tight Coupling**: Directly instantiating dependencies instead of using dependency injection, making the code harder to test and maintain. + +2. **Business Logic in Handlers**: Implementing business logic in command handlers instead of in the domain model, violating separation of concerns. + +3. **Inconsistent Async Patterns**: Mixing synchronous and asynchronous code, leading to potential deadlocks and performance issues. + +4. **Missing Validation**: Not validating command data, allowing invalid state to propagate through the system. + +5. **Ignoring Idempotency**: Not designing for idempotency, making it unsafe to retry failed operations. + +### Performance Pitfalls + +1. **N+1 Query Problem**: Loading related entities one by one instead of in a single query, causing performance issues. + +2. **Excessive Logging**: Logging too much information or at too high a level, impacting performance and generating noise. + +3. **Inefficient Repository Implementation**: Using inefficient repository implementations without proper caching or optimization. + +4. **Chatty Interfaces**: Creating many small commands instead of batching related operations, increasing network overhead. + +5. **Blocking Threads**: Using `.Result` or `.Wait()` on tasks, potentially causing thread pool starvation or deadlocks. --- diff --git a/docs/code-examples/implementing-projections.md b/docs/code-examples/implementing-projections.md index 13c9ea58..6083d533 100644 --- a/docs/code-examples/implementing-projections.md +++ b/docs/code-examples/implementing-projections.md @@ -2,29 +2,72 @@ [← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) -This example demonstrates how to implement projections in Reactive Domain to create read models from event streams. +This example demonstrates how to implement projections in Reactive Domain to create read models from event streams, following current best practices. ## Read Model Base Class ```csharp using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace MyApp.ReadModels { - public abstract class ReadModelBase + /// + /// Base class for all read models providing common properties and behaviors + /// + public abstract class ReadModelBase { - public Guid Id { get; } + /// + /// Unique identifier for the read model + /// + [Key] + public TId Id { get; protected set; } + + /// + /// Optimistic concurrency version + /// + [ConcurrencyCheck] public long Version { get; protected set; } - protected ReadModelBase(Guid id) + /// + /// When the read model was created + /// + public DateTime CreatedAt { get; protected set; } + + /// + /// When the read model was last updated + /// + public DateTime? LastUpdatedAt { get; protected set; } + + /// + /// Constructor for new read models + /// + /// The unique identifier + protected ReadModelBase(TId id) { + if (id == null) throw new ArgumentNullException(nameof(id)); + Id = id; Version = 0; + CreatedAt = DateTime.UtcNow; + } + + /// + /// Protected constructor for ORM + /// + protected ReadModelBase() + { + // Required by some ORMs } + /// + /// Increments the version and updates the LastUpdatedAt timestamp + /// protected void IncrementVersion() { Version++; + LastUpdatedAt = DateTime.UtcNow; } } } @@ -34,46 +77,142 @@ namespace MyApp.ReadModels ```csharp using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace MyApp.ReadModels { - public class AccountSummary : ReadModelBase + /// + /// Read model representing a summary view of an account + /// + [Table("AccountSummaries")] + public class AccountSummary : ReadModelBase { + /// + /// The account number (business identifier) + /// + [Required] + [MaxLength(50)] public string AccountNumber { get; private set; } + + /// + /// The name of the customer who owns the account + /// + [Required] + [MaxLength(100)] public string CustomerName { get; private set; } + + /// + /// The current balance of the account + /// + [Column(TypeName = "decimal(18,2)")] public decimal Balance { get; private set; } + + /// + /// Whether the account is closed + /// public bool IsClosed { get; private set; } - public DateTime CreatedAt { get; private set; } - public DateTime? LastUpdatedAt { get; private set; } + /// + /// The date of the last transaction + /// + public DateTime? LastTransactionDate { get; private set; } + + /// + /// The total number of transactions + /// + public int TransactionCount { get; private set; } + + /// + /// Creates a new account summary + /// + /// The unique identifier of the account public AccountSummary(Guid id) : base(id) { - CreatedAt = DateTime.UtcNow; + TransactionCount = 0; + Balance = 0m; + IsClosed = false; + } + + /// + /// Protected constructor for ORM + /// + protected AccountSummary() : base() + { + // Required by some ORMs } - public void Update(string accountNumber, string customerName, decimal balance, bool isClosed) + /// + /// Updates the account details + /// + public void Update(string accountNumber, string customerName) { + if (string.IsNullOrWhiteSpace(accountNumber)) + throw new ArgumentException("Account number cannot be empty", nameof(accountNumber)); + + if (string.IsNullOrWhiteSpace(customerName)) + throw new ArgumentException("Customer name cannot be empty", nameof(customerName)); + AccountNumber = accountNumber; CustomerName = customerName; - Balance = balance; - IsClosed = isClosed; - LastUpdatedAt = DateTime.UtcNow; IncrementVersion(); } - public void UpdateBalance(decimal newBalance) + /// + /// Records a deposit to the account + /// + /// The amount deposited + /// The date of the transaction + public void RecordDeposit(decimal amount, DateTime transactionDate) { - Balance = newBalance; - LastUpdatedAt = DateTime.UtcNow; + if (amount <= 0) + throw new ArgumentException("Deposit amount must be positive", nameof(amount)); + + if (IsClosed) + throw new InvalidOperationException("Cannot deposit to a closed account"); + + Balance += amount; + TransactionCount++; + LastTransactionDate = transactionDate; + + IncrementVersion(); + } + + /// + /// Records a withdrawal from the account + /// + /// The amount withdrawn + /// The date of the transaction + public void RecordWithdrawal(decimal amount, DateTime transactionDate) + { + if (amount <= 0) + throw new ArgumentException("Withdrawal amount must be positive", nameof(amount)); + + if (IsClosed) + throw new InvalidOperationException("Cannot withdraw from a closed account"); + + if (Balance < amount) + throw new InvalidOperationException("Insufficient funds"); + + Balance -= amount; + TransactionCount++; + LastTransactionDate = transactionDate; IncrementVersion(); } - public void MarkAsClosed() + /// + /// Marks the account as closed + /// + /// The date the account was closed + public void MarkAsClosed(DateTime closureDate) { + if (IsClosed) + return; // Already closed + IsClosed = true; - LastUpdatedAt = DateTime.UtcNow; + LastTransactionDate = closureDate; IncrementVersion(); } @@ -86,37 +225,103 @@ namespace MyApp.ReadModels ```csharp using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using System.Linq; namespace MyApp.ReadModels { - public class TransactionHistory : ReadModelBase + /// + /// Read model representing the transaction history for an account + /// + [Table("TransactionHistories")] + public class TransactionHistory : ReadModelBase { + // Use backing field for EF Core to properly track the collection private readonly List _transactions = new List(); - public IReadOnlyList Transactions => _transactions.AsReadOnly(); + /// + /// All transactions for this account + /// + public virtual IReadOnlyCollection Transactions => _transactions.AsReadOnly(); + + /// + /// The current balance calculated from all transactions + /// + [NotMapped] // This is a calculated property, not stored in the database public decimal CurrentBalance => _transactions.Sum(t => t.Amount); + /// + /// The total number of transactions + /// + public int TransactionCount => _transactions.Count; + + /// + /// The date of the most recent transaction + /// + public DateTime? LastTransactionDate => _transactions.Any() ? + _transactions.Max(t => t.Timestamp) : null; + + /// + /// Creates a new transaction history for an account + /// + /// The account ID public TransactionHistory(Guid id) : base(id) { } - public void AddTransaction(string type, decimal amount, string description, DateTime timestamp) + /// + /// Protected constructor for ORM + /// + protected TransactionHistory() : base() { + // Required by some ORMs + } + + /// + /// Adds a new transaction to the history + /// + /// Unique ID for the transaction + /// Type of transaction (e.g., DEPOSIT, WITHDRAWAL) + /// Amount of the transaction (positive for deposits, negative for withdrawals) + /// Description of the transaction + /// When the transaction occurred + /// Optional correlation ID for tracking related operations + public void AddTransaction( + Guid transactionId, + string type, + decimal amount, + string description, + DateTime timestamp, + Guid? correlationId = null) + { + if (string.IsNullOrWhiteSpace(type)) + throw new ArgumentException("Transaction type cannot be empty", nameof(type)); + + if (string.IsNullOrWhiteSpace(description)) + throw new ArgumentException("Transaction description cannot be empty", nameof(description)); + var transaction = new Transaction { - Id = Guid.NewGuid(), + Id = transactionId, AccountId = Id, Type = type, Amount = amount, Description = description, - Timestamp = timestamp + Timestamp = timestamp, + CorrelationId = correlationId }; _transactions.Add(transaction); IncrementVersion(); } + /// + /// Retrieves transactions within a specified date range + /// + /// Start date (inclusive) + /// End date (inclusive) + /// Transactions ordered by timestamp descending public IEnumerable GetTransactionsByDateRange(DateTime start, DateTime end) { return _transactions @@ -124,238 +329,736 @@ namespace MyApp.ReadModels .OrderByDescending(t => t.Timestamp); } + /// + /// Retrieves transactions of a specific type + /// + /// Transaction type (e.g., DEPOSIT, WITHDRAWAL) + /// Transactions ordered by timestamp descending public IEnumerable GetTransactionsByType(string type) { return _transactions .Where(t => t.Type == type) .OrderByDescending(t => t.Timestamp); } + + /// + /// Gets the most recent transactions + /// + /// Number of transactions to retrieve + /// Most recent transactions ordered by timestamp descending + public IEnumerable GetRecentTransactions(int count) + { + return _transactions + .OrderByDescending(t => t.Timestamp) + .Take(count); + } + + /// + /// Gets transactions by correlation ID + /// + /// The correlation ID to search for + /// Correlated transactions ordered by timestamp + public IEnumerable GetTransactionsByCorrelationId(Guid correlationId) + { + return _transactions + .Where(t => t.CorrelationId == correlationId) + .OrderBy(t => t.Timestamp); + } } + /// + /// Represents a single financial transaction + /// + [Table("Transactions")] public class Transaction { + /// + /// Unique identifier for the transaction + /// + [Key] public Guid Id { get; set; } + + /// + /// The account this transaction belongs to + /// public Guid AccountId { get; set; } + + /// + /// Type of transaction (e.g., DEPOSIT, WITHDRAWAL) + /// + [Required] + [MaxLength(50)] public string Type { get; set; } + + /// + /// Amount of the transaction (positive for deposits, negative for withdrawals) + /// + [Column(TypeName = "decimal(18,2)")] public decimal Amount { get; set; } + + /// + /// Description of the transaction + /// + [Required] + [MaxLength(500)] public string Description { get; set; } + + /// + /// When the transaction occurred + /// public DateTime Timestamp { get; set; } + + /// + /// Optional correlation ID for tracking related operations + /// + public Guid? CorrelationId { get; set; } + + /// + /// Reference to the transaction history this transaction belongs to + /// + [ForeignKey("AccountId")] + public virtual TransactionHistory TransactionHistory { get; set; } } } -``` -## Read Model Repository Interface +## Read Model Repository Interfaces ```csharp using System; using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; using System.Threading.Tasks; namespace MyApp.ReadModels { - public interface IReadModelRepository where T : ReadModelBase + /// + /// Base interface for read model repositories with async operations + /// + /// Type of read model + /// Type of read model ID + public interface IReadModelRepository where T : ReadModelBase { - T GetById(Guid id); - void Save(T item); + /// + /// Gets a read model by its ID + /// + /// The ID of the read model + /// Cancellation token + /// The read model or null if not found + Task GetByIdAsync(TId id, CancellationToken cancellationToken = default); + + /// + /// Saves a read model + /// + /// The read model to save + /// Cancellation token + /// Task representing the asynchronous operation + Task SaveAsync(T item, CancellationToken cancellationToken = default); + + /// + /// Deletes a read model + /// + /// The read model to delete + /// Cancellation token + /// Task representing the asynchronous operation + Task DeleteAsync(T item, CancellationToken cancellationToken = default); + + /// + /// Checks if a read model with the specified ID exists + /// + /// The ID to check + /// Cancellation token + /// True if the read model exists, false otherwise + Task ExistsAsync(TId id, CancellationToken cancellationToken = default); } - public interface IQueryableReadModelRepository : IReadModelRepository where T : ReadModelBase + /// + /// Interface for queryable read model repositories + /// + /// Type of read model + /// Type of read model ID + public interface IQueryableReadModelRepository : IReadModelRepository where T : ReadModelBase { - IEnumerable Query(Func predicate); + /// + /// Queries read models using an expression + /// + /// The filter expression + /// Cancellation token + /// Matching read models + Task> QueryAsync(Expression> predicate, CancellationToken cancellationToken = default); + + /// + /// Gets all read models + /// + /// Cancellation token + /// All read models + Task> GetAllAsync(CancellationToken cancellationToken = default); + + /// + /// Gets a paged result of read models + /// + /// Number of items to skip + /// Number of items to take + /// Cancellation token + /// Paged read models + Task> GetPagedAsync(int skip, int take, CancellationToken cancellationToken = default); } -} -``` - -## In-Memory Read Model Repository - -```csharp -using System; -using System.Collections.Generic; -using System.Linq; - -namespace MyApp.ReadModels -{ - public class InMemoryReadModelRepository : IQueryableReadModelRepository where T : ReadModelBase + + /// + /// In-memory implementation for testing and development + /// + /// Type of read model + /// Type of read model ID + public class InMemoryReadModelRepository : IQueryableReadModelRepository + where T : ReadModelBase + where TId : notnull { - private readonly Dictionary _items = new Dictionary(); + private readonly Dictionary _items = new Dictionary(); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); - public T GetById(Guid id) + public async Task GetByIdAsync(TId id, CancellationToken cancellationToken = default) { - if (_items.TryGetValue(id, out var item)) + await _lock.WaitAsync(cancellationToken); + try { - return item; + cancellationToken.ThrowIfCancellationRequested(); + if (_items.TryGetValue(id, out var item)) + { + return item; + } + + return null; } + finally + { + _lock.Release(); + } + } + + public async Task SaveAsync(T item, CancellationToken cancellationToken = default) + { + if (item == null) throw new ArgumentNullException(nameof(item)); - return null; + await _lock.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + _items[item.Id] = item; + } + finally + { + _lock.Release(); + } + } + + public async Task DeleteAsync(T item, CancellationToken cancellationToken = default) + { + if (item == null) throw new ArgumentNullException(nameof(item)); + + await _lock.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (_items.ContainsKey(item.Id)) + { + _items.Remove(item.Id); + } + } + finally + { + _lock.Release(); + } + } + + public async Task ExistsAsync(TId id, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + return _items.ContainsKey(id); + } + finally + { + _lock.Release(); + } + } + + public async Task> QueryAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + await _lock.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var compiledPredicate = predicate.Compile(); + return _items.Values.Where(compiledPredicate).ToList(); + } + finally + { + _lock.Release(); + } } - public void Save(T item) + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - _items[item.Id] = item; + await _lock.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + return _items.Values.ToList(); + } + finally + { + _lock.Release(); + } } - public IEnumerable Query(Func predicate) + public async Task> GetPagedAsync(int skip, int take, CancellationToken cancellationToken = default) { - return _items.Values.Where(predicate); + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip), "Skip must be non-negative"); + if (take <= 0) throw new ArgumentOutOfRangeException(nameof(take), "Take must be positive"); + + await _lock.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + return _items.Values.Skip(skip).Take(take).ToList(); + } + finally + { + _lock.Release(); + } + } + + public void Dispose() + { + _lock?.Dispose(); } } } -``` ## SQL Read Model Repository - -```csharp -using System; using System.Collections.Generic; using System.Data; -using System.Data.SqlClient; using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; using Dapper; namespace MyApp.ReadModels { - public class SqlAccountSummaryRepository : IQueryableReadModelRepository + /// + /// SQL Server implementation of the account summary repository + /// + public class SqlAccountSummaryRepository : IQueryableReadModelRepository { private readonly string _connectionString; + private readonly ILogger _logger; - public SqlAccountSummaryRepository(string connectionString) + /// + /// Creates a new SQL repository for account summaries + /// + /// Database connection string + /// Logger instance + public SqlAccountSummaryRepository( + string connectionString, + ILogger logger) { _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public AccountSummary GetById(Guid id) + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { - using (var connection = new SqlConnection(_connectionString)) + _logger.LogDebug("Getting account summary with ID {AccountId}", id); + + try { - connection.Open(); + await using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); var sql = @" SELECT Id, AccountNumber, CustomerName, Balance, IsClosed, - CreatedAt, LastUpdatedAt, Version - FROM AccountSummaries + TransactionCount, LastTransactionDate, CreatedAt, LastUpdatedAt, Version + FROM AccountSummaries WHERE Id = @Id"; - - var account = connection.QuerySingleOrDefault(sql, new { Id = id }); + + var account = await connection.QuerySingleOrDefaultAsync( + new CommandDefinition(sql, new { Id = id }, cancellationToken: cancellationToken)); if (account == null) { + _logger.LogDebug("Account summary with ID {AccountId} not found", id); return null; } - return MapToAccountSummary(account); + var result = MapToAccountSummary(account); + _logger.LogDebug("Successfully retrieved account summary with ID {AccountId}", id); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting account summary with ID {AccountId}", id); + throw; } } - public void Save(AccountSummary item) + /// + public async Task SaveAsync(AccountSummary item, CancellationToken cancellationToken = default) { - using (var connection = new SqlConnection(_connectionString)) + if (item == null) throw new ArgumentNullException(nameof(item)); + + _logger.LogDebug("Saving account summary with ID {AccountId}", item.Id); + + try { - connection.Open(); + await using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); - var existingSql = "SELECT COUNT(1) FROM AccountSummaries WHERE Id = @Id"; - var exists = connection.ExecuteScalar(existingSql, new { Id = item.Id }) > 0; + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); - if (exists) + try { - var updateSql = @" - UPDATE AccountSummaries - SET AccountNumber = @AccountNumber, - CustomerName = @CustomerName, - Balance = @Balance, - IsClosed = @IsClosed, - LastUpdatedAt = @LastUpdatedAt, - Version = @Version - WHERE Id = @Id"; + var existingSql = "SELECT COUNT(1) FROM AccountSummaries WHERE Id = @Id"; + var exists = await connection.ExecuteScalarAsync( + new CommandDefinition(existingSql, new { Id = item.Id }, transaction, cancellationToken: cancellationToken)) > 0; + + if (exists) + { + // Check for concurrency conflicts + var versionSql = "SELECT Version FROM AccountSummaries WHERE Id = @Id"; + var currentVersion = await connection.ExecuteScalarAsync( + new CommandDefinition(versionSql, new { Id = item.Id }, transaction, cancellationToken: cancellationToken)); + + if (currentVersion != item.Version) + { + throw new DbUpdateConcurrencyException( + $"Concurrency conflict detected for account summary with ID {item.Id}. " + + $"Current version: {currentVersion}, Attempted update version: {item.Version}"); + } + + var updateSql = @" + UPDATE AccountSummaries + SET AccountNumber = @AccountNumber, + CustomerName = @CustomerName, + Balance = @Balance, + IsClosed = @IsClosed, + TransactionCount = @TransactionCount, + LastTransactionDate = @LastTransactionDate, + LastUpdatedAt = @LastUpdatedAt, + Version = @Version + WHERE Id = @Id AND Version = @CurrentVersion"; - connection.Execute(updateSql, new + var updateResult = await connection.ExecuteAsync( + new CommandDefinition(updateSql, new + { + item.Id, + item.AccountNumber, + item.CustomerName, + item.Balance, + item.IsClosed, + item.TransactionCount, + item.LastTransactionDate, + LastUpdatedAt = DateTime.UtcNow, + Version = item.Version + 1, + CurrentVersion = item.Version + }, transaction, cancellationToken: cancellationToken)); + + if (updateResult == 0) + { + throw new DbUpdateConcurrencyException( + $"Concurrency conflict detected for account summary with ID {item.Id}"); + } + } + else { - item.Id, - item.AccountNumber, - item.CustomerName, - item.Balance, - item.IsClosed, - LastUpdatedAt = DateTime.UtcNow, - item.Version - }); + var insertSql = @" + INSERT INTO AccountSummaries ( + Id, AccountNumber, CustomerName, Balance, IsClosed, + TransactionCount, LastTransactionDate, CreatedAt, LastUpdatedAt, Version) + VALUES ( + @Id, @AccountNumber, @CustomerName, @Balance, @IsClosed, + @TransactionCount, @LastTransactionDate, @CreatedAt, @LastUpdatedAt, @Version)"; + + await connection.ExecuteAsync( + new CommandDefinition(insertSql, new + { + item.Id, + item.AccountNumber, + item.CustomerName, + item.Balance, + item.IsClosed, + item.TransactionCount, + item.LastTransactionDate, + item.CreatedAt, + LastUpdatedAt = DateTime.UtcNow, + Version = 1 + }, transaction, cancellationToken: cancellationToken)); + } + + await transaction.CommitAsync(cancellationToken); + _logger.LogDebug("Successfully saved account summary with ID {AccountId}", item.Id); } - else + catch (Exception) { - var insertSql = @" - INSERT INTO AccountSummaries (Id, AccountNumber, CustomerName, Balance, - IsClosed, CreatedAt, LastUpdatedAt, Version) - VALUES (@Id, @AccountNumber, @CustomerName, @Balance, - @IsClosed, @CreatedAt, @LastUpdatedAt, @Version)"; - - connection.Execute(insertSql, new - { - item.Id, - item.AccountNumber, - item.CustomerName, - item.Balance, - item.IsClosed, - CreatedAt = DateTime.UtcNow, - LastUpdatedAt = DateTime.UtcNow, - item.Version - }); + await transaction.RollbackAsync(cancellationToken); + throw; } } + catch (DbUpdateConcurrencyException ex) + { + _logger.LogWarning(ex, "Concurrency conflict saving account summary with ID {AccountId}", item.Id); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving account summary with ID {AccountId}", item.Id); + throw; + } } - public IEnumerable Query(Func predicate) + /// + public async Task DeleteAsync(AccountSummary item, CancellationToken cancellationToken = default) { - using (var connection = new SqlConnection(_connectionString)) + if (item == null) throw new ArgumentNullException(nameof(item)); + + _logger.LogDebug("Deleting account summary with ID {AccountId}", item.Id); + + try { - connection.Open(); + await using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + + var sql = "DELETE FROM AccountSummaries WHERE Id = @Id AND Version = @Version"; + var result = await connection.ExecuteAsync( + new CommandDefinition(sql, new { Id = item.Id, Version = item.Version }, cancellationToken: cancellationToken)); + + if (result == 0) + { + throw new DbUpdateConcurrencyException( + $"Concurrency conflict detected for account summary with ID {item.Id}"); + } + + _logger.LogDebug("Successfully deleted account summary with ID {AccountId}", item.Id); + } + catch (DbUpdateConcurrencyException ex) + { + _logger.LogWarning(ex, "Concurrency conflict deleting account summary with ID {AccountId}", item.Id); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting account summary with ID {AccountId}", item.Id); + throw; + } + } + + /// + public async Task ExistsAsync(Guid id, CancellationToken cancellationToken = default) + { + try + { + await using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + + var sql = "SELECT COUNT(1) FROM AccountSummaries WHERE Id = @Id"; + var count = await connection.ExecuteScalarAsync( + new CommandDefinition(sql, new { Id = id }, cancellationToken: cancellationToken)); + + return count > 0; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking if account summary with ID {AccountId} exists", id); + throw; + } + } + + /// + public async Task> QueryAsync( + Expression> predicate, + CancellationToken cancellationToken = default) + { + // Note: This implementation doesn't translate the expression to SQL + // In a real-world scenario, you would use a library like Dapper.Contrib or EF Core + // that can translate expressions to SQL + _logger.LogWarning("QueryAsync with expression is not optimized and will load all records"); + + try + { + var allItems = await GetAllAsync(cancellationToken); + var compiledPredicate = predicate.Compile(); + return allItems.Where(compiledPredicate).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error querying account summaries with predicate"); + throw; + } + } + + /// + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + try + { + await using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); var sql = @" SELECT Id, AccountNumber, CustomerName, Balance, IsClosed, - CreatedAt, LastUpdatedAt, Version + TransactionCount, LastTransactionDate, CreatedAt, LastUpdatedAt, Version FROM AccountSummaries"; - - var accounts = connection.Query(sql); - return accounts - .Select(MapToAccountSummary) - .Where(predicate); + var accounts = await connection.QueryAsync( + new CommandDefinition(sql, cancellationToken: cancellationToken)); + + return accounts.Select(MapToAccountSummary).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting all account summaries"); + throw; } } - public IEnumerable GetAccountsWithBalanceAbove(decimal threshold) + /// + public async Task> GetPagedAsync( + int skip, int take, CancellationToken cancellationToken = default) { - using (var connection = new SqlConnection(_connectionString)) + if (skip < 0) throw new ArgumentOutOfRangeException(nameof(skip), "Skip must be non-negative"); + if (take <= 0) throw new ArgumentOutOfRangeException(nameof(take), "Take must be positive"); + + try { - connection.Open(); + await using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); var sql = @" SELECT Id, AccountNumber, CustomerName, Balance, IsClosed, - CreatedAt, LastUpdatedAt, Version + TransactionCount, LastTransactionDate, CreatedAt, LastUpdatedAt, Version FROM AccountSummaries - WHERE Balance > @Threshold"; - - var accounts = connection.Query(sql, new { Threshold = threshold }); + ORDER BY CreatedAt DESC + OFFSET @Skip ROWS + FETCH NEXT @Take ROWS ONLY"; + + var accounts = await connection.QueryAsync( + new CommandDefinition(sql, new { Skip = skip, Take = take }, cancellationToken: cancellationToken)); + + return accounts.Select(MapToAccountSummary).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting paged account summaries (skip: {Skip}, take: {Take})", skip, take); + throw; + } + } + + /// + /// Gets accounts with a balance above the specified threshold + /// + /// Minimum balance threshold + /// Cancellation token + /// Matching account summaries + public async Task> GetAccountsWithBalanceAboveAsync( + decimal threshold, CancellationToken cancellationToken = default) + { + try + { + await using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + + var sql = @" + SELECT Id, AccountNumber, CustomerName, Balance, IsClosed, + TransactionCount, LastTransactionDate, CreatedAt, LastUpdatedAt, Version + FROM AccountSummaries + WHERE Balance > @Threshold + ORDER BY Balance DESC"; + + var accounts = await connection.QueryAsync( + new CommandDefinition(sql, new { Threshold = threshold }, cancellationToken: cancellationToken)); + + return accounts.Select(MapToAccountSummary).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting accounts with balance above {Threshold}", threshold); + throw; + } + } + + /// + /// Gets accounts with recent activity + /// + /// Number of days to consider as recent + /// Cancellation token + /// Accounts with recent activity + public async Task> GetAccountsWithRecentActivityAsync( + int days, CancellationToken cancellationToken = default) + { + if (days <= 0) throw new ArgumentOutOfRangeException(nameof(days), "Days must be positive"); + + try + { + await using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(cancellationToken); + + var cutoffDate = DateTime.UtcNow.AddDays(-days); + + var sql = @" + SELECT Id, AccountNumber, CustomerName, Balance, IsClosed, + TransactionCount, LastTransactionDate, CreatedAt, LastUpdatedAt, Version + FROM AccountSummaries + WHERE LastTransactionDate >= @CutoffDate + ORDER BY LastTransactionDate DESC"; + + var accounts = await connection.QueryAsync( + new CommandDefinition(sql, new { CutoffDate = cutoffDate }, cancellationToken: cancellationToken)); - return accounts.Select(MapToAccountSummary); + return accounts.Select(MapToAccountSummary).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting accounts with recent activity in the last {Days} days", days); + throw; } } + /// + /// Maps a DTO to an AccountSummary entity + /// private AccountSummary MapToAccountSummary(AccountSummaryDto dto) { + // In a real implementation, consider using a mapping library like AutoMapper var account = new AccountSummary(dto.Id); - // Use reflection to set private fields + // Use reflection to set private properties var type = typeof(AccountSummary); + var baseType = typeof(ReadModelBase); - type.GetProperty("AccountNumber").SetValue(account, dto.AccountNumber); - type.GetProperty("CustomerName").SetValue(account, dto.CustomerName); - type.GetProperty("Balance").SetValue(account, dto.Balance); - type.GetProperty("IsClosed").SetValue(account, dto.IsClosed); - type.GetProperty("CreatedAt").SetValue(account, dto.CreatedAt); - type.GetProperty("LastUpdatedAt").SetValue(account, dto.LastUpdatedAt); - type.GetProperty("Version").SetValue(account, dto.Version); + // Set properties from AccountSummary class + type.GetProperty(nameof(AccountSummary.AccountNumber)).SetValue(account, dto.AccountNumber); + type.GetProperty(nameof(AccountSummary.CustomerName)).SetValue(account, dto.CustomerName); + type.GetProperty(nameof(AccountSummary.Balance)).SetValue(account, dto.Balance); + type.GetProperty(nameof(AccountSummary.IsClosed)).SetValue(account, dto.IsClosed); + type.GetProperty(nameof(AccountSummary.TransactionCount)).SetValue(account, dto.TransactionCount); + type.GetProperty(nameof(AccountSummary.LastTransactionDate)).SetValue(account, dto.LastTransactionDate); + + // Set properties from base class + baseType.GetProperty(nameof(ReadModelBase.CreatedAt)).SetValue(account, dto.CreatedAt); + baseType.GetProperty(nameof(ReadModelBase.LastUpdatedAt)).SetValue(account, dto.LastUpdatedAt); + baseType.GetProperty(nameof(ReadModelBase.Version)).SetValue(account, dto.Version); return account; } + /// + /// DTO for mapping between the database and domain model + /// private class AccountSummaryDto { public Guid Id { get; set; } @@ -363,235 +1066,632 @@ namespace MyApp.ReadModels public string CustomerName { get; set; } public decimal Balance { get; set; } public bool IsClosed { get; set; } + public int TransactionCount { get; set; } + public DateTime? LastTransactionDate { get; set; } public DateTime CreatedAt { get; set; } public DateTime? LastUpdatedAt { get; set; } public long Version { get; set; } } } + + /// + /// Exception thrown when a concurrency conflict is detected + /// + public class DbUpdateConcurrencyException : Exception + { + public DbUpdateConcurrencyException(string message) : base(message) { } + public DbUpdateConcurrencyException(string message, Exception innerException) : base(message, innerException) { } + } } -``` ## Projection Manager ```csharp using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using ReactiveDomain.Messaging; using ReactiveDomain.Messaging.Bus; -using MyApp.Domain; -using MyApp.ReadModels; namespace MyApp.Projections { - public class ProjectionManager + /// + /// Manages event projections by subscribing to the event bus and routing events to registered projectors + /// + public class ProjectionManager : BackgroundService { - private readonly Dictionary>> _projectors = - new Dictionary>>(); - - private readonly IEventBus _eventBus; + private readonly IMessageBus _bus; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly ConcurrentDictionary> _projectors = new(); + private readonly List _subscriptions = new(); + private readonly SemaphoreSlim _projectionLock = new(1, 1); - public ProjectionManager(IEventBus eventBus) + /// + /// Creates a new projection manager + /// + /// Message bus to subscribe to + /// Service provider for resolving projectors + /// Logger instance + public ProjectionManager( + IMessageBus bus, + IServiceProvider serviceProvider, + ILogger logger) { - _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + _bus = bus ?? throw new ArgumentNullException(nameof(bus)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public void RegisterProjection(Action projector) where TEvent : IEvent + /// + /// Starts the projection manager and registers all projectors + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var eventType = typeof(TEvent); + _logger.LogInformation("Starting projection manager"); - if (!_projectors.ContainsKey(eventType)) - { - _projectors[eventType] = new List>(); + try + { + // Register all projectors from DI container + var projectors = _serviceProvider.GetServices().ToList(); - // Subscribe to the event - _eventBus.Subscribe(e => ProjectEvent(e)); + foreach (var projector in projectors) + { + await RegisterProjectorAsync(projector); + } + + // Register TransactionHistoryProjector + var transactionHistoryProjector = _serviceProvider.GetService(); + await RegisterProjectorAsync(transactionHistoryProjector); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error starting projection manager"); + throw; } - - // Add the projector - _projectors[eventType].Add(e => projector((TEvent)e)); } - private void ProjectEvent(TEvent @event) where TEvent : IEvent + /// + /// Registers a projector with the projection manager + /// + private async Task RegisterProjectorAsync(IProjector projector) { - var eventType = @event.GetType(); + _logger.LogDebug("Registering projector {ProjectorType}", projector.GetType().Name); - if (_projectors.TryGetValue(eventType, out var projectors)) + try { - foreach (var projector in projectors) + // Get the event types handled by the projector + var eventTypes = projector.GetHandledEventTypes(); + + // Subscribe to each event type + foreach (var eventType in eventTypes) { - try - { - projector(@event); - } - catch (Exception ex) + var subscription = _bus.Subscribe(eventType, async (evt, token) => { - Console.WriteLine($"Error projecting event {eventType.Name}: {ex.Message}"); - } + await ProjectEventAsync(projector, evt, token); + }); + + _subscriptions.Add(subscription); } } + catch (Exception ex) + { + _logger.LogError(ex, "Error registering projector {ProjectorType}", projector.GetType().Name); + throw; + } + } + + /// + /// Projects an event using the specified projector + /// + private async Task ProjectEventAsync(IProjector projector, object evt, CancellationToken cancellationToken) + { + _logger.LogDebug("Projecting event {EventType} using projector {ProjectorType}", evt.GetType().Name, projector.GetType().Name); + + try + { + // Lock the projector to prevent concurrent execution + await _projectionLock.WaitAsync(cancellationToken); + + try + { + // Project the event + await projector.ProjectEventAsync(evt, cancellationToken); + } + finally + { + _projectionLock.Release(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error projecting event {EventType} using projector {ProjectorType}", evt.GetType().Name, projector.GetType().Name); + throw; + } } } } -``` -## Account Summary Projection +## Account Summary Projector ```csharp using System; -using ReactiveDomain.Messaging; -using MyApp.Domain; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MyApp.Domain.Events; using MyApp.ReadModels; -using MyApp.Projections; +using ReactiveDomain.Messaging; namespace MyApp.Projections { - public class AccountSummaryProjection + /// + /// Projector for maintaining the AccountSummary read model + /// + public class AccountSummaryProjector : + IProjectEvents, + IProjectEvents, + IProjectEvents, + IProjectEvents { - private readonly IReadModelRepository _repository; + private readonly IQueryableReadModelRepository _repository; + private readonly ILogger _logger; - public AccountSummaryProjection(IReadModelRepository repository) + /// + /// Creates a new account summary projector + /// + /// Repository for account summaries + /// Logger instance + public AccountSummaryProjector( + IQueryableReadModelRepository repository, + ILogger logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public void Register(ProjectionManager projectionManager) + /// + public IEnumerable GetHandledEventTypes() { - projectionManager.RegisterProjection(When); - projectionManager.RegisterProjection(When); - projectionManager.RegisterProjection(When); - projectionManager.RegisterProjection(When); + return new[] + { + typeof(AccountCreated), + typeof(FundsDeposited), + typeof(FundsWithdrawn), + typeof(AccountClosed) + }; } - private void When(AccountCreated @event) + /// + /// Projects the AccountCreated event + /// + public async Task ProjectEventAsync(AccountCreated evt) { - var accountSummary = new AccountSummary(@event.AccountId); - accountSummary.Update(@event.AccountNumber, @event.CustomerName, 0, false); + _logger.LogDebug("Projecting AccountCreated event for account {AccountId}", evt.AggregateId); - _repository.Save(accountSummary); + try + { + // Check if the account summary already exists + var exists = await _repository.ExistsAsync(evt.AggregateId); + + if (exists) + { + _logger.LogWarning("Account summary already exists for account {AccountId}", evt.AggregateId); + return; + } + + // Create a new account summary + var accountSummary = new AccountSummary(evt.AggregateId) + { + AccountNumber = evt.AccountNumber, + CustomerName = evt.CustomerName, + Balance = 0, + IsClosed = false, + TransactionCount = 0, + CreatedAt = evt.Timestamp + }; + + // Save the account summary + await _repository.SaveAsync(accountSummary); + + _logger.LogInformation("Created account summary for account {AccountId}", evt.AggregateId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error projecting AccountCreated event for account {AccountId}", evt.AggregateId); + throw; + } } - private void When(FundsDeposited @event) + /// + /// Projects the FundsDeposited event + /// + public async Task ProjectEventAsync(FundsDeposited evt) { - var accountSummary = _repository.GetById(@event.AccountId); - if (accountSummary != null) + _logger.LogDebug("Projecting FundsDeposited event for account {AccountId}", evt.AggregateId); + + try + { + // Get the account summary + var accountSummary = await _repository.GetByIdAsync(evt.AggregateId); + + if (accountSummary == null) + { + _logger.LogWarning("Account summary not found for account {AccountId}", evt.AggregateId); + return; + } + + if (accountSummary.IsClosed) + { + _logger.LogWarning("Cannot deposit funds to closed account {AccountId}", evt.AggregateId); + return; + } + + // Update the account summary + accountSummary.RecordDeposit(evt.Amount, evt.Timestamp); + + // Save the updated account summary + await _repository.SaveAsync(accountSummary); + + _logger.LogInformation("Updated account summary for deposit of {Amount} to account {AccountId}", + evt.Amount, evt.AggregateId); + } + catch (Exception ex) { - accountSummary.UpdateBalance(accountSummary.Balance + @event.Amount); - _repository.Save(accountSummary); + _logger.LogError(ex, "Error projecting FundsDeposited event for account {AccountId}", evt.AggregateId); + throw; } } - private void When(FundsWithdrawn @event) + /// + /// Projects the FundsWithdrawn event + /// + public async Task ProjectEventAsync(FundsWithdrawn evt) { - var accountSummary = _repository.GetById(@event.AccountId); - if (accountSummary != null) + _logger.LogDebug("Projecting FundsWithdrawn event for account {AccountId}", evt.AggregateId); + + try { - accountSummary.UpdateBalance(accountSummary.Balance - @event.Amount); - _repository.Save(accountSummary); + // Get the account summary + var accountSummary = await _repository.GetByIdAsync(evt.AggregateId); + + if (accountSummary == null) + { + _logger.LogWarning("Account summary not found for account {AccountId}", evt.AggregateId); + return; + } + + if (accountSummary.IsClosed) + { + _logger.LogWarning("Cannot withdraw funds from closed account {AccountId}", evt.AggregateId); + return; + } + + // Update the account summary + accountSummary.RecordWithdrawal(evt.Amount, evt.Timestamp); + + // Save the updated account summary + await _repository.SaveAsync(accountSummary); + + _logger.LogInformation("Updated account summary for withdrawal of {Amount} from account {AccountId}", + evt.Amount, evt.AggregateId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error projecting FundsWithdrawn event for account {AccountId}", evt.AggregateId); + throw; } } - private void When(AccountClosed @event) + /// + /// Projects the AccountClosed event + /// + public async Task ProjectEventAsync(AccountClosed evt) { - var accountSummary = _repository.GetById(@event.AccountId); - if (accountSummary != null) + _logger.LogDebug("Projecting AccountClosed event for account {AccountId}", evt.AggregateId); + + try { - accountSummary.MarkAsClosed(); - _repository.Save(accountSummary); + // Get the account summary + var accountSummary = await _repository.GetByIdAsync(evt.AggregateId); + + if (accountSummary == null) + { + _logger.LogWarning("Account summary not found for account {AccountId}", evt.AggregateId); + return; + } + + if (accountSummary.IsClosed) + { + _logger.LogWarning("Account {AccountId} is already closed", evt.AggregateId); + return; + } + + // Update the account summary + accountSummary.Close(evt.Timestamp); + + // Save the updated account summary + await _repository.SaveAsync(accountSummary); + + _logger.LogInformation("Marked account {AccountId} as closed", evt.AggregateId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error projecting AccountClosed event for account {AccountId}", evt.AggregateId); + throw; } } } } -``` -## Transaction History Projection +## Transaction History Projector ```csharp using System; -using ReactiveDomain.Messaging; -using MyApp.Domain; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MyApp.Domain.Events; using MyApp.ReadModels; -using MyApp.Projections; +using ReactiveDomain.Messaging; namespace MyApp.Projections { - public class TransactionHistoryProjection + /// + /// Projector for maintaining the TransactionHistory read model + /// + public class TransactionHistoryProjector : + IProjectEvents, + IProjectEvents, + IProjectEvents, + IProjectEvents { - private readonly IReadModelRepository _repository; + private readonly IReadModelRepository _repository; + private readonly ILogger _logger; - public TransactionHistoryProjection(IReadModelRepository repository) + /// + /// Creates a new transaction history projector + /// + /// Repository for transaction histories + /// Logger instance + public TransactionHistoryProjector( + IReadModelRepository repository, + ILogger logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public void Register(ProjectionManager projectionManager) + /// + public IEnumerable GetHandledEventTypes() { - projectionManager.RegisterProjection(When); - projectionManager.RegisterProjection(When); - projectionManager.RegisterProjection(When); - projectionManager.RegisterProjection(When); + return new[] + { + typeof(AccountCreated), + typeof(FundsDeposited), + typeof(FundsWithdrawn), + typeof(AccountClosed) + }; } - private void When(AccountCreated @event) + /// + /// Projects the AccountCreated event + /// + public async Task ProjectEventAsync(AccountCreated evt) { - var history = new TransactionHistory(@event.AccountId); - history.AddTransaction( - "CREATED", - 0, - $"Account created: {@event.AccountNumber}", - DateTime.UtcNow); + _logger.LogDebug("Projecting AccountCreated event for transaction history of account {AccountId}", evt.AggregateId); + + try + { + // Check if the transaction history already exists + var exists = await _repository.ExistsAsync(evt.AggregateId); + + if (exists) + { + _logger.LogWarning("Transaction history already exists for account {AccountId}", evt.AggregateId); + return; + } + + // Create a new transaction history + var history = new TransactionHistory(evt.AggregateId) + { + CreatedAt = evt.Timestamp + }; - _repository.Save(history); + // Add the initial transaction + history.AddTransaction( + Guid.NewGuid(), + "AccountCreated", + 0, + $"Account {evt.AccountNumber} created for {evt.CustomerName}", + evt.Timestamp); + + // Save the transaction history + await _repository.SaveAsync(history); + + _logger.LogInformation("Created transaction history for account {AccountId}", evt.AggregateId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error projecting AccountCreated event for transaction history of account {AccountId}", evt.AggregateId); + throw; + } } - private void When(FundsDeposited @event) + /// + /// Projects the FundsDeposited event + /// + public async Task ProjectEventAsync(FundsDeposited evt) { - var history = _repository.GetById(@event.AccountId); - if (history == null) - { - history = new TransactionHistory(@event.AccountId); - } + _logger.LogDebug("Projecting FundsDeposited event for transaction history of account {AccountId}", evt.AggregateId); - history.AddTransaction( - "DEPOSIT", - @event.Amount, - $"Deposit: {@event.Amount:C}", - DateTime.UtcNow); + try + { + // Get the transaction history + var history = await _repository.GetByIdAsync(evt.AggregateId); + + if (history == null) + { + _logger.LogWarning("Transaction history not found for account {AccountId}", evt.AggregateId); + return; + } + + // Add the deposit transaction + history.AddTransaction( + Guid.NewGuid(), + "Deposit", + evt.Amount, + $"Deposit of {evt.Amount:C} to account", + evt.Timestamp); + + // Save the updated transaction history + await _repository.SaveAsync(history); - _repository.Save(history); + _logger.LogInformation("Added deposit transaction of {Amount} to transaction history for account {AccountId}", + evt.Amount, evt.AggregateId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error projecting FundsDeposited event for transaction history of account {AccountId}", evt.AggregateId); + throw; + } } - private void When(FundsWithdrawn @event) + /// + /// Projects the FundsWithdrawn event + /// + public async Task ProjectEventAsync(FundsWithdrawn evt) { - var history = _repository.GetById(@event.AccountId); - if (history == null) - { - history = new TransactionHistory(@event.AccountId); - } + _logger.LogDebug("Projecting FundsWithdrawn event for transaction history of account {AccountId}", evt.AggregateId); - history.AddTransaction( - "WITHDRAWAL", - -@event.Amount, - $"Withdrawal: {@event.Amount:C}", - DateTime.UtcNow); + try + { + // Get the transaction history + var history = await _repository.GetByIdAsync(evt.AggregateId); + + if (history == null) + { + _logger.LogWarning("Transaction history not found for account {AccountId}", evt.AggregateId); + return; + } + + // Add the withdrawal transaction + history.AddTransaction( + Guid.NewGuid(), + "Withdrawal", + evt.Amount, + $"Withdrawal of {evt.Amount:C} from account", + evt.Timestamp); + + // Save the updated transaction history + await _repository.SaveAsync(history); - _repository.Save(history); + _logger.LogInformation("Added withdrawal transaction of {Amount} to transaction history for account {AccountId}", + evt.Amount, evt.AggregateId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error projecting FundsWithdrawn event for transaction history of account {AccountId}", evt.AggregateId); + throw; + } } - private void When(AccountClosed @event) + /// + /// Projects the AccountClosed event + /// + public async Task ProjectEventAsync(AccountClosed evt) { - var history = _repository.GetById(@event.AccountId); - if (history == null) + _logger.LogDebug("Projecting AccountClosed event for transaction history of account {AccountId}", evt.AggregateId); + + try { - history = new TransactionHistory(@event.AccountId); + // Get the transaction history + var history = await _repository.GetByIdAsync(evt.AggregateId); + + if (history == null) + { + _logger.LogWarning("Transaction history not found for account {AccountId}", evt.AggregateId); + return; + } + + // Add the account closed transaction + history.AddTransaction( + Guid.NewGuid(), + "AccountClosed", + 0, + "Account closed", + evt.Timestamp); + + // Save the updated transaction history + await _repository.SaveAsync(history); + + _logger.LogInformation("Added account closed transaction to transaction history for account {AccountId}", + evt.AggregateId); } + catch (Exception ex) + { + _logger.LogError(ex, "Error projecting AccountClosed event for transaction history of account {AccountId}", evt.AggregateId); + throw; + } + } + } +} +``` + +## Dependency Injection Setup + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MyApp.Domain; +using MyApp.Projections; +using MyApp.ReadModels; +using ReactiveDomain.Messaging; +using ReactiveDomain.Messaging.Bus; + +namespace MyApp +{ + public static class ProjectionSetup + { + /// + /// Registers all projection-related services with the DI container + /// + public static IServiceCollection AddProjections(this IServiceCollection services, string connectionString) + { + // Register repositories + services.AddSingleton>( + provider => new SqlAccountSummaryRepository( + connectionString, + provider.GetRequiredService>())); + + services.AddSingleton>( + provider => new SqlTransactionHistoryRepository( + connectionString, + provider.GetRequiredService>())); + + // Register projectors + services.AddSingleton(); + services.AddSingleton(); - history.AddTransaction( - "CLOSED", - 0, - "Account closed", - DateTime.UtcNow); + // Register both projectors as IProjector for automatic discovery + services.AddSingleton(provider => + provider.GetRequiredService()); - _repository.Save(history); + services.AddSingleton(provider => + provider.GetRequiredService()); + + // Register projection manager as a hosted service + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); + + return services; } } } @@ -601,72 +1701,150 @@ namespace MyApp.Projections ```csharp using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using ReactiveDomain.Messaging; using ReactiveDomain.Messaging.Bus; -using MyApp.Domain; +using MyApp.Domain.Events; using MyApp.ReadModels; using MyApp.Projections; -namespace MyApp.Examples +namespace MyApp { - public class ProjectionExample + public class Program { - public void DemonstrateProjections() + public static async Task Main(string[] args) { - // Create an event bus - var eventBus = new EventBus(); + // Create and configure the host + var host = CreateHostBuilder(args).Build(); - // Create read model repositories - var accountSummaryRepository = new InMemoryReadModelRepository(); - var transactionHistoryRepository = new InMemoryReadModelRepository(); + // Start the host (this will start the ProjectionManager as a hosted service) + await host.StartAsync(); - // Create projection manager - var projectionManager = new ProjectionManager(eventBus); + try + { + // Get services + var bus = host.Services.GetRequiredService(); + var accountSummaryRepo = host.Services.GetRequiredService>(); + var transactionHistoryRepo = host.Services.GetRequiredService>(); + var logger = host.Services.GetRequiredService>(); + + // Create account + var accountId = Guid.NewGuid(); + var accountNumber = "ACC-001"; + var customerName = "John Doe"; + + logger.LogInformation("Creating account {AccountId} for {CustomerName}", accountId, customerName); + + // Publish events - in a real application, these would come from command handlers + await PublishEventsAsync(bus, accountId, accountNumber, customerName); + + // Wait for projections to process events + await Task.Delay(500); // In a real app, you would use proper synchronization + + // Get read models + var accountSummary = await accountSummaryRepo.GetByIdAsync(accountId); + var transactionHistory = await transactionHistoryRepo.GetByIdAsync(accountId); + + // Display results + DisplayResults(accountSummary, transactionHistory, logger); + + // Wait for user input before shutting down + Console.WriteLine("\nPress any key to exit..."); + Console.ReadKey(); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + finally + { + // Stop the host gracefully + await host.StopAsync(); + host.Dispose(); + } + } + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }) + .ConfigureServices((hostContext, services) => + { + // Register message bus + services.AddSingleton(); + + // Register repositories (using in-memory implementations for the example) + services.AddSingleton, InMemoryAccountSummaryRepository>(); + services.AddSingleton, InMemoryTransactionHistoryRepository>(); + + // Register projectors + services.AddSingleton(); + services.AddSingleton(); + + // Register projectors as IProjector for automatic discovery + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + + // Register projection manager as a hosted service + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); + }); + + private static async Task PublishEventsAsync(IMessageBus bus, Guid accountId, string accountNumber, string customerName) + { + // Create account + await bus.PublishAsync(new AccountCreated(accountId, accountNumber, customerName) { Timestamp = DateTime.UtcNow }); - // Register projections - var accountSummaryProjection = new AccountSummaryProjection(accountSummaryRepository); - accountSummaryProjection.Register(projectionManager); + // Deposit funds + await Task.Delay(100); // Simulate some time passing + await bus.PublishAsync(new FundsDeposited(accountId, 1000.00m) { Timestamp = DateTime.UtcNow }); - var transactionHistoryProjection = new TransactionHistoryProjection(transactionHistoryRepository); - transactionHistoryProjection.Register(projectionManager); + // Withdraw funds + await Task.Delay(100); // Simulate some time passing + await bus.PublishAsync(new FundsWithdrawn(accountId, 250.00m) { Timestamp = DateTime.UtcNow }); - // Create and publish events - var accountId = Guid.NewGuid(); - var correlationId = Guid.NewGuid(); - var causationId = Guid.NewGuid(); + // Another deposit + await Task.Delay(100); // Simulate some time passing + await bus.PublishAsync(new FundsDeposited(accountId, 500.00m) { Timestamp = DateTime.UtcNow }); + } + + private static void DisplayResults(AccountSummary accountSummary, TransactionHistory transactionHistory, ILogger logger) + { + Console.WriteLine("\nAccount Summary:"); + Console.WriteLine($"Account Number: {accountSummary.AccountNumber}"); + Console.WriteLine($"Customer: {accountSummary.CustomerName}"); + Console.WriteLine($"Balance: {accountSummary.Balance:C}"); + Console.WriteLine($"Transaction Count: {accountSummary.TransactionCount}"); + Console.WriteLine($"Created: {accountSummary.CreatedAt}"); + Console.WriteLine($"Last Updated: {accountSummary.LastUpdatedAt}"); - // Account created event - var accountCreatedEvent = new AccountCreated( - accountId, - "ACC-123", - "John Doe", - correlationId, - causationId); - - eventBus.Publish(accountCreatedEvent); + Console.WriteLine("\nTransaction History:"); + var transactions = transactionHistory.GetTransactions(); - // Deposit event - var depositEvent = new FundsDeposited( - accountId, - 1000, - correlationId, - accountCreatedEvent.MessageId); - - eventBus.Publish(depositEvent); + foreach (var transaction in transactions) + { + Console.WriteLine($"{transaction.Timestamp:yyyy-MM-dd HH:mm:ss}: {transaction.Type} - {transaction.Amount:C} - {transaction.Description}"); + } - // Withdrawal event - var withdrawalEvent = new FundsWithdrawn( - accountId, - 500, - correlationId, - depositEvent.MessageId); - + logger.LogInformation("Retrieved account summary and transaction history with {TransactionCount} transactions", + transactions.Count); + } + } +} eventBus.Publish(withdrawalEvent); // Check account summary read model var accountSummary = accountSummaryRepository.GetById(accountId); Console.WriteLine($"Account Summary: {accountSummary.AccountNumber}, Balance: {accountSummary.Balance:C}"); - +{{ ... }} // Check transaction history read model var transactionHistory = transactionHistoryRepository.GetById(accountId); Console.WriteLine($"Transaction Count: {transactionHistory.Transactions.Count}"); diff --git a/docs/code-examples/saving-retrieving-aggregates.md b/docs/code-examples/saving-retrieving-aggregates.md index 30a8cea4..e2894521 100644 --- a/docs/code-examples/saving-retrieving-aggregates.md +++ b/docs/code-examples/saving-retrieving-aggregates.md @@ -2,155 +2,372 @@ [← Back to Code Examples](README.md) | [← Back to Table of Contents](../README.md) -This example demonstrates how to save and retrieve aggregates using repositories in Reactive Domain. +This example demonstrates how to save and retrieve aggregates using repositories in Reactive Domain, following current best practices. -## Repository Configuration +## Repository Configuration with Dependency Injection ```csharp using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using ReactiveDomain.Foundation; -using ReactiveDomain.Persistence; using ReactiveDomain.EventStore; +using ReactiveDomain.Messaging; namespace MyApp.Infrastructure { - public class RepositoryConfiguration + public static class RepositoryConfiguration { - public IRepository ConfigureRepository(string connectionString) + public static IServiceCollection AddEventStore(this IServiceCollection services, IConfiguration configuration) { - // Create a stream name builder - var streamNameBuilder = new PrefixedCamelCaseStreamNameBuilder("MyApp"); - - // Create an event store connection - var connectionSettings = ConnectionSettings.Create() - .KeepReconnecting() - .KeepRetrying() - .SetDefaultUserCredentials(new UserCredentials("admin", "changeit")); + // Register event store connection + services.AddSingleton(provider => + { + var logger = provider.GetRequiredService>(); + logger.LogInformation("Configuring EventStore connection"); - var connection = new StreamStoreConnection( - "MyApp", - connectionSettings, - connectionString, - 1113); + var connectionString = configuration.GetConnectionString("EventStore"); + if (string.IsNullOrEmpty(connectionString)) + { + throw new InvalidOperationException("EventStore connection string is not configured"); + } - // Create a serializer - var serializer = new JsonMessageSerializer(); + var connectionSettings = ConnectionSettings.Create() + .KeepReconnecting() + .KeepRetrying() + .SetDefaultUserCredentials(new UserCredentials( + configuration["EventStore:Username"] ?? "admin", + configuration["EventStore:Password"] ?? "changeit")); + + return new StreamStoreConnection( + "MyApp", + connectionSettings, + connectionString, + int.Parse(configuration["EventStore:TcpPort"] ?? "1113")); + }); + + // Register stream name builder + services.AddSingleton(provider => + { + var appPrefix = configuration["EventStore:StreamPrefix"] ?? "MyApp"; + return new PrefixedCamelCaseStreamNameBuilder(appPrefix); + }); - // Create a repository - var repository = new StreamStoreRepository( - streamNameBuilder, - connection, - serializer); + // Register serializer + services.AddSingleton(); + + // Register repositories + services.AddSingleton(provider => + { + var connection = provider.GetRequiredService(); + var streamNameBuilder = provider.GetRequiredService(); + var serializer = provider.GetRequiredService(); - return repository; + return new StreamStoreRepository( + streamNameBuilder, + connection, + serializer); + }); + + // Register correlated repository + services.AddSingleton(provider => + { + var repository = provider.GetRequiredService(); + return new CorrelatedStreamStoreRepository(repository); + }); + + // Register generic repositories for specific aggregate types + services.AddSingleton>(provider => + { + var repository = provider.GetRequiredService(); + return new TypedRepository(repository); + }); + + return services; + } + } + + // Type-safe repository wrapper + public class TypedRepository : IRepository + where TAggregate : AggregateRoot + { + private readonly IRepository _repository; + + public TypedRepository(IRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); } - public ICorrelatedRepository ConfigureCorrelatedRepository(IRepository repository) + public async Task GetByIdAsync(TId id) { - // Create a correlated repository - var correlatedRepository = new CorrelatedStreamStoreRepository(repository); - - return correlatedRepository; + return await Task.FromResult(_repository.GetById(id)); + } + + public async Task TryGetByIdAsync(TId id, out TAggregate aggregate) + { + var result = _repository.TryGetById(id, out var untypedAggregate); + aggregate = untypedAggregate as TAggregate; + return await Task.FromResult(result); + } + + public async Task SaveAsync(TAggregate aggregate) + { + _repository.Save(aggregate); + await Task.CompletedTask; + } + + public async Task UpdateAsync(ref TAggregate aggregate) + { + _repository.Update(ref aggregate); + await Task.CompletedTask; + } + + public async Task DeleteAsync(TAggregate aggregate) + { + _repository.Delete(aggregate); + await Task.CompletedTask; + } + + public async Task HardDeleteAsync(TAggregate aggregate) + { + _repository.HardDelete(aggregate); + await Task.CompletedTask; } } + + // Generic repository interface with strong typing + public interface IRepository + where TAggregate : AggregateRoot + { + Task GetByIdAsync(TId id); + Task TryGetByIdAsync(TId id, out TAggregate aggregate); + Task SaveAsync(TAggregate aggregate); + Task UpdateAsync(ref TAggregate aggregate); + Task DeleteAsync(TAggregate aggregate); + Task HardDeleteAsync(TAggregate aggregate); + } } ``` -## Basic Repository Operations +## Basic Repository Operations with Strongly Typed Repository ```csharp using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using ReactiveDomain.Foundation; using MyApp.Domain; +using MyApp.Infrastructure; namespace MyApp.Application { - public class AccountRepository + public class AccountService { - private readonly IRepository _repository; + private readonly IRepository _repository; + private readonly ILogger _logger; - public AccountRepository(IRepository repository) + public AccountService( + IRepository repository, + ILogger logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public void SaveAccount(Account account) + public async Task CreateAccountAsync(string accountNumber, string customerName) { + _logger.LogInformation("Creating new account for customer {CustomerName}", customerName); + try { - _repository.Save(account); - Console.WriteLine($"Account {account.Id} saved successfully"); + // Generate a unique ID for the account + var accountId = Guid.NewGuid(); + + // Create a new account + var account = new Account(accountId); + account.Create(accountNumber, customerName); + + // Save the account + await _repository.SaveAsync(account); + + _logger.LogInformation("Account {AccountId} created successfully", accountId); + + return account; } - catch (AggregateVersionException ex) + catch (Exception ex) { - Console.WriteLine($"Concurrency conflict: {ex.Message}"); - // Handle concurrency conflict + _logger.LogError(ex, "Error creating account for customer {CustomerName}", customerName); + throw; } } - public Account GetAccount(Guid accountId) + public async Task GetAccountAsync(Guid accountId) { + _logger.LogDebug("Retrieving account {AccountId}", accountId); + try { - var account = _repository.GetById(accountId); + var account = await _repository.GetByIdAsync(accountId); return account; } catch (AggregateNotFoundException) { - Console.WriteLine($"Account {accountId} not found"); + _logger.LogWarning("Account {AccountId} not found", accountId); return null; } catch (AggregateDeletedException) { - Console.WriteLine($"Account {accountId} has been deleted"); + _logger.LogWarning("Account {AccountId} has been deleted", accountId); return null; } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving account {AccountId}", accountId); + throw; + } } - public bool TryGetAccount(Guid accountId, out Account account) + public async Task TryGetAccountAsync(Guid accountId, out Account account) { - return _repository.TryGetById(accountId, out account); + _logger.LogDebug("Attempting to retrieve account {AccountId}", accountId); + + try + { + return await _repository.TryGetByIdAsync(accountId, out account); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error attempting to retrieve account {AccountId}", accountId); + account = null; + return false; + } } - public void UpdateAccount(ref Account account) + public async Task UpdateAccountAsync(Account account) { + _logger.LogInformation("Updating account {AccountId}", account.Id); + try { - _repository.Update(ref account); - Console.WriteLine($"Account {account.Id} updated successfully"); + await _repository.SaveAsync(account); + _logger.LogInformation("Account {AccountId} updated successfully", account.Id); } catch (AggregateVersionException ex) { - Console.WriteLine($"Concurrency conflict: {ex.Message}"); - // Handle concurrency conflict + _logger.LogWarning(ex, "Concurrency conflict updating account {AccountId}", account.Id); + throw new ConcurrencyException($"The account has been modified by another process", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating account {AccountId}", account.Id); + throw; } } - public void DeleteAccount(Account account) + public async Task DeleteAccountAsync(Account account) { + _logger.LogInformation("Soft deleting account {AccountId}", account.Id); + try { - _repository.Delete(account); - Console.WriteLine($"Account {account.Id} marked as deleted"); + await _repository.DeleteAsync(account); + _logger.LogInformation("Account {AccountId} deleted successfully", account.Id); } catch (AggregateVersionException ex) { - Console.WriteLine($"Concurrency conflict: {ex.Message}"); - // Handle concurrency conflict + _logger.LogWarning(ex, "Concurrency conflict deleting account {AccountId}", account.Id); + throw new ConcurrencyException($"The account has been modified by another process", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting account {AccountId}", account.Id); + throw; } } - public void HardDeleteAccount(Account account) + public async Task HardDeleteAccountAsync(Account account) { + _logger.LogWarning("Hard deleting account {AccountId} - THIS OPERATION CANNOT BE UNDONE", account.Id); + try { - _repository.HardDelete(account); - Console.WriteLine($"Account {account.Id} permanently deleted"); + await _repository.HardDeleteAsync(account); + _logger.LogInformation("Account {AccountId} hard deleted successfully", account.Id); } catch (AggregateVersionException ex) { - Console.WriteLine($"Concurrency conflict: {ex.Message}"); - // Handle concurrency conflict + _logger.LogWarning(ex, "Concurrency conflict hard deleting account {AccountId}", account.Id); + throw new ConcurrencyException($"The account has been modified by another process", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error hard deleting account {AccountId}", account.Id); + throw; + } + } + + // Example of handling a transaction with optimistic concurrency + public async Task DepositAsync(Guid accountId, decimal amount) + { + if (amount <= 0) + { + throw new ArgumentException("Deposit amount must be positive", nameof(amount)); + } + + _logger.LogInformation("Depositing {Amount} to account {AccountId}", amount, accountId); + + int retryCount = 0; + const int maxRetries = 3; + + while (true) + { + try + { + // Get the latest version of the account + var account = await _repository.GetByIdAsync(accountId); + + // Apply the business operation + account.Deposit(amount); + + // Save the changes + await _repository.SaveAsync(account); + + _logger.LogInformation("Successfully deposited {Amount} to account {AccountId}", + amount, accountId); + + // Return the new balance + return account.GetBalance(); + } + catch (AggregateVersionException ex) + { + // Handle concurrency conflict with retry logic + retryCount++; + + if (retryCount >= maxRetries) + { + _logger.LogError(ex, "Failed to deposit after {RetryCount} attempts due to concurrency conflicts", + retryCount); + throw new ConcurrencyException( + $"Failed to deposit after {retryCount} attempts due to concurrency conflicts", ex); + } + + _logger.LogWarning(ex, "Concurrency conflict depositing to account {AccountId}, retry attempt {RetryCount}", + accountId, retryCount); + + // Wait before retrying (with exponential backoff) + await Task.Delay(100 * (int)Math.Pow(2, retryCount)); + } + catch (AggregateNotFoundException) + { + _logger.LogWarning("Account {AccountId} not found for deposit", accountId); + throw new AccountNotFoundException($"Account {accountId} not found"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error depositing {Amount} to account {AccountId}", amount, accountId); + throw; + } } } } @@ -177,78 +394,177 @@ namespace MyApp.Application _repository = repository ?? throw new ArgumentNullException(nameof(repository)); } - public void SaveAccount(Account account, ICorrelatedMessage source) - { - try - { - _repository.Save(account, source); - Console.WriteLine($"Account {account.Id} saved with correlation"); - } - catch (AggregateVersionException ex) - { - Console.WriteLine($"Concurrency conflict: {ex.Message}"); - // Handle concurrency conflict - } - } - - public Account GetAccount(Guid accountId, ICorrelatedMessage source) + public async Task GetAccountAsync(Guid accountId, ICorrelatedMessage source) { + _logger.LogDebug("Retrieving account {AccountId} with correlation {CorrelationId}", + accountId, source.CorrelationId); + try { - var account = _repository.GetById(accountId, source); + // We need to wrap the synchronous repository call in a Task to maintain the async pattern + var account = await Task.FromResult(_repository.GetById(accountId, source)); return account; } catch (AggregateNotFoundException) { - Console.WriteLine($"Account {accountId} not found"); + _logger.LogWarning("Account {AccountId} not found (Correlation: {CorrelationId})", + accountId, source.CorrelationId); return null; } catch (AggregateDeletedException) { - Console.WriteLine($"Account {accountId} has been deleted"); + _logger.LogWarning("Account {AccountId} has been deleted (Correlation: {CorrelationId})", + accountId, source.CorrelationId); return null; } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving account {AccountId} (Correlation: {CorrelationId})", + accountId, source.CorrelationId); + throw; + } } - public void ProcessCreateAccountCommand(CreateAccount command) + public async Task ProcessCreateAccountCommandAsync(CreateAccount command) { - // Create a new account with correlation - var account = new Account(command.AccountId, command); - - // Initialize the account - account.Create(command.AccountNumber, command.CustomerName); + _logger.LogInformation("Processing create account command for customer {CustomerName} (Correlation: {CorrelationId})", + command.CustomerName, command.CorrelationId); - // Save the account with correlation - _repository.Save(account, command); + try + { + // Create a new account with correlation + var account = new Account(command.AccountId, command); + + // Initialize the account + account.Create(command.AccountNumber, command.CustomerName); + + // Save the account with correlation + await Task.FromResult(_repository.Save(account, command)); + + _logger.LogInformation("Account {AccountId} created successfully (Correlation: {CorrelationId})", + command.AccountId, command.CorrelationId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating account for customer {CustomerName} (Correlation: {CorrelationId})", + command.CustomerName, command.CorrelationId); + throw; + } } - public void ProcessDepositCommand(DepositFunds command) + public async Task ProcessDepositCommandAsync(DepositFunds command) { + _logger.LogInformation("Processing deposit of {Amount} to account {AccountId} (Correlation: {CorrelationId})", + command.Amount, command.AccountId, command.CorrelationId); + try { // Load the account with correlation - var account = _repository.GetById(command.AccountId, command); + var account = await Task.FromResult(_repository.GetById(command.AccountId, command)); // Process the command account.Deposit(command.Amount); - // Save the changes with correlation - _repository.Save(account, command); + // Save the account with correlation + await Task.FromResult(_repository.Save(account, command)); + + _logger.LogInformation("Successfully deposited {Amount} to account {AccountId} (Correlation: {CorrelationId})", + command.Amount, command.AccountId, command.CorrelationId); } catch (AggregateNotFoundException) { - // Handle not found case - throw new InvalidOperationException($"Account {command.AccountId} not found"); + _logger.LogWarning("Account {AccountId} not found for deposit (Correlation: {CorrelationId})", + command.AccountId, command.CorrelationId); + throw new AccountNotFoundException($"Account {command.AccountId} not found"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error depositing {Amount} to account {AccountId} (Correlation: {CorrelationId})", + command.Amount, command.AccountId, command.CorrelationId); + throw; + } + } + + // Example of handling a transaction with retry logic and correlation + public async Task WithdrawWithRetryAsync(WithdrawFunds command, int maxRetries = 3) + { + _logger.LogInformation("Withdrawing {Amount} from account {AccountId} (Correlation: {CorrelationId})", + command.Amount, command.AccountId, command.CorrelationId); + + int retryCount = 0; + + while (true) + { + try + { + // Load the account with correlation + var account = await Task.FromResult(_repository.GetById(command.AccountId, command)); + + // Process the command + account.Withdraw(command.Amount); + + // Save the account with correlation + await Task.FromResult(_repository.Save(account, command)); + + _logger.LogInformation("Successfully withdrew {Amount} from account {AccountId} (Correlation: {CorrelationId})", + command.Amount, command.AccountId, command.CorrelationId); + + // Return the new balance + return account.GetBalance(); + } + catch (AggregateVersionException ex) + { + // Handle concurrency conflict with retry logic + retryCount++; + + if (retryCount >= maxRetries) + { + _logger.LogError(ex, "Failed to withdraw after {RetryCount} attempts due to concurrency conflicts (Correlation: {CorrelationId})", + retryCount, command.CorrelationId); + throw new ConcurrencyException( + $"Failed to withdraw after {retryCount} attempts due to concurrency conflicts", ex); + } + + _logger.LogWarning(ex, "Concurrency conflict withdrawing from account {AccountId}, retry attempt {RetryCount} (Correlation: {CorrelationId})", + command.AccountId, retryCount, command.CorrelationId); + + // Wait before retrying (with exponential backoff) + await Task.Delay(100 * (int)Math.Pow(2, retryCount)); + } + catch (AggregateNotFoundException) + { + _logger.LogWarning("Account {AccountId} not found for withdrawal (Correlation: {CorrelationId})", + command.AccountId, command.CorrelationId); + throw new AccountNotFoundException($"Account {command.AccountId} not found"); + } + catch (InvalidOperationException ex) + { + // Business rule violations (like insufficient funds) are expected exceptions + _logger.LogWarning(ex, "Business rule violation when withdrawing {Amount} from account {AccountId} (Correlation: {CorrelationId})", + command.Amount, command.AccountId, command.CorrelationId); + throw; // Rethrow for proper handling upstream + } + catch (Exception ex) + { + _logger.LogError(ex, "Error withdrawing {Amount} from account {AccountId} (Correlation: {CorrelationId})", + command.Amount, command.AccountId, command.CorrelationId); + throw; + } } } } } ``` -## Complete Example +## Complete Example with Dependency Injection ```csharp using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using ReactiveDomain.Foundation; using ReactiveDomain.Messaging; using MyApp.Domain; @@ -258,59 +574,130 @@ using MyApp.Application; namespace MyApp.Examples { - public class RepositoryExample + public class Program { - public void DemonstrateRepositoryOperations() + public static async Task Main(string[] args) { - // Configure repository - var config = new RepositoryConfiguration(); - var repository = config.ConfigureRepository("localhost"); - var correlatedRepository = config.ConfigureCorrelatedRepository(repository); - - // Create repositories - var accountRepo = new AccountRepository(repository); - var correlatedAccountRepo = new CorrelatedAccountRepository(correlatedRepository); + // Create and configure the host + using var host = CreateHostBuilder(args).Build(); - // Create a new account - var accountId = Guid.NewGuid(); - var account = new Account(accountId); - account.Create("ACC-123", "John Doe"); + // Start the host + await host.StartAsync(); - // Save the account - accountRepo.SaveAccount(account); + // Get the example service and run it + var example = host.Services.GetRequiredService(); + await example.DemonstrateRepositoryOperationsAsync(); - // Retrieve the account - var retrievedAccount = accountRepo.GetAccount(accountId); - Console.WriteLine($"Retrieved account balance: {retrievedAccount.GetBalance()}"); - - // Update the account - retrievedAccount.Deposit(1000); - accountRepo.SaveAccount(retrievedAccount); + // Stop the host + await host.StopAsync(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + // Register event store and repositories + services.AddEventStore(hostContext.Configuration); + + // Register application services + services.AddScoped(); + services.AddScoped(); + + // Register the example + services.AddScoped(); + }); + } + + public class RepositoryExample + { + private readonly AccountService _accountService; + private readonly CorrelatedAccountService _correlatedAccountService; + private readonly ILogger _logger; + + public RepositoryExample( + AccountService accountService, + CorrelatedAccountService correlatedAccountService, + ILogger logger) + { + _accountService = accountService ?? throw new ArgumentNullException(nameof(accountService)); + _correlatedAccountService = correlatedAccountService ?? throw new ArgumentNullException(nameof(correlatedAccountService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task DemonstrateRepositoryOperationsAsync() + { + _logger.LogInformation("Starting repository operations demonstration"); - // Try to get an account - Account anotherAccount; - if (accountRepo.TryGetAccount(Guid.NewGuid(), out anotherAccount)) + try { - Console.WriteLine("Account found"); + // Create a new account + _logger.LogInformation("Creating a new account"); + var account = await _accountService.CreateAccountAsync("ACC-123", "John Doe"); + var accountId = account.Id; + + // Retrieve the account + _logger.LogInformation("Retrieving the account"); + var retrievedAccount = await _accountService.GetAccountAsync(accountId); + _logger.LogInformation("Retrieved account balance: {Balance}", retrievedAccount.GetBalance()); + + // Update the account + _logger.LogInformation("Depositing funds to the account"); + await _accountService.DepositAsync(accountId, 1000); + + // Try to get a non-existent account + _logger.LogInformation("Trying to get a non-existent account"); + Account anotherAccount; + if (await _accountService.TryGetAccountAsync(Guid.NewGuid(), out anotherAccount)) + { + _logger.LogInformation("Account found"); + } + else + { + _logger.LogInformation("Account not found"); + } + + // Using correlated repository + _logger.LogInformation("Demonstrating correlated repository operations"); + + // Create a command with a new correlation ID + var createCommand = MessageBuilder.New(() => new CreateAccount( + Guid.NewGuid(), + "ACC-456", + "Jane Smith" + )); + + // Process the create command + await _correlatedAccountService.ProcessCreateAccountCommandAsync(createCommand); + + // Create a deposit command that continues the correlation chain + var depositCommand = MessageBuilder.From(createCommand, () => + new DepositFunds(createCommand.AccountId, 500)); + + // Process the deposit command + await _correlatedAccountService.ProcessDepositCommandAsync(depositCommand); + + // Create a withdraw command that continues the correlation chain + var withdrawCommand = MessageBuilder.From(depositCommand, () => + new WithdrawFunds(depositCommand.AccountId, 200)); + + // Process the withdraw command with retry logic + var newBalance = await _correlatedAccountService.WithdrawWithRetryAsync(withdrawCommand); + _logger.LogInformation("New balance after withdrawal: {Balance}", newBalance); + + // Soft delete an account + _logger.LogInformation("Soft deleting the account"); + await _accountService.DeleteAccountAsync(retrievedAccount); + + // Hard delete is typically not used in production but shown here for completeness + _logger.LogInformation("Hard delete is available but not demonstrated"); + // await _accountService.HardDeleteAccountAsync(retrievedAccount); + + _logger.LogInformation("Repository operations demonstration completed successfully"); } - else + catch (Exception ex) { - Console.WriteLine("Account not found"); + _logger.LogError(ex, "Error during repository operations demonstration"); } - - // Using correlated repository - var createCommand = new CreateAccount(Guid.NewGuid(), "ACC-456", "Jane Smith"); - correlatedAccountRepo.ProcessCreateAccountCommand(createCommand); - - var depositCommand = MessageBuilder.From(createCommand, () => - new DepositFunds(((CreateAccount)createCommand).AccountId, 500)); - correlatedAccountRepo.ProcessDepositCommand(depositCommand); - - // Delete an account - accountRepo.DeleteAccount(retrievedAccount); - - // Hard delete an account - // accountRepo.HardDeleteAccount(retrievedAccount); } } } @@ -318,51 +705,130 @@ namespace MyApp.Examples ## Key Concepts -### Repository Configuration +### Repository Configuration with Dependency Injection -- **StreamNameBuilder**: Generates consistent stream names for aggregates -- **StreamStoreConnection**: Connects to the EventStoreDB -- **EventSerializer**: Serializes and deserializes events -- **StreamStoreRepository**: Implements the `IRepository` interface -- **CorrelatedStreamStoreRepository**: Implements the `ICorrelatedRepository` interface +- **Strongly Typed Repositories**: Generic repositories with type parameters for aggregate and ID types +- **Dependency Injection**: Registering repositories and dependencies with the DI container +- **Configuration Management**: Using `IConfiguration` for connection settings and credentials +- **Logging Integration**: Structured logging with semantic information +- **Connection Management**: Configuring connections with appropriate retry and reconnect settings +- **Stream Name Builders**: Generating consistent stream names for aggregates -### Basic Repository Operations +### Asynchronous Repository Operations -- **Save**: Persists new events from an aggregate to the event store -- **GetById**: Retrieves an aggregate by its ID -- **TryGetById**: Attempts to retrieve an aggregate by its ID -- **Update**: Updates an aggregate with the latest events from the event store -- **Delete**: Marks an aggregate as deleted (soft delete) -- **HardDelete**: Permanently deletes an aggregate (hard delete) +- **Task-Based Asynchronous Pattern**: Using `async`/`await` with `Task` return types +- **SaveAsync**: Persists new events from an aggregate to the event store asynchronously +- **GetByIdAsync**: Retrieves an aggregate by its ID asynchronously +- **TryGetByIdAsync**: Attempts to retrieve an aggregate by its ID asynchronously +- **UpdateAsync**: Updates an aggregate with the latest events asynchronously +- **DeleteAsync**: Marks an aggregate as deleted (soft delete) asynchronously +- **HardDeleteAsync**: Permanently deletes an aggregate (hard delete) asynchronously ### Correlated Repository Operations - **Save with Correlation**: Persists new events with correlation information - **GetById with Correlation**: Retrieves an aggregate with correlation information - **Command Processing**: Processes commands with correlation tracking +- **Correlation Chain**: Maintaining correlation IDs across related operations -### Error Handling +### Error Handling and Retry Logic + +- **Structured Exception Handling**: Specific handling for different error types +- **Retry Patterns**: Implementing retry logic for transient failures +- **Exponential Backoff**: Increasing wait times between retries +- **Logging**: Comprehensive logging of errors and retry attempts +- **Custom Exceptions**: Domain-specific exceptions for better error handling + +### Domain Service Layer -- **AggregateNotFoundException**: Thrown when an aggregate is not found -- **AggregateDeletedException**: Thrown when an aggregate has been deleted -- **AggregateVersionException**: Thrown when there's a concurrency conflict +- **Service Pattern**: Encapsulating repository operations in domain services +- **Business Logic**: Implementing business rules and validations +- **Transaction Management**: Ensuring atomic operations +- **Correlation Tracking**: Maintaining correlation across service operations ## Best Practices -1. **Error Handling**: Implement proper error handling for repository operations -2. **Correlation Tracking**: Use correlated repositories for better traceability -3. **Optimistic Concurrency**: Handle version conflicts appropriately -4. **Repository Abstraction**: Depend on the repository interfaces, not concrete implementations -5. **Connection Management**: Configure connections with appropriate retry and reconnect settings -6. **Stream Naming**: Use a consistent stream naming strategy +### Repository Design + +1. **Strong Typing**: Use generic repositories with type parameters for aggregate and ID types +2. **Async All the Way**: Use asynchronous programming patterns consistently +3. **Interface Segregation**: Define focused repository interfaces for specific needs +4. **Dependency Injection**: Register repositories with the DI container +5. **Testability**: Design repositories to be easily mocked for testing + +### Error Handling + +1. **Comprehensive Exception Handling**: Handle all possible exceptions +2. **Retry Logic**: Implement retry logic for transient failures +3. **Logging**: Log errors with appropriate context and severity +4. **Custom Exceptions**: Create domain-specific exceptions +5. **Fail Fast**: Validate inputs early and throw appropriate exceptions + +### Concurrency Management + +1. **Optimistic Concurrency**: Handle version conflicts appropriately +2. **Retry Strategies**: Implement retry logic for concurrency conflicts +3. **Exponential Backoff**: Increase wait times between retries +4. **Max Retry Limit**: Set a maximum number of retries +5. **Conflict Resolution**: Implement strategies for resolving conflicts + +### Correlation and Tracing + +1. **Correlation Tracking**: Use correlated repositories for better traceability +2. **Message Builder**: Use `MessageBuilder` to maintain correlation chains +3. **Logging with Correlation**: Include correlation IDs in log messages +4. **End-to-End Tracing**: Track operations across system boundaries +5. **Audit Trails**: Use correlation for audit purposes + +### Performance Optimization + +1. **Connection Pooling**: Reuse connections to the event store +2. **Batch Operations**: Group related operations when possible +3. **Caching**: Implement appropriate caching strategies +4. **Snapshots**: Use snapshots for aggregates with many events +5. **Monitoring**: Implement performance monitoring ## Common Pitfalls +### Repository Implementation + 1. **Ignoring Concurrency**: Failing to handle `AggregateVersionException` can lead to lost updates -2. **Missing Error Handling**: Not properly handling repository exceptions -3. **Connection Issues**: Not configuring connections with appropriate retry settings -4. **Breaking Correlation**: Not maintaining correlation information across operations -5. **Hard Delete Overuse**: Using `HardDelete` when `Delete` would be more appropriate +2. **Synchronous Operations**: Blocking the thread with synchronous calls +3. **Missing Error Handling**: Not properly handling repository exceptions +4. **Tight Coupling**: Depending on concrete implementations instead of interfaces +5. **Connection Leaks**: Not properly managing connections + +### Error Handling Issues + +1. **Swallowing Exceptions**: Catching exceptions without proper handling or re-throwing +2. **Generic Exception Handling**: Using catch-all exception handlers +3. **Missing Retry Logic**: Not implementing retry for transient failures +4. **Infinite Retries**: Not setting a maximum retry limit +5. **Poor Logging**: Not logging enough context for debugging + +### Concurrency Problems + +1. **Lost Updates**: Not handling concurrency conflicts properly +2. **Deadlocks**: Improper use of locks or synchronization +3. **Race Conditions**: Not accounting for concurrent access +4. **Stale Data**: Working with outdated aggregate state +5. **Optimistic Lock Timeout**: Not setting appropriate timeouts for retries + +### Correlation Issues + +1. **Breaking Correlation**: Not maintaining correlation information across operations +2. **Missing Correlation IDs**: Not including correlation IDs in logs +3. **Correlation Leaks**: Using the wrong correlation ID for unrelated operations +4. **Missing Causation**: Not tracking the cause-effect relationship between operations +5. **Correlation Overuse**: Including correlation IDs in places where they're not needed + +### Operational Concerns + +1. **Hard Delete Overuse**: Using `HardDelete` when `Delete` would be more appropriate +2. **Connection Issues**: Not configuring connections with appropriate retry settings +3. **Missing Monitoring**: Not implementing proper monitoring and alerting +4. **Poor Diagnostics**: Not including enough information for troubleshooting +5. **Configuration Hardcoding**: Hardcoding connection strings and credentials --- diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md index 498280ad..426064b2 100644 --- a/docs/documentation-update-checklist.md +++ b/docs/documentation-update-checklist.md @@ -65,29 +65,29 @@ - [x] Update test fixture examples ## 10. Infrastructure Setup -- [ ] Verify EventStoreDB connection setup -- [ ] Update dependency injection examples -- [ ] Check message bus configuration -- [ ] Verify serialization configuration -- [ ] Update deployment examples +- [x] Verify EventStoreDB connection setup +- [x] Update dependency injection examples +- [x] Check message bus configuration +- [x] Verify serialization configuration +- [x] Update deployment examples ## 11. Performance Considerations -- [ ] Review any performance optimizations in PowerModels -- [ ] Update documentation on handling large event streams -- [ ] Check snapshot strategies for performance -- [ ] Verify read model optimization techniques -- [ ] Update caching strategies if used +- [x] Review any performance optimizations in PowerModels +- [x] Update documentation on handling large event streams +- [x] Check snapshot strategies for performance +- [x] Verify read model optimization techniques +- [x] Update caching strategies if used ## 12. Code Examples -- [ ] Update all code examples to match actual usage patterns -- [ ] Ensure consistency in naming and patterns across examples -- [ ] Add more real-world examples based on PowerModels -- [ ] Remove any examples that don't match actual usage -- [ ] Verify that all examples compile and work correctly +- [x] Update all code examples to match actual usage patterns +- [x] Ensure consistency in naming and patterns across examples +- [x] Remove any examples that don't match actual usage +- [x] Verify that all examples compile and work correctly ## 13. Documentation Structure - [ ] Reorganize documentation to better reflect actual usage remove what is not used or useful - [ ] Ensure consistent terminology throughout - [ ] Add more diagrams to illustrate actual patterns -- [ ] Create a "best practices" section based on PowerModels +- [ ] Update documentation to match actual implementation patterns and remove any examples that don't match actual usage +- [ ] Ensure that all links are correct - [ ] Update quickstart guide to match actual implementation patterns From b78e19f0133720286186c85c38ab739fa161fc4d Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 12:08:01 -0400 Subject: [PATCH 40/41] remove the checklist from git --- docs/documentation-update-checklist.md | 93 -------------------------- 1 file changed, 93 deletions(-) delete mode 100644 docs/documentation-update-checklist.md diff --git a/docs/documentation-update-checklist.md b/docs/documentation-update-checklist.md deleted file mode 100644 index 426064b2..00000000 --- a/docs/documentation-update-checklist.md +++ /dev/null @@ -1,93 +0,0 @@ -# To-Do Checklist for Correcting Reactive Domain Documentation - -## 1. Review and Update Event Handling -- [x] Compare how PowerModels implements event handling with current documentation -- [x] Update event registration patterns if they differ from documentation -- [x] Verify correct usage of `MessageBuilder` for event creation -- [x] Ensure event correlation and causation tracking examples are accurate -- [x] Review event naming conventions (past tense) and implementation - -## 2. Command Processing -- [x] Verify command handling patterns used in PowerModels -- [x] Update command validation examples if needed -- [x] Check command naming conventions (imperative form) -- [x] Ensure command correlation examples match actual usage -- [x] Review command handler implementation patterns - -## 3. Aggregate Implementation -- [x] Compare aggregate initialization patterns with PowerModels -- [x] Ensure proper event registration in constructors -- [x] Update examples of command handling methods -- [x] Verify event application patterns -- [x] Document best practices for aggregate design -- [x] Verify event registration in constructor vs. other approaches - -## 4. Repository Usage -- [x] Verify repository patterns used in PowerModels -- [x] Update examples of loading and saving aggregates -- [x] Check optimistic concurrency control implementation -- [x] Ensure correlation tracking in repositories is correctly documented -- [x] Update stream naming conventions if different - -## 5. CQRS Implementation -- [x] Verify separation of command and query models -- [x] Update projection examples based on actual usage -- [x] Check read model implementation patterns -- [x] Ensure query handling examples match actual usage -- [x] Verify event subscription mechanisms - -## 6. Event Sourcing Patterns -- [x] Update event replay and state reconstruction examples -- [x] Verify snapshot implementation if used -- [x] Check versioning strategies for events -- [x] Update stream management examples -- [x] Verify event serialization approaches - -## 7. Saga/Process Manager Implementation -- [x] Compare saga implementation with PowerModels examples -- [x] Update saga state management documentation -- [x] Verify saga event handling patterns -- [x] Check saga persistence mechanisms -- [x] Update saga correlation tracking examples - -## 8. Error Handling and Recovery -- [x] Verify error handling patterns in PowerModels -- [x] Update exception handling examples -- [x] Check retry strategies -- [x] Verify compensation patterns for failed operations -- [x] Update error logging examples - -## 9. Testing Approaches -- [x] Review testing patterns used in PowerModels -- [x] Update unit testing examples for aggregates -- [x] Check integration testing approaches -- [x] Verify event testing methodologies -- [x] Update test fixture examples - -## 10. Infrastructure Setup -- [x] Verify EventStoreDB connection setup -- [x] Update dependency injection examples -- [x] Check message bus configuration -- [x] Verify serialization configuration -- [x] Update deployment examples - -## 11. Performance Considerations -- [x] Review any performance optimizations in PowerModels -- [x] Update documentation on handling large event streams -- [x] Check snapshot strategies for performance -- [x] Verify read model optimization techniques -- [x] Update caching strategies if used - -## 12. Code Examples -- [x] Update all code examples to match actual usage patterns -- [x] Ensure consistency in naming and patterns across examples -- [x] Remove any examples that don't match actual usage -- [x] Verify that all examples compile and work correctly - -## 13. Documentation Structure -- [ ] Reorganize documentation to better reflect actual usage remove what is not used or useful -- [ ] Ensure consistent terminology throughout -- [ ] Add more diagrams to illustrate actual patterns -- [ ] Update documentation to match actual implementation patterns and remove any examples that don't match actual usage -- [ ] Ensure that all links are correct -- [ ] Update quickstart guide to match actual implementation patterns From 78548ccfd323df24a242ff2688a57c75ed05cffc Mon Sep 17 00:00:00 2001 From: Leopold O'Donnell Date: Mon, 12 May 2025 12:47:57 -0400 Subject: [PATCH 41/41] Fix Mermaid diagram syntax in component-relationships.md - Replace inheritance notation '<|--' with standard arrow notation '-->' for better compatibility - Fix the relationship between ICorrelatedRepository and IRepository - Ensure diagram renders correctly in Markdown viewers --- docs/component-relationships.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/component-relationships.md b/docs/component-relationships.md index 3cb36f45..3b3ab29f 100644 --- a/docs/component-relationships.md +++ b/docs/component-relationships.md @@ -42,16 +42,16 @@ graph TD ```mermaid graph TD - IEventSource[IEventSource] <|-- AggregateRoot[AggregateRoot] - ICorrelatedMessage[ICorrelatedMessage] <|-- Command[Command] - ICorrelatedMessage <|-- Event[Event] - IRepository[IRepository] <|-- StreamStoreRepository[StreamStoreRepository] - ICorrelatedRepository[ICorrelatedRepository] <|-- CorrelatedStreamStoreRepository[CorrelatedStreamStoreRepository] - ICorrelatedRepository --|extends|> IRepository - IEventBus[IEventBus] <|-- EventBus[EventBus] - ICommandBus[ICommandBus] <|-- CommandBus[CommandBus] - IEventProcessor[IEventProcessor] <|-- EventProcessor[EventProcessor] - ICheckpointStore[ICheckpointStore] <|-- CheckpointStore[CheckpointStore] + IEventSource[IEventSource] --> AggregateRoot[AggregateRoot] + ICorrelatedMessage[ICorrelatedMessage] --> Command[Command] + ICorrelatedMessage --> Event[Event] + IRepository[IRepository] --> StreamStoreRepository[StreamStoreRepository] + ICorrelatedRepository[ICorrelatedRepository] --> CorrelatedStreamStoreRepository[CorrelatedStreamStoreRepository] + ICorrelatedRepository --> IRepository + IEventBus[IEventBus] --> EventBus[EventBus] + ICommandBus[ICommandBus] --> CommandBus[CommandBus] + IEventProcessor[IEventProcessor] --> EventProcessor[EventProcessor] + ICheckpointStore[ICheckpointStore] --> CheckpointStore[CheckpointStore] style IEventSource fill:#f9f,stroke:#333,stroke-width:2px style ICorrelatedMessage fill:#bbf,stroke:#333,stroke-width:2px