EthicsComputerCourses
Join training +91 90532 10052

Project: Appointment Booking App

ASP.NET Core · Lesson 29 of 29 Advanced 8 min read Updated

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

FeatureWhere
Doctors listRazor Page / and GET /api/doctors
Free slots for a doctor and dateGET /api/doctors/{id}/slots?date=... and the booking page
Booking with validationRazor Page /Book?doctorId=1 and POST /api/appointments
ConfirmationRazor Page /Confirmed?id=... and GET /api/appointments/{id}
RulesMonday to Saturday, slots from 10:00 to 12:45 every 15 minutes, no past slots, no double booking

Step 1: Create the project

Terminal
dotnet new webapp -o ClinicBooking
cd ClinicBooking
dotnet package add Microsoft.EntityFrameworkCore.Sqlite
dotnet package add Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-ef

Add the connection string to appsettings.json, next to the existing Logging section:

JSON
{
  "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:

C#
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:

C#
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:

  • TimeProvider is injected instead of using DateTime.Now, so tests can fix the time, as in Testing with xUnit & WebApplicationFactory.
  • FreeSlotsAsync loads only the booked times for one doctor and day, then removes them and any past slots from the 12 fixed slots.
  • BookAsync checks first (for a friendly answer in the common case) and still catches DbUpdateException for the rare race that only the unique index can stop.
  • BookingRequest is 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:

C#
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:

C#
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:

Terminal
dotnet ef migrations add InitialCreate
dotnet ef database update
dotnet run

Step 6: Try the API

With the app running (use your own port, and any weekday date that is still in the future):

Terminal
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"
Output
["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:

Razor
@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:

C#
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:

Razor
@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:

C#
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/Today page with Identity so reception can see the day's list (Authentication with ASP.NET Core Identity).
  • Replace the numeric ID in /Confirmed?id=1 with 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 DbUpdateException into 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 TimeProvider and test the critical rules with WebApplicationFactory and 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
C#
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();
}
Terminal
curl -i -X DELETE http://localhost:5080/api/appointments/1
curl "http://localhost:5080/api/doctors/1/slots?date=2026-11-20"
Output
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?

2. Why does AppointmentDto leave out the mobile number?

3. Why is BookingService registered as scoped?

4. Why does the integration test use SQLite in memory instead of the EF Core in-memory provider?

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