@malamute/ai-rules 1.0.0 → 1.2.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/README.md +270 -121
- package/bin/cli.js +5 -2
- package/configs/_shared/.claude/rules/conventions/documentation.md +324 -0
- package/configs/_shared/.claude/rules/conventions/git.md +265 -0
- package/configs/_shared/.claude/rules/{performance.md → conventions/performance.md} +1 -1
- package/configs/_shared/.claude/rules/conventions/principles.md +334 -0
- package/configs/_shared/.claude/rules/devops/ci-cd.md +262 -0
- package/configs/_shared/.claude/rules/devops/docker.md +275 -0
- package/configs/_shared/.claude/rules/devops/nx.md +194 -0
- package/configs/_shared/.claude/rules/domain/backend/api-design.md +203 -0
- package/configs/_shared/.claude/rules/lang/csharp/async.md +220 -0
- package/configs/_shared/.claude/rules/lang/csharp/csharp.md +314 -0
- package/configs/_shared/.claude/rules/lang/csharp/linq.md +210 -0
- package/configs/_shared/.claude/rules/lang/python/async.md +337 -0
- package/configs/_shared/.claude/rules/lang/python/celery.md +476 -0
- package/configs/_shared/.claude/rules/lang/python/config.md +339 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/database/sqlalchemy.md +6 -1
- package/configs/_shared/.claude/rules/lang/python/deployment.md +523 -0
- package/configs/_shared/.claude/rules/lang/python/error-handling.md +330 -0
- package/configs/_shared/.claude/rules/lang/python/migrations.md +421 -0
- package/configs/_shared/.claude/rules/lang/python/python.md +172 -0
- package/configs/_shared/.claude/rules/lang/python/repository.md +383 -0
- package/configs/{python/.claude/rules → _shared/.claude/rules/lang/python}/testing.md +2 -69
- package/configs/_shared/.claude/rules/lang/typescript/async.md +447 -0
- package/configs/_shared/.claude/rules/lang/typescript/generics.md +356 -0
- package/configs/_shared/.claude/rules/lang/typescript/typescript.md +212 -0
- package/configs/_shared/.claude/rules/quality/error-handling.md +48 -0
- package/configs/_shared/.claude/rules/quality/logging.md +45 -0
- package/configs/_shared/.claude/rules/quality/observability.md +240 -0
- package/configs/_shared/.claude/rules/quality/testing-patterns.md +65 -0
- package/configs/_shared/.claude/rules/security/secrets-management.md +222 -0
- package/configs/_shared/.claude/skills/analysis/explore/SKILL.md +257 -0
- package/configs/_shared/.claude/skills/analysis/security-audit/SKILL.md +184 -0
- package/configs/_shared/.claude/skills/dev/api-endpoint/SKILL.md +126 -0
- package/configs/_shared/.claude/{commands/generate-tests.md → skills/dev/generate-tests/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/fix-issue.md → skills/git/fix-issue/SKILL.md} +6 -0
- package/configs/_shared/.claude/{commands/review-pr.md → skills/git/review-pr/SKILL.md} +6 -0
- package/configs/_shared/.claude/skills/infra/deploy/SKILL.md +139 -0
- package/configs/_shared/.claude/skills/infra/docker/SKILL.md +95 -0
- package/configs/_shared/.claude/skills/infra/migration/SKILL.md +158 -0
- package/configs/_shared/.claude/skills/nx/nx-affected/SKILL.md +72 -0
- package/configs/_shared/.claude/skills/nx/nx-lib/SKILL.md +375 -0
- package/configs/_shared/CLAUDE.md +52 -149
- package/configs/angular/.claude/rules/{components.md → core/components.md} +69 -15
- package/configs/angular/.claude/rules/core/resource.md +285 -0
- package/configs/angular/.claude/rules/core/signals.md +323 -0
- package/configs/angular/.claude/rules/http.md +338 -0
- package/configs/angular/.claude/rules/routing.md +291 -0
- package/configs/angular/.claude/rules/ssr.md +312 -0
- package/configs/angular/.claude/rules/state/signal-store.md +408 -0
- package/configs/angular/.claude/rules/{state.md → state/state.md} +2 -2
- package/configs/angular/.claude/rules/testing.md +7 -7
- package/configs/angular/.claude/rules/ui/aria.md +422 -0
- package/configs/angular/.claude/rules/ui/forms.md +424 -0
- package/configs/angular/.claude/rules/ui/pipes-directives.md +335 -0
- package/configs/angular/.claude/settings.json +1 -0
- package/configs/angular/.claude/skills/ngrx-slice/SKILL.md +362 -0
- package/configs/angular/.claude/skills/signal-store/SKILL.md +445 -0
- package/configs/angular/CLAUDE.md +24 -216
- package/configs/dotnet/.claude/rules/background-services.md +552 -0
- package/configs/dotnet/.claude/rules/configuration.md +426 -0
- package/configs/dotnet/.claude/rules/ddd.md +447 -0
- package/configs/dotnet/.claude/rules/dependency-injection.md +343 -0
- package/configs/dotnet/.claude/rules/mediatr.md +320 -0
- package/configs/dotnet/.claude/rules/middleware.md +489 -0
- package/configs/dotnet/.claude/rules/result-pattern.md +363 -0
- package/configs/dotnet/.claude/rules/validation.md +388 -0
- package/configs/dotnet/.claude/settings.json +21 -3
- package/configs/dotnet/CLAUDE.md +53 -286
- package/configs/fastapi/.claude/rules/background-tasks.md +254 -0
- package/configs/fastapi/.claude/rules/dependencies.md +170 -0
- package/configs/{python → fastapi}/.claude/rules/fastapi.md +61 -1
- package/configs/fastapi/.claude/rules/lifespan.md +274 -0
- package/configs/fastapi/.claude/rules/middleware.md +229 -0
- package/configs/fastapi/.claude/rules/pydantic.md +433 -0
- package/configs/fastapi/.claude/rules/responses.md +251 -0
- package/configs/fastapi/.claude/rules/routers.md +202 -0
- package/configs/fastapi/.claude/rules/security.md +222 -0
- package/configs/fastapi/.claude/rules/testing.md +251 -0
- package/configs/fastapi/.claude/rules/websockets.md +298 -0
- package/configs/fastapi/.claude/settings.json +33 -0
- package/configs/fastapi/CLAUDE.md +144 -0
- package/configs/flask/.claude/rules/blueprints.md +208 -0
- package/configs/flask/.claude/rules/cli.md +285 -0
- package/configs/flask/.claude/rules/configuration.md +281 -0
- package/configs/flask/.claude/rules/context.md +238 -0
- package/configs/flask/.claude/rules/error-handlers.md +278 -0
- package/configs/flask/.claude/rules/extensions.md +278 -0
- package/configs/flask/.claude/rules/flask.md +171 -0
- package/configs/flask/.claude/rules/marshmallow.md +206 -0
- package/configs/flask/.claude/rules/security.md +267 -0
- package/configs/flask/.claude/rules/testing.md +284 -0
- package/configs/flask/.claude/settings.json +33 -0
- package/configs/flask/CLAUDE.md +166 -0
- package/configs/nestjs/.claude/rules/common-patterns.md +300 -0
- package/configs/nestjs/.claude/rules/filters.md +376 -0
- package/configs/nestjs/.claude/rules/interceptors.md +317 -0
- package/configs/nestjs/.claude/rules/middleware.md +321 -0
- package/configs/nestjs/.claude/rules/modules.md +26 -0
- package/configs/nestjs/.claude/rules/pipes.md +351 -0
- package/configs/nestjs/.claude/rules/websockets.md +451 -0
- package/configs/nestjs/.claude/settings.json +16 -2
- package/configs/nestjs/CLAUDE.md +57 -215
- package/configs/nextjs/.claude/rules/api-routes.md +358 -0
- package/configs/nextjs/.claude/rules/authentication.md +355 -0
- package/configs/nextjs/.claude/rules/components.md +52 -0
- package/configs/nextjs/.claude/rules/data-fetching.md +249 -0
- package/configs/nextjs/.claude/rules/database.md +400 -0
- package/configs/nextjs/.claude/rules/middleware.md +303 -0
- package/configs/nextjs/.claude/rules/routing.md +324 -0
- package/configs/nextjs/.claude/rules/seo.md +350 -0
- package/configs/nextjs/.claude/rules/server-actions.md +353 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +6 -6
- package/configs/nextjs/.claude/settings.json +5 -0
- package/configs/nextjs/CLAUDE.md +69 -331
- package/package.json +23 -9
- package/src/cli.js +220 -0
- package/src/config.js +29 -0
- package/src/index.js +13 -0
- package/src/installer.js +361 -0
- package/src/merge.js +116 -0
- package/src/tech-config.json +29 -0
- package/src/utils.js +96 -0
- package/configs/python/.claude/rules/flask.md +0 -332
- package/configs/python/.claude/settings.json +0 -18
- package/configs/python/CLAUDE.md +0 -273
- package/src/install.js +0 -315
- /package/configs/_shared/.claude/rules/{accessibility.md → domain/frontend/accessibility.md} +0 -0
- /package/configs/_shared/.claude/rules/{security.md → security/security.md} +0 -0
- /package/configs/_shared/.claude/skills/{debug → dev/debug}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{learning → dev/learning}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{spec → dev/spec}/SKILL.md +0 -0
- /package/configs/_shared/.claude/skills/{review → git/review}/SKILL.md +0 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "src/Domain/**/*.cs"
|
|
4
|
+
- "src/**/Entities/**/*.cs"
|
|
5
|
+
- "src/**/ValueObjects/**/*.cs"
|
|
6
|
+
- "src/**/Aggregates/**/*.cs"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Domain-Driven Design (.NET)
|
|
10
|
+
|
|
11
|
+
## Entity Base Class
|
|
12
|
+
|
|
13
|
+
```csharp
|
|
14
|
+
// Domain/Common/Entity.cs
|
|
15
|
+
public abstract class Entity : IEquatable<Entity>
|
|
16
|
+
{
|
|
17
|
+
protected Entity(Guid id) => Id = id;
|
|
18
|
+
protected Entity() { } // For EF Core
|
|
19
|
+
|
|
20
|
+
public Guid Id { get; protected init; }
|
|
21
|
+
|
|
22
|
+
private readonly List<IDomainEvent> _domainEvents = [];
|
|
23
|
+
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
|
|
24
|
+
|
|
25
|
+
public void AddDomainEvent(IDomainEvent domainEvent) =>
|
|
26
|
+
_domainEvents.Add(domainEvent);
|
|
27
|
+
|
|
28
|
+
public void ClearDomainEvents() =>
|
|
29
|
+
_domainEvents.Clear();
|
|
30
|
+
|
|
31
|
+
public override bool Equals(object? obj) =>
|
|
32
|
+
obj is Entity entity && Equals(entity);
|
|
33
|
+
|
|
34
|
+
public bool Equals(Entity? other) =>
|
|
35
|
+
other is not null && Id == other.Id;
|
|
36
|
+
|
|
37
|
+
public override int GetHashCode() =>
|
|
38
|
+
Id.GetHashCode();
|
|
39
|
+
|
|
40
|
+
public static bool operator ==(Entity? left, Entity? right) =>
|
|
41
|
+
left?.Equals(right) ?? right is null;
|
|
42
|
+
|
|
43
|
+
public static bool operator !=(Entity? left, Entity? right) =>
|
|
44
|
+
!(left == right);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Aggregate Root
|
|
49
|
+
|
|
50
|
+
```csharp
|
|
51
|
+
// Domain/Common/AggregateRoot.cs
|
|
52
|
+
public abstract class AggregateRoot : Entity
|
|
53
|
+
{
|
|
54
|
+
protected AggregateRoot(Guid id) : base(id) { }
|
|
55
|
+
protected AggregateRoot() { }
|
|
56
|
+
|
|
57
|
+
// Aggregate roots control all access to child entities
|
|
58
|
+
// Only aggregate roots are returned from repositories
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Domain/Orders/Order.cs (Aggregate Root)
|
|
62
|
+
public class Order : AggregateRoot
|
|
63
|
+
{
|
|
64
|
+
private readonly List<OrderLine> _lines = [];
|
|
65
|
+
|
|
66
|
+
private Order(Guid id, Guid customerId) : base(id)
|
|
67
|
+
{
|
|
68
|
+
CustomerId = customerId;
|
|
69
|
+
Status = OrderStatus.Draft;
|
|
70
|
+
CreatedAt = DateTime.UtcNow;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public Guid CustomerId { get; }
|
|
74
|
+
public OrderStatus Status { get; private set; }
|
|
75
|
+
public DateTime CreatedAt { get; }
|
|
76
|
+
public DateTime? ConfirmedAt { get; private set; }
|
|
77
|
+
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
|
|
78
|
+
|
|
79
|
+
public Money Total => _lines
|
|
80
|
+
.Select(l => l.Subtotal)
|
|
81
|
+
.Aggregate(Money.Zero, (acc, m) => acc + m);
|
|
82
|
+
|
|
83
|
+
public static Result<Order> Create(Guid customerId)
|
|
84
|
+
{
|
|
85
|
+
if (customerId == Guid.Empty)
|
|
86
|
+
{
|
|
87
|
+
return Result.Failure<Order>(Error.Validation("CustomerId", "Invalid customer"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return new Order(Guid.NewGuid(), customerId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public Result AddLine(Product product, int quantity)
|
|
94
|
+
{
|
|
95
|
+
if (Status != OrderStatus.Draft)
|
|
96
|
+
{
|
|
97
|
+
return Result.Failure(OrderErrors.CannotModifyConfirmedOrder);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (quantity <= 0)
|
|
101
|
+
{
|
|
102
|
+
return Result.Failure(Error.Validation("Quantity", "Quantity must be positive"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
var existingLine = _lines.FirstOrDefault(l => l.ProductId == product.Id);
|
|
106
|
+
if (existingLine is not null)
|
|
107
|
+
{
|
|
108
|
+
existingLine.UpdateQuantity(existingLine.Quantity + quantity);
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
{
|
|
112
|
+
_lines.Add(OrderLine.Create(product, quantity));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return Result.Success();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public Result RemoveLine(Guid productId)
|
|
119
|
+
{
|
|
120
|
+
if (Status != OrderStatus.Draft)
|
|
121
|
+
{
|
|
122
|
+
return Result.Failure(OrderErrors.CannotModifyConfirmedOrder);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
var line = _lines.FirstOrDefault(l => l.ProductId == productId);
|
|
126
|
+
if (line is null)
|
|
127
|
+
{
|
|
128
|
+
return Result.Failure(Error.NotFound("OrderLine", productId));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_lines.Remove(line);
|
|
132
|
+
return Result.Success();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public Result Confirm()
|
|
136
|
+
{
|
|
137
|
+
if (Status != OrderStatus.Draft)
|
|
138
|
+
{
|
|
139
|
+
return Result.Failure(OrderErrors.AlreadyConfirmed);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!_lines.Any())
|
|
143
|
+
{
|
|
144
|
+
return Result.Failure(OrderErrors.EmptyOrder);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
Status = OrderStatus.Confirmed;
|
|
148
|
+
ConfirmedAt = DateTime.UtcNow;
|
|
149
|
+
|
|
150
|
+
AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
|
|
151
|
+
|
|
152
|
+
return Result.Success();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Value Object
|
|
158
|
+
|
|
159
|
+
```csharp
|
|
160
|
+
// Domain/Common/ValueObject.cs
|
|
161
|
+
public abstract class ValueObject : IEquatable<ValueObject>
|
|
162
|
+
{
|
|
163
|
+
protected abstract IEnumerable<object> GetAtomicValues();
|
|
164
|
+
|
|
165
|
+
public override bool Equals(object? obj) =>
|
|
166
|
+
obj is ValueObject other && Equals(other);
|
|
167
|
+
|
|
168
|
+
public bool Equals(ValueObject? other) =>
|
|
169
|
+
other is not null &&
|
|
170
|
+
GetAtomicValues().SequenceEqual(other.GetAtomicValues());
|
|
171
|
+
|
|
172
|
+
public override int GetHashCode() =>
|
|
173
|
+
GetAtomicValues()
|
|
174
|
+
.Aggregate(default(int), HashCode.Combine);
|
|
175
|
+
|
|
176
|
+
public static bool operator ==(ValueObject? left, ValueObject? right) =>
|
|
177
|
+
left?.Equals(right) ?? right is null;
|
|
178
|
+
|
|
179
|
+
public static bool operator !=(ValueObject? left, ValueObject? right) =>
|
|
180
|
+
!(left == right);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Domain/Common/ValueObjects/Money.cs
|
|
184
|
+
public sealed class Money : ValueObject
|
|
185
|
+
{
|
|
186
|
+
private Money(decimal amount, string currency)
|
|
187
|
+
{
|
|
188
|
+
Amount = amount;
|
|
189
|
+
Currency = currency;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
public decimal Amount { get; }
|
|
193
|
+
public string Currency { get; }
|
|
194
|
+
|
|
195
|
+
public static Money Zero => new(0, "USD");
|
|
196
|
+
|
|
197
|
+
public static Result<Money> Create(decimal amount, string currency = "USD")
|
|
198
|
+
{
|
|
199
|
+
if (amount < 0)
|
|
200
|
+
{
|
|
201
|
+
return Result.Failure<Money>(Error.Validation("Amount", "Amount cannot be negative"));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
|
|
205
|
+
{
|
|
206
|
+
return Result.Failure<Money>(Error.Validation("Currency", "Invalid currency code"));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return new Money(Math.Round(amount, 2), currency.ToUpperInvariant());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public static Money operator +(Money left, Money right)
|
|
213
|
+
{
|
|
214
|
+
if (left.Currency != right.Currency)
|
|
215
|
+
throw new InvalidOperationException("Cannot add different currencies");
|
|
216
|
+
|
|
217
|
+
return new Money(left.Amount + right.Amount, left.Currency);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
public static Money operator *(Money money, int quantity) =>
|
|
221
|
+
new(money.Amount * quantity, money.Currency);
|
|
222
|
+
|
|
223
|
+
protected override IEnumerable<object> GetAtomicValues()
|
|
224
|
+
{
|
|
225
|
+
yield return Amount;
|
|
226
|
+
yield return Currency;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Domain/Common/ValueObjects/Address.cs
|
|
231
|
+
public sealed class Address : ValueObject
|
|
232
|
+
{
|
|
233
|
+
private Address(string street, string city, string postalCode, string country)
|
|
234
|
+
{
|
|
235
|
+
Street = street;
|
|
236
|
+
City = city;
|
|
237
|
+
PostalCode = postalCode;
|
|
238
|
+
Country = country;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
public string Street { get; }
|
|
242
|
+
public string City { get; }
|
|
243
|
+
public string PostalCode { get; }
|
|
244
|
+
public string Country { get; }
|
|
245
|
+
|
|
246
|
+
public static Result<Address> Create(
|
|
247
|
+
string street, string city, string postalCode, string country)
|
|
248
|
+
{
|
|
249
|
+
if (string.IsNullOrWhiteSpace(street))
|
|
250
|
+
return Result.Failure<Address>(Error.Validation("Street", "Street is required"));
|
|
251
|
+
|
|
252
|
+
if (string.IsNullOrWhiteSpace(city))
|
|
253
|
+
return Result.Failure<Address>(Error.Validation("City", "City is required"));
|
|
254
|
+
|
|
255
|
+
if (string.IsNullOrWhiteSpace(postalCode))
|
|
256
|
+
return Result.Failure<Address>(Error.Validation("PostalCode", "Postal code is required"));
|
|
257
|
+
|
|
258
|
+
if (string.IsNullOrWhiteSpace(country))
|
|
259
|
+
return Result.Failure<Address>(Error.Validation("Country", "Country is required"));
|
|
260
|
+
|
|
261
|
+
return new Address(street.Trim(), city.Trim(), postalCode.Trim(), country.Trim().ToUpperInvariant());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
protected override IEnumerable<object> GetAtomicValues()
|
|
265
|
+
{
|
|
266
|
+
yield return Street;
|
|
267
|
+
yield return City;
|
|
268
|
+
yield return PostalCode;
|
|
269
|
+
yield return Country;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Domain Events
|
|
275
|
+
|
|
276
|
+
```csharp
|
|
277
|
+
// Domain/Common/IDomainEvent.cs
|
|
278
|
+
public interface IDomainEvent
|
|
279
|
+
{
|
|
280
|
+
Guid Id { get; }
|
|
281
|
+
DateTime OccurredAt { get; }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
public abstract record DomainEvent : IDomainEvent
|
|
285
|
+
{
|
|
286
|
+
public Guid Id { get; } = Guid.NewGuid();
|
|
287
|
+
public DateTime OccurredAt { get; } = DateTime.UtcNow;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Domain/Orders/Events/OrderConfirmedEvent.cs
|
|
291
|
+
public sealed record OrderConfirmedEvent(
|
|
292
|
+
Guid OrderId,
|
|
293
|
+
Guid CustomerId,
|
|
294
|
+
Money Total
|
|
295
|
+
) : DomainEvent;
|
|
296
|
+
|
|
297
|
+
// Publish events via MediatR after SaveChanges
|
|
298
|
+
public class DomainEventDispatcher(IPublisher publisher)
|
|
299
|
+
{
|
|
300
|
+
public async Task DispatchEventsAsync(IEnumerable<Entity> entities, CancellationToken ct)
|
|
301
|
+
{
|
|
302
|
+
var domainEvents = entities
|
|
303
|
+
.SelectMany(e => e.DomainEvents)
|
|
304
|
+
.ToList();
|
|
305
|
+
|
|
306
|
+
foreach (var entity in entities)
|
|
307
|
+
{
|
|
308
|
+
entity.ClearDomainEvents();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
foreach (var domainEvent in domainEvents)
|
|
312
|
+
{
|
|
313
|
+
await publisher.Publish(domainEvent, ct);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Repository Interface (Domain Layer)
|
|
320
|
+
|
|
321
|
+
```csharp
|
|
322
|
+
// Domain/Common/IRepository.cs
|
|
323
|
+
public interface IRepository<T> where T : AggregateRoot
|
|
324
|
+
{
|
|
325
|
+
Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
|
326
|
+
Task AddAsync(T entity, CancellationToken ct = default);
|
|
327
|
+
void Update(T entity);
|
|
328
|
+
void Remove(T entity);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Domain/Orders/IOrderRepository.cs
|
|
332
|
+
public interface IOrderRepository : IRepository<Order>
|
|
333
|
+
{
|
|
334
|
+
Task<IReadOnlyList<Order>> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default);
|
|
335
|
+
Task<Order?> GetWithLinesAsync(Guid orderId, CancellationToken ct = default);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Implementation in Infrastructure layer
|
|
339
|
+
public class OrderRepository(ApplicationDbContext context) : IOrderRepository
|
|
340
|
+
{
|
|
341
|
+
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct) =>
|
|
342
|
+
await context.Orders.FindAsync([id], ct);
|
|
343
|
+
|
|
344
|
+
public async Task<Order?> GetWithLinesAsync(Guid orderId, CancellationToken ct) =>
|
|
345
|
+
await context.Orders
|
|
346
|
+
.Include(o => o.Lines)
|
|
347
|
+
.FirstOrDefaultAsync(o => o.Id == orderId, ct);
|
|
348
|
+
|
|
349
|
+
public async Task AddAsync(Order entity, CancellationToken ct) =>
|
|
350
|
+
await context.Orders.AddAsync(entity, ct);
|
|
351
|
+
|
|
352
|
+
public void Update(Order entity) =>
|
|
353
|
+
context.Orders.Update(entity);
|
|
354
|
+
|
|
355
|
+
public void Remove(Order entity) =>
|
|
356
|
+
context.Orders.Remove(entity);
|
|
357
|
+
|
|
358
|
+
public async Task<IReadOnlyList<Order>> GetByCustomerIdAsync(Guid customerId, CancellationToken ct) =>
|
|
359
|
+
await context.Orders
|
|
360
|
+
.Where(o => o.CustomerId == customerId)
|
|
361
|
+
.ToListAsync(ct);
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## Domain Service
|
|
366
|
+
|
|
367
|
+
```csharp
|
|
368
|
+
// Domain/Orders/Services/OrderPricingService.cs
|
|
369
|
+
public class OrderPricingService
|
|
370
|
+
{
|
|
371
|
+
public Result<Money> CalculateDiscount(Order order, Customer customer)
|
|
372
|
+
{
|
|
373
|
+
var total = order.Total;
|
|
374
|
+
var discountPercent = customer.Tier switch
|
|
375
|
+
{
|
|
376
|
+
CustomerTier.Gold => 10,
|
|
377
|
+
CustomerTier.Silver => 5,
|
|
378
|
+
CustomerTier.Bronze => 2,
|
|
379
|
+
_ => 0
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
if (total.Amount > 1000)
|
|
383
|
+
{
|
|
384
|
+
discountPercent += 5; // Additional bulk discount
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
var discountAmount = total.Amount * discountPercent / 100;
|
|
388
|
+
return Money.Create(discountAmount, total.Currency);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
## Anti-Patterns
|
|
394
|
+
|
|
395
|
+
```csharp
|
|
396
|
+
// BAD: Anemic domain model
|
|
397
|
+
public class Order
|
|
398
|
+
{
|
|
399
|
+
public Guid Id { get; set; }
|
|
400
|
+
public OrderStatus Status { get; set; } // Public setter!
|
|
401
|
+
public List<OrderLine> Lines { get; set; } = []; // Exposed collection!
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Service with all logic
|
|
405
|
+
public class OrderService
|
|
406
|
+
{
|
|
407
|
+
public void Confirm(Order order)
|
|
408
|
+
{
|
|
409
|
+
if (order.Status != OrderStatus.Draft) throw new Exception("...");
|
|
410
|
+
order.Status = OrderStatus.Confirmed; // Logic outside entity!
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
// GOOD: Rich domain model
|
|
416
|
+
public class Order
|
|
417
|
+
{
|
|
418
|
+
private readonly List<OrderLine> _lines = [];
|
|
419
|
+
|
|
420
|
+
public OrderStatus Status { get; private set; } // Private setter
|
|
421
|
+
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); // Encapsulated
|
|
422
|
+
|
|
423
|
+
public Result Confirm() // Behavior inside entity
|
|
424
|
+
{
|
|
425
|
+
if (Status != OrderStatus.Draft)
|
|
426
|
+
return Result.Failure(OrderErrors.AlreadyConfirmed);
|
|
427
|
+
|
|
428
|
+
Status = OrderStatus.Confirmed;
|
|
429
|
+
AddDomainEvent(new OrderConfirmedEvent(Id));
|
|
430
|
+
return Result.Success();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
// BAD: Exposing IQueryable from repository
|
|
436
|
+
public interface IOrderRepository
|
|
437
|
+
{
|
|
438
|
+
IQueryable<Order> GetAll(); // Leaks persistence!
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// GOOD: Specific query methods
|
|
442
|
+
public interface IOrderRepository
|
|
443
|
+
{
|
|
444
|
+
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct);
|
|
445
|
+
Task<IReadOnlyList<Order>> GetByStatusAsync(OrderStatus status, CancellationToken ct);
|
|
446
|
+
}
|
|
447
|
+
```
|