@drunkcoding/dknet-implementation-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +38 -0
  3. package/LICENSE +21 -0
  4. package/README.md +261 -0
  5. package/agents/dknet-architect.md +49 -0
  6. package/agents/dknet-bdd-engineer.md +62 -0
  7. package/agents/dknet-implementer.md +75 -0
  8. package/package.json +52 -0
  9. package/plugin.json +26 -0
  10. package/skills/README.md +47 -0
  11. package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
  12. package/skills/dknet-bdd-tests/SKILL.md +355 -0
  13. package/skills/dknet-bdd-tests/checklist.md +39 -0
  14. package/skills/dknet-crud/SKILL.md +483 -0
  15. package/skills/dknet-ddd-principles/SKILL.md +87 -0
  16. package/skills/dknet-docs/SKILL.md +296 -0
  17. package/skills/dknet-docs/checklist.md +58 -0
  18. package/skills/dknet-docs/templates/README-template.md +68 -0
  19. package/skills/dknet-docs/templates/api-reference-template.md +275 -0
  20. package/skills/dknet-docs/templates/architecture-template.md +166 -0
  21. package/skills/dknet-docs/templates/data-model-template.md +99 -0
  22. package/skills/dknet-docs/templates/events-template.md +155 -0
  23. package/skills/dknet-dto-mapping/SKILL.md +278 -0
  24. package/skills/dknet-efcore-config/SKILL.md +379 -0
  25. package/skills/dknet-endpoint/SKILL.md +458 -0
  26. package/skills/dknet-entity/SKILL.md +483 -0
  27. package/skills/dknet-feature/SKILL.md +139 -0
  28. package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
  29. package/skills/dknet-feature-remove/SKILL.md +131 -0
  30. package/skills/dknet-messaging-events/SKILL.md +395 -0
  31. package/skills/dknet-package-adoption/SKILL.md +252 -0
  32. package/skills/dknet-platform-config/SKILL.md +342 -0
  33. package/skills/dknet-project-structure/SKILL.md +148 -0
  34. package/skills/dknet-queries-specs/SKILL.md +330 -0
  35. package/skills/dknet-scaffold/SKILL.md +209 -0
  36. package/skills/dknet-unit-tests/SKILL.md +382 -0
