Vertical Slice Folder Structure — Organizing by Feature
Structure a Vertical Slice Architecture project by feature: co-locating command, handler, validator, and endpoint in one folder, shared kernel placement, and how to scale the structure as features grow.
The Core Idea
Traditional layered structure (organize by technical concern):
Controllers/
PatientController.cs
PrescriptionController.cs
Services/
PatientService.cs
PrescriptionService.cs
Repositories/
PatientRepository.cs
PrescriptionRepository.cs
Vertical Slice structure (organize by feature):
Features/
Patients/
GetPatient/
GetPatientQuery.cs
GetPatientHandler.cs
GetPatientValidator.cs
GetPatientEndpoint.cs
CreatePatient/
CreatePatientCommand.cs
CreatePatientHandler.cs
CreatePatientValidator.cs
CreatePatientEndpoint.cs
Prescriptions/
CreatePrescription/
CreatePrescriptionCommand.cs
CreatePrescriptionHandler.cs
...
Add a feature → add a folder. Change a feature → touch one folder.Full Project Structure
src/
Api/
Features/
Patients/
GetPatient/
GetPatientQuery.cs
GetPatientHandler.cs
GetPatientValidator.cs
GetPatientResponse.cs
GetPatientEndpoint.cs
CreatePatient/
CreatePatientCommand.cs
CreatePatientHandler.cs
CreatePatientValidator.cs
CreatedPatientResponse.cs
CreatePatientEndpoint.cs
DischargePatient/
DischargePatientCommand.cs
DischargePatientHandler.cs
DischargePatientEndpoint.cs
Prescriptions/
CreatePrescription/
...
DispensePrescription/
...
GetActivePrescriptions/
...
LabResults/
SubmitLabResult/
...
GetLabHistory/
...
SharedKernel/
Behaviors/
ValidationBehavior.cs
LoggingBehavior.cs
PerformanceBehavior.cs
Exceptions/
NotFoundException.cs
ValidationException.cs
Models/
Result.cs
PagedResult.cs
Infrastructure/
Persistence/
ApplicationDbContext.cs
Configurations/
PatientConfiguration.cs
PrescriptionConfiguration.cs
Messaging/
ServiceBusPublisher.cs
Program.csFeature Folder Contents
// Features/Patients/GetPatient/GetPatientQuery.cs
public sealed record GetPatientQuery(Guid PatientId) : IRequest<Result<GetPatientResponse>>;
// Features/Patients/GetPatient/GetPatientResponse.cs
public sealed record GetPatientResponse(
Guid PatientId,
string Mrn,
string FirstName,
string LastName,
int ActivePrescriptionCount);
// Features/Patients/GetPatient/GetPatientValidator.cs
public sealed class GetPatientValidator : AbstractValidator<GetPatientQuery>
{
public GetPatientValidator()
{
RuleFor(q => q.PatientId).NotEmpty();
}
}
// Features/Patients/GetPatient/GetPatientHandler.cs
public sealed class GetPatientHandler : IRequestHandler<GetPatientQuery, Result<GetPatientResponse>>
{
private readonly ApplicationDbContext _db;
public GetPatientHandler(ApplicationDbContext db) => _db = db;
public async Task<Result<GetPatientResponse>> Handle(
GetPatientQuery query, CancellationToken ct)
{
var response = await _db.Patients
.Where(p => p.Id == new PatientId(query.PatientId))
.Select(p => new GetPatientResponse(
p.Id.Value,
p.Mrn.Value,
p.FirstName,
p.LastName,
p.Prescriptions.Count(pr => pr.IsActive)))
.FirstOrDefaultAsync(ct);
return response is null
? Result.Failure<GetPatientResponse>(DomainErrors.Patient.NotFound)
: Result.Success(response);
}
}
// Features/Patients/GetPatient/GetPatientEndpoint.cs
public sealed class GetPatientEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapGet("api/patients/{patientId:guid}",
async (Guid patientId, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new GetPatientQuery(patientId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
})
.WithName("GetPatient")
.RequireAuthorization();
}
}Endpoint Registration Pattern
// Scan and register all IEndpoint implementations automatically
public interface IEndpoint
{
void MapEndpoint(IEndpointRouteBuilder app);
}
// Extension method for Program.cs
public static class EndpointExtensions
{
public static IServiceCollection AddEndpoints(
this IServiceCollection services, Assembly assembly)
{
services.Scan(scan =>
scan.FromAssemblies(assembly)
.AddClasses(c => c.AssignableTo<IEndpoint>())
.AsImplementedInterfaces()
.WithScopedLifetime());
return services;
}
public static WebApplication MapEndpoints(this WebApplication app)
{
var endpoints = app.Services.GetRequiredService<IEnumerable<IEndpoint>>();
foreach (var endpoint in endpoints)
endpoint.MapEndpoint(app);
return app;
}
}
// Program.cs
builder.Services.AddEndpoints(typeof(Program).Assembly);
app.MapEndpoints();SharedKernel: What Goes There
SharedKernel contains things used across multiple features:
Infrastructure shared by features:
SharedKernel/Behaviors/ → MediatR pipeline behaviors (logging, validation)
SharedKernel/Models/ → Result, PagedResult, Error type
SharedKernel/Exceptions/ → NotFoundException, ValidationException
Domain primitives shared by features:
SharedKernel/Domain/ → base Entity, ValueObject, DomainEvent types
NOT in SharedKernel:
✗ Feature-specific logic — it belongs in the feature folder
✗ Database context — it is infrastructure
✗ Any class used by only one feature — keep it co-located Production issue I've seen: A team started with Vertical Slice but gradually moved "shared" helpers into a growing SharedKernel. After 6 months, SharedKernel had 40 classes — validators, helpers, formatters, adapters — more than many of the feature folders. It became a dumping ground for anything that felt "reusable." The real test: if a file is only used by one feature, it belongs in that feature's folder, not in SharedKernel. Resist premature extraction.
Key Takeaway
Structure projects by feature, not by technical layer. Each feature folder contains its command/query, handler, validator, response, and endpoint — all co-located. Use an
IEndpointinterface scanned at startup to register Minimal API endpoints.SharedKernelcontains only cross-feature infrastructure (Result type, MediatR behaviors, base types) — not feature logic. When adding a feature, touch one folder. When changing a feature, touch one folder.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.