This guide explains the architectural principles behind the DKNet Framework, and how they map onto its actual
packages and types — every type and API name below is real and grep-verified against src/, not illustrative.
DKNet Framework is a suite of independent .NET NuGet packages built around Domain-Driven Design (DDD) and the Onion Architecture pattern. Rather than a single application skeleton, DKNet expresses these patterns at package boundaries: each ring of the onion is a separate, opt-in package, so a consuming application pulls in only what it needs and can swap an implementation (a blob provider, an idempotency store) without touching domain code.
Three packages sit deliberately outside those rings:
DKNet.EfCore.Encryption attaches to the model as an EF Core ValueConverter, not through the hook pipeline.DKNet.EfCore.DtoGenerator and DKNet.SlimBus.Generators are Roslyn generators: they run at compile time and
ship no runtime service.Aspire.Hosting.ServiceBus configures an Aspire AppHost, not the application under it.Each row is one concern and the single package responsible for it. Nothing here is shared between two packages, and no package exists only to aggregate others.
| Concern | Owner | Attaches via |
|---|---|---|
| Entity identity, audit fields, domain-event queue | DKNet.EfCore.Abstractions |
base classes you derive from |
| Model build: configuration discovery, global filters, seeding, GUID v7 keys, sequences | DKNet.EfCore.Extensions |
UseAutoConfigModel<TContext>() on DbContextOptionsBuilder |
| Querying and persistence | DKNet.EfCore.Specifications |
AddSpecRepo<TDbContext>() |
Running code around SaveChanges |
DKNet.EfCore.Hooks |
AddDbContextWithHook<TDbContext>() |
| Dispatching domain events | DKNet.EfCore.Events |
a hook, registered by AddEventPublisher<TDbContext, TImpl>() |
| Field-level change history | DKNet.EfCore.AuditLogs |
a hook, registered by AddEfCoreAuditLogs<TDbContext, TPublisher>() |
| Row-level ownership | DKNet.EfCore.DataAuthorization |
a global query filter plus a hook, registered by AddDataOwnerProvider<TDbContext, TProvider>() |
Filling CreatedBy/UpdatedBy on a save |
DKNet.EfCore.AuditLogs from the signed-in user, else DKNet.EfCore.DataAuthorization from the ownership key |
AddCurrentUserProvider<TDbContext, TProvider>(); with no current-user value for the save, the ownership-key hook fills them instead |
| Column encryption | DKNet.EfCore.Encryption |
a ValueConverter on the model |
| DTO shapes | DKNet.EfCore.DtoGenerator |
compile time only |
| Command/query dispatch and automatic save | DKNet.SlimBus.Extensions |
AddSlimBusEfCoreInterceptor<TDbContext>() |
| Forwarding domain events onto the bus | DKNet.SlimBus.Extensions |
AddSlimBusEventPublisher<TDbContext>() |
| HTTP surface: endpoint groups, model binding, result mapping | DKNet.AspCore.Extensions |
UseEndpointConfigs() and .Response() |
| Retry safety for a write endpoint | DKNet.AspCore.Idempotency, plus one store package once deployed |
.RequiredIdempotentKey() on the route |
| Start-up work | DKNet.AspCore.Tasks |
AddBackgroundJob<TJob>() |
| Files, PDFs, template tokens, standalone cryptography | DKNet.Svc.* |
its own Add* method; none of them touch EF Core |
| Type scanning and framework-agnostic helpers | DKNet.Fw.Extensions |
plain static methods, no registration |
There is no DKNetOptions and no single aggregator — see Configuration & Setup for the four
registration conventions these methods fall into.
Every arrow is a real project reference in src/: dependencies only ever point inward, toward
DKNet.EfCore.Abstractions. DKNet.Svc.* (blob storage, encryption, PDF, transformation) sits in the
application ring alongside DKNet.SlimBus.Extensions but has no dependency on the EF Core rings at all, which is
why it carries no arrow here. DKNet.EfCore.Encryption is likewise absent: it attaches to the DbContext as a
value converter rather than through the hook pipeline.
DDD focuses modeling on the core business domain. DKNet supports this with rich, event-raising entities rather than
anemic data bags — see DKNet.EfCore.Abstractions for the full contract
surface.
State changes through intention-revealing methods that enforce invariants and raise events, not public setters:
using DKNet.EfCore.Abstractions.Entities;
public class Product : AuditedEntity // Guid-keyed, audit-tracked, event-capable
{
private Product() { }
public static Product Create(string name, decimal price, string createdBy)
{
var product = new Product { Name = name, Price = price, IsActive = true };
product.SetCreatedBy(createdBy);
product.AddEvent(new ProductCreatedEvent(product.Id));
return product;
}
public string Name { get; private set; } = string.Empty;
public decimal Price { get; private set; }
public bool IsActive { get; private set; }
public void UpdatePrice(decimal newPrice, string userId)
{
if (!IsActive) throw new InvalidOperationException("Cannot update price for an inactive product");
Price = newPrice;
SetUpdatedBy(userId);
AddEvent(new ProductPriceChangedEvent(Id, newPrice));
}
}
public record ProductCreatedEvent(Guid ProductId);
public record ProductPriceChangedEvent(Guid ProductId, decimal NewPrice);
There is no separate AggregateRoot type shipped by DKNet — Entity / AuditedEntity (both in
DKNet.EfCore.Abstractions) already carry the domain-event queue (IEventEntity.AddEvent) and, for
AuditedEntity, creation/modification tracking. A consistency boundary in DKNet is simply an entity you choose to
treat as one — model the entity graph so that only the root is fetched and saved directly by application code.
Within a solution, models are organized into bounded contexts, each with its own DbContext and entity set.
Persistence is reached only through DKNet.EfCore.Specifications’s
IRepositorySpec, so domain code never depends on EF Core directly, and application orchestration runs through
DKNet.SlimBus.Extensions handlers that load entities, invoke their
behavior, and let the auto-save interceptor persist the result.
Dependencies always flow inward toward the domain. DKNet expresses this at the package level, not via a single application project:
DKNet.EfCore.Abstractions (Entity, AuditedEntity, IEventEntity).DKNet.SlimBus.Extensions and the DKNet.Svc.* service adapters.DKNet.EfCore.Specifications for persistence, plus the SaveChanges
interceptor pipeline (DKNet.EfCore.Hooks,
DKNet.EfCore.Events,
DKNet.EfCore.AuditLogs,
DKNet.EfCore.DataAuthorization).DKNet.AspCore.* minimal-API endpoints that depend only on
the application layer.Because DKNet is a library suite, each ring is a separate NuGet package: a consuming application swaps an infrastructure adapter (a different blob provider, a different idempotency store) without touching domain or application code.
DKNet.SlimBus.Extensions is DKNet’s CQRS pipeline, built on
SlimMessageBus rather than MediatR — it is lighter (no reflection-heavy
pipeline behaviors) and result-based (FluentResults) for expected business failures. It also auto-saves the
DbContext after a successful write, so handlers don’t call SaveChangesAsync themselves.
using DKNet.SlimBus.Extensions;
using FluentResults;
public record DeactivateProduct(Guid ProductId) : Fluents.Requests.INoResponse;
internal sealed class DeactivateProductHandler(AppDbContext db)
: Fluents.Requests.IHandler<DeactivateProduct>
{
public async Task<IResultBase> OnHandle(DeactivateProduct request, CancellationToken cancellationToken)
{
var product = await db.Products.FindAsync([request.ProductId], cancellationToken);
if (product is null) return Result.Fail("Product not found");
product.Deactivate();
// No SaveChangesAsync call here — the auto-save interceptor does it after this returns Ok.
return Result.Ok();
}
}
Registration wires two things: AddSlimBusEfCoreInterceptor<TDbContext>() for auto-save, and plain SlimMessageBus
setup (AddSlimMessageBus, a transport provider) for dispatch. See the package page for the full fluent interface
surface (INoResponse, IHandler<TRequest>, request/response variants) and query-side handlers.
Nothing in DKNet chains the packages together for you — each one attaches at a different extension point. This is what a retry-safe write request touches, in order, when all of them are wired up:
Three properties follow from that ordering, and they are the reason the packages compose rather than collide:
IEndpointFilter, so a key that has already
been processed replays the cached response without the bus, the handler, or the DbContext ever being reached.IResult<T>; the
auto-save interceptor calls SaveChangesAsync only for a successful Fluents.Requests write, so a failed
command leaves nothing behind and needs no rollback code.For the SlimBus interceptor chain in isolation (including where your own IRequestHandlerInterceptor
implementations sit relative to auto-save), see the sequence diagram on the
DKNet.SlimBus.Extensions page.
Domain events are the one DKNet feature that spans four packages, so it is worth following a single event all the way from the domain method that raises it to the consumer that handles it:
The parts worth knowing before you rely on it:
AddEvent(instance) queues a pre-built event object. AddEvent<TEvent>() and the
[RaisesEvent] attribute instead map the entity onto the event type, so both require an IMapper registration —
without one, the save throws EventException rather than silently dropping the event.[RaisesEvent] is evaluated before the save, published after it. Property narrowing
([RaisesEvent(EventOperations.Updated, Properties = [...])]) reads EntityEntry.Property(...).IsModified,
which is only meaningful before SaveChanges completes. Two rules naming the same payload for the same
operation raise it once.AddEventPublisher<TDbContext, TImpl>() registers your own
publisher; AddSlimBusEventPublisher<TDbContext>() adds one that forwards each event onto SlimMessageBus, copying
an IEventItem’s AdditionalData onto the message as case-insensitive headers.DKNet.EfCore.Specifications — not a hand-rolled IRepository<T> — is
the current, supported way to query and persist through a DbContext. IRepositorySpec is not generic over the
entity: one injected instance serves every entity type in the DbContext, with the entity type inferred from the
Specification<TEntity> passed to each call.
using DKNet.EfCore.Specifications;
services.AddSpecRepo<AppDbContext>();
Its signature feature is the Dynamic Predicate Builder, for building type-safe EF Core predicates from
(propertyName, operation, value) triples at runtime — the shape search/filter APIs need when criteria aren’t known
at compile time:
using DKNet.EfCore.Specifications.Definitions;
using DKNet.EfCore.Specifications.Dynamics;
using DKNet.EfCore.Specifications.Extensions;
using LinqKit;
public sealed class ProductSearchSpec : Specification<Product>
{
public ProductSearchSpec(decimal? minPrice)
{
var predicate = CreatePredicate(p => p.IsActive); // ExpressionStarter<Product>
if (minPrice is not null)
predicate = predicate.DynamicAnd(nameof(Product.Price), Ops.GreaterThanOrEqual, minPrice);
WithFilter(predicate);
}
}
// One injected IRepositorySpec, any entity type:
var results = await repo.ToListAsync(new ProductSearchSpec(100m), cancellationToken);
The operations enum is Ops (in DKNet.EfCore.Specifications.Dynamics), and DynamicAnd/DynamicOr take the
property name, the operation, and the value directly — there is no fluent sub-builder. .AsExpandable() is not
something you add when querying through IRepositorySpec: RepositorySpec<TDbContext>.Query<TEntity> already calls
it before applying the specification. You only need it by hand when you build such a predicate and run it against a
raw DbSet/IQueryable yourself, bypassing the repository — LinqKit cannot expand the predicate into SQL without
it.
DKNet.EfCore.Repos / DKNet.EfCore.Repos.Abstractions (the older generic-repository packages) were removed; see
Migrating-Repos-To-Specifications.md for the call-site mapping
onto Specifications.
Audit, encryption, and authorization attach as opt-in interceptors on the same DbContext, rather than leaking
into domain or application code:
DKNet.EfCore.AuditLogs — captures an audit trail of entity changes, with
[SensitiveData]-aware redaction, and stamps CreatedBy/UpdatedBy from an optional ICurrentUserProvider
before it captures them.DKNet.EfCore.DataAuthorization — row-level, ownership-based
filtering via EF Core global query filters.DKNet.EfCore.Encryption — transparent column-level encryption via an EF
Core value converter (independent of the hook pipeline).The first two register against the same DKNet.EfCore.Hooks pipeline
(IBeforeSaveHookAsync/IAfterSaveHookAsync) that DKNet.EfCore.Events uses, and each is wired in independently
via its own DI extension — there is no single “cross-cutting concerns” package to opt into.