@polymorphism-tech/morph-spec 4.3.4 → 4.3.6
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/.morph/.morphversion +5 -0
- package/.morph/config/agents.json +948 -0
- package/.morph/config/config.json +9 -9
- package/.morph/project/context/README.md +17 -0
- package/.morph/project/context/detection-log.md +16 -0
- package/.morph/project/standards/inferred.md +59 -0
- package/.morph/standards/ai-agents/blazor-ui.md +364 -0
- package/.morph/standards/ai-agents/production.md +415 -0
- package/.morph/standards/ai-agents/setup.md +418 -0
- package/.morph/standards/ai-agents/team-orchestration.md +479 -0
- package/.morph/standards/ai-agents/workflows.md +354 -0
- package/.morph/standards/architecture/ddd/aggregates.md +120 -0
- package/.morph/standards/architecture/ddd/entities.md +99 -0
- package/.morph/standards/architecture/ddd/value-objects.md +124 -0
- package/.morph/standards/backend/api/minimal-api.md +494 -0
- package/.morph/standards/backend/api/rest.md +492 -0
- package/.morph/standards/backend/api/validation.md +88 -0
- package/.morph/standards/backend/authentication/passkeys.md +428 -0
- package/.morph/standards/backend/database/ef-core.md +199 -0
- package/.morph/standards/backend/database/migrations.md +393 -0
- package/.morph/standards/backend/database/postgresql/database.md +352 -0
- package/.morph/standards/backend/database/repository-patterns.md +528 -0
- package/.morph/standards/backend/database/vector-search-rag.md +541 -0
- package/.morph/standards/backend/dotnet/async.md +366 -0
- package/.morph/standards/backend/dotnet/core.md +117 -0
- package/.morph/standards/backend/dotnet/di.md +439 -0
- package/.morph/standards/backend/dotnet/program-cs-checklist.md +92 -0
- package/.morph/standards/backend/integrations/asaas/asaas-api.md +216 -0
- package/.morph/standards/backend/integrations/clerk/clerk-auth.md +290 -0
- package/.morph/standards/backend/integrations/hangfire/hangfire-jobs.md +350 -0
- package/.morph/standards/backend/integrations/resend/resend-email.md +385 -0
- package/.morph/standards/context/analytics.md +96 -0
- package/.morph/standards/context/bundles.md +110 -0
- package/.morph/standards/context/priming.md +78 -0
- package/.morph/standards/core/architecture.md +185 -0
- package/.morph/standards/core/coding.md +214 -0
- package/.morph/standards/core/git-branching-strategy.md +403 -0
- package/.morph/standards/core/git.md +185 -0
- package/.morph/standards/core/testing.md +295 -0
- package/.morph/standards/data/nosql/blob-storage.md +102 -0
- package/.morph/standards/data/nosql/cache/redis.md +97 -0
- package/.morph/standards/data/nosql/cosmos-db.md +118 -0
- package/.morph/standards/data/vector-search/azure-ai-search.md +121 -0
- package/.morph/standards/data/vector-search/rag-chunking.md +104 -0
- package/.morph/standards/frontend/blazor/design-checklist.md +222 -0
- package/.morph/standards/frontend/blazor/fluent-ui-setup.md +595 -0
- package/.morph/standards/frontend/blazor/fluent-ui.md +137 -0
- package/.morph/standards/frontend/blazor/html-conversion.md +184 -0
- package/.morph/standards/frontend/blazor/lifecycle.md +195 -0
- package/.morph/standards/frontend/blazor/pitfalls.md +198 -0
- package/.morph/standards/frontend/blazor/state.md +191 -0
- package/.morph/standards/frontend/design-system/animations.md +151 -0
- package/.morph/standards/frontend/design-system/naming.md +64 -0
- package/.morph/standards/frontend/nextjs/nextjs-patterns.md +198 -0
- package/.morph/standards/infrastructure/azure/azure.md +624 -0
- package/.morph/standards/infrastructure/azure/bicep/bicep-patterns.md +422 -0
- package/.morph/standards/infrastructure/azure/devops/azure-devops-setup.md +516 -0
- package/.morph/standards/infrastructure/azure/devops/local-development.md +520 -0
- package/.morph/standards/infrastructure/azure/services/functions.md +486 -0
- package/.morph/standards/infrastructure/azure/services/service-bus.md +459 -0
- package/.morph/standards/infrastructure/azure/services/storage.md +407 -0
- package/.morph/standards/infrastructure/docker/easypanel-deploy.md +196 -0
- package/.morph/standards/infrastructure/supabase/mcp-setup.md +252 -0
- package/.morph/standards/infrastructure/supabase/supabase-auth.md +176 -0
- package/.morph/standards/infrastructure/supabase/supabase-pgvector.md +169 -0
- package/.morph/standards/infrastructure/supabase/supabase-rls.md +184 -0
- package/.morph/standards/infrastructure/supabase/supabase-storage.md +153 -0
- package/.morph/standards/integration/api/graphql.md +91 -0
- package/.morph/standards/integration/api/grpc.md +114 -0
- package/.morph/standards/integration/api/rest-design.md +95 -0
- package/.morph/standards/integration/event-driven/cqrs.md +101 -0
- package/.morph/standards/integration/event-driven/event-sourcing.md +124 -0
- package/.morph/standards/integration/event-driven/service-bus.md +95 -0
- package/.morph/standards/observability/logging.md +131 -0
- package/.morph/standards/observability/metrics.md +121 -0
- package/.morph/standards/observability/monitoring.md +114 -0
- package/.morph/standards/observability/tracing.md +132 -0
- package/.morph/standards/workflows/parallel-execution.md +112 -0
- package/.morph/standards/workflows/thread-management.md +113 -0
- package/.morph/templates/.idea/morph-templates.xml +92 -0
- package/.morph/templates/.vscode/morph-templates.code-snippets +186 -0
- package/.morph/templates/IDE-SNIPPETS.md +266 -0
- package/.morph/templates/README.md +814 -0
- package/.morph/templates/REGISTRY.json +1677 -0
- package/.morph/templates/code/dotnet/backend/repository.cs +141 -0
- package/.morph/templates/code/dotnet/backend/service.cs +139 -0
- package/.morph/templates/code/dotnet/contracts/Commands.cs +74 -0
- package/.morph/templates/code/dotnet/contracts/Entities.cs +25 -0
- package/.morph/templates/code/dotnet/contracts/Queries.cs +74 -0
- package/.morph/templates/code/dotnet/contracts/README.md +74 -0
- package/.morph/templates/code/dotnet/contracts/api-contracts.cs +173 -0
- package/.morph/templates/code/dotnet/contracts/contracts.cs +217 -0
- package/.morph/templates/code/dotnet/database/migration.cs +83 -0
- package/.morph/templates/code/dotnet/frontend/component.razor +239 -0
- package/.morph/templates/code/dotnet/jobs/agent.cs +163 -0
- package/.morph/templates/code/dotnet/jobs/job.cs +171 -0
- package/.morph/templates/code/dotnet/test.cs +239 -0
- package/.morph/templates/code/sql/rls-policy.sql +57 -0
- package/.morph/templates/code/sql/supabase-migration.sql +100 -0
- package/.morph/templates/code/sql/supabase-migration.template.sql +113 -0
- package/.morph/templates/code/typescript/contracts.ts +168 -0
- package/.morph/templates/context/CONTEXT-FEATURE.md +276 -0
- package/.morph/templates/context/CONTEXT.md +181 -0
- package/.morph/templates/docs/proposal.md +182 -0
- package/.morph/templates/docs/spec.md +149 -0
- package/.morph/templates/examples/design-system-examples.md +357 -0
- package/.morph/templates/examples/spec-examples.md +90 -0
- package/.morph/templates/feature/decisions.md +187 -0
- package/.morph/templates/feature/recap.md +146 -0
- package/.morph/templates/feature/tasks.md +199 -0
- package/.morph/templates/infrastructure/azure/Dockerfile.example +82 -0
- package/.morph/templates/infrastructure/azure/README.md +286 -0
- package/.morph/templates/infrastructure/azure/app-insights.bicep +63 -0
- package/.morph/templates/infrastructure/azure/app-service.bicep +164 -0
- package/.morph/templates/infrastructure/azure/container-app-env.bicep +49 -0
- package/.morph/templates/infrastructure/azure/container-app.bicep +156 -0
- package/.morph/templates/infrastructure/azure/deploy-checklist.md +426 -0
- package/.morph/templates/infrastructure/azure/deploy.ps1 +229 -0
- package/.morph/templates/infrastructure/azure/deploy.sh +208 -0
- package/.morph/templates/infrastructure/azure/key-vault.bicep +91 -0
- package/.morph/templates/infrastructure/azure/main.bicep +189 -0
- package/.morph/templates/infrastructure/azure/parameters.dev.json +29 -0
- package/.morph/templates/infrastructure/azure/parameters.prod.json +29 -0
- package/.morph/templates/infrastructure/azure/parameters.staging.json +29 -0
- package/.morph/templates/infrastructure/azure/sql-database.bicep +103 -0
- package/.morph/templates/infrastructure/azure/storage.bicep +106 -0
- package/.morph/templates/infrastructure/docker/Dockerfile.template +58 -0
- package/.morph/templates/infrastructure/docker/docker-compose.template.yml +67 -0
- package/.morph/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -0
- package/.morph/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -0
- package/.morph/templates/infrastructure/docker/easypanel.template.json +54 -0
- package/.morph/templates/infrastructure/github/README.md +593 -0
- package/.morph/templates/infrastructure/github/actions/azure-auth/action.yml.hbs +22 -0
- package/.morph/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -0
- package/.morph/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -0
- package/.morph/templates/infrastructure/github/workflows/deploy-azure-app-service.yml.hbs +61 -0
- package/.morph/templates/infrastructure/github/workflows/deploy-easypanel.yml.hbs +31 -0
- package/.morph/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -0
- package/.morph/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -0
- package/.morph/templates/integrations/asaas-client.cs +387 -0
- package/.morph/templates/integrations/asaas-webhook.cs +351 -0
- package/.morph/templates/integrations/azure-identity-config.cs +288 -0
- package/.morph/templates/integrations/clerk-config.cs +258 -0
- package/.morph/templates/meta-prompts/fusion/fusion-agent.md +76 -0
- package/.morph/templates/meta-prompts/fusion/fusion-aggregator.md +100 -0
- package/.morph/templates/meta-prompts/hops/hop-retry.md +78 -0
- package/.morph/templates/meta-prompts/hops/hop-validation.md +97 -0
- package/.morph/templates/meta-prompts/hops/hop-wrapper.md +36 -0
- package/.morph/templates/meta-prompts/parallel-workers/parallel-coordinator.md +113 -0
- package/.morph/templates/meta-prompts/parallel-workers/parallel-worker.md +80 -0
- package/.morph/templates/meta-prompts/squad-leaders/backend-squad.md +90 -0
- package/.morph/templates/meta-prompts/squad-leaders/frontend-squad.md +126 -0
- package/.morph/templates/meta-prompts/squad-leaders/squad-leader.md +43 -0
- package/.morph/templates/meta-prompts/validators/checkpoint-validator.md +107 -0
- package/.morph/templates/meta-prompts/validators/pre-commit-validator.md +95 -0
- package/.morph/templates/saas/subscription.cs +347 -0
- package/.morph/templates/saas/tenant.cs +338 -0
- package/.morph/templates/state.template.json +17 -0
- package/.morph/templates/ui/FluentDesignTheme.cs +149 -0
- package/.morph/templates/ui/MudTheme.cs +281 -0
- package/.morph/templates/ui/design-system.css +226 -0
- package/bin/morph-spec.js +1 -1
- package/package.json +1 -1
- package/src/commands/project/update.js +185 -46
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Integration Standard: CQRS with MediatR
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Commands mutate state. Queries read state. Never mix them.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
7
|
+
```xml
|
|
8
|
+
<PackageReference Include="MediatR" Version="12.*" />
|
|
9
|
+
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.*" />
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```csharp
|
|
13
|
+
// Program.cs
|
|
14
|
+
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Command Pattern
|
|
18
|
+
```csharp
|
|
19
|
+
// Command (mutates state, returns result)
|
|
20
|
+
public record CreateOrderCommand(Guid UserId, List<OrderItem> Items)
|
|
21
|
+
: IRequest<CreateOrderResult>;
|
|
22
|
+
|
|
23
|
+
public record CreateOrderResult(Guid OrderId, decimal Total);
|
|
24
|
+
|
|
25
|
+
// Handler
|
|
26
|
+
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, CreateOrderResult>
|
|
27
|
+
{
|
|
28
|
+
public async Task<CreateOrderResult> Handle(CreateOrderCommand cmd, CancellationToken ct)
|
|
29
|
+
{
|
|
30
|
+
var order = Order.Create(cmd.UserId, cmd.Items);
|
|
31
|
+
await _repository.AddAsync(order, ct);
|
|
32
|
+
await _unitOfWork.SaveChangesAsync(ct);
|
|
33
|
+
|
|
34
|
+
// Publish domain event
|
|
35
|
+
await _publisher.Publish(new OrderCreatedEvent(order.Id), ct);
|
|
36
|
+
|
|
37
|
+
return new CreateOrderResult(order.Id, order.Total);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Query Pattern
|
|
43
|
+
```csharp
|
|
44
|
+
// Query (reads only, no side effects)
|
|
45
|
+
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDto?>;
|
|
46
|
+
|
|
47
|
+
// Handler — use read model, not domain entity
|
|
48
|
+
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto?>
|
|
49
|
+
{
|
|
50
|
+
public async Task<OrderDto?> Handle(GetOrderQuery query, CancellationToken ct)
|
|
51
|
+
{
|
|
52
|
+
return await _context.Orders
|
|
53
|
+
.Where(o => o.Id == query.OrderId)
|
|
54
|
+
.Select(o => new OrderDto(o.Id, o.Status, o.Total))
|
|
55
|
+
.FirstOrDefaultAsync(ct);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Pipeline Behaviors (Cross-Cutting)
|
|
61
|
+
```csharp
|
|
62
|
+
// Validation behavior
|
|
63
|
+
public class ValidationBehavior<TRequest, TResponse>
|
|
64
|
+
: IPipelineBehavior<TRequest, TResponse>
|
|
65
|
+
where TRequest : IRequest<TResponse>
|
|
66
|
+
{
|
|
67
|
+
public async Task<TResponse> Handle(TRequest request,
|
|
68
|
+
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
|
|
69
|
+
{
|
|
70
|
+
var failures = _validators
|
|
71
|
+
.SelectMany(v => v.Validate(request).Errors)
|
|
72
|
+
.Where(f => f != null)
|
|
73
|
+
.ToList();
|
|
74
|
+
|
|
75
|
+
if (failures.Any())
|
|
76
|
+
throw new ValidationException(failures);
|
|
77
|
+
|
|
78
|
+
return await next();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## DI Registration Order
|
|
84
|
+
```csharp
|
|
85
|
+
// 1. MediatR (includes handler scanning)
|
|
86
|
+
builder.Services.AddMediatR(cfg =>
|
|
87
|
+
{
|
|
88
|
+
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
|
|
89
|
+
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
|
90
|
+
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>));
|
|
91
|
+
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
|
|
92
|
+
});
|
|
93
|
+
// 2. FluentValidation
|
|
94
|
+
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Rules
|
|
98
|
+
- Commands return results, not void (enables error handling)
|
|
99
|
+
- Queries use DTOs, never expose domain entities
|
|
100
|
+
- Never call command handlers from query handlers
|
|
101
|
+
- One handler per Command/Query (no inheritance hierarchies)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Integration Standard: Event Sourcing
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
State is derived from a sequence of immutable events. Use only when audit trail or temporal queries are required.
|
|
5
|
+
|
|
6
|
+
## When to Use
|
|
7
|
+
✅ Financial transactions (audit required by law)
|
|
8
|
+
✅ Order lifecycle (multiple state transitions, replay needed)
|
|
9
|
+
✅ Inventory changes (why did stock go negative?)
|
|
10
|
+
❌ User profiles (too much churn, GDPR deletion complexity)
|
|
11
|
+
❌ Session/cache state (ephemeral, no audit need)
|
|
12
|
+
|
|
13
|
+
## Event Design
|
|
14
|
+
```csharp
|
|
15
|
+
// Base event
|
|
16
|
+
public abstract record DomainEvent
|
|
17
|
+
{
|
|
18
|
+
public Guid EventId { get; init; } = Guid.NewGuid();
|
|
19
|
+
public DateTime OccurredAt { get; init; } = DateTime.UtcNow;
|
|
20
|
+
public int Version { get; init; }
|
|
21
|
+
public string EventType => GetType().Name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Specific events — past tense, immutable
|
|
25
|
+
public record OrderPlaced(Guid OrderId, Guid UserId, decimal Total, List<OrderItem> Items)
|
|
26
|
+
: DomainEvent;
|
|
27
|
+
|
|
28
|
+
public record OrderShipped(Guid OrderId, string TrackingNumber, DateTime ShippedAt)
|
|
29
|
+
: DomainEvent;
|
|
30
|
+
|
|
31
|
+
public record OrderCancelled(Guid OrderId, string Reason)
|
|
32
|
+
: DomainEvent;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Aggregate with Event Sourcing
|
|
36
|
+
```csharp
|
|
37
|
+
public class Order : AggregateRoot
|
|
38
|
+
{
|
|
39
|
+
public OrderStatus Status { get; private set; }
|
|
40
|
+
public decimal Total { get; private set; }
|
|
41
|
+
|
|
42
|
+
// Reconstruct from events
|
|
43
|
+
public static Order Replay(IEnumerable<DomainEvent> events)
|
|
44
|
+
{
|
|
45
|
+
var order = new Order();
|
|
46
|
+
foreach (var e in events) order.Apply(e);
|
|
47
|
+
return order;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Command → validate → raise event
|
|
51
|
+
public void Ship(string trackingNumber)
|
|
52
|
+
{
|
|
53
|
+
if (Status != OrderStatus.Confirmed)
|
|
54
|
+
throw new InvalidOperationException($"Cannot ship order in {Status} status");
|
|
55
|
+
|
|
56
|
+
RaiseEvent(new OrderShipped(Id, trackingNumber, DateTime.UtcNow));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// State transition from event (idempotent)
|
|
60
|
+
protected override void Apply(DomainEvent @event)
|
|
61
|
+
{
|
|
62
|
+
switch (@event)
|
|
63
|
+
{
|
|
64
|
+
case OrderPlaced e:
|
|
65
|
+
Id = e.OrderId;
|
|
66
|
+
Total = e.Total;
|
|
67
|
+
Status = OrderStatus.Pending;
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
case OrderShipped e:
|
|
71
|
+
Status = OrderStatus.Shipped;
|
|
72
|
+
break;
|
|
73
|
+
|
|
74
|
+
case OrderCancelled:
|
|
75
|
+
Status = OrderStatus.Cancelled;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Event Store (Azure Cosmos DB pattern)
|
|
83
|
+
```csharp
|
|
84
|
+
public class CosmosEventStore : IEventStore
|
|
85
|
+
{
|
|
86
|
+
public async Task AppendAsync(Guid streamId, IEnumerable<DomainEvent> events, int expectedVersion)
|
|
87
|
+
{
|
|
88
|
+
foreach (var (evt, i) in events.Select((e, i) => (e, i)))
|
|
89
|
+
{
|
|
90
|
+
var document = new EventDocument
|
|
91
|
+
{
|
|
92
|
+
Id = $"{streamId}:{expectedVersion + i + 1}",
|
|
93
|
+
StreamId = streamId.ToString(),
|
|
94
|
+
Version = expectedVersion + i + 1,
|
|
95
|
+
EventType = evt.EventType,
|
|
96
|
+
Data = JsonSerializer.Serialize(evt, evt.GetType()),
|
|
97
|
+
OccurredAt = evt.OccurredAt
|
|
98
|
+
};
|
|
99
|
+
await _container.CreateItemAsync(document, new PartitionKey(streamId.ToString()));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Projections (Read Models)
|
|
106
|
+
```csharp
|
|
107
|
+
// Project events to read-optimized views
|
|
108
|
+
public class OrderSummaryProjection
|
|
109
|
+
{
|
|
110
|
+
public void Handle(OrderPlaced e, AppDbContext ctx)
|
|
111
|
+
=> ctx.OrderSummaries.Add(new OrderSummary { Id = e.OrderId, Total = e.Total, Status = "Pending" });
|
|
112
|
+
|
|
113
|
+
public void Handle(OrderShipped e, AppDbContext ctx)
|
|
114
|
+
=> ctx.OrderSummaries.Where(s => s.Id == e.OrderId).ExecuteUpdate(s => s.SetProperty(p => p.Status, "Shipped"));
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Trade-offs
|
|
119
|
+
| Pro | Con |
|
|
120
|
+
|-----|-----|
|
|
121
|
+
| Complete audit trail | Schema evolution complexity |
|
|
122
|
+
| Temporal queries (state at time T) | Higher storage cost |
|
|
123
|
+
| Event replay for debugging | Eventual consistency for read models |
|
|
124
|
+
| Natural integration events | GDPR deletion requires event masking |
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Integration Standard: Azure Service Bus
|
|
2
|
+
|
|
3
|
+
## Patterns
|
|
4
|
+
|
|
5
|
+
### Queue vs Topic
|
|
6
|
+
- **Queue**: Point-to-point, one consumer per message. Use for jobs, commands.
|
|
7
|
+
- **Topic + Subscription**: Fan-out, multiple consumers. Use for domain events.
|
|
8
|
+
|
|
9
|
+
### Sender Pattern
|
|
10
|
+
```csharp
|
|
11
|
+
public class ServiceBusSender<T>
|
|
12
|
+
{
|
|
13
|
+
private readonly ServiceBusSender _sender;
|
|
14
|
+
|
|
15
|
+
public async Task SendAsync(T message, CancellationToken ct = default)
|
|
16
|
+
{
|
|
17
|
+
var json = JsonSerializer.Serialize(message);
|
|
18
|
+
var serviceBusMessage = new ServiceBusMessage(json)
|
|
19
|
+
{
|
|
20
|
+
ContentType = "application/json",
|
|
21
|
+
MessageId = Guid.NewGuid().ToString(),
|
|
22
|
+
Subject = typeof(T).Name // For filtering
|
|
23
|
+
};
|
|
24
|
+
await _sender.SendMessageAsync(serviceBusMessage, ct);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Receiver Pattern (with IHostedService)
|
|
30
|
+
```csharp
|
|
31
|
+
public class OrderProcessor : IHostedService
|
|
32
|
+
{
|
|
33
|
+
private ServiceBusProcessor _processor;
|
|
34
|
+
|
|
35
|
+
public async Task StartAsync(CancellationToken ct)
|
|
36
|
+
{
|
|
37
|
+
_processor.ProcessMessageAsync += HandleMessageAsync;
|
|
38
|
+
_processor.ProcessErrorAsync += HandleErrorAsync;
|
|
39
|
+
await _processor.StartProcessingAsync(ct);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async Task HandleMessageAsync(ProcessMessageEventArgs args)
|
|
43
|
+
{
|
|
44
|
+
var order = JsonSerializer.Deserialize<OrderCreatedEvent>(args.Message.Body);
|
|
45
|
+
await _orderService.ProcessAsync(order!);
|
|
46
|
+
await args.CompleteMessageAsync(args.Message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private Task HandleErrorAsync(ProcessErrorEventArgs args)
|
|
50
|
+
{
|
|
51
|
+
_logger.LogError(args.Exception, "Service Bus error on {EntityPath}", args.EntityPath);
|
|
52
|
+
return Task.CompletedTask; // Abandon → retry → dead-letter
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Dead-Letter Processing
|
|
58
|
+
```csharp
|
|
59
|
+
// Check dead-letter queue periodically
|
|
60
|
+
var client = new ServiceBusClient(connectionString);
|
|
61
|
+
var receiver = client.CreateReceiver(queueName, new ServiceBusReceiverOptions
|
|
62
|
+
{
|
|
63
|
+
SubQueue = SubQueue.DeadLetter
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await foreach (var msg in receiver.ReceiveMessagesAsync())
|
|
67
|
+
{
|
|
68
|
+
_logger.LogError("Dead-lettered: {Reason} | Body: {Body}",
|
|
69
|
+
msg.DeadLetterReason, msg.Body.ToString());
|
|
70
|
+
await receiver.CompleteMessageAsync(msg);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Retry Policy
|
|
75
|
+
- MaxDeliveryCount: 5 (queue setting)
|
|
76
|
+
- Lock duration: 5 minutes (for long-processing messages)
|
|
77
|
+
- Dead-letter after MaxDeliveryCount exceeded
|
|
78
|
+
|
|
79
|
+
## Required Settings
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"ServiceBus": {
|
|
83
|
+
"ConnectionString": "from-key-vault",
|
|
84
|
+
"QueueName": "feature-name-queue",
|
|
85
|
+
"MaxConcurrentCalls": 4,
|
|
86
|
+
"PrefetchCount": 10
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Anti-Patterns
|
|
92
|
+
- Never use connection string in code — always Key Vault
|
|
93
|
+
- Never process in `HandleErrorAsync` — only log
|
|
94
|
+
- Never use topics for commands (use queues)
|
|
95
|
+
- Never use synchronous sends in Blazor components
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Observability Standard: Logging
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Structured logging with Serilog, enrichers, sinks, and log level configuration.
|
|
5
|
+
|
|
6
|
+
## Serilog Setup
|
|
7
|
+
|
|
8
|
+
### Package Installation
|
|
9
|
+
```xml
|
|
10
|
+
<PackageReference Include="Serilog.AspNetCore" Version="8.*" />
|
|
11
|
+
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.*" />
|
|
12
|
+
<PackageReference Include="Serilog.Enrichers.Process" Version="3.*" />
|
|
13
|
+
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.*" />
|
|
14
|
+
<PackageReference Include="Serilog.Sinks.ApplicationInsights" Version="4.*" />
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Program.cs Configuration
|
|
18
|
+
```csharp
|
|
19
|
+
Log.Logger = new LoggerConfiguration()
|
|
20
|
+
.ReadFrom.Configuration(builder.Configuration)
|
|
21
|
+
.Enrich.FromLogContext()
|
|
22
|
+
.Enrich.WithEnvironmentName()
|
|
23
|
+
.Enrich.WithMachineName()
|
|
24
|
+
.Enrich.WithProcessId()
|
|
25
|
+
.Enrich.WithThreadId()
|
|
26
|
+
.WriteTo.Console(outputTemplate:
|
|
27
|
+
"[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}")
|
|
28
|
+
.WriteTo.ApplicationInsights(
|
|
29
|
+
builder.Configuration["ApplicationInsights:ConnectionString"],
|
|
30
|
+
TelemetryConverter.Traces)
|
|
31
|
+
.CreateLogger();
|
|
32
|
+
|
|
33
|
+
builder.Host.UseSerilog();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### appsettings.json Level Configuration
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"Serilog": {
|
|
40
|
+
"MinimumLevel": {
|
|
41
|
+
"Default": "Information",
|
|
42
|
+
"Override": {
|
|
43
|
+
"Microsoft": "Warning",
|
|
44
|
+
"Microsoft.Hosting.Lifetime": "Information",
|
|
45
|
+
"System": "Warning",
|
|
46
|
+
"Microsoft.EntityFrameworkCore": "Warning"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Structured Logging Patterns
|
|
54
|
+
|
|
55
|
+
### Correct: Structured Properties
|
|
56
|
+
```csharp
|
|
57
|
+
// ✅ Use structured logging — properties are searchable in App Insights
|
|
58
|
+
logger.LogInformation("User {UserId} created order {OrderId} for {Amount:C}",
|
|
59
|
+
userId, orderId, amount);
|
|
60
|
+
|
|
61
|
+
// ✅ Log exceptions with context
|
|
62
|
+
logger.LogError(ex, "Failed to process payment for order {OrderId} (attempt {Attempt}/{MaxAttempts})",
|
|
63
|
+
orderId, attempt, maxAttempts);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Wrong: String Interpolation
|
|
67
|
+
```csharp
|
|
68
|
+
// ❌ Never use string interpolation — loses structured properties
|
|
69
|
+
logger.LogInformation($"User {userId} created order {orderId}");
|
|
70
|
+
|
|
71
|
+
// ❌ Never concatenate
|
|
72
|
+
logger.LogError("Failed for order " + orderId);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Log Scopes
|
|
76
|
+
```csharp
|
|
77
|
+
// Add correlation context for a request scope
|
|
78
|
+
using (logger.BeginScope(new Dictionary<string, object>
|
|
79
|
+
{
|
|
80
|
+
["CorrelationId"] = correlationId,
|
|
81
|
+
["UserId"] = userId,
|
|
82
|
+
["Feature"] = "checkout"
|
|
83
|
+
}))
|
|
84
|
+
{
|
|
85
|
+
// All logs within this scope include the properties above
|
|
86
|
+
await ProcessCheckoutAsync(cart);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Log Categories — What to Log
|
|
91
|
+
|
|
92
|
+
| Level | When |
|
|
93
|
+
|-------|------|
|
|
94
|
+
| `Trace` | Never in production (dev only) |
|
|
95
|
+
| `Debug` | Detailed flow, timing — disabled in prod |
|
|
96
|
+
| `Information` | Business events: order created, user signed in, job started |
|
|
97
|
+
| `Warning` | Degraded state: retry, rate limit, cache miss cascade |
|
|
98
|
+
| `Error` | Handled exceptions: payment failed, validation error |
|
|
99
|
+
| `Critical` | System failures: database unavailable, startup failure |
|
|
100
|
+
|
|
101
|
+
## Sensitive Data — NEVER Log
|
|
102
|
+
- Passwords or hashed passwords
|
|
103
|
+
- Credit card numbers or CVV
|
|
104
|
+
- Social security numbers
|
|
105
|
+
- JWT tokens or API keys
|
|
106
|
+
- Full connection strings
|
|
107
|
+
- PII beyond user ID (no email, phone, address)
|
|
108
|
+
|
|
109
|
+
## Performance Logging Pattern
|
|
110
|
+
```csharp
|
|
111
|
+
public class PerformanceLoggingBehavior<TRequest, TResponse>
|
|
112
|
+
: IPipelineBehavior<TRequest, TResponse>
|
|
113
|
+
{
|
|
114
|
+
private readonly ILogger<PerformanceLoggingBehavior<TRequest, TResponse>> _logger;
|
|
115
|
+
|
|
116
|
+
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
|
|
117
|
+
{
|
|
118
|
+
var sw = Stopwatch.StartNew();
|
|
119
|
+
var response = await next();
|
|
120
|
+
sw.Stop();
|
|
121
|
+
|
|
122
|
+
if (sw.ElapsedMilliseconds > 500)
|
|
123
|
+
{
|
|
124
|
+
_logger.LogWarning("Slow request {RequestName} took {ElapsedMs}ms",
|
|
125
|
+
typeof(TRequest).Name, sw.ElapsedMilliseconds);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Observability Standard: Metrics
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Custom metrics with .NET Meters API and Azure Monitor integration.
|
|
5
|
+
|
|
6
|
+
## Metrics Setup
|
|
7
|
+
|
|
8
|
+
### Packages
|
|
9
|
+
```xml
|
|
10
|
+
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="9.*" />
|
|
11
|
+
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="0.*" />
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### Meter Definition (per feature/service)
|
|
15
|
+
```csharp
|
|
16
|
+
// Define meters as static singletons
|
|
17
|
+
public static class AppMetrics
|
|
18
|
+
{
|
|
19
|
+
private static readonly Meter Meter = new("MyApp.Business", "1.0.0");
|
|
20
|
+
|
|
21
|
+
// Counters (monotonically increasing)
|
|
22
|
+
public static readonly Counter<long> OrdersCreated =
|
|
23
|
+
Meter.CreateCounter<long>("orders.created", "orders", "Total orders created");
|
|
24
|
+
|
|
25
|
+
public static readonly Counter<long> PaymentsFailed =
|
|
26
|
+
Meter.CreateCounter<long>("payments.failed", "failures", "Total payment failures");
|
|
27
|
+
|
|
28
|
+
// Histograms (distributions)
|
|
29
|
+
public static readonly Histogram<double> OrderProcessingTime =
|
|
30
|
+
Meter.CreateHistogram<double>("orders.processing_time", "ms", "Order processing duration");
|
|
31
|
+
|
|
32
|
+
// Gauges (current value)
|
|
33
|
+
public static readonly ObservableGauge<int> ActiveConnections =
|
|
34
|
+
Meter.CreateObservableGauge<int>("connections.active", GetActiveConnections, "connections");
|
|
35
|
+
|
|
36
|
+
private static int GetActiveConnections() => ConnectionPool.ActiveCount;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Recording Metrics
|
|
41
|
+
```csharp
|
|
42
|
+
// Counter increment
|
|
43
|
+
AppMetrics.OrdersCreated.Add(1, new TagList
|
|
44
|
+
{
|
|
45
|
+
{ "status", "success" },
|
|
46
|
+
{ "payment_method", order.PaymentMethod }
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Histogram measurement
|
|
50
|
+
var sw = Stopwatch.StartNew();
|
|
51
|
+
await ProcessOrderAsync(order);
|
|
52
|
+
AppMetrics.OrderProcessingTime.Record(sw.ElapsedMilliseconds, new TagList
|
|
53
|
+
{
|
|
54
|
+
{ "order_type", order.Type },
|
|
55
|
+
{ "region", order.Region }
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Register with OpenTelemetry
|
|
60
|
+
```csharp
|
|
61
|
+
builder.Services.AddOpenTelemetry()
|
|
62
|
+
.WithMetrics(metrics =>
|
|
63
|
+
{
|
|
64
|
+
metrics
|
|
65
|
+
.AddAspNetCoreInstrumentation()
|
|
66
|
+
.AddHttpClientInstrumentation()
|
|
67
|
+
.AddRuntimeInstrumentation()
|
|
68
|
+
.AddMeter("MyApp.Business")
|
|
69
|
+
.AddAzureMonitorMetricExporter(opts =>
|
|
70
|
+
{
|
|
71
|
+
opts.ConnectionString = config["ApplicationInsights:ConnectionString"];
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Required Business Metrics
|
|
77
|
+
|
|
78
|
+
| Metric | Type | Tags | Why |
|
|
79
|
+
|--------|------|------|-----|
|
|
80
|
+
| `requests.total` | Counter | route, method, status | Request volume |
|
|
81
|
+
| `requests.duration` | Histogram | route, status | Latency distribution |
|
|
82
|
+
| `errors.total` | Counter | type, severity | Error rate |
|
|
83
|
+
| `{domain}.operations` | Counter | operation, result | Business KPIs |
|
|
84
|
+
| `db.query_duration` | Histogram | table, operation | DB performance |
|
|
85
|
+
| `queue.depth` | Gauge | queue_name | Backlog monitoring |
|
|
86
|
+
| `cache.hit_rate` | Gauge | cache_name | Cache effectiveness |
|
|
87
|
+
|
|
88
|
+
## KQL Metric Queries
|
|
89
|
+
|
|
90
|
+
### Request Volume by Endpoint
|
|
91
|
+
```kql
|
|
92
|
+
customMetrics
|
|
93
|
+
| where name == "requests.total" and timestamp > ago(1h)
|
|
94
|
+
| summarize total = sum(value) by bin(timestamp, 5m), tostring(customDimensions["route"])
|
|
95
|
+
| render timechart
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### P50/P95/P99 Latency
|
|
99
|
+
```kql
|
|
100
|
+
customMetrics
|
|
101
|
+
| where name == "requests.duration" and timestamp > ago(1h)
|
|
102
|
+
| summarize
|
|
103
|
+
p50 = percentile(value, 50),
|
|
104
|
+
p95 = percentile(value, 95),
|
|
105
|
+
p99 = percentile(value, 99)
|
|
106
|
+
by bin(timestamp, 5m)
|
|
107
|
+
| render timechart
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Naming Conventions
|
|
111
|
+
|
|
112
|
+
| Pattern | Example | Description |
|
|
113
|
+
|---------|---------|-------------|
|
|
114
|
+
| `{domain}.{noun}.{verb}` | `orders.items.created` | Domain event count |
|
|
115
|
+
| `{resource}.{metric}` | `db.query_duration` | Resource measurement |
|
|
116
|
+
| `{service}.{noun}.active` | `connections.sockets.active` | Current state |
|
|
117
|
+
|
|
118
|
+
Rules:
|
|
119
|
+
- Lowercase with dots as separators
|
|
120
|
+
- Use consistent units: `ms` for duration, `bytes` for size, `count` for dimensionless
|
|
121
|
+
- Include relevant tags (avoid high-cardinality tags like userId)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Observability Standard: Monitoring
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
Application Insights configuration and KQL query patterns for production monitoring.
|
|
5
|
+
|
|
6
|
+
## Application Insights Setup
|
|
7
|
+
|
|
8
|
+
### Connection String (not Instrumentation Key)
|
|
9
|
+
```csharp
|
|
10
|
+
// appsettings.json — use connection string, not key
|
|
11
|
+
{
|
|
12
|
+
"ApplicationInsights": {
|
|
13
|
+
"ConnectionString": "InstrumentationKey=..." // from Key Vault
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Program.cs
|
|
18
|
+
builder.Services.AddApplicationInsightsTelemetry(
|
|
19
|
+
builder.Configuration["ApplicationInsights:ConnectionString"]);
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Sampling Configuration
|
|
23
|
+
```csharp
|
|
24
|
+
builder.Services.Configure<TelemetryConfiguration>(config =>
|
|
25
|
+
{
|
|
26
|
+
var sampler = new AdaptiveSamplingTelemetryProcessor(null!);
|
|
27
|
+
sampler.MaxTelemetryItemsPerSecond = 5; // Limit volume
|
|
28
|
+
sampler.ExcludedTypes = "Request"; // Don't sample requests
|
|
29
|
+
config.TelemetryProcessorChainBuilder.Use(_ => sampler).Build();
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Dependency Tracking
|
|
34
|
+
```csharp
|
|
35
|
+
// Auto-tracked: HTTP, SQL, Azure SDK calls
|
|
36
|
+
// Manual tracking for custom operations:
|
|
37
|
+
using var operation = telemetryClient.StartOperation<DependencyTelemetry>("ServiceBus.Send");
|
|
38
|
+
operation.Telemetry.Type = "Azure Service Bus";
|
|
39
|
+
operation.Telemetry.Target = queueName;
|
|
40
|
+
try
|
|
41
|
+
{
|
|
42
|
+
await sender.SendMessageAsync(message);
|
|
43
|
+
operation.Telemetry.Success = true;
|
|
44
|
+
}
|
|
45
|
+
catch (Exception ex)
|
|
46
|
+
{
|
|
47
|
+
operation.Telemetry.Success = false;
|
|
48
|
+
telemetryClient.TrackException(ex);
|
|
49
|
+
throw;
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## KQL Queries — Common Scenarios
|
|
54
|
+
|
|
55
|
+
### Request Success Rate (last 24h)
|
|
56
|
+
```kql
|
|
57
|
+
requests
|
|
58
|
+
| where timestamp > ago(24h)
|
|
59
|
+
| summarize
|
|
60
|
+
total = count(),
|
|
61
|
+
failed = countif(success == false),
|
|
62
|
+
successRate = round(100.0 * countif(success == true) / count(), 2)
|
|
63
|
+
| project total, failed, successRate
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Slow Requests (> 2s)
|
|
67
|
+
```kql
|
|
68
|
+
requests
|
|
69
|
+
| where duration > 2000 and timestamp > ago(1h)
|
|
70
|
+
| project timestamp, name, url, duration, resultCode, cloud_RoleInstance
|
|
71
|
+
| order by duration desc
|
|
72
|
+
| take 20
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Exception Summary
|
|
76
|
+
```kql
|
|
77
|
+
exceptions
|
|
78
|
+
| where timestamp > ago(24h)
|
|
79
|
+
| summarize count() by type, outerMessage
|
|
80
|
+
| order by count_ desc
|
|
81
|
+
| take 10
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Dependency Failures
|
|
85
|
+
```kql
|
|
86
|
+
dependencies
|
|
87
|
+
| where success == false and timestamp > ago(1h)
|
|
88
|
+
| project timestamp, name, type, target, duration, resultCode, data
|
|
89
|
+
| order by timestamp desc
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### User Activity Heatmap
|
|
93
|
+
```kql
|
|
94
|
+
customEvents
|
|
95
|
+
| where timestamp > ago(7d)
|
|
96
|
+
| summarize events = count() by bin(timestamp, 1h), name
|
|
97
|
+
| render timechart
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Alerting Rules
|
|
101
|
+
|
|
102
|
+
| Alert | Condition | Severity |
|
|
103
|
+
|-------|-----------|----------|
|
|
104
|
+
| High error rate | `requests` failure rate > 5% for 5 min | Critical |
|
|
105
|
+
| Slow response | P95 latency > 3s for 10 min | Warning |
|
|
106
|
+
| Exception spike | Exception count > 100/min | Critical |
|
|
107
|
+
| Dependency failure | Any dependency > 10 failures/min | Warning |
|
|
108
|
+
|
|
109
|
+
## Dashboard Required Sections
|
|
110
|
+
- Request volume + success rate (1h/24h/7d)
|
|
111
|
+
- Top slow endpoints
|
|
112
|
+
- Exception breakdown by type
|
|
113
|
+
- Dependency health grid
|
|
114
|
+
- Active user count
|