Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
395 changes: 395 additions & 0 deletions restaurant-reservations-clean-architecture/.editorconfig

Large diffs are not rendered by default.

482 changes: 482 additions & 0 deletions restaurant-reservations-clean-architecture/.gitignore

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project>
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
<PropertyGroup>


</PropertyGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<Project>
<PropertyGroup>
<!-- Enable central package management, https://learn.microsoft.com/en-us/nuget/consume-packages/Central-Package-Management -->
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageVersion Include="MailKit" Version="4.17.0" />
<PackageVersion Include="MediatR" Version="12.5.0" />
<PackageVersion Include="MediatR.Contracts" Version="2.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.8.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="MimeKit" Version="4.17.0" />
<PackageVersion Include="NetArchTest.Rules" Version="1.3.2" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageVersion Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Sinks.OpenTelemetry" Version="4.2.0" />
<PackageVersion Include="Serilog.Sinks.Seq" Version="9.1.0" />
<PackageVersion Include="System.Linq.Dynamic.Core" Version="1.7.3" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageVersion Include="Testcontainers.Redis" Version="4.13.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0-pre.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
</Project>
23 changes: 23 additions & 0 deletions restaurant-reservations-clean-architecture/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build-env
WORKDIR /src

COPY *.slnx ./
COPY Directory.Packages.props ./

