- General
- Architecture & design
- Entity Framework Core
- CQRS & messaging
- Performance
- Testing
- Deployment
- Troubleshooting
A suite of 28 independent .NET NuGet packages for building enterprise applications around Domain-Driven Design and Onion Architecture. There is no framework to adopt wholesale: each package registers itself and is usable on its own. Start from Which package do I need?.
.NET 10.0. src/Directory.Packages.props sets net10.0 solution-wide, so every shipped package targets it —
the two exceptions are the Roslyn source generators, DKNet.EfCore.DtoGenerator and DKNet.SlimBus.Generators,
which must target netstandard2.0 to load into the compiler. They still generate code for your net10.0 project.
src/global.json pins SDK 10.0.0 with rollForward: latestMajor.
Yes — MIT licensed, for commercial and non-commercial use.
src/Directory.Build.props sets TreatWarningsAsErrors, Nullable=enable and GenerateDocumentationFile
solution-wide, so a new warning, a missing XML doc comment, or a nullable mismatch breaks the build. Every
package has a sibling *.Tests project, and CI fails below 80% line coverage. The stricter per-area targets are
in Testing Strategy — they are targets, not a measured guarantee.
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Examples: Examples & Recipes, or the SlimBus.ApiEndpoints template in the DKNet.Templates repository
No. Most packages have nothing to do with DDD — DKNet.Fw.Extensions, DKNet.RandomCreator, the blob storage
family, DKNet.Svc.PdfGenerators, DKNet.Svc.Transformation, DKNet.AspCore.Tasks, and
DKNet.AspCore.Idempotency all work in any architecture. The DDD-shaped ones are DKNet.EfCore.Abstractions
(entities with a domain-event queue) and the packages that build on it.
No. A useful minimum is DKNet.EfCore.Abstractions plus DKNet.EfCore.Extensions; add the rest as the need
appears:
- Querying and persistence →
DKNet.EfCore.Specifications - Domain events →
DKNet.EfCore.Events(which needsDKNet.EfCore.Hookswiring viaAddDbContextWithHook) - Change history →
DKNet.EfCore.AuditLogs - Row-level isolation →
DKNet.EfCore.DataAuthorization
DKNet.EfCore.Repos and DKNet.EfCore.Repos.Abstractions were removed and were never published to NuGet. Use
DKNet.EfCore.Specifications; the call-site mapping is in
Migrating-Repos-To-Specifications.
Because there is nothing for it to do. No package needs another to be registered first (beyond the ordering listed in Registration order that matters), and an aggregator would force references you do not want. See Configuration & Setup.
Architecture Guide — it carries the layer map, the package dependency graph, a request lifecycle across packages, and the domain-event path from an entity method to a bus consumer.
Standard EF Core tooling — DKNet adds nothing:
dotnet ef migrations add YourMigrationName
dotnet ef database update
# For a deployment pipeline
dotnet ef migrations script --idempotent --output migration.sqlYes, and each is configured independently:
using DKNet.EfCore.Specifications;
using Microsoft.EntityFrameworkCore;
services.AddDbContext<CatalogContext>(options => options.UseSqlServer(catalogConnectionString));
services.AddDbContext<IdentityContext>(options => options.UseSqlServer(identityConnectionString));
services.AddSpecRepo<CatalogContext>();
services.AddSpecRepo<IdentityContext>();One caveat: AddDataOwnerProvider registers its query filter in a static model-builder list, so it applies to
every DbContext that calls UseAutoConfigModel() — not only the one passed as TDbContext. A second context
whose model contains IOwnedBy entities must also implement IDataOwnerDbContext, or keep those entities out of
its model.
Via DKNet.EfCore.DataAuthorization:
- Implement
IOwnedByon the entities that belong to a tenant. - Implement
IDataOwnerDbContexton theDbContext— required, and enforced by the generic constraint onAddDataOwnerProvider<TDbContext, TProvider>(). - Register an
IDataOwnerProviderthat returns the current owner key. - Build the model through
UseAutoConfigModel<TContext>(), which is what attaches the filter.
A global query filter then scopes every read, and a SaveChanges hook stamps the owner on new rows. Worked
example: Multi-tenant application.
In the after-save hook, so only once the database write has succeeded. Collection happens in the before-save
hook (that is the only point where [RaisesEvent] property narrowing can read IsModified). A failed save
publishes nothing. Details: A domain event end to end.
Four common causes, in order of likelihood:
- The
DbContextwas registered withAddDbContext, notAddDbContextWithHook— no hook interceptor, so no dispatch. - No publisher is registered. Use
AddEventPublisher<TDbContext, TImplementation>(), orAddSlimBusEventPublisher<TDbContext>()to forward events onto SlimMessageBus. - The save failed. Events are published after the write commits, not before.
- A publisher threw. Publisher failures are logged and swallowed, so the save succeeds and the event is lost — check the logs for the publisher's type name.
AddEvent<TEvent>() and [RaisesEvent] additionally require an IMapper registration; without one the save
throws EventException rather than dropping the event silently.
No. DKNet.SlimBus.Extensions is built on SlimMessageBus and does not
reference MediatR. Nothing stops you using MediatR elsewhere in your own application, but DKNet ships no MediatR
integration. Handler discovery is SlimMessageBus's own API:
using SlimMessageBus.Host;
using SlimMessageBus.Host.Memory;
using SlimMessageBus.Host.Serialization.SystemTextJson;
services.AddSlimMessageBus(mbb => mbb
.AddJsonSerializer()
.AddServicesFromAssembly(typeof(Program).Assembly) // discovers your Fluents handlers
.AddChildBus("Memory", bus => bus
.WithProviderMemory()
.AutoDeclareFrom(typeof(Program).Assembly)));Bring your own. DKNet ships no validation pipeline — declaring a FluentValidation AbstractValidator<T> next
to a command does nothing on its own. To run validation before a handler, register a SlimMessageBus
IRequestHandlerInterceptor<TRequest, TResponse> that resolves your validators and short-circuits with a failed
Result.
AddSlimBusEfCoreInterceptor<TDbContext>() registers an interceptor with Order = int.MaxValue, so it wraps the
handler and runs last. After the handler returns, it saves — but only when the response is non-null, is not a
failed IResultBase, and the request is a Fluents.Requests write. A failed command therefore leaves nothing
behind and needs no rollback code.
Yes — Fluents.EventsConsumers.IHandler<TEvent>.OnHandle returns a Task:
using DKNet.SlimBus.Extensions;
public class ProductCreatedHandler(IEmailService emailService)
: Fluents.EventsConsumers.IHandler<ProductCreatedEvent>
{
public Task OnHandle(ProductCreatedEvent message, CancellationToken cancellationToken) =>
emailService.SendNotificationAsync($"Product {message.ProductId} created");
}No published benchmarks exist, so treat any figure you see as unmeasured. What can be stated from the code:
- The hook pipeline is one EF Core interceptor per
DbContexttype, shared by Events, AuditLogs, and DataAuthorization — not one interceptor each. DKNet.EfCore.DtoGeneratorandDKNet.SlimBus.Generatorsdo their work at compile time and add no runtime reflection.[RaisesEvent]attribute lookups are cached per entityTypefor the lifetime of the process.DKNet.EfCore.Specificationscomposes a normalIQueryable, so EF Core's own query plan caching applies.
Anything beyond that should be measured in your own application.
Real APIs the packages give you, rather than general advice:
- Project instead of materialising entities.
repo.ToListAsync<TEntity, TModel>(spec, ct)andrepo.FirstOrDefaultAsync<TEntity, TModel>(spec, ct)project through Mapster — they need anIMapperregistration, and they applyAsNoTracking()for you. - Use keyset pagination for deep pages.
repo.ToKeysetPageAsync(spec, keySelector, cursor, pageSize, ct)generates an index seek instead of the growingOFFSETthatToPagedListAsyncproduces. - Delete in the database.
repo.BulkDeleteAsync<TEntity>(predicate, ct)issuesExecuteDeleteAsyncrather than loading and tracking rows. - Stream large result sets.
repo.ToPageEnumerable(spec)returns anIAsyncEnumerable<TEntity>and requires the specification to declare an ordering. - Check the SQL.
repo.Query(spec).ToQueryString()shows what the specification actually translated to — the pattern used throughout the test suite.
xUnit, Shouldly, and TestContainers.MsSql for integration tests — no mocked DbContext. See
Testing Strategy for conventions and coverage targets.
TestContainers for anything that exercises persistence. The EF Core in-memory provider does not translate global query filters, generated SQL, or sequences, so it silently passes tests that would fail against a real database — which is exactly the behaviour several DKNet packages depend on.
In-memory or no database at all for pure domain logic: entity invariants, the domain-event queue, and specification construction all test fine without a provider.
IEventEntity.GetEvents() returns the queued event instances and the queued event types as a tuple, so no DKNet
test helper is needed:
using Shouldly;
using Xunit;
[Fact]
public void UpdatePrice_RaisesPriceChangedEvent()
{
var product = Product.Create("Widget", 10.0m, "user");
product.UpdatePrice(15.0m, "user");
var (events, _) = product.GetEvents();
events.ShouldHaveSingleItem().ShouldBeOfType<ProductPriceChangedEvent>();
}Register a fake IDataOwnerProvider and let the filter do its job — it is attached at model-build time and cannot
be disabled per query:
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
[Fact]
public async Task Query_OnlyReturnsRowsForTheCurrentOwner()
{
// The provider is what decides the current owner; the filter is already on the model.
var invoices = await context.Set<Invoice>().ToListAsync();
invoices.ShouldAllBe(i => i.OwnedBy == "tenant1");
}Keep UseAutoConfigModel<TContext>() in the test host's registration. Dropping it removes the filter, and the
test then passes for the wrong reason.
Like any .NET application — DKNet adds no deployment requirement. Two things are worth checking:
DKNet.Svc.PdfGeneratorsneeds a Chromium browser (PuppeteerSharp downloads or locates one). A slim container image may not have its dependencies.- The relational idempotency stores need their table.
DKNet.AspCore.Idempotency.Relationalships aDbContextfor it; create it with a migration or withDKNet.EfCore.Relational.Helpers'CreateTableAsync<TEntity>().
Generate an idempotent script (dotnet ef migrations script --idempotent) or a migration bundle and run it as a
deployment step, rather than migrating from application start-up.
Standard .NET configuration providers — Azure Key Vault, AWS Secrets Manager, environment variables, user secrets for local work. The DKNet-specific values are the blob adapter connection strings, the idempotency store connection string, and the AES/RSA key material. See Environment and secrets.
A missing using, not a missing package. Several DKNet extensions live in their own namespace rather than
Microsoft.Extensions.DependencyInjection — the full map is in
Where each extension method lives. The ones that catch
people most often:
| Method | Namespace |
|---|---|
AddDbContextWithHook<T>() |
DKNet.EfCore.Hooks |
AddSpecRepo<T>() |
DKNet.EfCore.Specifications |
ToListAsync(spec, ct) and friends |
DKNet.EfCore.Specifications.Extensions |
AddIdempotencyWithRedisStore(...) |
DKNet.AspCore.Idempotency.RedisStore |
.Response(...) |
DKNet.AspCore.Extensions.Responses |
It is a DbContextOptionsBuilder<TContext> extension, not a ModelBuilder one. It goes inside the
AddDbContext/AddDbContextWithHook callback:
using DKNet.EfCore.Hooks;
using Microsoft.EntityFrameworkCore;
services.AddDbContextWithHook<AppDbContext>(options => options
.UseSqlServer(connectionString)
.UseAutoConfigModel<AppDbContext>());Your DbContext does not implement IDataOwnerDbContext. The constraint is deliberate — the older, unconstrained
signature let you register a context the ownership filter could not read, which silently disabled row isolation.
Fix and background: Migration Guide.
AddSpecRepo<TDbContext>() was not called, or was called for a different DbContext type than the one registered.
Two separate causes:
- Running it outside
IRepositorySpec.RepositorySpec<TDbContext>.Query<TEntity>calls.AsExpandable()for you; a predicate you build and execute against a rawDbSetneeds that call by hand or LinqKit cannot expand it. - Wrapping calls in null checks.
DynamicAnd/DynamicOralready handle a null or unusable value by skipping the clause rather than throwing, so an outerif (value != null)only hides which clause was dropped.
Three causes:
- No store registered.
.RequiredIdempotentKey()always adds the filter, but the filter needs anIIdempotencyKeyStorefromAddIdempotentKey/AddIdempotencyWith*Store; without it the route fails on its first request rather than running unprotected. - A second
AddIdempotentKeycall was ignored. It returns early when a named store is already registered, so a later call with differentIdempotencyOptionshas no effect. Register the store once, with the options you want. (The one exception: a named store does replace the in-process default store that the parameterlessAddIdempotentKey()registers, in either order — and it is that named call's options that apply.) - It deduplicates on one instance but not across them. That is the in-process default store: its keys live in one process's memory. Check the startup logs for the warning saying keys are process-local, lost on restart, and not shared between instances, then move to a SQL Server, PostgreSQL, or Redis store for deployed traffic.
If deduplication works but two callers collide or fail to, check the caller scope — see Security.
Call builder.Services.AddIdempotentKey() — no type argument, no connection string, no extra package. It
registers an in-process store that reserves each key atomically within the process, so the filter behaves as it
will in production for a single instance, and it holds no key past IdempotencyOptions.Expiration.
Keys are process-local, lost on restart, and not shared between instances, so it is for local development and unit
tests only; the app logs one startup warning to that effect while it is the store serving requests. When you
deploy, add a store package and call its AddIdempotencyWith*Store(...) — that named store takes over whichever
order the two registrations run in, so the AddIdempotentKey() call in shared composition code does not have to
be removed first. See Choosing a store.
- Getting Started — prerequisites and a first working setup
- Configuration & Setup — registration conventions and ordering
- Examples & Recipes — runnable implementations
- Architecture Guide — the composition story
- API Reference — per-package index
Still stuck? Open an issue or start a discussion.