Feature Slicing in C#: Organizing Code by Feature
If you have ever opened a .NET project and felt like you needed a map just to find where a single feature lives, you are not alone. Traditional layered architecture spreads one feature across Controllers, Services, Repositories, and Models -- four different folders, four different files, all for one thing. Feature slicing fixes that by grouping everything a feature needs into one place.
This article explains what feature slicing is in C#, why it works, and how to apply it to a real .NET project using ASP.NET Core Minimal APIs. No MediatR required.
What Is Feature Slicing?
Feature slicing is a code organization approach where you group code by what it does rather than what type of code it is. Instead of a folder for controllers and a separate folder for services, you create a folder for each feature -- and that folder holds everything that feature needs.
The mental model is simple: if you need to change how "Create Task" works in your app, you should open one folder, find everything relevant in that folder, and close that folder when you are done. Feature slicing makes that true.
This concept is closely related to what the community calls vertical slice architecture. The two terms overlap but are not identical -- feature slicing refers to the code organization practice (grouping code by feature in one folder), while vertical slice architecture additionally covers use-case boundary conventions and often implies CQRS and dispatching patterns. You can explore the architectural depth in this guide to mastering vertical slice architecture.
The Problem With Layered Architecture
Most .NET projects start with a structure organized by technical concern:
TaskTracker/
Controllers/
TaskController.cs
ProjectController.cs
UserController.cs
Services/
TaskService.cs
ProjectService.cs
UserService.cs
Repositories/
TaskRepository.cs
ProjectRepository.cs
UserRepository.cs
Models/
TaskEntity.cs
ProjectEntity.cs
On a small project, this structure feels clean. Every type of thing has its own folder. The problem arrives as the project grows. When you need to add "mark a task as complete," you touch TaskController.cs, TaskService.cs, TaskRepository.cs, and maybe TaskEntity.cs. Four files in four directories. Tracing a bug means reading across four layers. Understanding a feature means holding four files in your head simultaneously.
This friction compounds over time. Services accumulate methods for unrelated features. Repositories grow into god classes. The cohesion of individual features erodes because the structure encourages mixing them together.
What Feature Slicing Looks Like
Here is the same task tracker reorganized by feature:
TaskTracker/
Features/
Tasks/
CreateTask/
CreateTaskEndpoint.cs
CreateTaskHandler.cs
CreateTaskRequest.cs
CreateTaskResponse.cs
CompleteTask/
CompleteTaskEndpoint.cs
CompleteTaskHandler.cs
CompleteTaskRequest.cs
GetTasks/
GetTasksEndpoint.cs
GetTasksHandler.cs
GetTasksResponse.cs
Projects/
CreateProject/
CreateProjectEndpoint.cs
CreateProjectHandler.cs
CreateProjectRequest.cs
GetProjects/
GetProjectsEndpoint.cs
GetProjectsHandler.cs
GetProjectsResponse.cs
Shared/
Data/
AppDbContext.cs
Entities/
TaskEntity.cs
ProjectEntity.cs
Program.cs
Each feature is self-contained. CompleteTask owns its endpoint, its handler logic, and its input/output types. When something breaks in task completion, you open the CompleteTask folder. That is it.
Building Your First Feature Slice
Let us build the CreateTask feature slice step by step. This uses ASP.NET Core Minimal APIs in .NET 8/10 with no additional libraries beyond Entity Framework Core.
Request and Response Models
Start with the data contracts for this feature. These are scoped exclusively to CreateTask -- they are not shared with other features:
// Features/Tasks/CreateTask/CreateTaskRequest.cs
namespace TaskTracker.Features.Tasks.CreateTask;
public sealed record CreateTaskRequest(
string Title,
string Description,
Guid ProjectId);
// Features/Tasks/CreateTask/CreateTaskResponse.cs
namespace TaskTracker.Features.Tasks.CreateTask;
public sealed record CreateTaskResponse(
Guid TaskId,
string Title,
DateTimeOffset CreatedAt);
Another feature that also needs task data -- say, GetTasks -- defines its own response type with only the fields that feature actually needs. The duplication is intentional. Each slice owns its contract. If you publish a stable API contract to external consumers, you may want shared DTOs at that boundary -- but for internal use cases where you own both producer and consumer, per-feature types keep slices independent.
The Handler
The handler contains the business logic for this feature. It is a regular C# class with no framework ceremony:
// Features/Tasks/CreateTask/CreateTaskHandler.cs
namespace TaskTracker.Features.Tasks.CreateTask;
public sealed class CreateTaskHandler
{
private readonly AppDbContext _db;
private readonly TimeProvider _time;
public CreateTaskHandler(AppDbContext db, TimeProvider time)
{
_db = db;
_time = time;
}
public async Task<CreateTaskResponse> HandleAsync(
CreateTaskRequest request,
CancellationToken cancellationToken = default)
{
var task = new TaskEntity
{
Id = Guid.NewGuid(),
Title = request.Title,
Description = request.Description,
ProjectId = request.ProjectId,
CreatedAt = _time.GetUtcNow(),
IsCompleted = false
};
_db.Tasks.Add(task);
await _db.SaveChangesAsync(cancellationToken);
return new CreateTaskResponse(task.Id, task.Title, task.CreatedAt);
}
}
No abstractions wrapping abstractions. No mediator pipeline to trace through. A class that takes a request and returns a response. You can test this directly by providing a real or in-memory AppDbContext.
The Endpoint
The endpoint connects an HTTP route to the handler:
// Features/Tasks/CreateTask/CreateTaskEndpoint.cs
namespace TaskTracker.Features.Tasks.CreateTask;
public static class CreateTaskEndpoint
{
public static void Map(IEndpointRouteBuilder routes)
{
routes.MapPost("/tasks", async (
CreateTaskRequest request,
CreateTaskHandler handler,
CancellationToken cancellationToken) =>
{
var response = await handler.HandleAsync(request, cancellationToken);
return Results.Created($"/tasks/{response.TaskId}", response);
})
.WithName("CreateTask")
.WithTags("Tasks");
}
}
Wiring It All Together
Registration in Program.cs is explicit and readable:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Default")));
// Register handlers -- one per feature
builder.Services.AddScoped<CreateTaskHandler>();
builder.Services.AddScoped<CompleteTaskHandler>();
builder.Services.AddScoped<GetTasksHandler>();
builder.Services.AddScoped<CreateProjectHandler>();
builder.Services.AddScoped<GetProjectsHandler>();
var app = builder.Build();
// Map endpoints -- one per feature
CreateTaskEndpoint.Map(app);
CompleteTaskEndpoint.Map(app);
GetTasksEndpoint.Map(app);
CreateProjectEndpoint.Map(app);
GetProjectsEndpoint.Map(app);
app.Run();
As the project grows, you can automate registration with reflection or Scrutor. Starting explicit keeps things easy to trace and debug during early development. If you publish with trimming or Native AOT, prefer explicit registration or a source-generator approach -- reflection scanning can break without explicit roots. Source Generation vs Reflection in Needlr covers exactly this tradeoff and shows how to swap approaches.
Benefits You Will Actually Notice
Finding code becomes predictable. When a bug surfaces in "complete task," you open Features/Tasks/CompleteTask/. The investigation starts and ends in one place rather than spanning multiple class files across multiple folders.
Adding a feature is additive, not disruptive. New feature slices do not touch existing code. You add a new folder and a few files, then register them in Program.cs. The risk of breaking unrelated features is low because unrelated features are in separate folders.
Ownership lines up with the folder structure. Teams can own feature areas without coordinating on shared service classes. A developer working on task management does not need to understand how project management is implemented. This organizational benefit is explored in depth in this article on why developers are drawn to vertical slices.
Deleting a feature is clean -- if the feature is truly isolated. Remove the folder, remove the registration lines, and you are done. If the feature shares database migrations, authorization policies, or cross-feature dependencies, audit those before removing.
Tests mirror the feature structure. Your test project can use the same Features/FeatureName/ structure, giving you one test file per feature with clear scope. For how to organize this well, see From Chaos to Cohesion: How To Organize Code For Vertical Slices.
What Goes in Shared?
Feature slices are self-contained but not everything can be unique to one feature. The Shared/ folder handles genuinely cross-cutting concerns:
AppDbContext-- all features share one database context- Entity models -- the database table definitions (not DTOs; those stay in each feature)
- Infrastructure -- authentication middleware, global exception handlers
- Common utilities -- date helpers, string extensions, genuinely generic code
The practical rule: if two unrelated features need the same concept independently, move it to Shared/. If only one feature uses it, keep it in that feature's folder even if it looks like it might be reused someday. Premature sharing creates the same coupling problems as layered architecture -- just in a different folder.
Connecting Feature Slicing to Vertical Slice Architecture
If you have read about vertical slice architecture in ASP.NET Core, the examples here will look familiar. The key practical distinction is the absence of a required dispatching library. Many vertical slice guides assume MediatR as the mechanism for routing commands and queries to handlers. Feature slicing as shown here treats MediatR as optional infrastructure -- useful for pipeline behaviors like logging and validation, but not required for the organizational benefits.
The exploring an example vertical slice architecture in ASP.NET Core article shows a full working example that goes further with this approach, including how shared concerns are managed in a real project.
Feature slicing also connects naturally to patterns like CQRS. When you already have command and query objects per feature, the CQRS distinction becomes a naming and design convention rather than a structural change. You can explore how CQRS works in C# and clean architecture as a companion concept.
When Feature Slicing Works Well
Feature slicing is a strong fit for:
- Applications with distinct, independently evolving features (most web APIs and CRUD-heavy apps)
- Teams where multiple developers or squads work on the same codebase
- Projects where delivery speed matters and you want each new feature to be a contained, low-risk addition
- Codebases you want to make easier to onboard new developers into
It is worth pausing before applying feature slicing to:
- Very small projects with one developer and fewer than ten endpoints -- the overhead is real, even if it is modest
- Applications with complex shared domain logic that genuinely cuts across many features, where a domain-centric model may be a better fit
- Teams committed to a strict domain-driven design approach where the domain model is the primary organizational unit
Start Small and Expand
Feature slicing does not require a full rewrite. You can introduce it incrementally. Pick the next new feature your team needs to build, create a Features/FeatureName/ folder, and organize it as a slice. Leave existing layered code in place and migrate slice by slice as you have capacity.
The goal is not a perfect folder structure -- it is code that is easier to change, easier to understand, and easier to test. Feature slicing gets you there one feature at a time.
Frequently Asked Questions
What is feature slicing in C#?
Feature slicing in C# is a code organization approach where all code related to a single business feature -- the endpoint, the handler logic, the request and response types -- lives together in one folder. The goal is that understanding or changing a feature means working in one place, not navigating across multiple technical layers.
Is feature slicing the same as vertical slice architecture?
The terms overlap but describe different emphases. Feature slicing is a code organization practice: one folder per feature, all related code together. Vertical slice architecture extends that with explicit use-case boundaries and often implies CQRS and dispatching conventions. You can apply feature slicing without any dispatching library; vertical slice architecture in the strict sense typically includes the CQRS layer as well.
Do I need MediatR for feature slicing in C#?
No. MediatR is a popular choice for dispatching in feature-sliced applications but is not required. Plain handler classes, direct service invocations, and ASP.NET Core Minimal API delegates all work well. The organizational benefit comes from the folder structure, not from any specific library.
How is feature slicing different from clean architecture in C#?
Clean architecture organizes code into concentric dependency layers: domain, application, infrastructure, and presentation. Feature slicing organizes by business capability, grouping all layers for a feature together. The two approaches can coexist -- feature slices often map well to the application layer within a clean architecture boundary.
What code goes in the Shared folder in a feature-sliced project?
Code that is genuinely needed by multiple unrelated features: the DbContext, entity models (database table definitions), authentication utilities, and global infrastructure. DTOs, handler logic, and endpoint-specific types stay in their feature folders even if they look similar across features.
Can feature slicing work with Entity Framework Core?
Yes. Feature slices share a single AppDbContext defined in Shared/Data/. Each handler receives the context through dependency injection and queries it directly. You can add a repository abstraction if your team needs it for testing isolation, but direct DbContext access is common and works well.
How do I handle cross-feature dependencies?
Avoid direct feature-to-feature dependencies. When two features share a genuine concept, extract it to Shared/. When one feature needs data owned by another, prefer querying the database directly rather than calling another feature's handler. This keeps slices independent and prevents the hidden coupling that makes layered architecture difficult to change.

