diff --git a/benchmarks/repobench/repobench.csproj b/benchmarks/repobench/repobench.csproj
index a3070202..4dc1acf0 100644
--- a/benchmarks/repobench/repobench.csproj
+++ b/benchmarks/repobench/repobench.csproj
@@ -7,6 +7,7 @@
enable
enable
Kista.Benchmarks
+ false
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index af77d130..7907f625 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -20,6 +20,7 @@
* [Overview](filtering/index.md)
* [Filter Cache](filtering/filter-cache.md)
* [The Entity Manager](entity-manager/README.md)
+ * [Entity Validation](entity-manager/entity-validation.md)
* [HTTP Request Cancellation](entity-manager/http-request-cancellation.md)
* [Caching Entities](entity-manager/caching-entities.md)
* [Sample Application](sample-app.md)
diff --git a/docs/custom-repository/registration.md b/docs/custom-repository/registration.md
index d72f9e28..5104b46d 100644
--- a/docs/custom-repository/registration.md
+++ b/docs/custom-repository/registration.md
@@ -4,7 +4,13 @@ This page covers how to register custom repositories with the DI container using
## Basic Registration
-Use `AddRepository()` on the repository context builder:
+Use `AddRepository()` on the repository context builder. The driver's open-generic registrations (e.g. `InMemoryRepository<>` from `.UseInMemory()`) serve as defaults for all entities; `AddRepository()` registers a concrete type that overrides the open generic for that specific entity:
+
+```csharp
+builder.Services.AddRepositoryContext()
+ .UseInMemory() // default: InMemoryRepository for all entities
+ .AddRepository(); // override: SpecialProductRepo for Product only
+```
```csharp
builder.Services.AddRepositoryContext()
diff --git a/docs/entity-manager/README.md b/docs/entity-manager/README.md
index 06045a29..0a7c2020 100644
--- a/docs/entity-manager/README.md
+++ b/docs/entity-manager/README.md
@@ -1,56 +1,91 @@
# The Entity Manager
+
> **Renamed:** This project was renamed from **Deveel.Repository** to **Kista** on **May 26, 2025**. The name *Kista* is Old Norse for "chest" or "repository", better reflecting the project purpose as a data access framework.
-The `EntityManager` class is an optional application-layer service that wraps an `IRepository` and enriches every operation with cross-cutting concerns:
+The `EntityManager` (and `EntityManager`) is an optional application-layer service that wraps an `IRepository` and enriches every operation with cross-cutting concerns:
-- **Validation** — entities are validated (via `IEntityValidator`) before being added or updated.
-- **Caching** — frequently accessed entities can be served from a second-level cache (via `IEntityCache`).
+- **Validation** — entities are validated before being added or updated.
+- **Caching** — frequently accessed entities can be served from a second-level cache.
- **Timestamping** — entities implementing `IHaveTimeStamp` are automatically stamped on create/update.
- **Structured error handling** — operations return structured error results rather than throwing raw exceptions.
- **Logging** — all operations are logged through the standard `ILogger` infrastructure.
-`EntityManager` is designed to be used at the **application service layer**, sitting between your controllers / use-case handlers and the underlying repository.
+`EntityManager` is designed to sit between your controllers / use-case handlers and the underlying repository, at the **application service layer**.
-## Constructor
+## Prerequisites
-```csharp
-public EntityManager(
- IRepository repository,
- IEntityValidator? validator = null,
- IEntityCache? cache = null,
- ISystemTime? systemTime = null,
- IOperationErrorFactory? errorFactory = null,
- IServiceProvider? services = null,
- ILoggerFactory? loggerFactory = null)
-```
+The entity manager is packaged separately from the core repository:
+
+| Package | Description |
+|---------|-------------|
+| `Kista.Manager` | Base manager, validation abstractions, error factories |
+| `Kista.Manager.EasyCaching` | Second-level caching via EasyCaching |
+| `Kista.Manager.AspNetCore` | Automatic HTTP request cancellation for ASP.NET Core |
+| `Kista.Manager.DynamicLinq` | Dynamic LINQ query extensions for the manager |
-All parameters beyond `repository` are optional and resolved from the DI container at registration time (see below).
+```bash
+dotnet add package Kista.Manager
+```
## Registration
-Use `AddEntityManager` to register a concrete manager type (or a custom sub-class):
+There are two ways to register an entity manager: **per-repository** (explicit, recommended) and **global** (convenience for bulk registration).
+
+### Per-repository (recommended)
+
+Use `WithManagement()` on the `RepositoryBuilder` returned by `AddRepository()`. This binds the manager to a specific repository and entity type:
```csharp
-// Program.cs
+services.AddRepositoryContext()
+ .AddRepository(repo => repo
+ .WithManagement(mgmt => mgmt
+ .WithValidator()
+ .WithCacheKeyGenerator()
+ .WithOperationErrorFactory()
+ .WithEasyCaching(opts =>
+ {
+ opts.DefaultExpiration = TimeSpan.FromMinutes(15);
+ })))
+ .UseInMemory();
+```
-// Register the default EntityManager for MyEntity
-builder.Services.AddManagerFor();
+To register a manager with no additional services (the EntityManager itself is still registered):
-// Or register a custom sub-class that derives from EntityManager
-builder.Services.AddEntityManager();
+```csharp
+services.AddRepositoryContext()
+ .AddRepository(repo => repo
+ .WithManagement())
+ .UseInMemory();
```
-After registration, the DI container provides both the concrete type and all `EntityManager` base-type projections.
+### Global (convenience)
+
+Use `WithManagement()` on the `RepositoryContextBuilder` to register managers for **all tracked entity types** in one call:
+
+```csharp
+services.AddRepositoryContext()
+ .AddRepository(_ => { })
+ .AddRepository(_ => { })
+ .WithManagement() // registers EntityManager and EntityManager
+ .UseInMemory();
+```
-To register a custom validator:
+You can pass a `ManagementOptions` delegate to control auto-registration:
```csharp
-builder.Services.AddEntityValidator();
+services.AddRepositoryContext()
+ .AddRepository(_ => { })
+ .WithManagement(opts =>
+ {
+ opts.AutoRegisterManagers = true; // default: true
+ });
```
+> Both approaches coexist. Use per-repository when you need entity-specific configuration; use global when you want quick setup with no extras.
+
## Custom Managers
-Derive from `EntityManager` to add domain-specific business operations:
+Derive from `EntityManager` (or `EntityManager`) to add domain-specific business operations:
```csharp
public class OrderManager : EntityManager
@@ -66,7 +101,7 @@ public class OrderManager : EntityManager
{
var order = await FindAsync(orderId, ct);
if (order == null)
- return NotFound("ORDER_NOT_FOUND");
+ return OperationResult.Fail(...);
order.Ship();
return await UpdateAsync(order, ct);
@@ -74,22 +109,57 @@ public class OrderManager : EntityManager
}
```
+Register a custom manager by registering it directly in the DI container:
+
+```csharp
+services.AddScoped();
+```
+
+The base `EntityManager` is registered automatically when using `WithManagement()`, so DI can resolve both the base type and your custom type.
+
+## Operation Results
+
+EntityManager methods return `OperationResult` (for void-like operations) or `OperationResult` (for operations that return a value), rather than throwing exceptions for expected failures:
+
+| Method | Returns |
+|--------|---------|
+| `AddAsync(entity)` | `OperationResult` |
+| `UpdateAsync(entity)` | `OperationResult` |
+| `RemoveAsync(entity)` | `OperationResult` |
+| `FindAsync(key)` | `OperationResult` |
+| `FindFirstAsync(query)` | `OperationResult` |
+
+```csharp
+var result = await manager.AddAsync(person);
+if (result.IsSuccess)
+{
+ // entity added
+}
+else
+{
+ var error = result.Error;
+ // error.ErrorCode, error.Message
+}
+```
+
+This allows the caller to handle validation failures, not-found conditions, and infrastructure errors uniformly without try/catch blocks.
+
## Operation Cancellation
-Every async method accepts an optional `CancellationToken`. When no token is supplied (or `null` is passed), the manager checks for an `IOperationCancellationSource` registered in the DI container and uses its token automatically.
+Every async method accepts an optional `CancellationToken`. When no token is supplied, the manager checks for an `IOperationCancellationSource` registered in the DI container and uses its token automatically.
-This is useful for tying operation lifetime to an HTTP request:
+For ASP.NET Core, install `Kista.Manager.AspNetCore` and call `AddHttpRequestTokenSource()`:
```csharp
-// Register the ASP.NET Core HTTP cancellation source
-// (provided by Kista.Manager.AspNetCore)
builder.Services.AddHttpRequestTokenSource();
```
-With this in place, when the HTTP request is aborted, all in-flight repository operations are cancelled without any additional code in your manager.
+When the HTTP client disconnects, all in-flight repository operations are cancelled automatically.
See [HTTP Request Cancellation](http-request-cancellation.md) for full details.
-## Caching
+## See Also
-See [Caching Entities](caching-entities.md) for details on integrating second-level caching via [EasyCaching](https://easycaching.readthedocs.io/en/latest/).
+- [Entity Validation](entity-validation.md) — validators, error factories, and the validation flow
+- [Caching Entities](caching-entities.md) — cache registration, key generators, serialization
+- [HTTP Request Cancellation](http-request-cancellation.md) — automatic cancellation via ASP.NET Core
diff --git a/docs/entity-manager/caching-entities.md b/docs/entity-manager/caching-entities.md
index ba810fcd..d001dea3 100644
--- a/docs/entity-manager/caching-entities.md
+++ b/docs/entity-manager/caching-entities.md
@@ -1,7 +1,8 @@
# Caching Entities
+
> **Renamed:** This project was renamed from **Deveel.Repository** to **Kista** on **May 26, 2025**. The name *Kista* is Old Norse for "chest" or "repository", better reflecting the project purpose as a data access framework.
-The `EntityManager` supports an optional second-level cache via the `IEntityCache` service. When registered, the manager transparently caches entities on write and serves them from the cache on subsequent reads, reducing the number of calls to the underlying repository.
+The `EntityManager` supports an optional second-level cache via the `IEntityCache` service. When registered, the manager transparently caches entities on write and serves them from the cache on subsequent reads, reducing calls to the underlying repository.
## Installation
@@ -11,28 +12,58 @@ Install the EasyCaching integration package:
dotnet add package Kista.Manager.EasyCaching
```
+EasyCaching must be configured globally with a provider (e.g., in-memory, Redis, Memcached):
+
+```csharp
+builder.Services.AddEasyCaching(options =>
+ options.UseInMemory("default"));
+```
+
## Registration
-Register EasyCaching and the entity cache in the DI container:
+### Via EntityManagerBuilder (recommended)
+
+When using the per-repository `WithManagement()` callback, use `WithEasyCaching()` on the `EntityManagerBuilder`:
```csharp
-// Program.cs
-builder.Services.AddEasyCaching(options =>
-{
- options.UseInMemory("default");
-});
+services.AddRepositoryContext()
+ .AddRepository(repo => repo
+ .WithManagement(mgmt => mgmt
+ .WithEasyCaching(opts =>
+ {
+ opts.DefaultExpiration = TimeSpan.FromMinutes(15);
+ })))
+ .UseInMemory();
+```
+
+### Global registration
+
+Enable caching for all tracked entity types via the `RepositoryContextBuilder`:
+
+```csharp
+services.AddRepositoryContext()
+ .AddRepository(_ => { })
+ .AddRepository(_ => { })
+ .WithEasyCaching(opts =>
+ {
+ opts.DefaultExpiration = TimeSpan.FromMinutes(5);
+ });
+```
+
+### Direct registration (legacy)
-// Register the default EntityEasyCache
+The following methods on `IServiceCollection` are still supported but deprecated in favor of the fluent builder:
+
+```csharp
builder.Services.AddEntityEasyCacheFor();
-// Then register the manager (which picks up the cache automatically)
builder.Services.AddManagerFor();
```
You can configure cache options inline or from configuration:
```csharp
-// Inline configuration
+// Inline
builder.Services.AddEntityEasyCacheFor(options =>
{
options.Expiration = TimeSpan.FromMinutes(5);
@@ -55,26 +86,49 @@ The `EntityManager` intercepts the following operations to read from or
| `RemoveAsync` | `RemoveAsync` | Evicts the entity from the cache after removal. |
| `RemoveRangeAsync` | `RemoveAsync` (batch) | Evicts all removed entities from the cache. |
+### Cache invalidation
+
+The manager automatically invalidates cached entries when entities are updated or removed. There is no time-based invalidation by default; expiration is controlled by the EasyCaching provider configuration (e.g., `DefaultExpiration` in `WithEasyCaching()`).
+
+If an entity is modified outside the manager (directly through the repository), cached entries may become stale. In that case, call `RemoveAsync` through the manager or evict entries directly using the cache provider.
+
+### Cache on Find vs FindFirst
+
+`FindAsync` always checks the cache first. `FindFirstAsync` does **not** cache results, since queries are dynamic and caching their output is not generally safe without additional semantics.
+
## Cache Keys
-By default, the primary key of the entity is used to derive the cache key. To customize key generation, implement `IEntityCacheKeyGenerator` and register it:
+By default, the entity's primary key (`IHaveKey.Key`) is converted to a string and used as the cache key. The default key format is `{EntityType.Name}:{key}`.
+
+To customize key generation, implement `IEntityCacheKeyGenerator` and register it via the `EntityManagerBuilder`:
```csharp
-public class MyEntityCacheKeyGenerator : IEntityCacheKeyGenerator
+public class PersonKeyGenerator : IEntityCacheKeyGenerator
{
- public IEnumerable GetKeys(MyEntity entity)
- {
- yield return $"myentity:{entity.Id}";
- yield return $"myentity:by-name:{entity.Name}";
- }
+ public string GenerateKey(object key) => $"person:{key}";
+ public string[] GenerateAllKeys(Person entity) =>
+ [$"person:{entity.Id}", $"person:email:{entity.Email}"];
}
+services.AddRepositoryContext()
+ .AddRepository(repo => repo
+ .WithManagement(mgmt => mgmt
+ .WithCacheKeyGenerator()));
+```
+
+The `WithCacheKeyGenerator()` method scans the type for implemented `IEntityCacheKeyGenerator<>` interfaces and registers each one matching the builder's current entity type.
+
+Multiple keys from `GenerateAllKeys` enable lookups by alternate identifiers (e.g., email, external ID) while keeping a single entry in the cache.
+
+For legacy direct registration (deprecated):
+
+```csharp
builder.Services.AddEntityCacheKeyGenerator();
```
## Cache Serialization
-In some scenarios, entities must be converted to a serializable form before being stored in the cache (e.g., when the cache provider serializes objects to JSON or binary). To handle this, derive from `EntityEasyCache` and implement `IEntityEasyCacheConverter`:
+Some cache providers (Redis, distributed caches) require entities to be serialized to a specific format. To handle this, implement `IEntityEasyCacheConverter` and register a typed cache:
```csharp
public class MyEntityCacheConverter
@@ -92,3 +146,8 @@ Then register a typed `EntityEasyCache`:
```csharp
builder.Services.AddEntityEasyCache>();
```
+
+## See Also
+
+- [Entity Validation](entity-validation.md) — configure validators and error factories
+- [HTTP Request Cancellation](http-request-cancellation.md) — automatic cancellation via ASP.NET Core
diff --git a/docs/entity-manager/entity-validation.md b/docs/entity-manager/entity-validation.md
new file mode 100644
index 00000000..f41a7ef8
--- /dev/null
+++ b/docs/entity-manager/entity-validation.md
@@ -0,0 +1,137 @@
+# Entity Validation
+
+> **Renamed:** This project was renamed from **Deveel.Repository** to **Kista** on **May 26, 2025**. The name *Kista* is Old Norse for "chest" or "repository", better reflecting the project purpose as a data access framework.
+
+The `EntityManager` validates entities before creating or updating them. Validation is pluggable: implement a validator interface, register it via the fluent builder, and the manager invokes it automatically on every `AddAsync` and `UpdateAsync` call.
+
+## How Validation Works
+
+When `AddAsync` or `UpdateAsync` is called, the manager:
+
+1. Collects all registered `IEntityValidator` instances from the DI container.
+2. Calls `ValidateAsync` on each validator, passing the manager instance and the entity.
+3. If **any** validator yields validation errors, the operation returns `OperationResult.Fail` with the aggregated errors — the entity is **not** persisted.
+4. If no errors are produced, the operation proceeds to the repository.
+
+## Validator Interface
+
+Implement `IEntityValidator` (or `IEntityValidator` for keyed entities):
+
+```csharp
+public class PersonValidator : IEntityValidator
+{
+ public async IAsyncEnumerable ValidateAsync(
+ EntityManager manager,
+ Person entity,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(entity.Name))
+ yield return new ValidationResult("Name is required");
+
+ if (entity.Age < 0)
+ yield return new ValidationResult("Age must be non-negative");
+ }
+}
+```
+
+The interface uses `IAsyncEnumerable`, allowing validators to yield zero, one, or multiple errors. Validation is lazily enumerated: all results are collected before the operation proceeds or fails.
+
+### Rules
+
+- Validators run **before** any repository write.
+- Multiple validators can be registered for the same entity type; all are invoked.
+- Validation errors are surfaced as structured `ValidationResult` objects inside the `OperationResult.Error`.
+- Validators receive the `EntityManager` instance, enabling cross-entity validation (e.g., checking uniqueness via the manager).
+
+## Registration
+
+### Via EntityManagerBuilder (recommended)
+
+Use `WithValidator()` on the `EntityManagerBuilder`:
+
+```csharp
+services.AddRepositoryContext()
+ .AddRepository(repo => repo
+ .WithManagement(mgmt => mgmt
+ .WithValidator()))
+ .UseInMemory();
+```
+
+The validator type is **scanned** for all implemented `IEntityValidator<>` and `IEntityValidator<,>` interfaces. Only those matching the builder's current entity type are registered. This means a single class can implement validators for multiple entities:
+
+```csharp
+public class CompositeValidator :
+ IEntityValidator,
+ IEntityValidator
+{
+ // ...
+}
+
+// Both Person and Order validators are registered
+services.AddRepositoryContext()
+ .AddRepository(repo => repo
+ .WithManagement(mgmt => mgmt.WithValidator()))
+ .AddRepository(repo => repo
+ .WithManagement(mgmt => mgmt.WithValidator()));
+```
+
+### Legacy registration
+
+Register validators directly on `IServiceCollection`:
+
+```csharp
+builder.Services.AddEntityValidator();
+```
+
+This is still supported but the fluent builder is preferred.
+
+## Error Factories
+
+Error factories control the error codes and messages returned when operations fail. They are tightly coupled to validation because validation failures are returned through the same `OperationResult` mechanism.
+
+### Implementing an Error Factory
+
+Extend `OperationErrorFactory` (the concrete class, not merely `IOperationErrorFactory`):
+
+```csharp
+public class PersonErrorFactory : OperationErrorFactory { }
+```
+
+The base `OperationErrorFactory` provides default error codes for common failure modes (not-found, validation failed, concurrency conflict). Override methods to customize:
+
+```csharp
+public class PersonErrorFactory : OperationErrorFactory
+{
+ protected override string GetNotFoundErrorCode() => "PERSON_NOT_FOUND";
+ protected override string GetValidationErrorCode() => "PERSON_INVALID";
+}
+```
+
+### Registration
+
+```csharp
+services.AddRepositoryContext()
+ .AddRepository(repo => repo
+ .WithManagement(mgmt => mgmt
+ .WithValidator()
+ .WithOperationErrorFactory()))
+ .UseInMemory();
+```
+
+> The factory type **must** extend `OperationErrorFactory` (the concrete class), not merely implement `IOperationErrorFactory`. This constraint exists because the registration code wraps the factory in an `OperationErrorFactoryDecorator`, which requires the base class.
+
+### Why extend OperationErrorFactory?
+
+The decorator pattern adds entity-type context to errors at runtime. The decorator's constructor signature takes `OperationErrorFactory`, so a custom factory that only implements the interface cannot be wrapped. Extending the concrete class ensures compatibility.
+
+## Summary
+
+| Concern | Interface / Base | Registration |
+|---------|-----------------|-------------|
+| Validation | `IEntityValidator` | `WithValidator()` |
+| Error codes | `OperationErrorFactory` (class) | `WithOperationErrorFactory()` |
+
+## See Also
+
+- [Caching Entities](caching-entities.md) — second-level cache configuration
+- [HTTP Request Cancellation](http-request-cancellation.md) — automatic cancellation via ASP.NET Core
diff --git a/docs/index.md b/docs/index.md
index 178d52c8..99b5cb41 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -86,6 +86,8 @@ builder.Services.AddRepositoryContext()
.WithConnectionString("mongodb://..."));
```
+Each `Use*()` method registers **open-generic** repository types (`InMemoryRepository<>`, `EntityRepository<>`, `MongoRepository<>`) with the DI container. This means `IRepository` resolves automatically — you do **not** need to write a repository class for every entity type. When you also register a concrete repository via `AddRepository()`, the closed generic takes precedence over the open generic for that specific entity, letting you mix bulk defaults with per-entity customization.
+
Each driver call returns to the parent builder, allowing you to chain multiple concerns:
```csharp
diff --git a/docs/plans/entity-manager-builder.md b/docs/plans/entity-manager-builder.md
new file mode 100644
index 00000000..7f16935b
--- /dev/null
+++ b/docs/plans/entity-manager-builder.md
@@ -0,0 +1,102 @@
+# EntityManagerBuilder Plan
+
+> **Status: Implemented** — See `EntityManagerBuilder.cs`, `RepositoryBuilderExtensions.cs`, and `EntityManagerBuilderTests.cs`.
+
+## Goal
+Create an `EntityManagerBuilder` that provides a unified, scoped fluent API for registering entity-specific services (validators, cache key generators, error factories, caching) within `WithManagement()`, harmonized with `RepositoryBuilder` (per-repository level).
+
+## Design Decisions
+
+| Decision | Choice |
+|----------|--------|
+| Per-entity cache options | **Provider-specific** — `EasyCachingOptions`, etc. |
+| Builder surface area | **Callback return only** — `EntityManagerBuilder` is scoped via `RepositoryBuilder.WithManagement()` callback |
+| Entity-scoped methods | **No `*For` suffix** — entity type implied from `RepositoryBuilder` context |
+| Registration level | **Per-repository** — `WithManagement()` is on `RepositoryBuilder` (opt-in); global `WithManagement()` on `RepositoryContextBuilder` remains for bulk registration |
+
+## API Surface
+
+### Entry point — two `WithManagement()` overloads on `RepositoryBuilder` (extension methods in `Kista.Manager`)
+
+```csharp
+// Overload 1: Simple — registers EntityManager only
+public static RepositoryBuilder WithManagement(
+ this RepositoryBuilder builder,
+ ServiceLifetime lifetime = ServiceLifetime.Scoped)
+
+// Overload 2: Callback — registers EntityManager + configures optional services
+public static RepositoryBuilder WithManagement(
+ this RepositoryBuilder builder,
+ Action configure,
+ ServiceLifetime lifetime = ServiceLifetime.Scoped)
+```
+
+### EntityManagerBuilder methods (all return `EntityManagerBuilder`)
+
+| Method | What it registers |
+|--------|-------------------|
+| `WithValidator()` | Scans `IEntityValidator<>` / `IEntityValidator<,>` interfaces on `TValidator`, filters for `EntityType` |
+| `WithCacheKeyGenerator()` | Scans `IEntityCacheKeyGenerator<>` interfaces on `TGenerator`, filters for `EntityType` |
+| `WithOperationErrorFactory()` | Delegates to `services.AddOperationErrorFactory(EntityType, typeof(TFactory))` |
+| `WithEasyCaching(Action?)` | Per-entity EasyCaching (extension in `Kista.Manager.EasyCaching`) |
+
+### Target fluent usage
+
+```csharp
+services.AddRepositoryContext()
+ .AddRepository(repo => repo
+ .WithManagement(mgmt => mgmt
+ .WithValidator()
+ .WithCacheKeyGenerator()
+ .WithOperationErrorFactory()
+ .WithEasyCaching(opts => {
+ opts.DefaultExpiration = TimeSpan.FromMinutes(15);
+ })))
+ .AddRepository(repo => repo
+ .WithManagement()) // EntityManager only, no extras
+ .WithManagement(mgmt => mgmt // global — bulk register for all OTHER repos
+ .WithEasyCaching())
+ .UseInMemory();
+```
+
+## Files Created
+
+### `src/Kista.Manager/EntityManagerBuilder.cs`
+
+New class in `Kista` namespace with `internal` constructor accepting `RepositoryBuilder` + `ServiceLifetime`. No `*For` suffix on methods — entity type implied from context. Methods:
+
+- `WithValidator()` — runtime scanning of `IEntityValidator<>` / `IEntityValidator<,>` filtered by `EntityType`, registered with `TryAdd`
+- `WithCacheKeyGenerator()` — runtime scanning of `IEntityCacheKeyGenerator<>` filtered by `EntityType`, registered with `TryAdd`
+- `WithOperationErrorFactory()` — delegates to `services.AddOperationErrorFactory(EntityType, typeof(TFactory))`
+
+### `src/Kista.Manager/RepositoryBuilderExtensions.cs`
+
+Extension methods on `RepositoryBuilder`:
+
+- `WithManagement(ServiceLifetime)` — registers `EntityManager` (or `EntityManager` if key is `object`)
+- `WithManagement(Action, ServiceLifetime)` — same + configures optional services via callback
+
+## Files Modified
+
+### `src/Kista.Manager/RepositoryContextBuilderExtensions.cs`
+
+- Fixed duplicate `TryAdd` (was registering `managerType` twice)
+- Removed unused `repositoryInterface` variable
+
+### `src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs`
+
+- Added `EntityManagerBuilderExtensions.WithEasyCaching()` extension method
+
+## What Stays Unchanged
+
+- All `With*Caching()` methods on `RepositoryContextBuilder` (global config)
+- All existing cache extension methods on `IServiceCollection`
+- All existing tests (add new tests, don't break old)
+- `WithManagement(Action?, ServiceLifetime)` overload signature
+- `AddOperationErrorFactory` on `IServiceCollection` (not obsolete)
+- `MemoryCacheExtensions`, `FusionCache` and `DistributedCache` projects (not implemented yet)
+
+## Migration Path
+
+- Obsolete methods (`AddEntityValidator`, `AddEntityCacheKeyGenerator`, etc.) remain obsolete — their replacements live on `EntityManagerBuilder`
+- Consider extracting internal helpers from obsolete `ServiceCollectionExtensions` methods to avoid duplication with `EntityManagerBuilder`
diff --git a/docs/repository-implementations/in-memory.md b/docs/repository-implementations/in-memory.md
index 327deec4..bd9d71f5 100644
--- a/docs/repository-implementations/in-memory.md
+++ b/docs/repository-implementations/in-memory.md
@@ -42,6 +42,24 @@ builder.Services.AddRepositoryContext()
.WithInitialData(new[] { new MyEntity { /* ... */ } }));
```
+### Bulk vs Per-Entity
+
+`.UseInMemory()` registers the **open generic** `InMemoryRepository<>` so that `IRepository`, `IRepository`, and any other entity type resolve to `InMemoryRepository` automatically — no per-entity class is needed:
+
+```csharp
+builder.Services.AddRepositoryContext()
+ .UseInMemory(); // IRepository, IRepository, etc. all work
+```
+
+For entities that need custom query methods or overrides, create a concrete repository class and register it with `AddRepository()`. The concrete type overrides the open generic for that entity only:
+
+```csharp
+builder.Services.AddRepositoryContext()
+ .UseInMemory()
+ .AddRepository(); // PersonRepository used for Person
+ // InMemoryRepository used for everything else
+```
+
### Pre-seeding Data
You can seed initial data using the `WithInitialData` method:
diff --git a/src/Kista.DynamicLinq/BoundedCache.cs b/src/Kista.DynamicLinq/BoundedCache.cs
new file mode 100644
index 00000000..533ccbe1
--- /dev/null
+++ b/src/Kista.DynamicLinq/BoundedCache.cs
@@ -0,0 +1,161 @@
+// Copyright 2023-2026 Antonello Provenzano
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+namespace Kista {
+ ///
+ /// A bounded, thread-safe LRU cache base class used by
+ /// and .
+ ///
+ /// The type of value stored in the cache.
+ public abstract class BoundedCache {
+ private readonly SemaphoreSlim _semaphore = new(1, 1);
+ private readonly Dictionary> _map;
+ private readonly LinkedList _order;
+ private readonly int _maxCapacity;
+ private long _hits;
+ private long _misses;
+
+ ///
+ /// Initializes a new instance with the specified maximum capacity.
+ ///
+ /// The maximum number of entries before LRU eviction begins.
+ /// Thrown when is less than 1.
+ protected BoundedCache(int maxCapacity) {
+ if (maxCapacity < 1)
+ throw new ArgumentOutOfRangeException(nameof(maxCapacity), "MaxCapacity must be at least 1.");
+
+ _maxCapacity = maxCapacity;
+ _map = new Dictionary>(maxCapacity, StringComparer.Ordinal);
+ _order = new LinkedList();
+ Statistics = new InternalStatistics(this);
+ }
+
+ ///
+ /// Initializes a new instance with the default maximum capacity of 1024 entries.
+ ///
+ protected BoundedCache()
+ : this(1024) {
+ }
+
+ ///
+ /// Initializes a new instance with the specified configuration options.
+ ///
+ /// The configuration options that determine the cache capacity.
+ /// Thrown when is null.
+ protected BoundedCache(BoundedFilterCacheOptions options)
+ : this(options?.MaxCapacity ?? throw new ArgumentNullException(nameof(options))) {
+ }
+
+ ///
+ /// Represents an entry in the cache with a string key and a value.
+ ///
+ protected record CacheEntry(string Key, TValue Value);
+
+ private sealed class InternalStatistics(BoundedCache cache) : IFilterCacheStatistics {
+ public long Hits => Volatile.Read(ref cache._hits);
+ public long Misses => Volatile.Read(ref cache._misses);
+ public int CurrentSize => cache._map.Count;
+ public int MaxCapacity => cache._maxCapacity;
+
+ public double HitRate {
+ get {
+ var total = Hits + Misses;
+ return total == 0 ? 0 : (double)Hits / total;
+ }
+ }
+
+ public void Reset() {
+ Volatile.Write(ref cache._hits, 0);
+ Volatile.Write(ref cache._misses, 0);
+ }
+ }
+
+ ///
+ /// Gets the statistics for this cache instance.
+ ///
+ public IFilterCacheStatistics Statistics { get; }
+
+ ///
+ /// Attempts to retrieve a value from the cache by key, promoting it to the
+ /// most-recently-used position on a hit.
+ ///
+ /// The cache key.
+ /// When returned, the cached value if found; otherwise default.
+ /// true if the key was found; otherwise false.
+ protected bool TryGetCore(string key, out TValue? value) {
+ ArgumentNullException.ThrowIfNull(key);
+
+ _semaphore.Wait();
+ try {
+ if (_map.TryGetValue(key, out var node)) {
+ _order.Remove(node);
+ _order.AddFirst(node);
+ Interlocked.Increment(ref _hits);
+ value = node.Value.Value;
+ return true;
+ }
+
+ Interlocked.Increment(ref _misses);
+ value = default;
+ return false;
+ } finally {
+ _semaphore.Release();
+ }
+ }
+
+ ///
+ /// Stores a value in the cache. If the key already exists, the value is updated
+ /// and promoted to the most-recently-used position. If the cache is at capacity,
+ /// the least recently used entry is evicted.
+ ///
+ /// The cache key.
+ /// The value to store.
+ protected void SetCore(string key, TValue value) {
+ ArgumentNullException.ThrowIfNull(key);
+ ArgumentNullException.ThrowIfNull(value);
+
+ _semaphore.Wait();
+ try {
+ if (_map.TryGetValue(key, out var existingNode)) {
+ _order.Remove(existingNode);
+ } else if (_map.Count >= _maxCapacity) {
+ var lruNode = _order.Last;
+ if (lruNode != null) {
+ _order.RemoveLast();
+ _map.Remove(lruNode.Value.Key);
+ }
+ }
+
+ var entry = new CacheEntry(key, value);
+ var newNode = _order.AddFirst(entry);
+ _map[key] = newNode;
+ } finally {
+ _semaphore.Release();
+ }
+ }
+
+ ///
+ /// Removes all entries from the cache. Does not reset the counters.
+ ///
+ public void Clear() {
+ _semaphore.Wait();
+ try {
+ _map.Clear();
+ _order.Clear();
+ } finally {
+ _semaphore.Release();
+ }
+ }
+ }
+}
diff --git a/src/Kista.DynamicLinq/BoundedExpressionCache.cs b/src/Kista.DynamicLinq/BoundedExpressionCache.cs
index 40fa4d38..3c292201 100644
--- a/src/Kista.DynamicLinq/BoundedExpressionCache.cs
+++ b/src/Kista.DynamicLinq/BoundedExpressionCache.cs
@@ -33,18 +33,9 @@ namespace Kista {
/// expression tree can be used directly with filtering methods.
///
///
- /// The implementation uses a for O(1) key lookup
- /// and a to maintain access order for LRU eviction.
- /// All public operations are protected by a (initialized
- /// as a binary semaphore) to ensure thread safety under concurrent access. The semaphore
- /// uses spin-waiting before falling back to kernel-level waiting, reducing
- /// context-switch overhead under moderate contention typical of high-throughput
- /// query paths.
- ///
- ///
- /// Statistics are tracked using reads and writes on the hit
- /// and miss counters, providing lock-free access to
- /// properties without impacting cache throughput.
+ /// The implementation is backed by which provides the
+ /// LRU eviction mechanism and thread safety using a
+ /// for O(1) key lookup and a to maintain access order.
///
///
///
@@ -67,14 +58,7 @@ namespace Kista {
///
///
///
- public sealed class BoundedExpressionCache : IExpressionCache {
- private readonly SemaphoreSlim _semaphore = new(1, 1);
- private readonly Dictionary> _map;
- private readonly LinkedList _order;
- private readonly int _maxCapacity;
- private long _hits;
- private long _misses;
-
+ public sealed class BoundedExpressionCache : BoundedCache, IExpressionCache {
///
/// Initializes a new instance of the class
/// with the specified maximum capacity.
@@ -86,22 +70,14 @@ public sealed class BoundedExpressionCache : IExpressionCache {
///
/// Thrown when is less than 1.
///
- public BoundedExpressionCache(int maxCapacity) {
- if (maxCapacity < 1)
- throw new ArgumentOutOfRangeException(nameof(maxCapacity), "MaxCapacity must be at least 1.");
-
- _maxCapacity = maxCapacity;
- _map = new Dictionary>(maxCapacity, StringComparer.Ordinal);
- _order = new LinkedList();
- Statistics = new InternalStatistics(this);
+ public BoundedExpressionCache(int maxCapacity) : base(maxCapacity) {
}
///
/// Initializes a new instance of the class
/// with the default maximum capacity of 1024 entries.
///
- public BoundedExpressionCache()
- : this(1024) {
+ public BoundedExpressionCache() : base() {
}
///
@@ -114,111 +90,17 @@ public BoundedExpressionCache()
///
/// Thrown when is null.
///
- public BoundedExpressionCache(BoundedFilterCacheOptions options)
- : this(options?.MaxCapacity ?? throw new ArgumentNullException(nameof(options))) {
- }
-
- private sealed record CacheEntry(string Key, LambdaExpression Expression);
-
- private sealed class InternalStatistics(BoundedExpressionCache cache) : IFilterCacheStatistics {
- ///
- public long Hits => Volatile.Read(ref cache._hits);
- ///
- public long Misses => Volatile.Read(ref cache._misses);
- ///
- public int CurrentSize => cache._map.Count;
- ///
- public int MaxCapacity => cache._maxCapacity;
-
- ///
- public double HitRate {
- get {
- var total = Hits + Misses;
- return total == 0 ? 0 : (double)Hits / total;
- }
- }
-
- ///
- public void Reset() {
- Volatile.Write(ref cache._hits, 0);
- Volatile.Write(ref cache._misses, 0);
- }
+ public BoundedExpressionCache(BoundedFilterCacheOptions options) : base(options) {
}
///
- public IFilterCacheStatistics Statistics { get; }
-
- ///
- ///
- /// On a cache hit, the accessed entry is promoted to the most-recently-used
- /// position in the LRU ordering. This ensures that frequently used expressions
- /// are not evicted.
- ///
public bool TryGet(string key, out LambdaExpression? expression) {
- ArgumentNullException.ThrowIfNull(key);
-
- _semaphore.Wait();
- try {
- if (_map.TryGetValue(key, out var node)) {
- _order.Remove(node);
- _order.AddFirst(node);
- Interlocked.Increment(ref _hits);
- expression = node.Value.Expression;
- return true;
- }
-
- Interlocked.Increment(ref _misses);
- expression = null;
- return false;
- } finally {
- _semaphore.Release();
- }
+ return TryGetCore(key, out expression);
}
///
- ///
- /// If the key already exists, the stored expression is updated and the entry
- /// is promoted to the most-recently-used position. If the cache is at capacity
- /// and the key is new, the least recently used entry is evicted before insertion.
- ///
public void Set(string key, LambdaExpression expression) {
- ArgumentNullException.ThrowIfNull(key);
- ArgumentNullException.ThrowIfNull(expression);
-
- _semaphore.Wait();
- try {
- if (_map.TryGetValue(key, out var existingNode)) {
- _order.Remove(existingNode);
- } else if (_map.Count >= _maxCapacity) {
- var lruNode = _order.Last;
- if (lruNode != null) {
- _order.RemoveLast();
- _map.Remove(lruNode.Value.Key);
- }
- }
-
- var entry = new CacheEntry(key, expression);
- var newNode = _order.AddFirst(entry);
- _map[key] = newNode;
- } finally {
- _semaphore.Release();
- }
- }
-
- ///
- ///
- /// This method removes all cached expressions but does not reset the
- /// counters. Call
- /// separately if you need to clear the statistics as well.
- ///
- public void Clear() {
- _semaphore.Wait();
- try {
- _map.Clear();
- _order.Clear();
- } finally {
- _semaphore.Release();
- }
+ SetCore(key, expression);
}
}
}
diff --git a/src/Kista.DynamicLinq/BoundedFilterCache.cs b/src/Kista.DynamicLinq/BoundedFilterCache.cs
index 7d4b7628..80d01f11 100644
--- a/src/Kista.DynamicLinq/BoundedFilterCache.cs
+++ b/src/Kista.DynamicLinq/BoundedFilterCache.cs
@@ -80,18 +80,9 @@ public int MaxCapacity {
/// of queries per minute with identical filter shapes.
///
///
- /// The cache uses a combination of a for O(1)
- /// key lookup and a to track access order for LRU eviction.
- /// All operations are protected by a (initialized as a
- /// binary semaphore) to ensure thread safety under concurrent access. The semaphore
- /// uses spin-waiting before falling back to kernel-level waiting, reducing
- /// context-switch overhead under moderate contention typical of high-throughput
- /// query paths.
- ///
- ///
- /// Statistics are tracked using reads and writes on the hit
- /// and miss counters, providing lock-free access to
- /// properties without impacting cache throughput.
+ /// The implementation is backed by which provides the
+ /// LRU eviction mechanism and thread safety using a
+ /// for O(1) key lookup and a to maintain access order.
///
///
///
@@ -114,14 +105,7 @@ public int MaxCapacity {
///
///
///
- public sealed class BoundedFilterCache : IFilterCache {
- private readonly SemaphoreSlim _semaphore = new(1, 1);
- private readonly Dictionary> _map;
- private readonly LinkedList _order;
- private readonly int _maxCapacity;
- private long _hits;
- private long _misses;
-
+ public sealed class BoundedFilterCache : BoundedCache, IFilterCache {
///
/// Initializes a new instance of the class
/// with the specified maximum capacity.
@@ -133,22 +117,14 @@ public sealed class BoundedFilterCache : IFilterCache {
///
/// Thrown when is less than 1.
///
- public BoundedFilterCache(int maxCapacity) {
- if (maxCapacity < 1)
- throw new ArgumentOutOfRangeException(nameof(maxCapacity), "MaxCapacity must be at least 1.");
-
- _maxCapacity = maxCapacity;
- _map = new Dictionary>(maxCapacity, StringComparer.Ordinal);
- _order = new LinkedList();
- Statistics = new InternalStatistics(this);
+ public BoundedFilterCache(int maxCapacity) : base(maxCapacity) {
}
///
/// Initializes a new instance of the class
/// with the default maximum capacity of 1024 entries.
///
- public BoundedFilterCache()
- : this(new BoundedFilterCacheOptions().MaxCapacity) {
+ public BoundedFilterCache() : base() {
}
///
@@ -161,111 +137,17 @@ public BoundedFilterCache()
///
/// Thrown when is null.
///
- public BoundedFilterCache(BoundedFilterCacheOptions options)
- : this(options?.MaxCapacity ?? throw new ArgumentNullException(nameof(options))) {
- }
-
- private sealed record CacheEntry(string Expression, Delegate Lambda);
-
- private sealed class InternalStatistics(BoundedFilterCache cache) : IFilterCacheStatistics {
- ///
- public long Hits => Volatile.Read(ref cache._hits);
- ///
- public long Misses => Volatile.Read(ref cache._misses);
- ///
- public int CurrentSize => cache._map.Count;
- ///
- public int MaxCapacity => cache._maxCapacity;
-
- ///
- public double HitRate {
- get {
- var total = Hits + Misses;
- return total == 0 ? 0 : (double)Hits / total;
- }
- }
-
- ///
- public void Reset() {
- Volatile.Write(ref cache._hits, 0);
- Volatile.Write(ref cache._misses, 0);
- }
+ public BoundedFilterCache(BoundedFilterCacheOptions options) : base(options) {
}
///
- public IFilterCacheStatistics Statistics { get; }
-
- ///
- ///
- /// On a cache hit, the accessed entry is promoted to the most-recently-used
- /// position in the LRU ordering. This ensures that frequently used expressions
- /// are not evicted.
- ///
- public bool TryGet(string expression, out Delegate? lambda) {
- ArgumentNullException.ThrowIfNull(expression);
-
- _semaphore.Wait();
- try {
- if (_map.TryGetValue(expression, out var node)) {
- _order.Remove(node);
- _order.AddFirst(node);
- Interlocked.Increment(ref _hits);
- lambda = node.Value.Lambda;
- return true;
- }
-
- Interlocked.Increment(ref _misses);
- lambda = null;
- return false;
- } finally {
- _semaphore.Release();
- }
+ public bool TryGet(string expression, out Delegate? labda) {
+ return TryGetCore(expression, out labda);
}
///
- ///
- /// If the key already exists, the stored delegate is updated and the entry
- /// is promoted to the most-recently-used position. If the cache is at capacity
- /// and the key is new, the least recently used entry is evicted before insertion.
- ///
public void Set(string expression, Delegate lambda) {
- ArgumentNullException.ThrowIfNull(expression);
- ArgumentNullException.ThrowIfNull(lambda);
-
- _semaphore.Wait();
- try {
- if (_map.TryGetValue(expression, out var existingNode)) {
- _order.Remove(existingNode);
- } else if (_map.Count >= _maxCapacity) {
- var lruNode = _order.Last;
- if (lruNode != null) {
- _order.RemoveLast();
- _map.Remove(lruNode.Value.Expression);
- }
- }
-
- var entry = new CacheEntry(expression, lambda);
- var newNode = _order.AddFirst(entry);
- _map[expression] = newNode;
- } finally {
- _semaphore.Release();
- }
- }
-
- ///
- ///
- /// This method removes all cached delegates but does not reset the
- /// counters. Call
- /// separately if you need to clear the statistics as well.
- ///
- public void Clear() {
- _semaphore.Wait();
- try {
- _map.Clear();
- _order.Clear();
- } finally {
- _semaphore.Release();
- }
+ SetCore(expression, lambda);
}
}
}
diff --git a/src/Kista.DynamicLinq/RepositoryExtensions.cs b/src/Kista.DynamicLinq/RepositoryExtensions.cs
index 9a39ec3e..9e9d0c3a 100644
--- a/src/Kista.DynamicLinq/RepositoryExtensions.cs
+++ b/src/Kista.DynamicLinq/RepositoryExtensions.cs
@@ -45,6 +45,7 @@ public static class RepositoryExtensions {
/// Returns an instance of that matches the given expression,
/// otherwise null if no entity is found.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask FindFirstAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.FindFirstAsync(new DynamicLinqFilter(paramName, expression), cancellationToken);
@@ -74,6 +75,7 @@ public static class RepositoryExtensions {
/// Returns an instance of that matches the given expression,
/// otherwise null if no entity is found.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask FindFirstAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.FindFirstAsync(new DynamicLinqFilter(paramName, expression), cancellationToken);
@@ -98,6 +100,7 @@ public static class RepositoryExtensions {
/// Returns an instance of that matches the given expression,
/// otherwise null if no entity is found.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask FindFirstAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.FindFirstAsync(new DynamicLinqFilter(expression), cancellationToken);
@@ -124,6 +127,7 @@ public static class RepositoryExtensions {
/// Returns an instance of that matches the given expression,
/// otherwise null if no entity is found.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask FindFirstAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.FindFirstAsync(new DynamicLinqFilter(expression), cancellationToken);
@@ -156,6 +160,7 @@ public static class RepositoryExtensions {
/// Returns a list of that match the
/// given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask> FindAllAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.FindAllAsync(new DynamicLinqFilter(paramName, expression), cancellationToken);
@@ -186,6 +191,7 @@ public static ValueTask> FindAllAsync(this IRepository that match the
/// given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask> FindAllAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.FindAllAsync(new DynamicLinqFilter(paramName, expression), cancellationToken);
@@ -211,6 +217,7 @@ public static ValueTask> FindAllAsync(this IReposi
/// Returns a list of that match the
/// given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask> FindAllAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.FindAllAsync(new DynamicLinqFilter(expression), cancellationToken);
@@ -238,6 +245,7 @@ public static ValueTask> FindAllAsync(this IRepository that match the
/// given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask> FindAllAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.FindAllAsync(new DynamicLinqFilter(expression), cancellationToken);
@@ -270,6 +278,7 @@ public static ValueTask> FindAllAsync(this IReposi
///
/// Returns the number of entities that match the given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask CountAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.AsFilterable().CountAsync(new DynamicLinqFilter(paramName, expression), cancellationToken);
@@ -300,6 +309,7 @@ public static ValueTask CountAsync(this IRepository repo
///
/// Returns the number of entities that match the given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask CountAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.AsFilterable().CountAsync(new DynamicLinqFilter(paramName, expression), cancellationToken);
@@ -325,6 +335,7 @@ public static ValueTask CountAsync(this IRepository
/// Returns the number of entities that match the given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask CountAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.AsFilterable().CountAsync(new DynamicLinqFilter(expression), cancellationToken);
@@ -352,6 +363,7 @@ public static ValueTask CountAsync(this IRepository repo
///
/// Returns the number of entities that match the given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask CountAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.AsFilterable().CountAsync(new DynamicLinqFilter(expression), cancellationToken);
@@ -387,6 +399,7 @@ public static ValueTask CountAsync(this IRepositorytrue if the repository contains at least one entity
/// that matches the given expression, otherwise false.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask ExistsAsync(this IRepository repository, string paramName, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.ExistsAsync(new DynamicLinqFilter(paramName, expression), cancellationToken);
@@ -418,6 +431,7 @@ public static ValueTask ExistsAsync(this IRepositorytrue if the repository contains at least one entity
/// that matches the given expression, otherwise false.
///
+ [Obsolete("Use DynamicLinqFilter directly with the repository's query methods instead. Query capabilities should not be exposed through the IRepository contract.", false)]
public static ValueTask ExistsAsync(this IRepository repository, string expression, CancellationToken cancellationToken = default)
where TEntity : class
=> repository.ExistsAsync(DynamicLinqFilter.DefaultParameterName, expression, cancellationToken);
diff --git a/src/Kista.Manager.DynamicLinq/EntityManagerExtensions.cs b/src/Kista.Manager.DynamicLinq/EntityManagerExtensions.cs
index 48dd443d..6838de6d 100644
--- a/src/Kista.Manager.DynamicLinq/EntityManagerExtensions.cs
+++ b/src/Kista.Manager.DynamicLinq/EntityManagerExtensions.cs
@@ -42,6 +42,7 @@ public static class EntityManagerExtensions {
/// containing the first entity that matches the given expression,
/// or a failure result if no entity was found or an error occurred.
///
+ [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)]
public static ValueTask> FindFirstAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null)
where TEntity : class
=> manager.FindFirstAsync(new Query(new DynamicLinqFilter(expression)), cancellationToken);
@@ -70,6 +71,7 @@ public static ValueTask> FindFirstAsync(this E
/// containing the first entity that matches the given expression,
/// or a failure result if no entity was found or an error occurred.
///
+ [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)]
public static ValueTask> FindFirstAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null)
where TEntity : class
where TKey : notnull
@@ -95,6 +97,7 @@ public static ValueTask> FindFirstAsync(
///
/// Returns a list of entities that matches the given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)]
public static ValueTask> FindAllAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null)
where TEntity : class
=> manager.FindAllAsync(new Query(new DynamicLinqFilter(expression)), cancellationToken);
@@ -121,6 +124,7 @@ public static ValueTask> FindAllAsync(this EntityManager
///
/// Returns a list of entities that matches the given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)]
public static ValueTask> FindAllAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null)
where TEntity : class
where TKey : notnull
@@ -146,6 +150,7 @@ public static ValueTask> FindAllAsync(this EntityM
///
/// Returns the number of entities that matches the given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)]
public static ValueTask CountAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null)
where TEntity : class
=> manager.CountAsync(new DynamicLinqFilter(expression), cancellationToken);
@@ -172,6 +177,7 @@ public static ValueTask CountAsync(this EntityManager ma
///
/// Returns the number of entities that matches the given expression.
///
+ [Obsolete("Use DynamicLinqFilter directly with EntityManager's query methods instead. Query capabilities should not be exposed through the EntityManager contract.", false)]
public static ValueTask CountAsync(this EntityManager manager, string expression, CancellationToken? cancellationToken = null)
where TEntity : class
where TKey : notnull
diff --git a/src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs b/src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs
index 292611f1..31479910 100644
--- a/src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs
+++ b/src/Kista.Manager.EasyCaching/Caching/RepositoryContextBuilderExtensions.cs
@@ -17,6 +17,53 @@
using Microsoft.Extensions.Options;
namespace Kista.Caching {
+ ///
+ /// Extension methods for configuring EasyCaching on an .
+ ///
+ public static class EntityManagerBuilderExtensions {
+ ///
+ /// Enables EasyCaching-based entity caching for the entity type
+ /// being configured by the .
+ ///
+ /// The entity manager builder.
+ ///
+ /// An optional delegate to configure the EasyCaching options.
+ ///
+ ///
+ /// The service lifetime for the cache registration (default: Singleton).
+ ///
+ /// The builder for chaining.
+ public static EntityManagerBuilder WithEasyCaching(
+ this EntityManagerBuilder builder,
+ Action? configure = null,
+ ServiceLifetime lifetime = ServiceLifetime.Singleton) {
+
+ var entityType = builder.EntityType;
+ var options = new EasyCachingOptions();
+ configure?.Invoke(options);
+
+ var cacheType = typeof(EntityEasyCache<>).MakeGenericType(entityType);
+ var cacheInterface = typeof(IEntityCache<>).MakeGenericType(entityType);
+
+ builder.Services.TryAdd(new ServiceDescriptor(cacheInterface, cacheType, lifetime));
+ builder.Services.TryAdd(new ServiceDescriptor(cacheType, cacheType, lifetime));
+
+ if (options.DefaultExpiration.HasValue || !string.IsNullOrEmpty(options.CacheKeyPrefix)) {
+ var entityOptionsType = typeof(EntityCacheOptions<>).MakeGenericType(entityType);
+ var expiration = options.DefaultExpiration;
+ var prefix = options.CacheKeyPrefix;
+
+ builder.Services.AddSingleton(typeof(IConfigureOptions<>).MakeGenericType(entityOptionsType), sp => {
+ return Activator.CreateInstance(
+ typeof(ConfiguredEntityCacheOptions<>).MakeGenericType(entityType),
+ expiration, prefix)!;
+ });
+ }
+
+ return builder;
+ }
+ }
+
///
/// Options for configuring EasyCaching-based entity caching.
///
diff --git a/src/Kista.Manager/EntityManagerBuilder.cs b/src/Kista.Manager/EntityManagerBuilder.cs
new file mode 100644
index 00000000..eb449345
--- /dev/null
+++ b/src/Kista.Manager/EntityManagerBuilder.cs
@@ -0,0 +1,138 @@
+// Copyright 2023-2026 Antonello Provenzano
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using Kista.Caching;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Kista {
+ ///
+ /// A fluent builder for configuring entity-specific services
+ /// (validators, cache key generators, error factories, caching)
+ /// for a single entity type.
+ ///
+ ///
+ ///
+ /// Created by
+ /// and scoped to the entity and key types of the repository being configured.
+ ///
+ ///
+ public class EntityManagerBuilder {
+ private readonly RepositoryBuilder _repoBuilder;
+ private readonly ServiceLifetime _lifetime;
+
+ ///
+ /// Constructs the builder with the given repository builder and lifetime.
+ ///
+ ///
+ ///
+ internal EntityManagerBuilder(RepositoryBuilder repoBuilder, ServiceLifetime lifetime) {
+ _repoBuilder = repoBuilder;
+ _lifetime = lifetime;
+ }
+
+ ///
+ /// Gets the underlying service collection for direct registration.
+ ///
+ public IServiceCollection Services => _repoBuilder.Services;
+
+ ///
+ /// Gets the entity type managed by the repository.
+ ///
+ public Type EntityType => _repoBuilder.EntityType;
+
+ ///
+ /// Gets the entity key type managed by the repository.
+ ///
+ public Type EntityKeyType => _repoBuilder.EntityKeyType;
+
+ ///
+ /// Registers a validator type by scanning its implemented
+ /// and
+ /// interfaces, filtering for those matching the current entity and key types.
+ ///
+ /// The type of the validator to register.
+ /// This builder for chaining.
+ public EntityManagerBuilder WithValidator()
+ where TValidator : class {
+ var validatorType = typeof(TValidator);
+
+ if (!validatorType.IsClass || validatorType.IsAbstract)
+ throw new ArgumentException($"The type {validatorType} is not a concrete class", nameof(TValidator));
+
+ var interfaceTypes = validatorType.GetInterfaces().Where(x => x.IsGenericType);
+ foreach (var interfaceType in interfaceTypes) {
+
+ var genericDef = interfaceType.GetGenericTypeDefinition();
+
+ if (genericDef == typeof(IEntityValidator<>)) {
+ var entityType = interfaceType.GetGenericArguments()[0];
+ if (entityType == EntityType) {
+ var compareType = typeof(IEntityValidator<>).MakeGenericType(entityType);
+ Services.TryAdd(new ServiceDescriptor(compareType, validatorType, _lifetime));
+ }
+ } else if (genericDef == typeof(IEntityValidator<,>)) {
+ var args = interfaceType.GetGenericArguments();
+ if (args[0] == EntityType) {
+ var compareType = typeof(IEntityValidator<,>).MakeGenericType(args[0], args[1]);
+ Services.TryAdd(new ServiceDescriptor(compareType, validatorType, _lifetime));
+ }
+ }
+ }
+
+ Services.Add(new ServiceDescriptor(validatorType, validatorType, _lifetime));
+ return this;
+ }
+
+ ///
+ /// Registers a cache key generator type by scanning its implemented
+ /// interfaces,
+ /// filtering for those matching the current entity type.
+ ///
+ /// The type of the key generator to register.
+ /// This builder for chaining.
+ public EntityManagerBuilder WithCacheKeyGenerator()
+ where TGenerator : class {
+ var generatorType = typeof(TGenerator);
+
+ if (!generatorType.IsClass || generatorType.IsAbstract)
+ throw new ArgumentException($"The type {generatorType} is not a concrete class", nameof(TGenerator));
+
+ var entityTypes = generatorType.GetInterfaces()
+ .Where(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEntityCacheKeyGenerator<>))
+ .Select(x => x.GetGenericArguments()[0]);
+
+ foreach (var entityType in entityTypes.Where(entityType => entityType == EntityType)) {
+ var compareType = typeof(IEntityCacheKeyGenerator<>).MakeGenericType(entityType);
+ Services.TryAdd(new ServiceDescriptor(compareType, generatorType, _lifetime));
+ }
+
+ Services.Add(new ServiceDescriptor(generatorType, generatorType, _lifetime));
+ return this;
+ }
+
+ ///
+ /// Registers an operation error factory for the current entity type.
+ ///
+ ///
+ /// The type of the to register.
+ ///
+ /// This builder for chaining.
+ public EntityManagerBuilder WithOperationErrorFactory()
+ where TFactory : class, IOperationErrorFactory {
+ Services.AddOperationErrorFactory(EntityType, typeof(TFactory));
+ return this;
+ }
+ }
+}
diff --git a/src/Kista.Manager/RepositoryBuilderExtensions.cs b/src/Kista.Manager/RepositoryBuilderExtensions.cs
new file mode 100644
index 00000000..c1bd4992
--- /dev/null
+++ b/src/Kista.Manager/RepositoryBuilderExtensions.cs
@@ -0,0 +1,79 @@
+// Copyright 2023-2026 Antonello Provenzano
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Kista {
+ ///
+ /// Extension methods for configuring entity management on a .
+ ///
+ public static class RepositoryBuilderExtensions {
+ ///
+ /// Registers an for the entity type
+ /// of this repository.
+ ///
+ /// The repository builder.
+ ///
+ /// The service lifetime of the entity manager (default: Scoped).
+ ///
+ /// The repository builder for chaining.
+ public static RepositoryBuilder WithManagement(
+ this RepositoryBuilder builder,
+ ServiceLifetime lifetime = ServiceLifetime.Scoped) {
+ RegisterEntityManager(builder, lifetime);
+ return builder;
+ }
+
+ ///
+ /// Registers an for the entity type
+ /// of this repository, and configures entity-specific services via the
+ /// provided callback.
+ ///
+ /// The repository builder.
+ ///
+ /// A delegate that configures entity-specific services
+ /// (validators, cache key generators, error factories, caching).
+ ///
+ ///
+ /// The service lifetime of the entity manager (default: Scoped).
+ ///
+ /// The repository builder for chaining.
+ public static RepositoryBuilder WithManagement(
+ this RepositoryBuilder builder,
+ Action configure,
+ ServiceLifetime lifetime = ServiceLifetime.Scoped) {
+ var mgmtBuilder = new EntityManagerBuilder(builder, lifetime);
+ configure(mgmtBuilder);
+ RegisterEntityManager(builder, lifetime);
+ return builder;
+ }
+
+ ///
+ /// Registers the entity manager for the repository's entity type.
+ ///
+ private static void RegisterEntityManager(RepositoryBuilder builder, ServiceLifetime lifetime) {
+ var entityType = builder.EntityType;
+ var keyType = builder.EntityKeyType;
+
+ if (keyType == typeof(object)) {
+ var managerType = typeof(EntityManager<>).MakeGenericType(entityType);
+ builder.Services.TryAdd(new ServiceDescriptor(managerType, managerType, lifetime));
+ } else {
+ var managerType = typeof(EntityManager<,>).MakeGenericType(entityType, keyType);
+ builder.Services.TryAdd(new ServiceDescriptor(managerType, managerType, lifetime));
+ }
+ }
+ }
+}
diff --git a/src/Kista.Manager/RepositoryContextBuilderExtensions.cs b/src/Kista.Manager/RepositoryContextBuilderExtensions.cs
index 564620b8..f193b035 100644
--- a/src/Kista.Manager/RepositoryContextBuilderExtensions.cs
+++ b/src/Kista.Manager/RepositoryContextBuilderExtensions.cs
@@ -46,9 +46,6 @@ public static RepositoryContextBuilder WithManagement(
foreach (var entityType in builder.RegisteredEntityTypes) {
var managerType = typeof(EntityManager<>).MakeGenericType(entityType);
builder.Services.TryAdd(new ServiceDescriptor(managerType, managerType, lifetime));
-
- var repositoryInterface = typeof(IRepository<>).MakeGenericType(entityType);
- builder.Services.TryAdd(new ServiceDescriptor(managerType, managerType, lifetime));
}
}
diff --git a/src/Kista/RepositoryExtensions.cs b/src/Kista/RepositoryExtensions.cs
index 0dd5ae81..b9173914 100644
--- a/src/Kista/RepositoryExtensions.cs
+++ b/src/Kista/RepositoryExtensions.cs
@@ -685,6 +685,7 @@ public static PageResult GetPage(this IRepositorytrue if any entity exists in the repository
/// that matches the given filter, otherwise false.
///
+ [Obsolete("Query capabilities should not be exposed through the IRepository contract. Use the abstract Repository base class instead.", false)]
public static ValueTask ExistsAsync(this IRepository