A vertically-sliced DDD + Clean Architecture + CQRS reference implementation in .NET 9, backed by EF Core / SQLite and exposed via a HotChocolate GraphQL API with real-time subscriptions.
PlanManagementSystem.sln
src/
PlanManagementSystem.Domain/ # Pure domain model + invariants (no EF, no GraphQL)
PlanManagementSystem.Application/ # Use cases (MediatR commands/queries + validators + behaviors)
PlanManagementSystem.Infrastructure/ # EF Core + SQLite + repository + system clock
PlanManagementSystem.Presentation.GraphQL/ # HotChocolate schema, resolvers, types, payloads, subscriptions
PlanManagementSystem.Api/ # ASP.NET Core composition root
tests/
PlanManagementSystem.Domain.Tests/ # Pure unit tests of VOs and aggregate behavior
PlanManagementSystem.Application.Tests/ # Handler tests with mocked ports
PlanManagementSystem.IntegrationTests/ # GraphQL + persistence integration tests
| Concern | Decision |
|---|---|
| Aggregate boundary | Plan owns Step lifecycle; ordering enforced inside the aggregate |
| Value objects | PlanName, StepName, EstimatedTime — immutable, factory methods |
| Persistence | EF Core 9, SQLite, value converters for VOs, backing-field mapping |
| Concurrency | Domain invariants; unique order index intentionally not used (circular) |
| API style | GraphQL via HotChocolate 13 (Query + Mutation + Subscription) |
| Pub/sub | In-memory topic sender now; swap to Redis provider for scale-out |
| Cross-cutting | MediatR pipeline: logging + validation; domain exceptions = GraphQL errors |
- .NET 9 SDK (project pins
9.0.201viaglobal.json)
# Restore + build everything (warnings-as-errors policy in effect)
dotnet build
# Run all unit + integration tests (xUnit)
dotnet test
# Run the API (applies migrations to plans.db on startup)
dotnet run --project src/PlanManagementSystem.Api
# API listens on the URL printed in the console; default http://localhost:5000
# GraphQL endpoint: http://localhost:<port>/graphql
# GraphQL IDE: http://localhost:<port>/graphql (Banana Cake Pop)
# Health: http://localhost:<port>/healthDefault SQLite file is created next to the API binary (plans.db). Override via configuration:
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=C:/data/plans.db"
}
}The startup migration runner respects Database:ApplyMigrationsOnStartup (default true). Set to false in production if you prefer explicit dotnet ef database update runs.
# Add a new migration
dotnet ef migrations add <Name> `
--project src/PlanManagementSystem.Infrastructure `
--startup-project src/PlanManagementSystem.Infrastructure `
--output-dir Persistence/Migrations
# Apply migrations manually
dotnet ef database update `
--project src/PlanManagementSystem.Infrastructure `
--startup-project src/PlanManagementSystem.Apitype Query {
plans: [Plan!]!
planById(planId: UUID!): Plan
}
type Mutation {
createPlan(input: CreatePlanInput!): CreatePlanPayload!
addStep(input: AddStepInput!): AddStepPayload!
reorderStep(input: ReorderStepInput!): ReorderStepPayload!
updateStepEstimatedTime(input: UpdateStepEstimatedTimeInput!): UpdateStepEstimatedTimePayload!
removeStep(input: RemoveStepInput!): RemoveStepPayload!
}
type Subscription {
planCreated: Plan!
stepAdded(planId: UUID!): StepAddedEvent!
stepReordered(planId: UUID!): StepReorderedEvent!
stepEstimatedTimeUpdated(planId: UUID!): StepEstimatedTimeUpdatedEvent!
stepRemoved(planId: UUID!): StepRemovedEvent!
}mutation Create {
createPlan(input: { name: "Sprint 42" }) {
plan { id name createdAt }
}
}
mutation AddStep {
addStep(input: {
planId: "<plan-guid>",
name: "Design",
estimatedTime: "1h 30m"
}) {
step { id name order estimatedTime { totalMinutes display } }
plan { steps { name order } }
}
}
mutation Reorder {
reorderStep(input: {
planId: "<plan-guid>",
stepId: "<step-guid>",
newOrder: 1
}) {
plan { steps { name order } }
}
}
query GetPlans {
plans {
id name
steps { name order estimatedTime { totalMinutes display } }
}
}Accepted inputs (after trimming whitespace):
<n>m— minutes only (e.g.45m,90m)<n>h— hours only (e.g.1h,7h)<n>h <n>m— hours + minutes (e.g.1h 30m)
0 < totalMinutes < 480. Invalid forms (1h 60m, 0m, 8h, abc, 1:30, 1h30m) return a VALIDATION_ERROR with an explicit message.
subscription OnStepAdded($planId: UUID!) {
stepAdded(planId: $planId) {
planId
step { id name order estimatedTime { display } }
}
}Subscriptions are partitioned by planId to limit fan-out. Transport: WebSocket (graphql-transport-ws); CORS allows any origin in dev.
Domain exceptions thrown anywhere in the MediatR pipeline are translated to GraphQL errors with explicit code extensions:
| Domain message contains | GraphQL code |
|---|---|
not found |
NOT_FOUND |
out of range, duplicate, conflict |
CONFLICT |
| anything else | VALIDATION_ERROR |
LoggingBehavior emits Information-level entries on success and Error-level on exception, including elapsed milliseconds. EF Core and ASP.NET Core logs are throttled to Warning in appsettings.json and elevated to Information in appsettings.Development.json.
PlanManagementSystem.Domain.Tests - 92 tests (VOs + Plan aggregate)
PlanManagementSystem.Application.Tests - 20 tests (handlers + pipeline behaviors)
PlanManagementSystem.IntegrationTests - 14 tests (GraphQL + SQLite round-trip)
Run a single project:
dotnet test tests/PlanManagementSystem.Domain.Tests- Subscriptions use the in-memory topic provider. For multi-instance scale-out, swap to
RedisSubscriptionsProviderand wire it viaAddRedisSubscriptions. - No optimistic concurrency token is configured; correctness relies on aggregate invariants and single-write transactions.
- CORS is open by default for development convenience — tighten in production.