💡 Also available: Java/Spring Boot version
Duration: 45 minutes
Learning Objectives:
- Transform user stories into actionable backlog items using Copilot
- Generate acceptance criteria with AI assistance
- Create test-driven implementations from requirements
- Practice the full software development workflow with AI
In this lab, you'll experience the complete journey from a vague user requirement to working, tested code. You'll use GitHub Copilot to:
- Decompose a user story into specific backlog items
- Generate acceptance criteria for each item
- Create test cases from acceptance criteria
- Implement features using TDD principles from Lab 1
This simulates real-world Agile development where requirements are refined into actionable work items.
- ✅ Completed Lab 1 (TDD with NotificationService)
- ✅ Understand Red-Green-Refactor cycle
- ✅ Familiar with Copilot Chat and slash commands
- ✅ Repository at clean state:
git statusshows no uncommitted changes
Your product owner provides this user story:
User Story: As a user, I want to manage a list of tasks with priorities and due dates so I can track my work and focus on what's most important.
This is intentionally vague - typical of early requirements. Your job is to break it down into concrete, implementable features.
Open Copilot Chat (Ctrl+Alt+I / Cmd+Shift+I) and enter:
I have this user story: "As a user, I want to manage a list of tasks with priorities and due dates so I can track my work and focus on what's most important."
Generate 5 backlog items with acceptance criteria for implementing this feature in our Task Manager application. Consider the existing Domain/Application/Infrastructure/API architecture.
Copilot should generate something like:
Description: Extend the Task domain entity to support priority levels (Low, Medium, High, Critical).
Acceptance Criteria:
- Task entity has a Priority property (enum)
- Priority is required when creating a task
- Priority can be updated after creation
- Priority is persisted to the database
- Invalid priority values are rejected
Technical Notes:
- Update Task entity in Domain layer
- Add Priority enum as value object
- Update database schema/migrations
- Respect DDD aggregate rules
Description: Extend the Task domain entity to support due dates for deadline tracking.
Acceptance Criteria:
- Task entity has a DueDate property (nullable DateTime)
- Due date is optional when creating a task
- Due date can be set/updated/cleared after creation
- Due date must be in the future when set
- Due date is persisted to the database
Technical Notes:
- Update Task entity in Domain layer
- Add validation for future dates only
- Handle null case for tasks without deadlines
- Consider timezone handling
Description: Update task creation logic to accept priority and optional due date.
Acceptance Criteria:
- POST /tasks endpoint accepts priority and dueDate fields
- Priority is required in the request
- DueDate is optional in the request
- Invalid priority values return 400 Bad Request
- Past due dates return 400 Bad Request with clear error message
- Created task returns 201 with full task details including priority and due date
Technical Notes:
- Update CreateTaskCommand in Application layer
- Add validation logic
- Update API endpoint and request DTOs
- Add unit tests for all validation rules
Description: Allow users to retrieve tasks filtered by priority level.
Acceptance Criteria:
- GET /tasks?priority={level} endpoint supports filtering
- Valid priority values: Low, Medium, High, Critical
- Multiple priority values can be specified: ?priority=High&priority=Critical
- Invalid priority values return 400 Bad Request
- Returns empty array if no tasks match
- Results are ordered by due date (earliest first, nulls last)
Technical Notes:
- Add query parameters to endpoint
- Implement filtering in repository
- Add sorting by due date
- Unit test filtering logic
- Integration test endpoint behavior
Description: Provide an endpoint to retrieve tasks with approaching deadlines.
Acceptance Criteria:
- GET /tasks/due-soon endpoint returns tasks due in next 7 days
- Tasks without due dates are excluded
- Results ordered by due date (soonest first)
- Configurable time window (default 7 days, max 30 days)
- Returns 200 with array of tasks
- Query parameter: ?days={number} to customize window
Technical Notes:
- Add new endpoint in API layer
- Create query handler in Application layer
- Calculate date range based on current time
- Add unit tests for date calculations
- Consider timezone implications
For this lab, we'll implement Backlog Item 3: Create Task with Priority and Due Date.
Why this item?
- ✅ Touches all layers (Domain, Application, Infrastructure, API)
- ✅ Demonstrates validation logic
- ✅ Requires TDD approach
- ✅ Foundation for other items (Items 1 & 2 are prerequisites)
⚠️ TDD REMINDER: In this section, we'll follow Red-Green-Refactor:
- RED: Write tests FIRST that fail
- GREEN: Implement code to make tests pass
- REFACTOR: Improve code quality while keeping tests green
Before we can create tasks with priority and due date, we need to add these properties to the Task entity (Items 1 & 2).
Ask Copilot Chat:
Create a Priority enum as a value object in the Domain layer following DDD patterns. Include values: Low, Medium, High, Critical. Follow our .NET coding conventions.
Expected Output - src/TaskManager.Domain/ValueObjects/Priority.cs:
namespace TaskManager.Domain.ValueObjects;
public enum Priority
{
Low = 0,
Medium = 1,
High = 2,
Critical = 3
}Or, for a more DDD approach with value object:
namespace TaskManager.Domain.ValueObjects;
public sealed record Priority
{
public static readonly Priority Low = new(0, nameof(Low));
public static readonly Priority Medium = new(1, nameof(Medium));
public static readonly Priority High = new(2, nameof(High));
public static readonly Priority Critical = new(3, nameof(Critical));
public int Value { get; }
public string Name { get; }
private Priority(int value, string name)
{
Value = value;
Name = name;
}
public static Priority FromValue(int value) => value switch
{
0 => Low,
1 => Medium,
2 => High,
3 => Critical,
_ => throw new ArgumentException($"Invalid priority value: {value}", nameof(value))
};
public static Priority FromName(string name) => name?.ToLowerInvariant() switch
{
"low" => Low,
"medium" => Medium,
"high" => High,
"critical" => Critical,
_ => throw new ArgumentException($"Invalid priority name: {name}", nameof(name))
};
}Note: Our implementation uses three priority levels (Low, Medium, High) for simplicity. The Critical level is optional.
Following TDD: Write tests FIRST before implementing!
Use the /tests command or ask Copilot Chat:
Generate xUnit tests for the Task entity in tests/TaskManager.UnitTests/Domain/Entities/TaskTests.cs that verify:
- Task.Create with valid title and priority succeeds
- Task.Create with valid title, priority, and future due date succeeds
- Task.Create with null/empty/whitespace title throws ArgumentException
- Task.Create with past due date throws ArgumentException
- Task.Create with null due date is allowed
- UpdatePriority updates the priority correctly
- UpdateDueDate with future date succeeds
- UpdateDueDate with past date throws ArgumentException
- MarkAsCompleted sets IsCompleted and CompletedAt
Use FakeItEasy for any dependencies if needed.
Run tests - they should FAIL because the Task entity doesn't exist yet or doesn't have these properties:
dotnet testExpected result: Tests fail with compilation errors or NotImplementedException. This is the RED phase! ✅
Now that we have failing tests, implement the code to make them pass.
Use @workspace to find the Task entity:
@workspace Where is the Task entity defined?
Then ask Copilot to update it:
Update the Task entity in #file:src/TaskManager.Domain/Entities/Task.cs to add:
1. Priority property (required)
2. DueDate property (nullable DateTime)
3. Validation: DueDate must be in future if provided
4. Factory method to create tasks with these properties
Follow DDD patterns: private constructor, factory method, invariant enforcement
Example Updated Entity:
namespace TaskManager.Domain.Entities;
public sealed class Task
{
public Guid Id { get; private set; }
public string Title { get; private set; }
public string? Description { get; private set; }
public Priority Priority { get; private set; }
public DateTime? DueDate { get; private set; }
public bool IsCompleted { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? CompletedAt { get; private set; }
// Private constructor - use factory method
private Task(
Guid id,
string title,
string? description,
Priority priority,
DateTime? dueDate)
{
Id = id;
Title = title;
Description = description;
Priority = priority;
DueDate = dueDate;
IsCompleted = false;
CreatedAt = DateTime.UtcNow;
}
public static Task Create(
string title,
string? description,
Priority priority,
DateTime? dueDate = null)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Title cannot be null or empty", nameof(title));
if (dueDate.HasValue && dueDate.Value <= DateTime.UtcNow)
throw new ArgumentException("Due date must be in the future", nameof(dueDate));
return new Task(Guid.NewGuid(), title, description, priority, dueDate);
}
public void UpdatePriority(Priority priority)
{
Priority = priority ?? throw new ArgumentNullException(nameof(priority));
}
public void UpdateDueDate(DateTime? dueDate)
{
if (dueDate.HasValue && dueDate.Value <= DateTime.UtcNow)
throw new ArgumentException("Due date must be in the future", nameof(dueDate));
DueDate = dueDate;
}
public void MarkAsCompleted()
{
if (IsCompleted)
throw new InvalidOperationException("Task is already completed");
IsCompleted = true;
CompletedAt = DateTime.UtcNow;
}
}Run tests again:
dotnet testExpected result: All tests pass! This is the GREEN phase! ✅
Review the code and tests:
- Are there any code smells?
- Can validation logic be extracted?
- Are error messages clear?
- Is the code following DDD patterns?
If you make changes, re-run tests to ensure they still pass:
dotnet testPart 2 Complete! You've successfully added Priority and DueDate to the Task entity using proper TDD.
Now implement the full feature: Create Task with Priority and Due Date through the API.
Ask Copilot Chat:
Create a CreateTaskCommand in the Application layer with properties:
- Title (required)
- Description (optional)
- Priority (required, string)
- DueDate (optional, DateTime?)
Include validation attributes and follow CQRS patterns.
Expected Output - src/TaskManager.Application/Commands/CreateTaskCommand.cs:
namespace TaskManager.Application.Commands;
public sealed record CreateTaskCommand
{
public required string Title { get; init; }
public string? Description { get; init; }
public required string Priority { get; init; }
public DateTime? DueDate { get; init; }
}Ask Copilot:
Create xUnit tests for CreateTaskCommandHandler in tests/TaskManager.UnitTests/Commands/CreateTaskCommandHandlerTests.cs. Test:
- Valid command creates task with correct properties
- Invalid priority string throws exception
- Past due date throws exception
- Null title throws exception
Use FakeItEasy for ITaskRepository and ILogger
Run tests - they should FAIL (handler doesn't exist yet):
dotnet testAsk Copilot:
Implement CreateTaskCommandHandler in Application layer that:
1. Parses priority string to Priority value object
2. Validates due date is in future (if provided)
3. Creates Task entity using factory method
4. Saves via ITaskRepository
5. Returns created task
Follow Clean Architecture and use ILogger for structured logging
Expected Output - src/TaskManager.Application/Commands/CreateTaskCommandHandler.cs:
namespace TaskManager.Application.Commands;
public sealed class CreateTaskCommandHandler
{
private readonly ITaskRepository _repository;
private readonly ILogger<CreateTaskCommandHandler> _logger;
public CreateTaskCommandHandler(
ITaskRepository repository,
ILogger<CreateTaskCommandHandler> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<Domain.Entities.Task> HandleAsync(
CreateTaskCommand command,
CancellationToken cancellationToken = default)
{
if (command == null)
throw new ArgumentNullException(nameof(command));
_logger.LogInformation(
"Creating task with title {Title} and priority {Priority}",
command.Title,
command.Priority);
// Parse priority from string
var priority = Priority.FromName(command.Priority);
// Create task entity (validates due date)
var task = Domain.Entities.Task.Create(
command.Title,
command.Description,
priority,
command.DueDate);
// Save via repository
await _repository.AddAsync(task, cancellationToken);
_logger.LogInformation(
"Task created successfully with ID {TaskId}",
task.Id);
return task;
}
}Run tests - they should PASS:
dotnet testAsk Copilot:
Create CreateTaskRequest and TaskResponse DTOs in API layer (src/TaskManager.Api/Models/) for the POST /tasks endpoint. Use records with required properties where appropriate.
Expected Output - Two DTO files that map between HTTP and Application layers. These are simple data structures, so no tests are needed.
Following TDD: Write integration tests BEFORE implementing the endpoint!
Use @workspace to find the endpoint extensions:
@workspace Where are the API endpoints defined?
Then create integration tests FIRST:
Create integration tests for POST /tasks endpoint in tests/TaskManager.IntegrationTests/Api/TaskEndpointsTests.cs that verify:
- Valid request with all fields returns 201 Created with task details and Location header
- Valid request with only required fields returns 201 Created
- Invalid priority returns 400 Bad Request with ProblemDetails
- Past due date returns 400 Bad Request with ProblemDetails
- Missing/empty/whitespace title returns 400 Bad Request
- Optional fields (description, dueDate) handled correctly
Use WebApplicationFactory<Program> pattern and xUnit.
Run the integration tests - they should FAIL with 404 Not Found (endpoint doesn't exist yet):
dotnet test tests/TaskManager.IntegrationTests/Expected result: All integration tests fail. This is the RED phase! ✅
Now implement the endpoint to make the tests pass:
Implement POST /tasks endpoint in #file:src/TaskManager.Api/Extensions/EndpointExtensions.cs that:
1. Maps CreateTaskRequest DTO to CreateTaskCommand
2. Calls CreateTaskCommandHandler to create the task
3. Maps the domain Task entity to TaskResponse DTO
4. Returns 201 Created with Location header and TaskResponse body
5. Handles ArgumentException (validation errors) → 400 Bad Request with ProblemDetails
6. Handles unexpected exceptions → 500 Internal Server Error with ProblemDetails
Use minimal API pattern, dependency injection for handler, and ILogger for logging.
Run the integration tests again:
dotnet test tests/TaskManager.IntegrationTests/Expected result: All integration tests pass! This is the GREEN phase! ✅
Create a tasks.http file in the API project for manual testing with the REST Client extension:
Create a tasks.http file in src/TaskManager.Api/ with test scenarios for POST /tasks endpoint including:
- Valid requests with all fields
- Valid requests with required fields only
- All priority levels (Low, Medium, High)
- Invalid priority
- Missing/empty/whitespace title
- Past due date
- Future due date
- Optional field combinations
Use REST Client format with @baseUrl variable set to http://localhost:5215
This file allows manual testing without writing curl commands repeatedly.
dotnet build
dotnet testAll tests should pass! ✅
Expected output:
- Unit tests: All passing (14+ for CreateTaskCommandHandler, 11+ for Task entity)
- Integration tests: All passing (8 for TaskEndpointsTests)
- Build: 0 warnings, 0 errors
cd src/TaskManager.Api
dotnet run --launch-profile httpThe API will start on http://localhost:5215 (configured in Properties/launchSettings.json).
If you created the tasks.http file in Step 3.2.4:
- Install the REST Client extension in VS Code (by Huachao Mao)
- Open
src/TaskManager.Api/tasks.http - Click "Send Request" above any test scenario
- View the response in a split pane
This is the easiest way to test your API!
Valid Request:
curl -X POST http://localhost:5215/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Complete Lab 2",
"description": "Finish requirements to code lab",
"priority": "High",
"dueDate": "2025-10-25T17:00:00Z"
}'Expected Response: 201 Created with Location header
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"title": "Complete Lab 2",
"description": "Finish requirements to code lab",
"priority": "High",
"status": "Todo",
"dueDate": "2025-10-25T17:00:00Z",
"createdAt": "2025-10-20T14:30:00Z",
"updatedAt": "2025-10-20T14:30:00Z"
}Invalid Priority:
curl -X POST http://localhost:5215/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Test Task",
"priority": "SuperUrgent",
"dueDate": "2025-10-25T17:00:00Z"
}'Expected Response: 400 Bad Request with ProblemDetails
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "Validation Error",
"status": 400,
"detail": "Invalid priority name: SuperUrgent (Parameter 'name')"
}Past Due Date:
curl -X POST http://localhost:5215/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Test Task",
"priority": "Low",
"dueDate": "2020-01-01T00:00:00Z"
}'Expected Response: 400 Bad Request with ProblemDetails
Confirm that:
- ✅ Valid requests return 201 Created with Location header
- ✅ Invalid priority returns 400 Bad Request with clear error message
- ✅ Past due dates return 400 Bad Request with validation error
- ✅ Missing/empty title returns 400 Bad Request
- ✅ Optional fields (description, dueDate) can be omitted
- ✅ Response includes all task properties (id, title, priority, status, timestamps)
- Decomposition: Copilot helped break vague user story into concrete items
- Acceptance Criteria: Generated testable, specific criteria for each item
- Technical Context: Understood existing architecture and suggested appropriate patterns
- Comprehensive Coverage: Identified edge cases and validation rules
- Red-Green-Refactor Applied: Tests written FIRST at every layer
- Domain Layer TDD: Task entity tests → implementation → refactor
- Application Layer TDD: Handler tests → implementation → validation
- API Layer TDD: Integration tests → endpoint implementation → manual testing
- Test Coverage: Unit tests for logic, integration tests for full stack
- All Layers Tested: Each layer validated independently with proper test pyramid
- Dependencies Flow Inward: API → Application → Domain
- Domain Purity: No infrastructure concerns in entities
- Application Logic: Commands and handlers orchestrate use cases
- API Responsibility: Only request/response mapping, no business logic
- Generate acceptance criteria tests
- Implement repository filtering
- Add API endpoint with query parameters
- Test with multiple priority filters
- Write tests for date range calculations
- Create query handler in Application layer
- Add API endpoint
- Test edge cases (timezone boundaries)
- Generate backlog item with acceptance criteria
- Create UpdateTaskCommand
- Implement PUT /tasks/{id} endpoint
- Test validation and error cases
You've completed this lab successfully when:
- ✅ User story decomposed into 5 backlog items with acceptance criteria
- ✅ Priority value object created in Domain layer
- ✅ Task entity updated with Priority and DueDate
- ✅ CreateTaskCommand and handler implemented with tests
- ✅ POST /tasks endpoint working with proper validation
- ✅ All tests passing (unit and integration)
- ✅ Manual testing confirms expected behavior
- ✅ Clean Architecture principles maintained throughout
Problem: Backlog items don't consider existing architecture
Solution: Use @workspace to give context: "Given our Clean Architecture structure..."
Problem: Missing validation tests
Solution: Explicitly ask: "Generate tests for all guard clauses and edge cases"
Problem: ITaskRepository doesn't have needed methods
Solution: Update repository interface first, then implement in Infrastructure layer
Problem: Due date validation fails unexpectedly
Solution: Use DateTime.UtcNow consistently, consider timezone handling
Move on to Lab 3: Code Generation & Refactoring where you'll:
- Scaffold complete API endpoints with Copilot
- Refactor legacy code using
/refactorcommand - Apply Object Calisthenics principles
- Use
@workspacefor cross-file understanding
Why ADRs?
As you make key design or architectural choices (e.g., how to model priorities, validation, or API structure), it's best practice to capture your reasoning in an Architecture Decision Record (ADR). This helps your team understand why decisions were made and makes future changes easier to justify.
Sample Copilot Prompt:
Write an Architecture Decision Record (ADR) for our approach to modeling task priorities as a value object in the Domain layer. Include:
- Context and alternatives considered
- Decision summary
- Consequences (tradeoffs, future impact)
Format as Markdown.
Where to put ADRs:
- Save ADRs in the
docs/adr/folder (create it if it doesn't exist). - Use a clear filename, e.g.,
docs/adr/0001-task-priority-value-object.md.
Learn more: ADR GitHub site