ASP.NET Core Web API demonstrating domain-driven incident lifecycle rules, JWT-protected command endpoints, optimistic concurrency with ETags, and integration-tested HTTP behaviour.
This project is designed to go beyond basic CRUD. Incidents are treated as real domain entities with enforced lifecycle transitions, state-changing endpoints are protected with authentication, and the API implements conditional requests and concurrency checks using standard HTTP semantics.
- Layered backend design across API, Application, Domain, and Infrastructure
- Domain-enforced business rules instead of relying only on controller validation
- JWT authentication for protected command endpoints
- Optimistic concurrency using
ETagandIf-Match - Conditional GET support using
If-None-Match - DTO-based API contracts
- Automated testing across both domain logic and HTTP behaviour
- Containerised local setup with Docker Compose and PostgreSQL
- C#
- .NET 10
- ASP.NET Core Web API
- Entity Framework Core
- PostgreSQL
- JWT Bearer authentication
- xUnit
- SQLite for API integration tests
- Docker / Docker Compose
The solution is organised into separate projects with clear responsibilities:
- API – controllers, authentication, HTTP concerns, response behaviour, ETag handling
- Application – DTOs, interfaces, service orchestration, command/query handling
- Domain – entities, enums, lifecycle rules, and business invariants
- Infrastructure – EF Core persistence, mappings, token generation, and database access
Request flow:
HTTP request -> Controller -> Application service -> DbContext -> Database
Incidents are not treated as freely editable records. The domain model enforces valid transitions between states.
Supported statuses:
OpenAssignedInProgressWaitingResolvedInvalidClosed
Examples of enforced rules:
- an incident can be assigned from
Open,Assigned,InProgress, orWaiting - progress can only start from
AssignedorWaiting - resolving requires the incident to be
InProgress - closing is only allowed from
ResolvedorInvalid
Invalid transitions are rejected at the domain level and mapped to appropriate HTTP responses.
The API includes JWT authentication for state-changing endpoints.
Implemented behaviour:
POST /auth/loginissues a JWT for a known userGETendpoints are available anonymously- command endpoints require authentication
This keeps reads simple while protecting write operations.
A key focus of this project is HTTP-aware concurrency behaviour.
For GET /api/incidents/{id}, the API:
- returns
404 Not Foundwhen the incident does not exist - returns
200 OKwith an incident response DTO when found - includes an
ETagrepresenting the current row version - supports
If-None-Match - returns
304 Not Modifiedwhen the client's cached version is still current
For state-changing endpoints, the API uses If-Match preconditions:
428 Precondition RequiredwhenIf-Matchis missing400 Bad RequestwhenIf-Matchis malformed412 Precondition Failedwhen the supplied ETag is stale409 Conflictwhen the requested operation violates domain lifecycle rules
This demonstrates optimistic concurrency at the HTTP contract level rather than relying only on database exceptions.
POST /auth/login
GET /api/incidents/{id}
POST /api/incidentsPOST /api/incidents/{id}/assign-engineerPOST /api/incidents/{id}/start-progressPOST /api/incidents/{id}/mark-waitingPOST /api/incidents/{id}/resolvePOST /api/incidents/{id}/mark-invalidPOST /api/incidents/{id}/close
IncidentManagementApi.sln
├── src
│ ├── API
│ │ ├── Controllers
│ │ ├── Http
│ │ ├── Program.cs
│ ├── Application
│ │ ├── DTOs
│ │ ├── Interfaces
│ │ └── Services
│ ├── Domain
│ │ ├── Entities
│ │ └── Enums
│ └── Infrastructure
│ ├── Auth
│ ├── Configurations
│ ├── Migrations
│ └── Persistence
└── tests
├── API.Tests
└── Domain.Tests
From the repository root:
docker compose up -d --buildThis starts the API and a PostgreSQL database. Database migrations run automatically on startup.
The API will be available at http://localhost:8080.
curl http://localhost:8080/auth/login \
--request POST \
--header 'Content-Type: application/json' \
--data '{
"username": "User1",
"password": "VerySecretPassword1!"
}'Copy the returned token and export it:
export JWT_TOKEN="<PASTE_JWT_TOKEN_HERE>"curl http://localhost:8080/api/incidents \
--request POST \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $JWT_TOKEN" \
--data '{
"title": "Test Incident",
"description": "Description of incident",
"severity": "Critical",
"networkElementId": 1
}'Copy the returned id from the response body:
export INCIDENT_ID="<PASTE_INCIDENT_ID_HERE>"curl -i http://localhost:8080/api/incidents/$INCIDENT_IDThe response headers will include an ETag, for example:
ETag: W/"1"Copy that value and export it:
export ETAG='W/"1"'Example: assign an engineer
curl http://localhost:8080/api/incidents/$INCIDENT_ID/assign-engineer \
--request POST \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $JWT_TOKEN" \
--header "If-Match: $ETAG" \
--data '{
"engineerId": 1
}'After a successful command, retrieve the incident again to get the latest ETag before issuing another state-changing request:
curl -i http://localhost:8080/api/incidents/$INCIDENT_IDThen update the ETAG variable with the new value before sending the next command.
An updated ETag is also returned in the response headers of a successful command.
To move an incident to Resolved, it must first be in InProgress.
curl http://localhost:8080/api/incidents/$INCIDENT_ID/start-progress \
--request POST \
--header "Authorization: Bearer $JWT_TOKEN" \
--header "If-Match: $ETAG"After a successful command, retrieve the incident again and update the ETAG value before issuing the next state-changing request.
curl http://localhost:8080/api/incidents/$INCIDENT_ID/resolve \
--request POST \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $JWT_TOKEN" \
--header "If-Match: $ETAG" \
--data '{
"resolutionSummary": "Incident resolution description"
}'docker compose downThe solution includes both domain unit tests and API integration tests.
- incident creation guard clauses
- valid and invalid lifecycle transitions
- assignment rules
- waiting, resolving, invalidation, and close behaviour
- invariant protection on failed operations
- authenticated and unauthenticated endpoint access
- GET success, 404, and conditional 304
- incident creation behaviour
- If-Match precondition handling
- successful command execution
- invalid transitions mapped to 409 Conflict
The goal of the test suite is to verify both business rules and the HTTP contract exposed by the API.
- return structured ProblemDetails bodies for command failures
- add filtering, sorting, and pagination for incident queries
- replace in-memory users with persistent identity storage
- add refresh tokens and a fuller authentication flow
- expand documentation with additional request/response examples
I built this project to practise backend concerns that matter in real systems: domain modelling, API design, authentication, optimistic concurrency, HTTP semantics, and automated testing.