Skip to content

RamkumarSelvarajinfo/PlanManagementSystem

Repository files navigation

Plan Management System

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.

Solution layout

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

Architectural decisions (summary)

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

Prerequisites

  • .NET 9 SDK (project pins 9.0.201 via global.json)

Build, test, run

# 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>/health

Connection string

Default 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.

Migrations (manual control)

# 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.Api

GraphQL schema overview

type 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!
}

Sample queries

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 } }
  }
}

EstimatedTime grammar (strict)

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 example (graphql-ws)

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.

Error mapping

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

Logging

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.

Tests

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

Known limitations

  • Subscriptions use the in-memory topic provider. For multi-instance scale-out, swap to RedisSubscriptionsProvider and wire it via AddRedisSubscriptions.
  • 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages