Skip to main content

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

Updated 9 min readMarkdown

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.

Example project: github.com/nadirbad/VerticalSliceArchitecture is a complete Vertical Slice Architecture example in C# and .NET 10 (600+ stars). It models a clinic's appointment scheduling, not a todo app.

What's in the Vertical Slice Architecture Example Project

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:

Feature Endpoint What It Does
Book Appointment POST /api/appointments Creates appointment with conflict detection
Get Appointments GET /api/appointments Filtered, paginated list
Get by ID GET /api/appointments/{id} Single appointment with details
Complete POST /api/appointments/{id}/complete Marks appointment done
Cancel POST /api/appointments/{id}/cancel Cancels 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 600+ 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.


About the author. I'm Nadir Badnjevic, a software architect with 18+ years building .NET and Azure systems. I maintain the Vertical Slice Architecture template for .NET (600+ stars). More about me.

About the author

  • Software architect with 18+ years designing and shipping .NET and Azure systems. I write about Vertical Slice Architecture, Event Storming, and architecture decisions, and maintain the open-source VSA template for .NET.

No comments yet. Be the first to comment.