DocumentTracker is a small ASP.NET Core MVC training app for junior developers learning to maintain an existing C# application. It uses Razor views, Bootstrap, PostgreSQL, Dapper, services, repositories, validation, dependency injection, ILogger, xUnit, and Moq.
It intentionally avoids Entity Framework Core, Minimal APIs, Blazor, React, complex authentication frameworks, microservices, CQRS, and MediatR.
src/DocumentTracker/Controllers: MVC controllers. They handle routes, model state, redirects, and view selection.src/DocumentTracker/Services: business rules, validation beyond data annotations, role checks, and logging decisions.src/DocumentTracker/Repositories: Dapper and SQL only. User input is passed through parameters.src/DocumentTracker/Models: database/domain models and enums.src/DocumentTracker/ViewModels: models designed for Razor forms and pages. These prevent overposting.src/DocumentTracker/Views: Razor pages for list, details, create, edit, and soft delete confirmation.database: PostgreSQL schema and seed scripts.tests/DocumentTracker.Tests: xUnit tests for services, controllers, SQL inspection, and an optional PostgreSQL-backed repository example.Dockerfile: production-style container build for the MVC app.docker-compose.yml: local PostgreSQL-only development stack.docker-compose.production.example.yml: app-plus-database deployment example.src/DocumentTracker/libman.json: pinned client-side library restore for Bootstrap, jQuery, and validation scripts.docs/deployment.md: deployment and production configuration notes.
- Install .NET 10 SDK.
- Install Docker Desktop or another Docker-compatible runtime.
- Start PostgreSQL:
docker compose up -dThe Compose stack runs PostgreSQL on host port 5433, creates the document_tracker database, applies database/schema.sql, loads database/seed.sql, and creates a separate document_tracker_test database for integration-style tests.
- Run the app:
dotnet run --project src/DocumentTracker/DocumentTracker.csproj- Open the URL printed by
dotnet run.
Docker is recommended for training because every developer gets the same database, user, password, port, schema, and seed data. If you prefer a local PostgreSQL installation instead, create the database manually:
createdb document_tracker
psql -d document_tracker -f database/schema.sql
psql -d document_tracker -f database/seed.sqlThen configure the connection string in src/DocumentTracker/appsettings.json, user secrets, or environment variables.
{
"ConnectionStrings": {
"DocumentTracker": "Host=localhost;Port=5433;Database=document_tracker;Username=postgres;Password=postgres"
}
}For environment variables, use:
export ConnectionStrings__DocumentTracker="Host=localhost;Port=5433;Database=document_tracker;Username=postgres;Password=postgres"If port 5433 is already in use, change the host-side port in docker-compose.yml and update the connection string to match.
wwwroot/lib/ is not committed source. It is generated from src/DocumentTracker/libman.json during build through Microsoft.Web.LibraryManager.Build.
The pinned libraries are:
- Bootstrap
5.3.3 - jQuery
3.7.1 - jQuery Validation
1.21.0 - jQuery Validation Unobtrusive
4.0.0
To restore them explicitly:
dotnet build src/DocumentTracker/DocumentTracker.csprojThis keeps the app realistic: frontend assets are versioned by manifest, restored during builds, and excluded from Git.
Soft delete requires the DocumentAdmin role. This training app does not use a full authentication framework. The role provider first checks HttpContext.User.IsInRole("DocumentAdmin"), then falls back to:
{
"Training": {
"CurrentUserRole": "DocumentAdmin"
}
}Set Training:CurrentUserRole to another value, such as Reviewer, to see delete rejected.
dotnet test DocumentTracker.slnxThe repository integration-style test only runs real database work when this environment variable is set:
docker compose up -d
export DOCUMENTTRACKER_TEST_CONNECTION_STRING="Host=localhost;Port=5433;Database=document_tracker_test;Username=postgres;Password=postgres"
dotnet test DocumentTracker.slnxUse the dedicated document_tracker_test database because the integration test recreates schema objects and deletes rows from documents.
Start PostgreSQL:
docker compose up -dStop PostgreSQL without deleting data:
docker compose downReset PostgreSQL data, schema, and seed rows:
docker compose down -v
docker compose up -dBuild the app container:
docker build -t documenttracker:local .Run the production example stack:
cp .env.example .env
docker compose -f docker-compose.production.example.yml up --build -dThe app listens on http://localhost:8080 in the production example. The health endpoint is available at:
http://localhost:8080/health
See docs/deployment.md for production configuration notes, including environment variables and the limits of the training role fallback.
The production Compose example also persists ASP.NET Core Data Protection keys in a named volume so antiforgery tokens and future cookie-based features survive container restarts.
List/search flow:
GET /Documents?searchTerm=finance calls DocumentsController.Index, then DocumentService.SearchAsync, then DocumentRepository.SearchAsync. The repository runs parameterized SQL using @SearchTerm and @SearchPattern. Only rows with deleted_at_utc IS NULL are returned.
Create flow:
GET /Documents/Create shows a Razor form backed by DocumentCreateViewModel. POST /Documents/Create validates model state, calls DocumentService.CreateAsync, checks duplicate document numbers, then saves through DocumentRepository.CreateAsync inside a transaction.
Edit flow:
GET /Documents/Edit/{id} loads an editable view model only if the document exists and is not deleted. POST /Documents/Edit/{id} validates the view model, rejects deleted records, checks duplicate document numbers, updates UpdatedAtUtc, and saves through the repository.
Delete flow:
GET /Documents/Delete/{id} shows a confirmation page. POST /Documents/Delete/{id} calls DocumentService.SoftDeleteAsync, which requires DocumentAdmin, verifies the document still exists and is not already deleted, then sets DeletedAtUtc.
- The browser requests a route such as
/Documents/Create. - ASP.NET Core routing selects
DocumentsController. - The controller validates HTTP concerns such as model state, route IDs, and redirects.
- The service applies business rules such as uniqueness, deleted-record checks, status validation, role checks, and timestamps.
- The repository opens a PostgreSQL connection and executes parameterized Dapper SQL.
- The controller returns a Razor view or redirect.
- Razor renders HTML using the view model.
DocumentsController.Index: start here when tracing list/search.DocumentRepository.SearchAsync: inspect SQL parameters for server-side search.DocumentsController.CreatePOST: inspect model binding andModelState.DocumentService.CreateAsync: inspect data annotation validation and uniqueness checks.DocumentService.UpdateAsync: inspect deleted-record protection andUpdatedAtUtc.DocumentService.SoftDeleteAsync: inspect the role check and soft-delete rule.DocumentRepository.CreateAsync,UpdateAsync, andSoftDeleteAsync: inspect transaction usage.
- Controllers stay thin and do not contain SQL.
- Services contain business rules and return friendly validation errors.
- Repositories contain Dapper SQL only.
- SQL uses parameters for user input.
- Create/edit pages use view models, not the
Documentmodel directly. - POST actions have
[ValidateAntiForgeryToken]. - Document number uniqueness is checked in service code and enforced by the database.
- Edit rejects missing and deleted documents.
- Delete is a soft delete and requires
DocumentAdmin. - Logs contain IDs and outcomes, not sensitive form data.
- Tests cover success, validation failures, duplicate numbers, missing/deleted edit cases, delete role checks, SQL parameter use, and controller validation behavior.
- Adding Entity Framework Core even though the app uses Dapper.
- Putting SQL in controllers or services.
- Filtering search results only in JavaScript instead of on the server.
- Concatenating user input into SQL strings.
- Binding create/edit forms directly to the database model, causing overposting risk.
- Physically deleting rows instead of setting
DeletedAtUtc. - Forgetting
[ValidateAntiForgeryToken]on POST actions. - Trusting only UI validation and skipping service-level validation.
- Returning raw exception messages to users.
- Logging full submitted descriptions or other unnecessary user-entered data.