COPY src/RestaurantReservation.Domain/*.csproj ./src/RestaurantReservation.Domain/
COPY src/RestaurantReservation.Application/*.csproj ./src/RestaurantReservation.Application/
COPY src/RestaurantReservation.Infrastructure/*.csproj ./src/RestaurantReservation.Infrastructure/
COPY src/RestaurantReservation.Api/*.csproj ./src/RestaurantReservation.Api/

RUN dotnet restore src/RestaurantReservation.Api/RestaurantReservation.Api.csproj

COPY . .

RUN dotnet publish src/RestaurantReservation.Api/RestaurantReservation.Api.csproj -c Release -o /app/out

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build-env /app/out .

EXPOSE 8080
ENTRYPOINT ["dotnet", "RestaurantReservation.Api.dll"]
110 changes: 110 additions & 0 deletions restaurant-reservations-clean-architecture/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Restaurant Reservation API

This is a backend API for managing restaurant reservations following the Clean Architecture Principles. This is a learning project from the [C# Academy Website](https://thecsharpacademy.com/project/100008/system-design-clean-architecture-reservations). It handles real-world business concerns such as seating parties across multiple tables if their party size exceeds the size of a single table, as well as preventing race-condition double bookings at the database level.

## Technogies Used
- .NET10/ASP.NET Core
- PostgreSQL 17 - database/persistence
- Entity Framework Core - ORM, Code-First migrations, Fluent API
- MediatR 12 - CQRS-style command/query (request response) pipeline
- FluentValidation - command validation
- ASP.NET Core Identity - authentication, JWT bearer tokens
- Redis/Hybrid Cache - caching to improve API response times
- Seq - structured logging (via Serilog)
- MailKit/Mailpit - for sending email notifications after reservation scheduling/rescheduling/cancellation/completion /local SMTP testing
- Docker & Docker Compose - containerization
- xUnit, FluentAssertions, TestContainers - for architecture/integration testing

**Getting Started**
You can run this project via Docker Compose, which spins up the API as well as PostgreSQL, Redis, Seq and Mailpit.
Clone the repo and navigate to the solution root directory and run the following command:

```bash
docker compose up -d --build
```

***Once everything is up and running:***

| Service | URL |
|------------------------------------|---------------------------------|
| API (Scalar for testing endpoints) | http://localhost:8080/scalar/v1 |
| Seq (logs) | http://localhost:8081 |
| Mailpit (email) | http://localhost:8025 |


There is a default admin and customer user seeded into the database as well as restaurants/tables etc.

***Default User Information***

| Username | Password |
|------------------------------------|------------------------|
| admin@example.com | Pa$$w0rd |
| customer@example.com | Pa$$w0rd |

***Architecture Overview***
This project follows Clean Architecture Principals (with some Domain-Driven-Design thrown in). This application is organized into four layers:
- ***Domain*** - Core business entiries and rules (Restaurant, Table, TableGroup, Reservation etc.). This layer has no dependencies on any other layers. Business invariants (e.g. A table can only be added to a table group if they belong to the same restaurant) are enforced on this layer.
- ***Application*** - Depends only on the Domain Layer. Use cases are implemented at this layer via MediatR commands and queries (CQRS).
- ***Infrastructure*** - Depends on the Application and Domain layers. This layer handles Database persistence via EF Core as well as Email, Identity/authentication. This layer implements interfaces defined at the Application layer.
- ***API*** - Depends on the Application and Infrastructure layers. This is the ASP.NET Core Web API layer. It is implemented using Minimal API endpoints, request/response mapping as well as mapping errors/failure responses to Problem Details. This layer also handles the main wiring of dependency injection.

Dependencies flow inward:
```mermaid
graph TD
A[API Layer] --> B[Infrastructure Layer]
B --> C[Application Layer]
C --> D[Domain Layer]
```
Dependencies are enforeced via a dedicated architecture test suite.

## Key Design Decisions
***Multi-Table Reservations***
The original design rejected any reservation if no single table could seat the full party — a party of 10 would be turned away even if two free 6-seat tables sat right next to each other. Real restaurants don't work this way: staff combine adjacent tables to seat larger parties.
Rather than computing table combinations dynamically, this project models combinability as pre-approved, restaurant-configured TableGroups — a known, finite set of tables that are physically allowed to be pushed together. Availability search checks single tables first, and only falls back to checking TableGroups (requiring all member tables to be free) if no single table fits. This keeps the search cheap and predictable — a simple loop over a small list, while still matching how restaurants actually operate.

***Preventing Double-Bookings at the Database Level***
Checking table availability in application code before inserting a reservation leaves a race-condition window: two near-simultaneous requests can both see a table as free and both succeed, double-booking it. Rather than relying solely on optimistic concurrency (which protects against conflicting updates, not conflicting inserts), this project uses a PostgreSQL EXCLUDE constraint on the reservation_tables table, enforced via a GiST index, to reject any overlapping reservation for the same table at the database level. This was done by creating a new migration and editing the migration file with raw PostgreSQL:
```postgresql
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE reservation_tables
ADD CONSTRAINT no_overlapping_table_reservations
EXCLUDE USING gist (
table_id WITH =,
tsrange(
scheduled_reservation_reservation_day + scheduled_reservation_reservation_start,
scheduled_reservation_reservation_day + scheduled_reservation_reservation_end
) WITH &&);
```
After which the migration was applied.
This is verified by an integration test that fires two concurrent reservation attempts for the same table and confirms exactly one succeeds.

***PostgreSQL over SQL Server***
Beyond PostgreSQL's technical merits, this choice was also deliberate for a couple of reasons First, gaining experience with more than one database provider is something that I feel is very important for a developer, even though EF Core abstracts much of the actual SQL, I still felt it was important to use a different database provider than previous projects. Second, C# and .NET still carry a lingering reputation as a "Windows-only" ecosystem, despite .NET Core being open-source and cross-platform. This entire project was built on a GNU/Linux distribution, using .NET Core/ASP.NET Core and an open-source database to demonstrate that modern .NET applications aren't tied to any single vendor or operating system.

***Mailpit over a Real Email Provider***
Since this is a learning project and not an actual restaurant reservation system, using a real email provider (e.g., Gmail via an app password) would add setup friction without adding real value — every reviewer would need to configure their own credentials just to see email notifications work. Using MailKit: reservation confirmations and updates are sent exactly as they would be in production, but anyone running the project can simply open Mailpit's web UI and see the emails arrive in real time, with zero configuration required.

## Testing
This project uses both Architecture tests to ensure/enfore dependencies and Integration tests to test the entire application stack. The decision to exclude Unit Tests was deliberate for the reason being as I was initially writing them. I realized I was testing much of the same logic that was better tested via integration tests.

# What Else Is Implemented
- Pagination
- Sorting
- Filtering
- Result Pattern

# A Note About Scalar
For those endpoints that require authention/authorization there will be a drop-down that says ***Auth Type*** please be sure to select ***Bearer*** and then paste the JWT Token where it says ***Bearer Token***.

## Things I Learned
- Clean Architecture - This was obviously the most important aspect of this project/application. I learned how dependencies should flow, and how separating different concerns (e.g. business logic, from database logic) makes for a much cleaner codebase.
- Docker - This was perhaps the second most important aspect of this project, or possibly it can be considered as tied for first with Clean Architecture. This is the first project from the C# Academy that I have personally done that required the use of docker. At first this was challenging because it was admittedly new to me, but once I got the hang of it and actually dockerized my own application and ran it entirely containerized I saw both the value and power of using Docker. It is also true as I read that Docker solved the age-old problem of "It works on my machine but not on others".
- Caching - Caching was not an explict require for this project but in my research I saw the value of caching and wanted to implement it in this project. I decided to use the Hybrid Cache approach available in .NET which allows you to combine local caching with an external caching service (Redis).
- Problem Details - Learning about problem details and structuring standardized, consistent and easily parseable error messages to inform the end user of any issues with the system.
- Domain Driven Design - In my study of Clean Architecture, I learned that it is at times combined with Domain Driven Design. I will not say that this project adhers strictly to DDD, but I tried to implement it as best as I could where I could.

## Areas To Improve Upon
There is always room from improvement especially in software development. While I have learned a tremendous amount and have made many improvements in my own skillset while implementing this project. There is always so much more to learn. I know there are areas of this project that may not be optimal or could have been implemented better. So as with each project I will take what I learned and apply it further and keep learning and improving.

## Helpful Resources Used
[codewithmukesh](https://codewithmukesh.com/courses/dotnet-webapi-zero-to-hero/lessons/)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Solution>
<Folder Name="/SolutionItems/">
<File Path=".editorconfig" />
<File Path="Directory.Build.props" />
<File Path="Directory.Packages.props" />
<File Path="docker-compose.yml" />
<File Path="Dockerfile" />
<File Path="README.md" />
</Folder>
<Folder Name="/src/">
<Project Path="src/RestaurantReservation.Api/RestaurantReservation.Api.csproj" />
<Project Path="src/RestaurantReservation.Application/RestaurantReservation.Application.csproj" />
<Project Path="src/RestaurantReservation.Domain/RestaurantReservation.Domain.csproj" />
<Project Path="src/RestaurantReservation.Infrastructure/RestaurantReservation.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/RestaurantReservation.ArchitectureTests/RestaurantReservation.ArchitectureTests.csproj" />
<Project Path="tests/RestaurantReservation.IntegrationTests/RestaurantReservation.IntegrationTests.csproj" />
</Folder>
</Solution>
84 changes: 84 additions & 0 deletions restaurant-reservations-clean-architecture/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: api
ports:
- "8080:8080"
environment:
ASPNETCORE_HTTP_PORTS: 8080
ASPNETCORE_ENVIRONMENT: Docker
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=reservationDB;Username=postgres;Password=postgres"
ConnectionStrings__Redis: "redis:6379,password=redis"
Seq__ServerUrl: "http://seq:5341"
Smtp__Host: "mailpit"
Smtp__Port: "1025"
depends_on:
postgres:
condition: "service_healthy"
redis:
condition: "service_started"
seq:
condition: "service_started"
mailpit:
condition: "service_started"
postgres:
image: postgres:17-alpine
container_name: postgres
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: reservationDB
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d reservationDB"]
interval: 10s
timeout: 5s
retries: 5

seq:
image: datalust/seq:latest
container_name: seq
environment:
ACCEPT_EULA: Y
SEQ_FIRSTRUN_NOAUTHENTICATION: "true"
ports:
- "5341:5341"
- "8081:80"
volumes:
- seq-data:/data

mailpit:
image: axllent/mailpit
container_name: mailpit
restart: unless-stopped
environment:
MP_MAX_MESSAGES: 5000
MP_DATABASE: /data/mailpit.db
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
ports:
- "8025:8025"
- "1025:1025"
volumes:
- mailpit-data:/data

redis:
image: redis:latest
container_name: redis
ports:
- "6379:6379"
command: redis-server --requirepass redis --appendonly yes
volumes:
- redis-data:/data

volumes:
postgres_data:
seq-data:
mailpit-data:
redis-data:
Loading