@@ -0,0 +1,395 @@
1
+ ---
2
+ name: dknet-messaging-events
3
+ description: Explains how this template wires SlimMessageBus as its command/query/event backbone, how the two domain-event styles (manual AddEvent vs declared RaisesEvent) reach a subscriber, and how to forward an event to an external Azure Service Bus topic. Use whenever adding a domain event, wiring an internal or external event consumer, or reasoning about how a command/query travels from an endpoint to its handler.
4
+ ---
5
+
6
+ # DKNet messaging and events (SlimMessageBus)
7
+
8
+ Every command, query, and domain event in this template travels through `SlimMessageBus`'s
9
+ `IMessageBus`, not a MediatR `IMediator`. There is no `IMediator` anywhere in the solution.
10
+
11
+ ## The MediatR mapping
12
+
13
+ If you know MediatR, the shapes map directly onto `DKNet.SlimBus.Extensions`' `Fluents` contracts:
14
+
15
+ | MediatR | This template | Example |
16
+ |---|---|---|
17
+ | `IRequest` (no response) | `Fluents.Requests.INoResponse` + `Fluents.Requests.IHandler<TRequest>` returning `Task<IResultBase>` | a delete/cancel command |
18
+ | `IRequest<TResponse>` | `Fluents.Requests.IWitResponse<TDto>` + `Fluents.Requests.IHandler<TRequest,TDto>` returning `Task<IResult<TDto>>` | `CreatePurchaseOrderRequest` |
19
+ | `IRequest<TResponse>` (read) | `Fluents.Queries.IWitResponse<TDto>` + `Fluents.Queries.IHandler<TRequest,TDto>` returning `Task<TDto?>` | `GetPurchaseOrderByIdQuery` |
20
+ | `IRequest<PagedList<T>>` | `Fluents.Queries.IWitPageResponse<TDto>` + `Fluents.Queries.IPageHandler<TRequest,TDto>` returning `Task<IPagedList<TDto>>` (`X.PagedList`) | `ListPurchaseOrdersQuery` |
21
+ | `INotification` | a plain `sealed record`, queued via `entity.AddEvent(...)` or composed by `[RaisesEvent]` | `PurchaseOrderCreatedEvent` |
22
+ | `INotificationHandler<T>` | `Fluents.EventsConsumers.IHandler<TEvent>` | `PurchaseOrderCreatedEventHandler` |
23
+
24
+ A handler's method is always `OnHandle`, never `Handle`. Endpoints never call a handler directly —
25
+ they resolve `IMessageBus` and call `bus.Send(request, cancellationToken: ct)` for commands/queries.
26
+ Only `EventPublisher` (below) calls `bus.Publish(...)`; application code never publishes an event by
27
+ hand.
28
+
29
+ Handlers never call `SaveChanges`. `AddSlimBusEfCoreInterceptor<CoreDbContext>()` (wired in
30
+ `AddServiceBus`, see below) saves automatically after a write handler's `OnHandle` returns
31
+ successfully.
32
+
33
+ **Auto-discovery, no per-message registration.** `AutoDeclareFrom(serviceAssembly)` scans the
34
+ `Minimal.AppServices` assembly and declares every request/handler pair it finds by convention;
35
+ `AddServicesFromAssembly(serviceAssembly)` registers the discovered handler classes in DI. Adding a
36
+ new `*Request` + `*Handler` pair needs no wiring beyond writing the two classes — see the
37
+ `dknet-crud` skill for how requests, validators, and handlers are shaped.
38
+
39
+ ## Wiring: `Minimal.Infra/Extensions/ServiceBusSetup.cs`
40
+
41
+ ```csharp
42
+ public static IServiceCollection AddServiceBus(
43
+ this IServiceCollection service,
44
+ IConfiguration configuration,
45
+ Assembly serviceAssembly,
46
+ FeatureOptions features)
47
+ {
48
+ var busConnectionString = configuration.GetConnectionString(SharedConsts.AzureBusConnectionString)!;
49
+
50
+ service.AddSlimBusEfCoreInterceptor<CoreDbContext>()
51
+ .AddSlimMessageBus(mbb =>
52
+ {
53
+ mbb.AddJsonSerializer(); // global serializer for every child bus
54
+ mbb.AddMemoryBus(serviceAssembly);
55
+
56
+ if (features.EnableServiceBus && !string.IsNullOrWhiteSpace(busConnectionString))
57
+ mbb.AddAzureBus(busConnectionString);
58
+ });
59
+
60
+ return service;
61
+ }
62
+ ```
63
+
64
+ `AddJsonSerializer()` is a single, global setting shared by every child bus below it — it is not
65
+ per-child configuration.
66
+
67
+ ### The `"ImMemory"` child bus — always registered
68
+
69
+ ```csharp
70
+ internal static MessageBusBuilder AddMemoryBus(this MessageBusBuilder builder, Assembly serviceAssembly)
71
+ {
72
+ builder.AddChildBus("ImMemory", me =>
73
+ me.WithProviderMemory(cf =>
74
+ {
75
+ cf.EnableMessageHeaders = false;
76
+ cf.EnableMessageSerialization = false;
77
+ cf.EnableBlockingPublish = false;
78
+ })
79
+ .AutoDeclareFrom(serviceAssembly)
80
+ .AddServicesFromAssembly(serviceAssembly));
81
+ return builder;
82
+ }
83
+ ```
84
+
85
+ This is the MediatR-like dispatcher every command, query, and domain event in the solution runs
86
+ through, regardless of any feature flag. `EnableMessageHeaders = false` and
87
+ `EnableMessageSerialization = false` mean the message object is passed by reference in-process, no
88
+ header envelope or JSON round-trip. `EnableBlockingPublish = false` means `bus.Publish(...)` for a
89
+ domain event does not wait for every subscriber to finish before returning — a slow or hung internal
90
+ consumer does not block the HTTP response.
91
+
92
+ ## Domain events: two styles, same publisher
93
+
94
+ Both styles are covered in full in the `dknet-entity` skill; here only what matters for
95
+ messaging.
96
+
97
+ - **Manual** — `PurchaseOrder`'s constructor calls `AddEvent(new PurchaseOrderCreatedEvent(...))` by
98
+ hand; the record is a plain hand-written type next to the entity.
99
+ - **Declared** — `Product` carries `[RaisesEvent(EventOperations.Created, Include = [...])]` and
100
+ `[RaisesEvent(EventOperations.Updated, nameof(Price))]`; DKNet's EF Core save hook raises the event
101
+ itself after a successful save. Composed names fold the narrowing property in:
102
+ `[RaisesEvent(EventOperations.Updated, nameof(Price))]` on `Product` generates
103
+ `ProductPriceUpdatedEvent`, not `ProductUpdatedEvent`. An `Updated` rule only fires when that
104
+ property's value actually changed on that save — calling `ChangePrice` with the price it already
105
+ holds raises nothing.
106
+
107
+ Either way, the entity only **queues** the event. `Minimal.Infra/Services/EventPublisher.cs` is what
108
+ actually calls the bus:
109
+
110
+ ```csharp
111
+ internal sealed class EventPublisher(IMessageBus bus) : DefaultEventPublisher
112
+ {
113
+ public override async Task PublishAsync(object eventObj, CancellationToken cancellationToken = default)
114
+ {
115
+ await bus.Publish(eventObj, cancellationToken: cancellationToken);
116
+ }
117
+ }
118
+ ```
119
+
120
+ `DefaultEventPublisher` drains the queued events only **after** `SaveChangesAsync` succeeds — a
121
+ subscriber never sees an event for a write that got rolled back. The reverse also holds: **a
122
+ subscriber failure does not roll back the write that raised it.** `SaveChanges` has already
123
+ committed by the time any consumer runs. If a rule must be able to fail the request, put that check
124
+ in the request validator or the domain method, never in an event handler.
125
+
126
+ Ordering follows registration/declaration order on the entity; nothing in this template depends on
127
+ a specific order across multiple handlers of the same event, and multiple consumers per event are
128
+ allowed (both an internal and an external consumer subscribe to the same `ProductCreatedEvent`, see
129
+ below).
130
+
131
+ **Consumers are always hand-written.** Neither `[RaisesEvent]` nor `AddEvent` generates a consumer —
132
+ only the raise side is automatic for the declared style.
133
+
134
+ Internal consumers live in `Minimal.AppServices/<Feature>/V1/Events/`:
135
+
136
+ ```csharp
137
+ // Minimal.AppServices/ManualSample/V1/Events/PurchaseOrderCreatedEventHandler.cs
138
+ internal sealed class PurchaseOrderCreatedEventHandler(ILogger<PurchaseOrderCreatedEventHandler> logger)
139
+ : Fluents.EventsConsumers.IHandler<PurchaseOrderCreatedEvent>
140
+ {
141
+ public Task OnHandle(PurchaseOrderCreatedEvent notification, CancellationToken cancellationToken)
142
+ {
143
+ if (logger.IsEnabled(LogLevel.Information))
144
+ {
145
+ logger.LogInformation(
146
+ "PurchaseOrderCreatedEvent received for purchase order {PurchaseOrderId} ({CustomerName}, {Amount}).",
147
+ notification.Id, notification.CustomerName, notification.Amount);
148
+ }
149
+ return Task.CompletedTask;
150
+ }
151
+ }
152
+ ```
153
+
154
+ ```csharp
155
+ // Minimal.AppServices/AutomatedSample/V1/Events/ProductEventHandlers.cs
156
+ internal sealed class ProductCreatedEventHandler(ILogger<ProductCreatedEventHandler> logger)
157
+ : Fluents.EventsConsumers.IHandler<ProductCreatedEvent>
158
+ {
159
+ public Task OnHandle(ProductCreatedEvent notification, CancellationToken cancellationToken)
160
+ {
161
+ if (logger.IsEnabled(LogLevel.Information))
162
+ logger.LogInformation("AutomatedSample product created: {ProductId}", notification.Id);
163
+ return Task.CompletedTask;
164
+ }
165
+ }
166
+ ```
167
+
168
+ ## External events — Azure Service Bus
169
+
170
+ A second child bus, `"AzureBus"`, is added only when **both** conditions hold:
171
+
172
+ | Condition | Where |
173
+ |---|---|
174
+ | `FeatureManagement:EnableServiceBus` is `true` | `Minimal.Share/Options/FeatureOptions.cs` |
175
+ | `ConnectionStrings:AzureBus` is a non-empty connection string | checked in `AddServiceBus` |
176
+
177
+ ```csharp
178
+ private static MessageBusBuilder AddAzureBus(this MessageBusBuilder builder, string connectionString)
179
+ {
180
+ builder.AddChildBus("AzureBus", azb =>
181
+ {
182
+ azb.AddServicesFromAssembly(typeof(InfraSetup).Assembly)
183
+ .WithProviderServiceBus(st =>
184
+ {
185
+ st.ConnectionString = connectionString;
186
+ st.ClientFactory = (_, settings) => new ServiceBusClient(
187
+ settings.ConnectionString,
188
+ new ServiceBusClientOptions { TransportType = ServiceBusTransportType.AmqpWebSockets });
189
+
190
+ st.TopologyProvisioning = new ServiceBusTopologySettings
191
+ {
192
+ Enabled = false,
193
+ CanProducerCreateTopic = true,
194
+ CanProducerCreateQueue = true,
195
+ CanConsumerCreateSubscription = true,
196
+ CanConsumerCreateQueue = true,
197
+ CreateSubscriptionOptions = op =>
198
+ {
199
+ op.EnableBatchedOperations = true;
200
+ op.MaxDeliveryCount = 10;
201
+ op.AutoDeleteOnIdle = TimeSpan.FromDays(60);
202
+ op.DeadLetteringOnMessageExpiration = true;
203
+ op.DefaultMessageTimeToLive = TimeSpan.FromDays(7);
204
+ }
205
+ };
206
+ });
207
+
208
+ azb.Produce<ProductCreatedEvent>(o => o.DefaultTopic("product-tp"));
209
+ azb.Consume<ProductCreatedEvent>(o => o.Path("product-tp")
210
+ .SubscriptionName("product-sub")
211
+ .WithConsumer<ProductCreatedNotificationHandler>());
212
+ });
213
+ return builder;
214
+ }
215
+ ```
216
+
217
+ Connects over AMQP-over-WebSockets, which works through most corporate proxies that block raw AMQP.
218
+
219
+ `TopologyProvisioning.Enabled = false` means the template does **not** create the topic or
220
+ subscription for you — provision `product-tp` and `product-sub` yourself (Bicep, Pulumi, or the
221
+ portal) before running against a real namespace. The `Can*Create*` flags and
222
+ `CreateSubscriptionOptions` (including `MaxDeliveryCount = 10` and `DeadLetteringOnMessageExpiration`)
223
+ only apply when `Enabled` is flipped to `true`. With the shipped `Enabled = false`, those values are
224
+ documentation of intent, not enforced configuration — whoever provisions the real subscription must
225
+ set them to match by hand.
226
+
227
+ The same event type — `ProductCreatedEvent` — flows on both buses. There is no separate "external"
228
+ event record. The `Produce`/`Consume` declaration in `AddAzureBus` is what forwards an
229
+ already-declared internal event externally; nothing about the event itself changes.
230
+
231
+ External consumers live in `Minimal.Infra/Features/<Feature>/ExternalEvents/`, are `internal
232
+ sealed`, and are discovered by the same `AddServicesFromAssembly(typeof(InfraSetup).Assembly)` call
233
+ inside `AddAzureBus` — no separate registration:
234
+
235
+ ```csharp
236
+ // Minimal.Infra/Features/AutomatedSample/ExternalEvents/ProductCreatedNotificationHandler.cs
237
+ internal sealed class ProductCreatedNotificationHandler(ILogger<ProductCreatedNotificationHandler> logger)
238
+ : Fluents.EventsConsumers.IHandler<ProductCreatedEvent>
239
+ {
240
+ public Task OnHandle(ProductCreatedEvent notification, CancellationToken cancellationToken)
241
+ {
242
+ if (logger.IsEnabled(LogLevel.Information))
243
+ {
244
+ logger.LogInformation(
245
+ "External broker received product-created event for {ProductId}", notification.Id);
246
+ }
247
+ return Task.CompletedTask;
248
+ }
249
+ }
250
+ ```
251
+
252
+ ### Recipe: forward an internal domain event externally
253
+
254
+ 1. In `AddAzureBus`, next to the `ProductCreatedEvent` lines, add:
255
+ ```csharp
256
+ azb.Produce<TEvent>(o => o.DefaultTopic("<topic-name>"));
257
+ azb.Consume<TEvent>(o => o.Path("<topic-name>")
258
+ .SubscriptionName("<subscription-name>")
259
+ .WithConsumer<THandler>());
260
+ ```
261
+ 2. Write `THandler` as a `Fluents.EventsConsumers.IHandler<TEvent>` under
262
+ `Minimal.Infra/Features/<Feature>/ExternalEvents/`. External-system concerns belong in `Infra`,
263
+ never `AppServices`.
264
+ 3. Nothing else — `azb.AddServicesFromAssembly(typeof(InfraSetup).Assembly)` already picks up the
265
+ new handler by assembly scan.
266
+
267
+ ### Recipe: consume an event another service publishes
268
+
269
+ Only the `Consume` half is needed — this service produces nothing for that event:
270
+
271
+ ```csharp
272
+ azb.Consume<TExternalEvent>(o => o.Path("<their-topic-name>")
273
+ .SubscriptionName("<your-subscription-name>")
274
+ .WithConsumer<THandler>());
275
+ ```
276
+
277
+ `THandler` still goes in `Minimal.Infra/Features/<Feature>/ExternalEvents/` and still needs no
278
+ manual DI registration. Do not add a matching `azb.Produce<TExternalEvent>(...)` — that would make
279
+ this service claim ownership of an event type it does not raise.
280
+
281
+ ## What `EnableServiceBus` switches off
282
+
283
+ It gates the **Azure Service Bus child bus only**. The in-memory child bus is always registered
284
+ regardless of the flag — a service with `EnableServiceBus` off still handles every request and still
285
+ raises domain events in-process. Turning it off only stops this service producing to and consuming
286
+ from Azure Service Bus: `ProductCreatedEvent` is still published in-memory and handled by
287
+ `ProductCreatedEventHandler`, but it is never produced to `product-tp`, and
288
+ `ProductCreatedNotificationHandler` never fires.
289
+
290
+ ## Local development
291
+
292
+ `Minimal.AppHost/AppHost.cs` (Aspire orchestration) wires only Redis and PostgreSQL today:
293
+
294
+ ```csharp
295
+ var cache = builder.AddRedis("Redis");
296
+ var postgres = builder.AddPostgres("Postgres");
297
+ ...
298
+ builder.AddProject("Api", "../Minimal.Api/Minimal.Api.csproj")
299
+ .WithReference(cache, "Redis")
300
+ .WithReference(apDb, "AppDb")
301
+ //.WaitFor(bus)
302
+ .WaitFor(cache)
303
+ .WaitFor(apDb);
304
+ ```
305
+
306
+ The `.WaitFor(bus)` line is commented out and no `bus` resource is added above it — no Azure Service
307
+ Bus emulator is wired into `AppHost.cs` as shipped. `Minimal.AppHost/Configs/busConfig.json` exists
308
+ and is copied to the build output, but nothing in `AppHost.cs` references it — it's a config file
309
+ waiting for an emulator resource, not something a consumer touches to run the app today.
310
+
311
+ DKNet also carries an `Aspire.Hosting.ServiceBus` project that runs the emulator locally, but it is
312
+ **not published to NuGet** — usable only via a project reference to a local DKNet clone, not
313
+ `dotnet add package`.
314
+
315
+ ## Testing events
316
+
317
+ **BDD — log-capture pattern.** `Minimal.App.TestSupport/TestLogCapture.cs` is an `ILoggerProvider`
318
+ that queues every formatted log line into an in-memory collection, registered as an additional
319
+ provider alongside the host's normal logging. A scenario asserts on the resulting text instead of on
320
+ internal call order:
321
+
322
+ ```gherkin
323
+ Scenario: Creating a purchase order raises PurchaseOrderCreatedEvent
324
+ When I create a purchase order for customer "Acme Pte Ltd" with amount 250.00
325
+ Then a log line reports the purchase order created event was received
326
+ ```
327
+
328
+ ```csharp
329
+ [Then("a log line reports the purchase order created event was received")]
330
+ public void ThenALogLineReportsThePurchaseOrderCreatedEventWasReceived() =>
331
+ factory.LogCapture.Messages.ShouldContain(m => m.Contains("PurchaseOrderCreatedEvent received", StringComparison.Ordinal));
332
+ ```
333
+
334
+ The automated sample proves the same shape for the declared-event style:
335
+
336
+ ```gherkin
337
+ Scenario: Creating a product raises the internal ProductCreatedEvent
338
+ When I create a product named "Widget" with price 9.99
339
+ Then a log line reports the automated sample product was created
340
+ ```
341
+
342
+ **xUnit — a unit test for an external handler.** Because the test hosts never set
343
+ `ConnectionStrings:AzureBus`, nothing in either suite ever routes a message onto the `AzureBus` child
344
+ bus, so `ProductCreatedNotificationHandler` is invoked directly instead:
345
+
346
+ ```csharp
347
+ [Fact]
348
+ public async Task OnHandle_ShouldLogTheExternalBrokerReceipt()
349
+ {
350
+ var logCapture = new TestLogCapture();
351
+ using var loggerFactory = LoggerFactory.Create(b => b.AddProvider(logCapture));
352
+ var handler = new ProductCreatedNotificationHandler(loggerFactory.CreateLogger<ProductCreatedNotificationHandler>());
353
+ var productId = Guid.NewGuid();
354
+ var notification = new ProductCreatedEvent { Id = productId, Name = "Widget", Price = 9.99m };
355
+
356
+ await handler.OnHandle(notification, CancellationToken.None);
357
+
358
+ logCapture.Messages.ShouldContain(m => m.Contains(productId.ToString(), StringComparison.Ordinal));
359
+ }
360
+ ```
361
+
362
+ **The honest gap.** The full `Produce → topic → Consume` path against a real or emulated Azure
363
+ Service Bus namespace is not exercised by either shipped suite. `ProductCreatedNotificationHandlerTests`
364
+ proves the handler's own behavior; it does not prove a message actually crosses the broker. Treat
365
+ that path as untested until you add integration coverage against a real namespace or an emulator.
366
+
367
+ ## Handler failure and retry — only what the code shows
368
+
369
+ - **In-memory bus:** no retry policy anywhere in `ServiceBusSetup.cs`. An exception from an internal
370
+ `OnHandle` is not retried; it propagates like any other in-process exception.
371
+ - **Azure bus:** `MaxDeliveryCount = 10` and `DeadLetteringOnMessageExpiration = true` are the values
372
+ `CreateSubscriptionOptions` sets, but (as above) they only take effect through SlimMessageBus's own
373
+ provisioning, or if you set them by hand when provisioning `product-sub` yourself.
374
+
375
+ ## Common mistakes
376
+
377
+ - **What you might expect:** publishing a domain event directly from a command handler.
378
+ **What actually happens:** only `EventPublisher`, called by DKNet's save-hook after a successful
379
+ `SaveChanges`, ever calls `bus.Publish(...)`. A handler that calls `bus.Publish` itself bypasses the
380
+ "only after a committed write" guarantee.
381
+ - **What you might expect:** an `[RaisesEvent(EventOperations.Updated, ...)]` fires on every call to
382
+ the method that touches that property. **What actually happens:** it only fires when the value
383
+ actually changed on that save — see `dknet-entity` for the mechanics.
384
+ - **What you might expect:** placing a new event consumer in `Minimal.Api` gets it discovered like
385
+ the others. **What actually happens:** discovery only scans the `Minimal.AppServices` assembly
386
+ (internal) and the `Minimal.Infra` assembly (external, inside `AddAzureBus`). A consumer in
387
+ `Minimal.Api` is never registered.
388
+ - **What you might expect:** setting `EnableServiceBus: true` is enough to start producing to Azure.
389
+ **What actually happens:** `ConnectionStrings:AzureBus` must also be a non-empty string. Either one
390
+ missing and the `AzureBus` child bus, and everything registered only on it, silently does not exist
391
+ — no error, no log, just no external traffic.
392
+ - **What you might expect:** an external consumer belongs next to the internal one, in
393
+ `Minimal.AppServices/<Feature>/V1/Events/`. **What actually happens:** external-system consumers
394
+ belong in `Minimal.Infra/Features/<Feature>/ExternalEvents/` — that is the assembly `AddAzureBus`
395
+ scans, and it keeps the external-system dependency out of `AppServices`.
@@ -0,0 +1,252 @@
1
+ ---
2
+ name: dknet-package-adoption
3
+ description: Add DKNet's Core, EF Core, messaging/CQRS, or blob-storage NuGet packages to an EXISTING .NET project that was NOT created from the DKNet.Minimal.Template. Use when a consumer wants to reuse pieces of the DKNet framework in their own project layout without scaffolding a new solution.
4
+ ---
5
+
6
+ # Skill: Adopting DKNet Packages in an Existing Project
7
+
8
+ This skill is for a project that already exists with its own namespaces and folder layout — it does not assume `dotnet new dknet-minimal` was run, and never references `Minimal.*` types. If you're scaffolding a brand-new solution from the template instead, use **dknet-project-structure** and the other `dknet-*` skills.
9
+
10
+ Each package below is independent — install only what the feature needs. All packages target **.NET 10.0+** (EF Core packages additionally need **EF Core 10.0+**); consult `Directory.Packages.props` (or your project's own central version file) before adding a version attribute per-project.
11
+
12
+ ---
13
+
14
+ ## When to Use
15
+
16
+ - Adding one or more `DKNet.*` NuGet packages to a pre-existing .NET API/service/library
17
+ - The consuming project has its own entities, `DbContext`, and DI composition root — this skill wires DKNet into that, it doesn't replace it
18
+ - NOT for scaffolding a new solution from `DKNet.Minimal.Template` (see `dknet-project-structure`)
19
+
20
+ ## Inputs Required
21
+
22
+ 1. Which capability you need (persistence helpers, repositories, dynamic query filtering, CQRS/messaging, blob storage)
23
+ 2. Your existing `DbContext` type (if adding EF Core packages) and EF Core provider (SQL Server, PostgreSQL, SQLite, etc. — every package below is provider-agnostic)
24
+ 3. For blob storage: which backend (Azure Blob Storage, AWS S3, or local filesystem)
25
+
26
+ ---
27
+
28
+ ## Core — `DKNet.Fw.Extensions`
29
+
30
+ Framework-level extension methods with no dependency on EF Core or any other DKNet package: string/number parsing (`ExtractDigits`, `IsNumber`), `DateTime` helpers (`LastDayOfMonth`, `Quarter`), enum `[Display]` attribute lookup (`GetAttribute<T>`, `GetEnumInfo`), reflection-based property/type checks, and a fluent assembly-scanning API (`TypeExtractors` — `assembly.Extract().Classes().NotAbstract()...`). Stateless and thread-safe; no DI registration needed.
31
+
32
+ ```bash
33
+ dotnet add package DKNet.Fw.Extensions
34
+ ```
35
+
36
+ ```csharp
37
+ using DKNet.Fw.Extensions;
38
+
39
+ var digits = "Invoice #: INV-2024-00123".ExtractDigits(); // "202400123"
40
+ var quarter = DateTime.UtcNow.Quarter(); // 1-4
41
+ ```
42
+
43
+ ---
44
+
45
+ ## EF Core Layer
46
+
47
+ Adopt these incrementally — `Abstractions` alone is useful; `Extensions`, `Repos`, and `Specifications` build on it.
48
+
49
+ ### `DKNet.EfCore.Abstractions`
50
+
51
+ Base entity classes and interfaces for DDD-flavored persistence: `Entity<TKey>` / `AuditedEntity<TKey>` (with domain-event support via `AddEvent(...)`), `ISoftDeletableEntity`, `IConcurrencyEntity<TKey>`, plus `[Sequence]` / `[SqlSequence]` / `[IgnoreEntity]` attributes.
52
+
53
+ ```bash
54
+ dotnet add package DKNet.EfCore.Abstractions
55
+ ```
56
+
57
+ ```csharp
58
+ using DKNet.EfCore.Abstractions.Entities;
59
+
60
+ public class Invoice : AuditedEntity<Guid>
61
+ {
62
+ public Invoice(string number, string createdBy)
63
+ {
64
+ Number = number;
65
+ SetCreatedBy(createdBy);
66
+ AddEvent(new InvoiceCreatedEvent(Id, number));
67
+ }
68
+
69
+ private Invoice() { } // for EF Core
70
+
71
+ public string Number { get; private set; } = null!;
72
+ }
73
+
74
+ public record InvoiceCreatedEvent(Guid InvoiceId, string Number);
75
+ ```
76
+
77
+ `AuditedEntity<TKey>` has only a parameterless constructor and a `(TKey id)` constructor — there is no constructor overload that takes `createdBy` directly. Call `SetCreatedBy(userName, createdOn?)` in your own constructor's body instead.
78
+
79
+ ### `DKNet.EfCore.Extensions`
80
+
81
+ Automatic entity configuration discovery, global query filters, and structured data seeding — layered on top of your existing `DbContext`, no `DbSet` rewrite required.
82
+
83
+ ```bash
84
+ dotnet add package DKNet.EfCore.Extensions
85
+ ```
86
+
87
+ ```csharp
88
+ // Program.cs — provider is whatever this project already uses (SQL Server, PostgreSQL, ...)
89
+ // UseAutoConfigModel/UseAutoDataSeeding are DbContextOptionsBuilder extensions: they scan the
90
+ // given assemblies for IEntityTypeConfiguration<T> / IDataSeedingConfiguration<T> implementations
91
+ // instead of requiring them wired up by hand. Existing DbSets on AppDbContext stay as they are.
92
+ services.AddDbContext<AppDbContext>(options =>
93
+ options.UseNpgsql(connectionString) // or UseSqlServer / UseSqlite / etc.
94
+ .UseAutoConfigModel<AppDbContext>([typeof(Invoice).Assembly])
95
+ .UseAutoDataSeeding([typeof(Invoice).Assembly]));
96
+ ```
97
+
98
+ Optional: inherit `DefaultEntityTypeConfiguration<TEntity>` for new `IEntityTypeConfiguration<T>` classes to get audit/soft-delete columns configured for free — `base.Configure(builder)` first, then add your own indexes/constraints.
99
+
100
+ `DKNet.EfCore.Repos` and `DKNet.EfCore.Repos.Abstractions` are retired and never published to NuGet —
101
+ both projects are marked not packable and every public type is obsolete. Do not add them. `IRepositorySpec`
102
+ from `DKNet.EfCore.Specifications` below is the supported way to query and persist.
103
+
104
+ ### `DKNet.EfCore.Specifications`
105
+
106
+ Composable query objects (`Specification<TEntity>`) plus runtime dynamic-predicate building (`DynamicAnd`/`DynamicOr` over `(propertyName, operation, value)` triples) — useful for search/filter endpoints where the filter shape isn't known at compile time. Requires `DKNet.EfCore.Repos.Abstractions` for the `IRepositorySpec` extension methods it hangs off.
107
+
108
+ ```bash
109
+ dotnet add package DKNet.EfCore.Specifications
110
+ ```
111
+
112
+ ```csharp
113
+ // Program.cs — registers IRepositorySpec, scoped to AppDbContext
114
+ services.AddSpecRepo<AppDbContext>();
115
+ ```
116
+
117
+ ```csharp
118
+ using LinqKit;
119
+ using DKNet.EfCore.Specifications.Definitions;
120
+ using DKNet.EfCore.Specifications.Dynamics;
121
+
122
+ public class InvoiceSearchSpec : Specification<Invoice>
123
+ {
124
+ public InvoiceSearchSpec(string? numberContains, bool? isClosed)
125
+ {
126
+ var predicate = PredicateBuilder.New<Invoice>(true);
127
+
128
+ if (!string.IsNullOrEmpty(numberContains))
129
+ predicate = predicate.DynamicAnd("Number", Ops.Contains, numberContains);
130
+ if (isClosed.HasValue)
131
+ predicate = predicate.DynamicAnd("IsClosed", Ops.Equal, isClosed.Value);
132
+
133
+ WithFilter(predicate);
134
+ }
135
+ }
136
+
137
+ // Usage — IRepositorySpec injected via DI
138
+ // var results = await repository.ToListAsync(new InvoiceSearchSpec(numberContains: "INV-2024", isClosed: false));
139
+ ```
140
+
141
+ `Ops` (not a generic "operations" enum) is the operator type `DynamicAnd`/`DynamicOr` take; an unconvertible filter value is silently skipped rather than throwing. `.AsExpandable()` is required if you build predicates directly against `DbContext` instead of going through the `IRepositorySpec` extensions (which apply it for you).
142
+
143
+ ---
144
+
145
+ ## Messaging / CQRS — `DKNet.SlimBus.Extensions`
146
+
147
+ Fluent request/query/event-handler interfaces over SlimMessageBus with automatic EF Core `SaveChanges` after a successful command and `FluentResults`-based error handling — no MediatR-style pipeline needed.
148
+
149
+ ```bash
150
+ dotnet add package DKNet.SlimBus.Extensions
151
+ ```
152
+
153
+ ```csharp
154
+ // Program.cs — AddSlimBusEfCoreInterceptor is DKNet's; AddSlimMessageBus/AddChildBus/WithProviderMemory
155
+ // come from the underlying SlimMessageBus.Host(.Memory) packages that DKNet.SlimBus.Extensions depends on.
156
+ services.AddSlimBusEfCoreInterceptor<AppDbContext>()
157
+ .AddSlimMessageBus(mbb =>
158
+ {
159
+ mbb.AddJsonSerializer();
160
+ mbb.AddChildBus("Default", cb => cb
161
+ .WithProviderMemory() // in-process; swap for a real transport when you need cross-process messaging
162
+ .AutoDeclareFrom(typeof(CreateInvoiceHandler).Assembly)
163
+ .AddServicesFromAssembly(typeof(CreateInvoiceHandler).Assembly));
164
+ });
165
+
166
+ // Command
167
+ public record CreateInvoice(string Number) : Fluents.Requests.IWitResponse<InvoiceDto>;
168
+
169
+ internal sealed class CreateInvoiceHandler(AppDbContext db, IMapper mapper)
170
+ : Fluents.Requests.IHandler<CreateInvoice, InvoiceDto>
171
+ {
172
+ public async Task<IResult<InvoiceDto>> OnHandle(CreateInvoice request, CancellationToken ct)
173
+ {
174
+ if (await db.Set<Invoice>().AnyAsync(i => i.Number == request.Number, ct))
175
+ return Result.Fail<InvoiceDto>($"Invoice {request.Number} already exists.");
176
+
177
+ var invoice = new Invoice(request.Number, "system");
178
+ db.Add(invoice);
179
+ // SaveChanges runs automatically after a successful handler — no explicit call needed.
180
+ return Result.Ok(mapper.Map<InvoiceDto>(invoice));
181
+ }
182
+ }
183
+ ```
184
+
185
+ Commands auto-save on success; queries (`Fluents.Queries.IHandler<...>`) never trigger a save, since they're read-only.
186
+
187
+ ---
188
+
189
+ ## Blob Storage — `DKNet.Svc.BlobStorage.Abstractions` + a provider
190
+
191
+ `IBlobService` is the provider-agnostic contract (`SaveAsync`, `GetAsync`, `ListItemsAsync`, `DeleteAsync`, `CheckExistsAsync`); pick exactly one provider package for the backend you actually run against.
192
+
193
+ ```bash
194
+ dotnet add package DKNet.Svc.BlobStorage.Abstractions
195
+ dotnet add package DKNet.Svc.BlobStorage.AzureStorage # or .AwsS3 / .Local
196
+ ```
197
+
198
+ ```json
199
+ // appsettings.json
200
+ {
201
+ "BlobService": {
202
+ "AzureStorage": {
203
+ "ConnectionString": "UseDevelopmentStorage=true",
204
+ "ContainerName": "documents"
205
+ }
206
+ }
207
+ }
208
+ ```
209
+
210
+ ```csharp
211
+ // Program.cs
212
+ services.AddAzureStorageAdapter(configuration); // registers IBlobService
213
+
214
+ // Usage — identical code regardless of which provider package is installed
215
+ using DKNet.Svc.BlobStorage.Abstractions;
216
+ using static DKNet.Svc.BlobStorage.Abstractions.BlobDetails;
217
+
218
+ public class DocumentService(IBlobService blobService)
219
+ {
220
+ // BlobStreamData reads from the stream without buffering it all in memory; the caller still
221
+ // owns and disposes the stream after the save call completes.
222
+ public Task SaveAsync(string fileName, Stream content, string contentType) =>
223
+ blobService.SaveAsync(new BlobStreamData($"documents/{fileName}", content) { ContentType = contentType });
224
+ }
225
+ ```
226
+
227
+ Swapping providers later (e.g. `.Local` in dev, `.AzureStorage` in production) only changes the registration call and configuration section — application code against `IBlobService` doesn't change.
228
+
229
+ ---
230
+
231
+ ## Validation Checklist
232
+
233
+ - [ ] Only the packages the feature actually needs were added (no blanket "add everything")
234
+ - [ ] EF Core additions layer onto the existing `DbContext`/provider — no assumption of a specific database engine
235
+ - [ ] No `Minimal.*` namespace or template folder path (`Minimal.Domains`, `Minimal.AppServices`, …) appears anywhere in the guidance followed
236
+ - [ ] Repositories/specs/handlers registered in DI (`AddSpecRepo`, `AddSlimBusEfCoreInterceptor`, `AddAzureStorageAdapter`, etc.) — nothing relies on auto-discovery unless the package documents it
237
+ - [ ] For blob storage, exactly one provider package installed alongside `Abstractions`
238
+ - [ ] `dotnet build` passes with the new package references
239
+
240
+ ## Common Mistakes
241
+
242
+ | Mistake | Fix |
243
+ |---------|-----|
244
+ | Adding `DKNet.EfCore.Repos` / `.Repos.Abstractions` | Both are retired, unpublished, and obsolete — use `IRepositorySpec` from `DKNet.EfCore.Specifications` instead |
245
+ | Building a dynamic predicate directly against `DbContext` without `.AsExpandable()` | Required for LinqKit to translate the expression; the `IRepositorySpec` extensions already apply it |
246
+ | Installing more than one blob storage provider package for the same `IBlobService` | Register exactly one — the last registration wins and the others are dead weight |
247
+ | Assuming a specific EF Core provider (SQL Server, Postgres, …) is required | Every package here is provider-agnostic; it only needs a working `DbContext` |
248
+ | Copying `Minimal.*` namespaces/paths from the template's docs | This skill — and any project using it — has its own namespaces; the template's layout doesn't apply |
249
+
250
+ ## Next Steps
251
+
252
+ Once the package is wired in, each package's own reference doc in the [DKNet repository](https://github.com/baoduy/DKNet) covers deeper API surface and advanced scenarios (custom query filters, keyset pagination, interceptors, retry policies) — e.g. `docs/Core/DKNet.Fw.Extensions.md`, `docs/EfCore/DKNet.EfCore.*.md`, `docs/Messaging/DKNet.SlimBus.Extensions.md`, `docs/Services/DKNet.Svc.BlobStorage.Abstractions.md`.