Skip to main content

Command Palette

Search for a command to run...

Getting Started with the Vertical Slice Architecture Template for .NET 10

Updated
8 min read
N

I am hands-on Software Architect and Tech Lead with over 17 years of experience designing, developing and deploying various commercial software architectures on the full stack.

I built this template because I was tired of creating the same boilerplate every time I started a new project. After writing about Vertical Slice Architecture and seeing the pattern gain traction, I wanted a starting point that showed how all the pieces fit together in a real domain—not another todo app.

This guide walks you through cloning the template, running it, understanding its structure, and adding your first feature.

What's Included in the Template

The Vertical Slice Architecture template models a medical clinic appointment scheduling system. Patients book appointments with doctors, and the system enforces real-world constraints: no double-booking, appointments scheduled in advance, and controlled state transitions.

Features implemented:

FeatureEndpointWhat It Does
Book AppointmentPOST /api/appointmentsCreates appointment with conflict detection
Get AppointmentsGET /api/appointmentsFiltered, paginated list
Get by IDGET /api/appointments/{id}Single appointment with details
CompletePOST /api/appointments/{id}/completeMarks appointment done
CancelPOST /api/appointments/{id}/cancelCancels with required reason

Tech stack:

  • .NET 10 Minimal APIs
  • MediatR for request/response pattern
  • FluentValidation for declarative validation
  • ErrorOr for result pattern error handling
  • Entity Framework Core 10 (in-memory or SQL Server)
  • xUnit + FluentAssertions for testing

The template has over 540 stars on GitHub. It's not production-ready as-is. Think of it as a learning tool that shows patterns you can adapt for your own projects.

Prerequisites

.NET 10 SDK

Download from dot.net. Verify your installation:

dotnet --version
# Should output 10.0.x

IDE Options

Pick what works for you:

  • Visual Studio 2022 (17.8+) — Full IDE experience, best debugging
  • JetBrains Rider — Cross-platform, strong refactoring tools
  • VS Code — Lightweight, free

Optional: Docker for SQL Server

The template runs with an in-memory database by default—no setup required. If you want persistent data with SQL Server, you'll need Docker:

docker pull mcr.microsoft.com/azure-sql-edge:latest

Azure SQL Edge works on all platforms including Apple Silicon.

Installation and Setup

Clone the Repository

git clone https://github.com/nadirbad/VerticalSliceArchitecture.git
cd VerticalSliceArchitecture

Database Configuration

Option 1: In-Memory (Default)

Nothing to configure. The template uses an in-memory database by default. Sample data gets seeded automatically in development mode.

Option 2: SQL Server

Start a SQL Server container:

docker run --cap-add SYS_PTRACE -e 'ACCEPT_EULA=1' -e 'MSSQL_SA_PASSWORD=yourStrong(!)Password' -p 1433:1433 --name azuresqledge -d mcr.microsoft.com/azure-sql-edge

Update src/Api/appsettings.json:

{
  "UseInMemoryDatabase": false,
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=VerticalSliceDb;User Id=sa;Password=yourStrong(!)Password;TrustServerCertificate=True"
  }
}

Apply migrations:

dotnet ef database update --project src/Application --startup-project src/Api

Running the API

dotnet run --project src/Api/Api.csproj

Open https://localhost:7098 in your browser. Swagger UI loads at the root with sample patient and doctor IDs ready to use.

Try booking an appointment:

curl -X POST https://localhost:7098/api/appointments \
  -H "Content-Type: application/json" \
  -d '{
    "patientId": "11111111-1111-1111-1111-111111111111",
    "doctorId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
    "start": "2027-01-20T09:00:00Z",
    "end": "2027-01-20T09:30:00Z",
    "notes": "Annual checkup"
  }'

Exploring the Codebase

Solution Structure

src/
├── Api/                          # ASP.NET Core entry point
│   └── Program.cs                # Minimal hosting, Swagger config
│
└── Application/                  # All features and domain logic
    ├── Scheduling/               # Feature slices
    │   ├── BookAppointment.cs
    │   ├── CancelAppointment.cs
    │   ├── CompleteAppointment.cs
    │   ├── GetAppointments.cs
    │   └── GetAppointmentById.cs
    │
    ├── Domain/                   # Entities and domain events
    │   ├── Appointment.cs
    │   ├── Patient.cs
    │   └── Doctor.cs
    │
    ├── Common/                   # Shared infrastructure
    │   └── Behaviours/           # MediatR pipeline behaviors
    │
    └── Infrastructure/
        └── Persistence/          # EF Core DbContext

