Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ NetEvolve Pulse delivers a high-performance CQRS mediator with an interceptor-en

- Typed CQRS mediator with single-handler enforcement for commands and queries, plus fan-out event dispatch
- Interceptor pipeline for logging, metrics, tracing, validation, retries, and other cross-cutting concerns via `IMediatorConfigurator`
- **Distributed query caching** via `ICacheableQuery<TResponse>` and `AddQueryCaching()` — transparent `IDistributedCache` integration with configurable `JsonSerializerOptions` and absolute/sliding expiration via `QueryCachingOptions`
- **Outbox pattern** with background processor for reliable event delivery via `AddOutbox()`
- OpenTelemetry-friendly hooks through `AddActivityAndMetrics()` and TimeProvider-aware flows for deterministic testing and scheduling
- Minimal DI setup with `services.AddPulse(...)`, scoped lifetimes, and opt-in configurators per application
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse;
namespace NetEvolve.Pulse;

using System.Linq;
using Microsoft.Extensions.DependencyInjection;
Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Dapr/DaprMessageTransport.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse;
namespace NetEvolve.Pulse;

using System.Text.Json;
using Dapr.Client;
Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Dapr/DaprMessageTransportOptions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse;
namespace NetEvolve.Pulse;

using NetEvolve.Pulse.Extensibility;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Configurations;
namespace NetEvolve.Pulse.Configurations;

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse;
namespace NetEvolve.Pulse;

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse;
namespace NetEvolve.Pulse;

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse;
namespace NetEvolve.Pulse;

using Microsoft.EntityFrameworkCore;
using NetEvolve.Pulse.Extensibility;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse;
namespace NetEvolve.Pulse;

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.EntityFramework/IOutboxDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse;
namespace NetEvolve.Pulse;

using Microsoft.EntityFrameworkCore;
using NetEvolve.Pulse.Configurations;
Expand Down
23 changes: 23 additions & 0 deletions src/NetEvolve.Pulse.Extensibility/CacheExpirationMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Specifies how cached entries created by the distributed cache interceptor should expire.
/// </summary>
/// <seealso cref="QueryCachingOptions"/>
/// <seealso cref="ICacheableQuery{TResponse}"/>
public enum CacheExpirationMode
{
/// <summary>
/// The cache entry expires at an absolute point in time calculated as
/// the current instant plus <see cref="ICacheableQuery{TResponse}.Expiry"/>.
/// This is the default mode.
/// </summary>
Absolute = 0,

/// <summary>
/// The cache entry expiry window is reset each time the entry is accessed.
/// The entry expires after <see cref="ICacheableQuery{TResponse}.Expiry"/> elapses
/// since the last access.
/// </summary>
Sliding = 1,
}
53 changes: 53 additions & 0 deletions src/NetEvolve.Pulse.Extensibility/ICacheableQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Represents a query that supports distributed caching of its response.
/// Queries implementing this interface are eligible for transparent caching via
/// <c>DistributedCacheQueryInterceptor</c>.
/// </summary>
/// <typeparam name="TResponse">The type of data returned by the query.</typeparam>
/// <remarks>
/// <para><strong>Opt-In Caching:</strong></para>
/// Caching is opt-in per query type. Only queries that implement <see cref="ICacheableQuery{TResponse}"/>
/// will be cached by the interceptor. Queries that do not implement this interface always reach the handler.
/// <para><strong>Cache Key:</strong></para>
/// The <see cref="CacheKey"/> property uniquely identifies the cached entry. Ensure it is deterministic
/// and unique per logical query result (e.g., include query parameters in the key).
/// <para><strong>Expiry:</strong></para>
/// When <see cref="Expiry"/> is <see langword="null"/>, the entry is stored without an explicit expiry,
/// relying on the cache's default eviction policy. Provide a <see cref="TimeSpan"/> value to set an
/// absolute expiration relative to the time of caching.
/// </remarks>
/// <example>
/// <code>
/// public record GetCustomerByIdQuery(string CustomerId)
/// : ICacheableQuery&lt;CustomerDetailsDto&gt;
/// {
/// public string? CorrelationId { get; set; }
/// public string CacheKey =&gt; $"customer:{CustomerId}";
/// public TimeSpan? Expiry =&gt; TimeSpan.FromMinutes(5);
/// }
/// </code>
/// </example>
/// <seealso cref="IQuery{TResponse}"/>
public interface ICacheableQuery<TResponse> : IQuery<TResponse>
{
/// <summary>
/// Gets the key used to store and retrieve the response from the distributed cache.
/// </summary>
/// <remarks>
/// The key must be unique for each distinct query result. Include relevant query parameters
/// to avoid cache collisions between different query instances.
/// </remarks>
string CacheKey { get; }

/// <summary>
/// Gets the duration after which the cached entry expires, or <see langword="null"/> to use the cache's default.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, the entry is stored without an explicit absolute expiration.
/// When a <see cref="TimeSpan"/> value is provided, it is used as the absolute expiration
/// relative to the time of caching.
/// </remarks>
TimeSpan? Expiry { get; }
}
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Extensibility/IEventDispatcher.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Defines the contract for dispatching events to their registered handlers.
Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Extensibility/IEventOutbox.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Defines the contract for an outbox that stores events for reliable delivery.
Expand Down
47 changes: 47 additions & 0 deletions src/NetEvolve.Pulse.Extensibility/IMediatorConfigurator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,53 @@ public interface IMediatorConfigurator
/// <seealso href="https://prometheus.io/docs/introduction/overview/">Prometheus Documentation</seealso>
IMediatorConfigurator AddActivityAndMetrics();

