Project: Appointment Booking App
Time to put the course together. In this project you will build ClinicBooking, an appointment system for a small clinic in Pune with two doctors. Patients choose a doctor, pick a date and a free 15-minute slot, and book with their name and mobile number. The same rules are available as a JSON API for a future mobile app, and the database itself guarantees that no slot is ever booked twice.
You will use almost everything from the earlier lessons: a Razor Pages project, EF Core with SQLite and migrations, a service registered with dependency injection, a minimal API with TypedResults, ProblemDetails and .NET 10 validation, Razor Pages forms with model validation and Post/Redirect/Get, and an integration test. Build it step by step and run it after each step.
What you will build
| Feature | Where |
|---|---|
| Doctors list | Razor Page / and GET /api/doctors |
| Free slots for a doctor and date | GET /api/doctors/{id}/slots?date=... and the booking page |
| Booking with validation | Razor Page /Book?doctorId=1 and POST /api/appointments |
| Confirmation | Razor Page /Confirmed?id=... and GET /api/appointments/{id} |
| Rules | Monday to Saturday, slots from 10:00 to 12:45 every 15 minutes, no past slots, no double booking |
Step 1: Create the project
dotnet new webapp -o ClinicBooking
cd ClinicBooking
dotnet package add Microsoft.EntityFrameworkCore.Sqlite
dotnet package add Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-efAdd the connection string to appsettings.json, next to the existing Logging section:
{
"ConnectionStrings": {
"Clinic": "Data Source=clinic.db"
}
}Step 2: Entities and the DbContext
Create a Data folder. The entities are the same shape as in EF Core: DbContext & Models, and the context adds the unique index and seed data:
using Microsoft.EntityFrameworkCore;
namespace ClinicBooking.Data;
public class Doctor
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Speciality { get; set; }
public int FeeRupees { get; set; }
public bool IsActive { get; set; } = true;
}
public class Appointment
{
public int Id { get; set; }
public int DoctorId { get; set; }
public Doctor Doctor { get; set; } = null!;
public required string PatientName { get; set; }
public required string Mobile { get; set; }
public DateOnly Date { get; set; }
public TimeOnly Time { get; set; }
}
public class ClinicDb(DbContextOptions<ClinicDb> options) : DbContext(options)
{
public DbSet<Doctor> Doctors => Set<Doctor>();
public DbSet<Appointment> Appointments => Set<Appointment>();
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Doctor>().HasData(
new Doctor { Id = 1, Name = "Dr. Meera Kulkarni", Speciality = "General Physician", FeeRupees = 500 },
new Doctor { Id = 2, Name = "Dr. Farhan Shaikh", Speciality = "Dentist", FeeRupees = 700 });
model.Entity<Appointment>(a =>
{
a.Property(x => x.PatientName).HasMaxLength(60);
a.Property(x => x.Mobile).HasMaxLength(10);
a.HasIndex(x => new { x.DoctorId, x.Date, x.Time }).IsUnique();
});
}
}The unique index on doctor, date and time is the final safety net. Even if two patients press "Book" for the same slot in the same millisecond, the database accepts only one of them.
Step 3: The booking service
All rules live in one service, so the API and the pages cannot disagree. Create Services/BookingService.cs:
using System.ComponentModel.DataAnnotations;
using ClinicBooking.Data;
using Microsoft.EntityFrameworkCore;
namespace ClinicBooking.Services;
public class BookingRequest
{
[Range(1, int.MaxValue)]
public int DoctorId { get; set; }
[Required, StringLength(60, MinimumLength = 2)]
[Display(Name = "Patient name")]
public string PatientName { get; set; } = "";
[Required]
[RegularExpression(@"^[6-9]\d{9}$", ErrorMessage = "Enter a 10-digit Indian mobile number.")]
[Display(Name = "Mobile number")]
public string Mobile { get; set; } = "";
public DateOnly Date { get; set; }
public TimeOnly Time { get; set; }
}
public enum BookingStatus { Booked, DoctorNotFound, SlotNotAvailable }
public record BookingResult(BookingStatus Status, Appointment? Appointment = null);
public class BookingService(ClinicDb db, TimeProvider clock)
{
private static readonly TimeOnly FirstSlot = new(10, 0);
private const int SlotMinutes = 15;
private const int SlotCount = 12;
public static IEnumerable<TimeOnly> AllSlots() =>
Enumerable.Range(0, SlotCount).Select(i => FirstSlot.AddMinutes(i * SlotMinutes));
public async Task<List<TimeOnly>> FreeSlotsAsync(int doctorId, DateOnly date, CancellationToken ct = default)
{
DateTime now = clock.GetLocalNow().DateTime;
DateOnly today = DateOnly.FromDateTime(now);
if (date.DayOfWeek == DayOfWeek.Sunday || date < today)
{
return [];
}
List<TimeOnly> booked = await db.Appointments
.Where(a => a.DoctorId == doctorId && a.Date == date)
.Select(a => a.Time)
.ToListAsync(ct);
return AllSlots()
.Where(t => !booked.Contains(t))
.Where(t => date > today || t > TimeOnly.FromDateTime(now))
.ToList();
}
public async Task<BookingResult> BookAsync(BookingRequest request, CancellationToken ct = default)
{
Doctor? doctor = await db.Doctors
.FirstOrDefaultAsync(d => d.Id == request.DoctorId && d.IsActive, ct);
if (doctor is null)
{
return new BookingResult(BookingStatus.DoctorNotFound);
}
List<TimeOnly> free = await FreeSlotsAsync(doctor.Id, request.Date, ct);
if (!free.Contains(request.Time))
{
return new BookingResult(BookingStatus.SlotNotAvailable);
}
var appointment = new Appointment
{
Doctor = doctor,
PatientName = request.PatientName.Trim(),
Mobile = request.Mobile,
Date = request.Date,
Time = request.Time
};
db.Appointments.Add(appointment);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Another request booked this slot between our check and our insert
return new BookingResult(BookingStatus.SlotNotAvailable);
}
return new BookingResult(BookingStatus.Booked, appointment);
}
}Points to notice:
TimeProvideris injected instead of usingDateTime.Now, so tests can fix the time, as in Testing with xUnit & WebApplicationFactory.FreeSlotsAsyncloads only the booked times for one doctor and day, then removes them and any past slots from the 12 fixed slots.BookAsyncchecks first (for a friendly answer in the common case) and still catchesDbUpdateExceptionfor the rare race that only the unique index can stop.BookingRequestis an input model with data annotations, used by both the API and the page, so validation rules exist in one place.
Step 4: The JSON API
Create Api/ClinicApi.cs with the endpoints in one extension method:
using ClinicBooking.Data;
using ClinicBooking.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace ClinicBooking.Api;
public record DoctorDto(int Id, string Name, string Speciality, int FeeRupees);
public record AppointmentDto(int Id, string Doctor, string PatientName, DateOnly Date, string Time)
{
public static AppointmentDto From(Appointment a) =>
new(a.Id, a.Doctor.Name, a.PatientName, a.Date, a.Time.ToString("HH:mm"));
}
public static class ClinicApi
{
public static void MapClinicApi(this IEndpointRouteBuilder routes)
{
var api = routes.MapGroup("/api").WithTags("Clinic");
api.MapGet("/doctors", async (ClinicDb db, CancellationToken ct) =>
await db.Doctors.AsNoTracking()
.Where(d => d.IsActive)
.OrderBy(d => d.Id)
.Select(d => new DoctorDto(d.Id, d.Name, d.Speciality, d.FeeRupees))
.ToListAsync(ct));
api.MapGet("/doctors/{id:int}/slots",
async (int id, DateOnly date, BookingService booking, CancellationToken ct) =>
(await booking.FreeSlotsAsync(id, date, ct)).Select(t => t.ToString("HH:mm")));
api.MapPost("/appointments", CreateAsync);
api.MapGet("/appointments/{id:int}", GetAsync);
}
public static async Task<Results<Created<AppointmentDto>, ProblemHttpResult>> CreateAsync(
BookingRequest request, BookingService booking, CancellationToken ct)
{
BookingResult result = await booking.BookAsync(request, ct);
return result.Status switch
{
BookingStatus.Booked => TypedResults.Created(
$"/api/appointments/{result.Appointment!.Id}", AppointmentDto.From(result.Appointment)),
BookingStatus.DoctorNotFound => TypedResults.Problem(
"No such doctor.", statusCode: StatusCodes.Status404NotFound),
_ => TypedResults.Problem(
"That slot is not available. Please choose another.", statusCode: StatusCodes.Status409Conflict)
};
}
public static async Task<Results<Ok<AppointmentDto>, NotFound>> GetAsync(int id, ClinicDb db, CancellationToken ct)
{
Appointment? a = await db.Appointments.AsNoTracking()
.Include(x => x.Doctor)
.FirstOrDefaultAsync(x => x.Id == id, ct);
return a is null ? TypedResults.NotFound() : TypedResults.Ok(AppointmentDto.From(a));
}
}AppointmentDto deliberately leaves out the mobile number: an API should not return personal data the client does not need.
Step 5: Program.cs
Replace the template's Program.cs:
using ClinicBooking.Api;
using ClinicBooking.Data;
using ClinicBooking.Services;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddDbContext<ClinicDb>(o =>
o.UseSqlite(builder.Configuration.GetConnectionString("Clinic")));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<BookingService>();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.MapStaticAssets();
app.MapRazorPages().WithStaticAssets();
app.MapClinicApi();
app.Run();BookingService is scoped because it uses the scoped ClinicDb; TimeProvider.System is a singleton. AddValidation validates BookingRequest for the API before CreateAsync runs. Now create the database:
dotnet ef migrations add InitialCreate
dotnet ef database update
dotnet runStep 6: Try the API
With the app running (use your own port, and any weekday date that is still in the future):
curl "http://localhost:5080/api/doctors/1/slots?date=2026-11-20"
curl -X POST http://localhost:5080/api/appointments -H "Content-Type: application/json" -d '{"doctorId":1,"patientName":"Rohan Mehta","mobile":"9000000001","date":"2026-11-20","time":"10:30"}'
curl "http://localhost:5080/api/doctors/1/slots?date=2026-11-20"
curl "http://localhost:5080/api/doctors/1/slots?date=2026-11-22"["10:00","10:15","10:30","10:45","11:00","11:15","11:30","11:45","12:00","12:15","12:30","12:45"]
{"id":1,"doctor":"Dr. Meera Kulkarni","patientName":"Rohan Mehta","date":"2026-11-20","time":"10:30"}
["10:00","10:15","10:45","11:00","11:15","11:30","11:45","12:00","12:15","12:30","12:45"]
[]The booking returns 201 Created, 10:30 disappears from the free list, and 22 November 2026 is a Sunday, so it has no slots. Sending the same POST again returns status 409 with ProblemDetails whose detail is "That slot is not available. Please choose another.", and a POST with "mobile":"12345" returns 400 with the validation message before any booking code runs.
Step 7: The Razor Pages
Replace the body of Pages/Index.cshtml with a doctor list. Its page model loads DoctorDto records exactly like the API query:
@page
@model IndexModel
@{ ViewData["Title"] = "Shanti Clinic"; }
<h1>Book an appointment</h1>
@foreach (var d in Model.Doctors)
{
<div class="card mb-3 p-3">
<h2 class="h5">@d.Name</h2>
<p>@d.Speciality, consultation fee ₹@d.FeeRupees</p>
<a class="btn btn-primary" asp-page="/Book" asp-route-doctorId="@d.Id">Choose a slot</a>
</div>
}The booking page model reuses BookingService and BookingRequest:
using ClinicBooking.Data;
using ClinicBooking.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
namespace ClinicBooking.Pages;
public class BookModel(ClinicDb db, BookingService booking) : PageModel
{
[BindProperty]
public BookingRequest Input { get; set; } = new();
public string DoctorName { get; private set; } = "";
public List<SelectListItem> Slots { get; private set; } = [];
public async Task<IActionResult> OnGetAsync(int doctorId, DateOnly? date)
{
Input.DoctorId = doctorId;
Input.Date = date ?? DateOnly.FromDateTime(DateTime.Today.AddDays(1));
return await LoadAsync() ? Page() : NotFound();
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
BookingResult result = await booking.BookAsync(Input);
if (result.Status == BookingStatus.Booked)
{
return RedirectToPage("/Confirmed", new { id = result.Appointment!.Id });
}
ModelState.AddModelError("Input.Time", "That slot was just taken. Please choose another.");
}
return await LoadAsync() ? Page() : NotFound();
}
private async Task<bool> LoadAsync()
{
Doctor? doctor = await db.Doctors.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == Input.DoctorId && d.IsActive);
if (doctor is null)
{
return false;
}
DoctorName = doctor.Name;
Slots = (await booking.FreeSlotsAsync(doctor.Id, Input.Date))
.Select(t => new SelectListItem(t.ToString("HH:mm"), t.ToString("HH:mm")))
.ToList();
return true;
}
}And its view, with a GET form to change the date and a POST form to book:
@page
@model BookModel
@{ ViewData["Title"] = "Choose a slot"; }
<h1>Book with @Model.DoctorName</h1>
<form method="get">
<input type="hidden" name="doctorId" value="@Model.Input.DoctorId" />
<label>Date <input type="date" name="date" value="@Model.Input.Date.ToString("yyyy-MM-dd")" /></label>
<button type="submit">Show free slots</button>
</form>
@if (Model.Slots.Count == 0)
{
<p>No free slots on this day. Please choose another date.</p>
}
else
{
<form method="post">
<input type="hidden" asp-for="Input.DoctorId" />
<input type="hidden" asp-for="Input.Date" />
<label asp-for="Input.Time">Time</label>
<select asp-for="Input.Time" asp-items="Model.Slots"></select>
<span asp-validation-for="Input.Time"></span>
<label asp-for="Input.PatientName"></label>
<input asp-for="Input.PatientName" />
<span asp-validation-for="Input.PatientName"></span>
<label asp-for="Input.Mobile"></label>
<input asp-for="Input.Mobile" />
<span asp-validation-for="Input.Mobile"></span>
<button type="submit">Confirm booking</button>
</form>
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}Finally, Pages/Confirmed.cshtml loads the appointment by id (with Include(a => a.Doctor)) in OnGetAsync, returns NotFound() when it does not exist, and shows "Your appointment with Dr. Meera Kulkarni is confirmed for 20 Nov 2026 at 10:30. Please arrive 10 minutes early." The page flow follows Post/Redirect/Get: refreshing the confirmation page never books twice.
Step 8: Test the most important rule
Add a test project as in the testing lesson. Because the EF Core in-memory provider ignores unique indexes, the factory uses SQLite in memory with one open connection:
public class ClinicFactory : WebApplicationFactory<Program>
{
private readonly SqliteConnection _connection = new("Data Source=:memory:");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
_connection.Open();
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<ClinicDb>>();
services.AddDbContext<ClinicDb>(o => o.UseSqlite(_connection));
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_connection.Dispose();
}
}
public class BookingTests(ClinicFactory factory) : IClassFixture<ClinicFactory>
{
[Fact]
public async Task Same_slot_cannot_be_booked_twice()
{
using (var scope = factory.Services.CreateScope())
{
scope.ServiceProvider.GetRequiredService<ClinicDb>().Database.EnsureCreated();
}
var client = factory.CreateClient();
var body = new { doctorId = 2, patientName = "Kabir Singh", mobile = "9000000002",
date = "2030-01-07", time = "11:00" };
var first = await client.PostAsJsonAsync("/api/appointments", body, TestContext.Current.CancellationToken);
var second = await client.PostAsJsonAsync("/api/appointments", body, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
}EnsureCreated is fine here: it builds the schema and seed doctors in the throw-away test database. The date is a Monday far in the future, so the test does not start failing as time passes.
Ideas to extend the project
- Protect a
/Staff/Todaypage with Identity so reception can see the day's list (Authentication with ASP.NET Core Identity). - Replace the numeric ID in
/Confirmed?id=1with a random booking reference, so patients cannot guess other people's confirmations. - Add cancellation with
ExecuteDeleteAsync, a rate limit on bookings, and a SignalR display for the waiting room. - Add OpenAPI with
AddOpenApi()for the mobile team, then deploy with Docker or Nginx as in Deployment.
Key points
- Keep business rules in one service (
BookingService) used by both the API and the pages. - Let the database enforce uniqueness with an index, and turn
DbUpdateExceptioninto a friendly 409 or form error. - Use input models with data annotations for both minimal API validation and Razor Pages
ModelState. - Return DTOs from the API, leave out personal data the client does not need, and follow Post/Redirect/Get in pages.
- Inject
TimeProviderand test the critical rules withWebApplicationFactoryand SQLite in memory.
Exercise
Add appointment cancellation. Add DELETE /api/appointments/{id:int} that deletes the appointment and returns 204 No Content, or 404 if it does not exist, and refuses (409) to cancel appointments whose date is in the past. Then show that the cancelled slot appears again in the free slot list.
Show solution
api.MapDelete("/appointments/{id:int}", CancelAsync);
public static async Task<Results<NoContent, NotFound, ProblemHttpResult>> CancelAsync(
int id, ClinicDb db, TimeProvider clock, CancellationToken ct)
{
DateOnly today = DateOnly.FromDateTime(clock.GetLocalNow().DateTime);
Appointment? a = await db.Appointments.FirstOrDefaultAsync(x => x.Id == id, ct);
if (a is null)
{
return TypedResults.NotFound();
}
if (a.Date < today)
{
return TypedResults.Problem("Past appointments cannot be cancelled.",
statusCode: StatusCodes.Status409Conflict);
}
db.Appointments.Remove(a);
await db.SaveChangesAsync(ct);
return TypedResults.NoContent();
}curl -i -X DELETE http://localhost:5080/api/appointments/1
curl "http://localhost:5080/api/doctors/1/slots?date=2026-11-20"HTTP/1.1 204 No Content ["10:00","10:15","10:30","10:45","11:00","11:15","11:30","11:45","12:00","12:15","12:30","12:45"]
Other response headers are omitted. Because free slots are calculated from the appointments that exist, deleting the row is enough for 10:30 to appear again; no separate "slots" table needs updating.
Quick Quiz
1. Why does BookAsync catch DbUpdateException even after checking that the slot is free?
Check-then-insert has a race; the database constraint is the real guarantee.
2. Why does AppointmentDto leave out the mobile number?
Return the minimum data required, especially personal data.
3. Why is BookingService registered as scoped?
A service that uses a scoped context must not live longer than the request.
4. Why does the integration test use SQLite in memory instead of the EF Core in-memory provider?
SQLite is a real relational database, so constraints behave as in production.
Learn ASP.NET Core with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