Notice the Scheduling/ folder. Each file is a complete feature—command, validator, handler, and endpoint in one place. No jumping between Controllers, Services, and Repositories folders. For a deeper dive into folder organization options, see VSA Folder Structure: 4 Approaches Compared.

Feature Anatomy

Open BookAppointment.cs. A single file contains four components:

public static class BookAppointment
{
    // 1. Command - What the caller sends
    public record Command(
        Guid PatientId,
        Guid DoctorId,
        DateTimeOffset Start,
        DateTimeOffset End,
        string? Notes) : IRequest<ErrorOr<Result>>;

    // 2. Result - What the caller gets back
    public record Result(Guid Id, DateTime StartUtc, DateTime EndUtc);

    // 3. Endpoint - HTTP binding
    internal static class Endpoint
    {
        public static async Task<IResult> Handle(
            Command command,
            ISender mediator)
        {
            var result = await mediator.Send(command);
            return result.Match(
                success => Results.Created($"/api/appointments/{success.Id}", success),
                errors => MinimalApiProblemHelper.Problem(errors));
        }
    }

    // 4. Validator - Input validation
    internal sealed class Validator : AbstractValidator<Command>
    {
        public Validator()
        {
            RuleFor(v => v.PatientId).NotEmpty();
            RuleFor(v => v.DoctorId).NotEmpty();
            RuleFor(v => v.Start).LessThan(v => v.End);
            // ... more rules
        }
    }

    // 5. Handler - Business logic
    internal sealed class Handler : IRequestHandler<Command, ErrorOr<Result>>
    {
        public async Task<ErrorOr<Result>> Handle(
            Command request,
            CancellationToken cancellationToken)
        {
            // Check conflicts, create appointment, save
        }
    }
}

Everything for booking an appointment lives in one file. When requirements change, you modify one file. When you review a PR, you see the complete context.

Testing Setup

Tests mirror the feature structure:

dotnet test

# Unit tests - validators and domain logic
dotnet test tests/Application.UnitTests

# Integration tests - API endpoints
dotnet test tests/Application.IntegrationTests

Unit tests use FluentValidation's TestValidate helper:

public class BookAppointmentValidatorTests
{
    private readonly BookAppointment.Validator _validator = new();

    [Fact]
    public void Should_Have_Error_When_PatientId_Is_Empty()
    {
        var command = new BookAppointment.Command(
            Guid.Empty, Guid.NewGuid(),
            DateTimeOffset.UtcNow.AddHours(1),
            DateTimeOffset.UtcNow.AddHours(2),
            null);

        var result = _validator.TestValidate(command);
        result.ShouldHaveValidationErrorFor(x => x.PatientId);
    }
}

Creating Your First Feature

Let's add a RescheduleAppointment feature that moves an existing appointment to a new time slot.

Step 1: Create the Feature File

Create src/Application/Scheduling/RescheduleAppointment.cs:

using ErrorOr;
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using VerticalSliceArchitecture.Application.Common;
using VerticalSliceArchitecture.Application.Domain;
using VerticalSliceArchitecture.Application.Infrastructure.Persistence;

namespace VerticalSliceArchitecture.Application.Scheduling;

public static class RescheduleAppointment
{
    public record Command(
        Guid AppointmentId,
        DateTimeOffset NewStart,
        DateTimeOffset NewEnd) : IRequest<ErrorOr<Result>>;

    public record Result(Guid Id, DateTime StartUtc, DateTime EndUtc);

    internal static class Endpoint
    {
        public static async Task<IResult> Handle(
            Guid appointmentId,
            Command command,
            ISender mediator)
        {
            if (appointmentId != command.AppointmentId)
            {
                return Results.BadRequest(new { error = "Route ID does not match command" });
            }

            var result = await mediator.Send(command);
            return result.Match(
                success => Results.Ok(success),
                errors => MinimalApiProblemHelper.Problem(errors));
        }
    }

    internal sealed class Validator : AbstractValidator<Command>
    {
        public Validator()
        {
            RuleFor(v => v.AppointmentId).NotEmpty();
            RuleFor(v => v.NewStart).LessThan(v => v.NewEnd)
                .WithMessage("Start time must be before end time");
            RuleFor(v => v.NewStart)
                .Must(start => start > DateTimeOffset.UtcNow.AddMinutes(15))
                .WithMessage("Must reschedule at least 15 minutes in advance");
        }
    }