/// <summary>
/// Registers the distributed cache interceptor for queries.
/// Queries implementing <see cref="ICacheableQuery{TResponse}"/> are transparently served from
/// <c>IDistributedCache</c> when a cache entry exists,
/// or stored after a cache miss.
/// </summary>
/// <param name="configure">
/// An optional delegate that configures <see cref="QueryCachingOptions"/>.
/// When <see langword="null"/>, default options are used.
/// </param>
/// <returns>The current configurator instance for method chaining.</returns>
/// <remarks>
/// <para><strong>Opt-In:</strong></para>
/// Only queries that implement <see cref="ICacheableQuery{TResponse}"/> participate in caching.
/// All other queries pass through to the handler unchanged.
/// <para><strong>Prerequisite:</strong></para>
/// An <c>IDistributedCache</c> implementation must be
/// registered in the DI container (e.g. via <c>services.AddDistributedMemoryCache()</c>).
/// When no cache is registered the interceptor falls through without error.
/// <para><strong>Custom Serialization:</strong></para>
/// Use <see cref="QueryCachingOptions.JsonSerializerOptions"/> to supply custom
/// <see cref="System.Text.Json.JsonSerializerOptions"/> for cache serialization.
/// <para><strong>Expiration Mode:</strong></para>
/// Use <see cref="QueryCachingOptions.ExpirationMode"/> to choose between
/// absolute (<see cref="CacheExpirationMode.Absolute"/>, default) and
/// sliding (<see cref="CacheExpirationMode.Sliding"/>) cache expiration.
/// </remarks>
/// <example>
/// <code>
/// services.AddDistributedMemoryCache();
/// services.AddPulse(config =>
/// {
/// config.AddQueryCaching(options =>
/// {
/// options.ExpirationMode = CacheExpirationMode.Sliding;
/// options.JsonSerializerOptions = new JsonSerializerOptions
/// {
/// PropertyNamingPolicy = JsonNamingPolicy.CamelCase
/// };
/// });
/// });
/// </code>
/// </example>
/// <seealso cref="ICacheableQuery{TResponse}"/>
/// <seealso cref="QueryCachingOptions"/>
IMediatorConfigurator AddQueryCaching(Action<QueryCachingOptions>? configure = null);

/// <summary>
/// Configures a custom event dispatcher to control how events are dispatched to their handlers.
/// This allows customization of the execution strategy (parallel, sequential, rate-limited, etc.).
Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Extensibility/IMessageTransport.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Defines the contract for transporting outbox messages to external systems or handlers.
Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Extensibility/IOutboxRepository.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Defines the contract for outbox message persistence operations.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Defines the contract for managing transaction scope when storing outbox messages.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Defines a handler for processing events that supports priority-based ordering.
Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Extensibility/OutboxMessage.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Represents a message stored in the outbox for reliable delivery.
Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Extensibility/OutboxMessageSchema.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

using System.Diagnostics.CodeAnalysis;

Expand Down
2 changes: 1 addition & 1 deletion src/NetEvolve.Pulse.Extensibility/OutboxMessageStatus.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Extensibility;
namespace NetEvolve.Pulse.Extensibility;

/// <summary>
/// Represents the processing status of an outbox message.
Expand Down
73 changes: 73 additions & 0 deletions src/NetEvolve.Pulse.Extensibility/QueryCachingOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
namespace NetEvolve.Pulse.Extensibility;

using System.Text.Json;

