A .NET REST API for managing online courses, events, instructors, participants, and registrations. Demonstrates practical application of Domain-Driven Design, Clean Architecture, and production-grade testing practices.
- Architecture
- DDD Patterns
- Domain Model
- API Endpoints
- Database Strategy
- Caching
- Testing
- Getting Started
- Technology Stack
The solution is structured as four projects following Clean Architecture. Dependencies only point inward — infrastructure and presentation know about application and domain, but domain knows nothing about the others.
Presentation → Application → Domain
Infrastructure → Application → Domain
| Project | Responsibility |
|---|---|
Domain |
Business models, validation rules, Value Objects, repository contracts |
Application |
Services, DTOs (Input/Output), caching for lookup entities |
Infrastructure |
EF Core DbContext, repositories, entity configs, migrations |
Presentation |
Minimal API endpoints, request models, HTTP result mapping |
Domain/Modules/{Module}/Models/
Domain/Modules/{Module}/Contracts/I{Entity}Repository.cs
Domain/Common/ValueObjects/ ← shared Value Objects
Application/Modules/{Module}/I{Entity}Service.cs
Application/Modules/{Module}/{Entity}Service.cs
Application/Modules/{Module}/Inputs/
Application/Modules/{Module}/Outputs/
Application/Modules/{Module}/Caching/ ← lookup entities only
Infrastructure/Persistence/EFC/Repositories/
Infrastructure/Persistence/EFC/Configurations/
Infrastructure/Persistence/Entities/
Infrastructure/Persistence/ ← DatabaseSeeder, PersistenceDatabaseInitializer
Presentation/Endpoints/
Presentation/Models/{Module}/
Tests/Unit/Domain/
Tests/Unit/Application/
Tests/Integration/Infrastructure/
Tests/E2E/
Three Value Objects live in Domain/Common/ValueObjects/ and are used as property types on domain models:
| Value Object | Used on | Validation |
|---|---|---|
Email |
Participant.Email |
Regex format check, not null/whitespace |
PhoneNumber |
Participant.PhoneNumber |
Not null/whitespace |
Price |
CourseEvent.Price |
Non-negative decimal |
Each VO is:
- Sealed and immutable —
Valueis the only public member - Created via static factory —
Email.Create(value, paramName)throwsArgumentExceptionon invalid input - Structurally equal —
IEquatable<T>+==/!=operator overloads - JSON-serializable — inline
JsonConverterensures round-trip through System.Text.Json without manual configuration
// Domain model using VOs
public Email Email { get; private set; }
public PhoneNumber PhoneNumber { get; private set; }
// Repository extracting primitives for EF
entity.Email = participant.Email.Value;All domain models share a single validation path. Constructors and Update() both delegate to a private SetValues() method, ensuring invariants are enforced regardless of how the model is created or mutated.
// [JsonConstructor] — used by deserializer, accepts VO types
private Participant(Guid id, string firstName, ..., Email email, PhoneNumber phoneNumber, ...)
=> SetValues(firstName, ..., email, phoneNumber, ...);
// Factory — takes primitives, creates VOs, calls constructor
public static Participant Create(string firstName, ..., string email, string phoneNumber, ...)
=> new(Guid.NewGuid(), firstName, ..., Email.Create(email, nameof(email)), ...);
// Reconstitute — hydrates from DB, same shape as Create but with existing ID
public static Participant Reconstitute(Guid id, string firstName, ..., string email, ...) => ...;
// Update — mutates existing instance via the same SetValues path
public void Update(string firstName, ..., string email, string phoneNumber, ...)
=> SetValues(firstName, ..., Email.Create(email, nameof(email)), ...);Reconstitute(...)is used exclusively by repositories — it bypasses side effects a freshCreatemight trigger (e.g. raising domain events in future)- Service update flow:
existing = await repo.GetAsync(id)→existing.Update(...)→await repo.UpdateAsync(existing)
All service methods return ResultBase or ResultBase<T> instead of throwing exceptions for control flow. Endpoints call .ToHttpResult() which maps to the correct HTTP status code.
// Service
if (existing is null) return ResultBase.NotFound("Course not found.");
return ResultBase.Ok(MapToResult(existing));
// Endpoint
var result = await service.GetCourseAsync(id);
return result.ToHttpResult(); // → 200 OK or 404 Not FoundAvailable result states: Ok, NotFound, BadRequest, Conflict, Unprocessable.
RepositoryBase<TModel, TId, TEntity, TDbContext> provides default CRUD operations. Each concrete repository implements:
ToEntity(model)— maps domain model → EF entity (extracts.Valuefrom VOs)ToModel(entity)— maps EF entity → domain model viaReconstitute(...)(includes required lookups viaInclude(...), fails fast if missing)
Transactions are used explicitly in repositories with multi-step atomic operations (CourseRegistrationRepository, CourseEventRepository, ParticipantRepository). Raw SQL (Database.SqlQuery<T> / Database.ExecuteSqlAsync) is used only for seat-availability checks and relation-table cleanup in those same repositories.
| Entity | Key Properties |
|---|---|
Course |
Title, Description, DurationInDays |
CourseEvent |
CourseId, EventDate, Price (VO), Seats, CourseEventTypeId, VenueTypeId |
CourseEventType |
Name (lookup) |
CourseRegistration |
CourseEventId, ParticipantId, StatusId, PaymentMethodId, RegisteredAt |
CourseRegistrationStatus |
Name (lookup) |
Participant |
FirstName, LastName, Email (VO), PhoneNumber (VO), ContactTypeId |
ParticipantContactType |
Name (lookup) |
Instructor |
Name, RoleId |
InstructorRole |
Name (lookup) |
Location |
StreetName, PostalCode, City |
InPlaceLocation |
LocationId, RoomName, Capacity |
PaymentMethod |
Name (lookup) |
VenueType |
Name (lookup) |
All endpoints are prefixed with /api. OpenAPI spec available at /openapi/v1.json.
| Resource | Endpoints |
|---|---|
| Courses | GET /api/courses · GET /api/courses/{id} · POST · PUT /{id} · DELETE /{id} |
| Course Events | GET /api/course-events · GET /{id} · GET /course/{courseId} · POST · PUT /{id} · DELETE /{id} |
| Course Event Types | Full CRUD |
| Course Registrations | GET (all/by id/by participant/by event) · POST · PUT /{id} · DELETE /{id} |
| Course Registration Statuses | Full CRUD |
| Participants | Full CRUD |
| Participant Contact Types | Full CRUD |
| Instructors | Full CRUD |
| Instructor Roles | Full CRUD |
| Locations | Full CRUD |
| In-Place Locations | GET (all/by id/by location) · POST · PUT /{id} · DELETE /{id} |
| Payment Methods | GET (all/by id/by name) · POST · PUT /{id} · DELETE /{id} |
| Venue Types | GET (all/by id/by name) · POST · PUT /{id} · DELETE /{id} |
The infrastructure layer switches database provider based on environment:
| Environment | Provider | Schema init |
|---|---|---|
| Development | SQLite in-memory (shared cache) | EnsureCreatedAsync |
| Production | SQL Server | MigrateAsync |
| Tests | SQLite in-memory (via DB_PROVIDER=Sqlite) |
EnsureCreatedAsync |
ContextRegistrationExtension checks env.IsDevelopment() to register the correct provider. A singleton SqliteConnection is kept open for the lifetime of the dev app so the in-memory database survives across requests.
DatabaseSeeder seeds reference data in both dev and production on startup, in dependency order:
- Fixed-ID lookup tables (VenueTypes, PaymentMethods, etc.)
- Auto-ID lookup tables
- Transactional data (Courses, Events, Participants, Registrations)
Seeding is idempotent — guarded by AnyAsync() checks before inserting. It runs via PersistenceDatabaseInitializer.InitializeAsync called from Program.cs after app.Build().
SQL Server-specific migrations live in Infrastructure/Persistence/EFC/Migrations/. They are only applied in production. Dev and test environments use EnsureCreatedAsync — no migrations needed.
Configurations take a bool isSqlite constructor parameter to handle the concurrency token difference between providers:
if (isSqlite)
e.Property(x => x.Concurrency).IsConcurrencyToken().IsRequired(false);
else
e.Property(x => x.Concurrency).IsRowVersion().IsConcurrencyToken().IsRequired();Frequently-read, rarely-changed lookup entities (venue types, payment methods, etc.) are cached in memory by the Application layer. Each entity can be looked up by ID, by name, or as a full list — and all cached entries for an entity are cleared on any write.
One thing worth noting: when renaming an entity, the cache has to be told to remove the old name before the entity is updated. The eviction reads the name off the object, so if the update happens first, it evicts the wrong key and the old name stays stale in cache. The service layer enforces the correct order, and there are tests that verify this.
All tests passing.
| Type | Coverage | Technology |
|---|---|---|
| Unit | Domain models, services, Value Objects, input DTOs | NSubstitute mocks, no DB |
| Cache integration | Cache eviction and lookup behaviour | Real MemoryCache, no DB |
| Integration | Every repository | SQLite in-memory via SqliteInMemoryFixture |
| E2E | Every endpoint | WebApplicationFactory + SQLite in-memory, HTTP round-trips |
- Domain model tests: constructor,
Update(...),Reconstitute(...), all validation paths - Value Object tests:
Email,PhoneNumber,Price— valid/invalid inputs, equality, operators - Application service tests: all CRUD flows, result states, NSubstitute mocks for repositories
- Input DTO validation tests
Regular unit tests use mocks, which only check whether a method was called — not what state the data was in when it was called. For caching, that's not enough: the order of operations matters.
These tests use a real MemoryCache instead of a mock, so they can actually check what ends up stored and what gets evicted. For example, after renaming an entity, the test looks up the old name and the new name directly in the cache to confirm the old one is gone and the new one is there.
There's also a test that deliberately does things in the wrong order to show the bug it would cause — proving the correct order is actually necessary, not just a style choice.
- Real EF Core DbContext against SQLite in-memory
- Every repository tested: CRUD, ordering, filtering, constraint violations
SqliteInMemoryFixturesetsDB_PROVIDER=Sqlite; infrastructure detects this and uses SQLite automatically
- Full HTTP request/response via
WebApplicationFactory - Data is deleted and reseeded between tests via raw SQL for isolation
- Covers every endpoint: status codes, response shape, ordering, error responses
# Run all
dotnet test
# By type
dotnet test Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit"
dotnet test Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Integration"
dotnet test Tests/Tests.csproj --filter "FullyQualifiedName~Tests.E2E"
# Single test class
dotnet test Tests/Tests.csproj --filter "FullyQualifiedName~CourseEvent_Tests"- .NET SDK
- SQL Server (for production profile)
dotnet restore
dotnet dev-certs https --trust
dotnet run --project PresentationThe dev profile uses SQLite in-memory and seeds data automatically on startup.
- API:
https://localhost:7118/http://localhost:5400 - OpenAPI:
https://localhost:7118/openapi/v1.json
-
Set the connection string in
Presentation/appsettings.json:{ "ConnectionStrings": { "CoursesOnlineDatabase": "Data Source=localhost;Initial Catalog=CoursesOnline;..." } } -
Apply migrations:
dotnet ef database update --project Infrastructure --startup-project Presentation
-
Run with the production profile:
dotnet run --project Presentation --launch-profile https-prod
| Concern | Technology |
|---|---|
| Framework | ASP.NET Core — Minimal API |
| ORM | Entity Framework Core |
| Database (prod) | SQL Server |
| Database (dev/test) | SQLite in-memory |
| Testing | xUnit, NSubstitute, WebApplicationFactory |
| API docs | OpenAPI (built-in ASP.NET Core) |
| Nullable reference types | Enabled project-wide |