    internal sealed class Handler(ApplicationDbContext context)
        : IRequestHandler<Command, ErrorOr<Result>>
    {
        public async Task<ErrorOr<Result>> Handle(
            Command request,
            CancellationToken cancellationToken)
        {
            var appointment = await context.Appointments
                .FirstOrDefaultAsync(a => a.Id == request.AppointmentId, cancellationToken);

            if (appointment is null)
            {
                return Error.NotFound("Appointment.NotFound",
                    $"Appointment {request.AppointmentId} not found");
            }

            if (appointment.Status != AppointmentStatus.Scheduled)
            {
                return Error.Validation("Appointment.CannotReschedule",
                    "Only scheduled appointments can be rescheduled");
            }

            // Check for conflicts at new time
            var hasConflict = await context.Appointments
                .AnyAsync(a =>
                    a.Id != request.AppointmentId &&
                    a.DoctorId == appointment.DoctorId &&
                    a.Status == AppointmentStatus.Scheduled &&
                    a.StartUtc < request.NewEnd.UtcDateTime &&
                    a.EndUtc > request.NewStart.UtcDateTime,
                    cancellationToken);

            if (hasConflict)
            {
                return Error.Conflict("Appointment.Conflict",
                    "Doctor has a conflicting appointment at the new time");
            }

            // Update times (you'd add a Reschedule method to the domain entity)
            // For now, this shows the pattern
            await context.SaveChangesAsync(cancellationToken);

            return new Result(appointment.Id, appointment.StartUtc, appointment.EndUtc);
        }
    }
}

Step 2: Register the Endpoint

Open src/Application/Scheduling/AppointmentEndpoints.cs and add:

group.MapPost("/{appointmentId:guid}/reschedule", RescheduleAppointment.Endpoint.Handle)
    .WithName("RescheduleAppointment")
    .WithSummary("Reschedule an existing appointment")
    .Produces<RescheduleAppointment.Result>()
    .ProducesProblem(StatusCodes.Status404NotFound)
    .ProducesProblem(StatusCodes.Status409Conflict);

Step 3: Add a Test

Create tests/Application.UnitTests/Scheduling/RescheduleAppointmentValidatorTests.cs:

using FluentValidation.TestHelper;
using VerticalSliceArchitecture.Application.Scheduling;

namespace VerticalSliceArchitecture.Application.UnitTests.Scheduling;

public class RescheduleAppointmentValidatorTests
{
    private readonly RescheduleAppointment.Validator _validator = new();

    [Fact]
    public void Should_Have_Error_When_Start_After_End()
    {
        var command = new RescheduleAppointment.Command(
            Guid.NewGuid(),
            DateTimeOffset.UtcNow.AddHours(2),
            DateTimeOffset.UtcNow.AddHours(1));

        var result = _validator.TestValidate(command);
        result.ShouldHaveValidationErrorFor(x => x.NewStart);
    }

    [Fact]
    public void Should_Not_Have_Error_When_Valid()
    {
        var start = DateTimeOffset.UtcNow.AddHours(1);
        var command = new RescheduleAppointment.Command(
            Guid.NewGuid(), start, start.AddHours(1));

        var result = _validator.TestValidate(command);
        result.ShouldNotHaveAnyValidationErrors();
    }
}

Run tests to verify:

dotnet test

Customization Options

Switching Databases

The ConfigureServices.cs file controls database selection:

public static IServiceCollection AddInfrastructure(
    this IServiceCollection services,
    IConfiguration configuration)
{
    if (configuration.GetValue<bool>("UseInMemoryDatabase"))
    {
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseInMemoryDatabase("VerticalSliceDb"));
    }
    else
    {
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(
                configuration.GetConnectionString("DefaultConnection")));
    }
    // ...
}

For PostgreSQL, swap UseSqlServer for UseNpgsql and add the Npgsql.EntityFrameworkCore.PostgreSQL package.

Adding New Domains

The template focuses on scheduling, but you can add parallel domains:

  1. Create a new folder under Application/ (e.g., Billing/)
  2. Add feature files following the same pattern
  3. Create a domain entity in Domain/
  4. Register endpoints in a new BillingEndpoints.cs
  5. Map endpoints in Program.cs: app.MapBillingEndpoints()

Keep domains isolated. Features within a domain can share its entities, but don't couple domains to each other.

Modifying Pipeline Behaviors

MediatR pipeline behaviors run for every request. The template includes:

  • ValidationBehaviour — Runs FluentValidation before the handler
  • PerformanceBehaviour — Logs slow requests

Add your own in Common/Behaviours/:

public class LoggingBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Log before
        var response = await next();
        // Log after
        return response;
    }
}

Register in ConfigureServices.cs:

options.AddOpenBehavior(typeof(LoggingBehaviour<,>));

Next Steps

From here:

The template is a starting point, not a framework. Take what works for your domain. Skip what doesn't.


Found this useful? Star the GitHub repo to help others discover it.