/// <summary>
/// Configuration options for the distributed query caching interceptor.
/// </summary>
/// <remarks>
/// Configure these options when calling <see cref="IMediatorConfigurator.AddQueryCaching"/>.
/// </remarks>
/// <seealso cref="IMediatorConfigurator.AddQueryCaching"/>
/// <seealso cref="ICacheableQuery{TResponse}"/>
public sealed class QueryCachingOptions
{
/// <summary>
/// Gets or sets the <see cref="System.Text.Json.JsonSerializerOptions"/> used when
/// serializing responses to the cache and deserializing them back.
/// </summary>
/// <remarks>
/// Defaults to <see cref="JsonSerializerOptions.Default"/>.
/// Provide custom options when your response types require non-default converters,
/// naming policies, or other serialization settings.
/// </remarks>
public JsonSerializerOptions JsonSerializerOptions { get; set; } = JsonSerializerOptions.Default;

/// <summary>
/// Gets or sets how the <see cref="ICacheableQuery{TResponse}.Expiry"/> value is interpreted
/// when storing entries in the distributed cache.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>
/// <description>
/// <see cref="CacheExpirationMode.Absolute"/> (default) — sets
/// <c>DistributedCacheEntryOptions.AbsoluteExpirationRelativeToNow</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="CacheExpirationMode.Sliding"/> — sets
/// <c>DistributedCacheEntryOptions.SlidingExpiration</c>, resetting the
/// expiry window on each cache access.
/// </description>
/// </item>
/// </list>
/// When <see cref="ICacheableQuery{TResponse}.Expiry"/> is <see langword="null"/>,
/// <see cref="DefaultExpiry"/> is used if set; otherwise no expiry is applied and the
/// cache's default eviction policy is used.
/// </remarks>
public CacheExpirationMode ExpirationMode { get; set; } = CacheExpirationMode.Absolute;

/// <summary>
/// Gets or sets the fallback expiry duration used when
/// <see cref="ICacheableQuery{TResponse}.Expiry"/> returns <see langword="null"/>.
/// </summary>
/// <remarks>
/// When <see langword="null"/> (default), entries whose query returns a <see langword="null"/>
/// <see cref="ICacheableQuery{TResponse}.Expiry"/> are stored without an explicit expiration,
/// relying on the cache's default eviction policy.
/// When a <see cref="TimeSpan"/> value is provided, it is applied according to
/// <see cref="ExpirationMode"/> in the same way as a per-query expiry would be.
/// </remarks>
/// <example>
/// <code>
/// services.AddPulse(config => config.AddQueryCaching(options =>
/// {
/// // All queries without an explicit Expiry get a 10-minute TTL
/// options.DefaultExpiry = TimeSpan.FromMinutes(10);
/// }));
/// </code>
/// </example>
public TimeSpan? DefaultExpiry { get; set; }
}
54 changes: 54 additions & 0 deletions src/NetEvolve.Pulse.Extensibility/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ NetEvolve.Pulse.Extensibility delivers the core contracts for building CQRS medi
- Strongly typed handler interfaces with single-handler guarantees for commands and queries
- Interceptor interfaces for cross-cutting concerns (logging, validation, metrics, caching)
- Fluent mediator configuration via `IMediatorConfigurator` and extension methods
- **`ICacheableQuery<TResponse>`** — opt-in caching contract that pairs with the `AddQueryCaching()` interceptor in `NetEvolve.Pulse`
- **Outbox pattern contracts** including `IEventOutbox`, `IOutboxRepository`, and `IMessageTransport`
- Designed for framework-agnostic use while pairing seamlessly with NetEvolve.Pulse
- Test-friendly primitives including `Void` responses and TimeProvider awareness
Expand Down Expand Up @@ -113,6 +114,59 @@ services.AddPulse(config =>
});
```

### Cacheable Queries (`ICacheableQuery<TResponse>`)

Opt specific queries into transparent `IDistributedCache` caching by implementing `ICacheableQuery<TResponse>` instead of `IQuery<TResponse>`. The interface adds two properties that control the cache entry:

```csharp
using NetEvolve.Pulse.Extensibility;

// Implement ICacheableQuery<TResponse> on the queries you want cached
public record GetCustomerByIdQuery(Guid CustomerId)
: ICacheableQuery<CustomerDetailsDto>
{
public string? CorrelationId { get; set; }

// Unique cache key — include all parameters that distinguish results
public string CacheKey => $"customer:{CustomerId}";

// null = rely on cache default; TimeSpan = expiry duration
public TimeSpan? Expiry => TimeSpan.FromMinutes(10);
}

public record CustomerDetailsDto(Guid Id, string Name, string Email);
```

To activate caching, register an `IDistributedCache` implementation and call `AddQueryCaching()` during Pulse setup (requires `NetEvolve.Pulse`). Use `QueryCachingOptions` to configure serialization and expiration behavior:

```csharp
services.AddDistributedMemoryCache();
services.AddPulse(config => config.AddQueryCaching(options =>
{
// Absolute (default) or Sliding expiration
options.ExpirationMode = CacheExpirationMode.Sliding;

// Fallback expiry for queries that return null from ICacheableQuery<TResponse>.Expiry
options.DefaultExpiry = TimeSpan.FromMinutes(10);

// Custom JSON serializer options
options.JsonSerializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
}));
```

`QueryCachingOptions` properties:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `JsonSerializerOptions` | `JsonSerializerOptions` | `JsonSerializerOptions.Default` | Options used for cache serialization and deserialization |
| `ExpirationMode` | `CacheExpirationMode` | `Absolute` | Whether `Expiry` is treated as absolute or sliding expiration |
| `DefaultExpiry` | `TimeSpan?` | `null` | Fallback expiry used when `ICacheableQuery<TResponse>.Expiry` returns `null` |

Queries that do **not** implement `ICacheableQuery<TResponse>` always reach the handler unchanged. When `IDistributedCache` is not registered the interceptor falls through silently.

## Configuration

```csharp
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace NetEvolve.Pulse.Interceptors;
namespace NetEvolve.Pulse.Interceptors;

using System;
using Microsoft.Extensions.DependencyInjection;
Expand Down
Loading
Loading