Every feature in one folder
A production-ready .NET backend template built on Vertical Slice Architecture. Each use case is a folder holding its endpoint, handler, validator and mapping. Fourteen projects, one Core, no MediatR, no repositories. JWT auth, EF Core with PostgreSQL, OpenTelemetry, Aspire, Docker and 116 tests are already wired.
POST /api/shipmentsFeatures/Shipments/CreateShipment/The use case. DbContext injected directly, returns a Result instead of throwing, publishes a domain event.
- Domains
- 4
- Projects
- 14
- Tests
- 116
- Endpoints
- 20
- Framework
- .NET 10
Domains
Projects
Tests
Endpoints
Framework
Open one folder, see the whole use case
No jumping between Domain, Application, Infrastructure and Web to follow a single request. Everything CreateShipment needs sits in CreateShipment.
namespace Core.Shipments.Features.CreateShipment;
public sealed record CreateShipmentRequest(
string OrderId,
Address Address,
string Carrier,
string ReceiverEmail,
List<ShipmentItemRequest> Items);
public class CreateShipmentApiEndpoint : IApiEndpoint
{
public void MapEndpoint(WebApplication app)
{
app.MapPost(RouteConsts.BaseRoute, Handle);
}
private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IValidator<CreateShipmentRequest> validator,
ICreateShipmentHandler handler,
CancellationToken cancellationToken)
{
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var response = await handler.HandleAsync(request, cancellationToken);
return response.IsError
? response.Errors.ToProblem()
: Results.Ok(response.Value);
}
}internal interface ICreateShipmentHandler : IHandler
{
Task<Result<ShipmentResponse>> HandleAsync(
CreateShipmentRequest request, CancellationToken cancellationToken);
}
internal sealed class CreateShipmentHandler(
ShipmentsDbContext context,
IStockModuleApi stockApi,
IEventPublisher eventPublisher,
ILogger<CreateShipmentHandler> logger)
: ICreateShipmentHandler
{
public async Task<Result<ShipmentResponse>> HandleAsync(
CreateShipmentRequest request,
CancellationToken cancellationToken)
{
var shipmentExists = await context.Shipments
.AnyAsync(x => x.OrderId == request.OrderId, cancellationToken);
if (shipmentExists)
{
return ShipmentErrors.AlreadyExists(request.OrderId);
}
var stockResponse = await stockApi.CheckStockAsync(
CreateCheckStockRequest(request), cancellationToken);
if (!stockResponse.IsSuccess)
{
return stockResponse.Errors;
}
var shipment = request.MapToShipment(new Faker().Commerce.Ean8());
await context.Shipments.AddAsync(shipment, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
await eventPublisher.PublishAsync(
new ShipmentCreatedEvent(shipment), cancellationToken);
return shipment.MapToResponse();
}
}public class CreateShipmentRequestValidator : AbstractValidator<CreateShipmentRequest>
{
public CreateShipmentRequestValidator()
{
RuleFor(shipment => shipment.OrderId).NotEmpty();
RuleFor(shipment => shipment.Carrier).NotEmpty();
RuleFor(shipment => shipment.ReceiverEmail).NotEmpty();
RuleFor(shipment => shipment.Items).NotEmpty();
RuleFor(shipment => shipment.Address)
.Cascade(CascadeMode.Stop)
.NotNull()
.WithMessage("Address must not be null")
.SetValidator(new AddressValidator());
}
}
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(address => address.Street).NotEmpty();
RuleFor(address => address.City).NotEmpty();
RuleFor(address => address.Zip).NotEmpty();
}
}internal static class CreateShipmentMappingExtensions
{
public static Shipment MapToShipment(
this CreateShipmentRequest request, string shipmentNumber)
=> Shipment.Create(
shipmentNumber,
request.OrderId,
request.Address,
request.Carrier,
request.ReceiverEmail,
request.Items
.Select(x => new ShipmentItem { Product = x.Product, Quantity = x.Quantity })
.ToList());
public static ShipmentResponse MapToResponse(this Shipment shipment)
=> new(
shipment.Number,
shipment.OrderId,
shipment.Address,
shipment.Carrier,
shipment.ReceiverEmail,
shipment.Status,
shipment.Items
.Select(x => new ShipmentItemResponse(x.Product, x.Quantity))
.ToList());
}public static class CoreRegistration
{
public static IServiceCollection AddCore(
this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped<IEventPublisher, EventPublisher>();
// Stocks cross-domain API + tracing decorator
services.AddScoped<StockModuleApi>();
services.AddScoped<IStockModuleApi>(provider =>
new TracedStockModuleApi(provider.GetRequiredService<StockModuleApi>()));
// Carriers cross-domain API + tracing decorator
services.AddScoped<CarrierModuleApi>();
services.AddScoped<ICarrierModuleApi>(provider =>
new TracedCarrierModuleApi(provider.GetRequiredService<CarrierModuleApi>()));
// Discover endpoints, handlers and validators across the single Core assembly
services.RegisterApiEndpointsFromAssemblyContaining(typeof(CoreRegistration));
services.RegisterHandlersFromAssemblyContaining(typeof(CoreRegistration));
services.AddValidatorsFromAssembly(typeof(CoreRegistration).Assembly);
return services.AddInfrastructure(configuration);
}
}The route
- The request record and the endpoint live in the same file, so the contract is right where the route is.
- Endpoints implement IApiEndpoint and are discovered by scanning the Core assembly once at startup.
- Validation runs before the handler and failures come back as a ValidationProblem.
- Business errors map to ProblemDetails through ToProblem(). Exceptions are for bugs, not for flow.
The logic
- An internal interface plus one sealed class. The interface exists so the endpoint can inject it, nothing more.
- DbContext goes straight into the handler. There is no repository and no unit of work to maintain.
- Result<ShipmentResponse> makes every failure an explicit value the caller has to deal with.
- Cross-domain work goes through IStockModuleApi, never through another domain's DbContext.
The rules
- FluentValidation rules sit in the slice, next to the request they describe.
- Value objects get their own validator and compose with SetValidator, so rules are not duplicated.
- Validators register themselves through AddValidatorsFromAssembly alongside endpoints and handlers.
- Thirteen validators ship with the template as working examples.
Request in, response out
- Two extension methods. No AutoMapper, no profiles, no reflection at startup.
- Mapping stays private to the slice, so changing one use case cannot break another.
- You can read what a field maps to without opening a configuration class.
- Delete the feature and the mapping goes with it.
One call wires it all
- AddCore scans a single assembly for endpoints, handlers and validators.
- Cross-domain contracts are registered with a tracing decorator, so every call shows up as a span.
- No module registry to keep in sync and no partial startup configuration to forget.
- Program.cs stays short enough to read in one screen.
Guardrails, not folder conventions
Vertical slices are easy to navigate and easy to abuse. These three things keep a growing slice honest, and the last one fails the build if it is not.
Contracts between domains
Shipments never touches the Stocks DbContext. It calls a contract interface, wrapped in a tracing decorator so the hop is visible in Jaeger.
IStockModuleApiTracedStockModuleApiInternal-only slices
CheckStock and DecreaseStock have handlers and no HTTP endpoint at all. They are reachable only through the contract, never from the outside.
CheckStockDecreaseStockResult and validation
A Result type with typed errors, one validator per use case and a global exception handler returning ProblemDetails. Business failures never throw.
Result<T>ErrorProblemDetails[Fact]
public void Domain_ShouldNotHaveDependencyOn_CoreInfrastructureOrHost()
{
var result = Types.InAssembly(ModuleAssemblies.DomainAssembly)
.Should()
.NotHaveDependencyOnAny(
CoreNamespace,
InfrastructureNamespace,
HostNamespace,
CommonApiNamespace,
CommonInfrastructureNamespace)
.GetResult();
Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}Three NetArchTest rules run with the rest of the suite: Domain must not know about Core, Infrastructure or the host, Infrastructure must not know about Core or the host, and Core must not know about the host. Layering is checked by the build instead of trusted to code review.
The boring parts, already decided
Everything a new .NET API needs before it can do anything useful, configured and running.
Minimal APIs, no MediatR
Endpoints, handlers and validators are discovered by scanning one assembly. Handlers are plain classes, so a stack trace leads straight to your code.
EF Core and PostgreSQL
A DbContext per domain with its own schema and migration history, entity configurations and an auditing interceptor.
Auth that survives production
ASP.NET Identity with JWT, refresh tokens and revocation, plus policy-based authorization defined per domain.
Result pattern and validation
A Result type with typed errors, FluentValidation per use case and a global exception handler returning ProblemDetails.
Serilog, Seq and OpenTelemetry
Structured logs land in Seq, traces and metrics in Jaeger. Each domain has its own activity source and tracing middleware.
Aspire and Docker Compose
Run it with .NET Aspire locally, or bring the API, PostgreSQL, Seq and Jaeger up with Docker Compose and a real health check.
Tests you can run today
116 tests: unit tests, integration tests on Testcontainers with Respawn cleanup, and NetArchTest rules for the layering.
Quality gates in the build
Meziantou, Sonar and Roslynator analyzers with warnings as errors, central package management and .editorconfig.
Swagger and 21 ready requests
Swagger UI in Development plus 21 .http files with an environment file, so you can call every endpoint from your IDE.
Slices across the layers, not instead of them
Each use case cuts vertically through the API, application and data access it needs, while the Domain layer stays free of infrastructure. That is what the architecture tests protect.

