The Engine's Domain: Entities, Invariants, Validation
A capstone starts from the data: records that carry invariants, validated once at the boundary so every layer below can trust them.
What you are building
A Task Management Engine โ the core of a project tracker:
- Users (
Id,Name,RoleโAdmin/Member), - Projects (
Id,Name,OwnerId), - Tasks (
Id,ProjectId,Title,AssigneeId?,PriorityโLow/Medium/High,StatusโTodo/InProgress/Done), - operations wrapped in a service layer with validation and authorization, backed by a repository, reporting via LINQ, state persisted as JSON.
Domain first, and make invalid states unrepresentable
Model choices as enums, not strings โ the compiler then rejects half the bug space:
public enum Priority { Low, Medium, High }
public enum Status { Todo, InProgress, Done }
Validation lives at the boundary (the service methods that accept input), one place, not smeared through every consumer:
- title: required, non-empty after trimming, โค 120 chars,
- priority/status: must be a defined enum value (
Enum.IsDefinedโ a cast(Priority)99compiles and is garbage), - assignee must exist and belong to the same system the task belongs to.
A failed check throws ArgumentException (input problem) โ the service's
contract. InvalidOperationException is reserved for state problems
(completing an already-done task).
Records for entities
Entities are immutable-with-replacement here: changing a task produces a new
record in the store. Records give value equality and with โ the engine's
mutations become visible, comparable state transitions:
public sealed record Task(
int Id, int ProjectId, string Title,
int? AssigneeId, Priority Priority, Status Status);
var opened = t with { Status = Status.InProgress, AssigneeId = uid };
IDs and the store
The repository owns ID allocation (monotonic counter). Services never invent IDs. Everything else in the engine is built on this foundation.