Getting Started with the Vertical Slice Architecture Template for .NET 10 C#
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:
Create a new folder under
Application/(e.g.,Billing/)Add feature files following the same pattern
Create a domain entity in
Domain/Register endpoints in a new
BillingEndpoints.csMap 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:
Understand the theory: Read Vertical Slice Architecture in C# and .NET 10: Complete Guide for the architectural reasoning behind this approach
Compare alternatives: See Vertical Slice Architecture vs. Clean Architecture if you're deciding between patterns
Explore folder options: Check out Vertical Slice Architecture Folder Structure: 4 Approaches Compared to see different organization strategies
Contribute: The template repo welcomes issues and PRs
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.
Comments
Join the discussionNo comments yet. Be the first to comment.