@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.
- package/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +38 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/agents/dknet-architect.md +49 -0
- package/agents/dknet-bdd-engineer.md +62 -0
- package/agents/dknet-implementer.md +75 -0
- package/package.json +52 -0
- package/plugin.json +26 -0
- package/skills/README.md +47 -0
- package/skills/dknet-auth-and-ownership/SKILL.md +418 -0
- package/skills/dknet-bdd-tests/SKILL.md +355 -0
- package/skills/dknet-bdd-tests/checklist.md +39 -0
- package/skills/dknet-crud/SKILL.md +483 -0
- package/skills/dknet-ddd-principles/SKILL.md +87 -0
- package/skills/dknet-docs/SKILL.md +296 -0
- package/skills/dknet-docs/checklist.md +58 -0
- package/skills/dknet-docs/templates/README-template.md +68 -0
- package/skills/dknet-docs/templates/api-reference-template.md +275 -0
- package/skills/dknet-docs/templates/architecture-template.md +166 -0
- package/skills/dknet-docs/templates/data-model-template.md +99 -0
- package/skills/dknet-docs/templates/events-template.md +155 -0
- package/skills/dknet-dto-mapping/SKILL.md +278 -0
- package/skills/dknet-efcore-config/SKILL.md +379 -0
- package/skills/dknet-endpoint/SKILL.md +458 -0
- package/skills/dknet-entity/SKILL.md +483 -0
- package/skills/dknet-feature/SKILL.md +139 -0
- package/skills/dknet-feature-lifecycle/SKILL.md +144 -0
- package/skills/dknet-feature-remove/SKILL.md +131 -0
- package/skills/dknet-messaging-events/SKILL.md +395 -0
- package/skills/dknet-package-adoption/SKILL.md +252 -0
- package/skills/dknet-platform-config/SKILL.md +342 -0
- package/skills/dknet-project-structure/SKILL.md +148 -0
- package/skills/dknet-queries-specs/SKILL.md +330 -0
- package/skills/dknet-scaffold/SKILL.md +209 -0
- 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`.
|