Fourteen projects you can hold in your head
Four layers, four domains inside each, and a Core project where every feature lives. Nothing is more than two folders away.
- Four domains live as namespaces inside Core, Domain and Infrastructure. No project per domain to keep in sync.
- A slice is a folder: endpoint, handler, validators, mapping and any events it raises.
- Deleting a feature means deleting a folder. Nothing is stranded in a shared DTO project.
- Adding a feature means copying a folder and renaming it. Registration is automatic.
- Outgrow a slice and you can promote it. The folder boundary already shows exactly what moves.
Core/ # every vertical sliceShipments/Features/CreateShipment/CreateShipment.Endpoint.csCreateShipment.Handler.csCreateShipment.Validators.csCreateShipment.Mapping.csEvents/ # event + two handlersStocks/InternalApi/ # contracts + decoratorsDomain/ # entities, value objects, policiesInfrastructure/ # DbContexts, migrations, authCommon/ # Result, events, error handlingModules.Common.Tests.Architecture/ # layer rulesWebApi.Host/ # Program.cs, seeding, .http filesAppHost/ # .NET AspireTests/ # unit + TestcontainersNetProjectTemplate.slnx # modern solution format
Free Project Template, .NET 10.
Clone it, run it, and start on your own features instead of your own plumbing.
Running before your coffee gets cold
Three steps from the email in your inbox to a request hitting a real database.
- 1
Drop your email
The template arrives in 2-3 minutes as ready-to-run solution.
- 2
Start the stack
Aspire provisions PostgreSQL for you. Docker Compose is there too and waits for the database health check before starting the API.
dotnet run --project src/AppHost
- 3
Add your own feature
Copy a feature folder and rename it. Add your code. Run the solution. Swagger is on localhost:5000, Seq on 8081, Jaeger on 16686. Open a .http file, fire a request and you're ready.
Which one should you start from?
Both templates build the same shipping application with the same stack. They differ in how hard the boundaries are.
Projects
- .NET Backend
- 14 projects, one Core
- Modular Monolith
- 25 projects, three per module
Boundaries
- .NET Backend
- Namespaces plus layer architecture tests
- Modular Monolith
- Separate projects plus PublicApi contracts
Cross-domain calls
- .NET Backend
- Contract interfaces with tracing decorators inside Core
- Modular Monolith
- Contract interfaces in dedicated PublicApi projects
Best for
- .NET Backend
- Teams that want the smallest solution to navigate
- Modular Monolith
- Teams that may extract a module into a service later
Stack
- .NET Backend
- .NET 10, EF Core, Postgres, JWT, OTel, Aspire
- Modular Monolith
- Identical
Need harder boundaries, with each module in its own projects and its own public contract? Start from the Modular Monolith template instead.
See the Modular Monolith templateQuestions people ask
What is Vertical Slice Architecture?
Code is organised by business capability instead of by technical layer. Rather than a Command in one project, a Handler in another and a DTO in a third, everything a use case needs lives in one folder. You read a feature top to bottom in one place, and you delete it the same way.
How is this different from the Modular Monolith template?
Same application, same stack, harder boundaries in the other one. This template keeps the four domains as namespaces inside a single Core project and relies on architecture tests, which gives you fourteen projects instead of twenty-five. The Modular Monolith template gives each module its own projects and a PublicApi contract, which is what you want if you expect to extract a service later.
Why are there no repositories?
DbContext is already a unit of work and DbSet is already a repository. Wrapping them again costs you LINQ, change tracking and half of EF Core's query capability in exchange for an abstraction most teams never swap out. Integration tests run against a real PostgreSQL container instead.
Why are there no service classes?
A ShipmentService starts with one method and ends up with fifteen, and every caller drags in all of them. Here each use case is its own handler class: the Shipments domain has eight slices, from CreateShipment to CancelShipment. The constructor injects only what that one use case needs. If you need to write unit tests, you will need only 2 mocked dependencies instead of 10.
Why is there no MediatR?
A handler is just a class. The template registers handlers by assembly scanning and the endpoint injects the interface it needs, so there is no pipeline to configure, no behaviour ordering to reason about and one less licensed dependency.
Which .NET version does the template target?
.NET 10, with the modern .slnx solution format. The patterns work on .NET 8 and 9 too, though you will need to change the target framework and step back a few package versions.
Is it really free?
Yes, no credit card required. You will also get my .NET newsletter, and you can unsubscribe whenever you want.
Your next feature is one folder away
The full .NET backend template, free, in your inbox. Clone it, run it, and spend day one on the part that is actually yours.
Free Project Template. No credit card.