Feature Slice Folder Structure in .NET: Organizing a Real Project
Organizing a .NET project using feature slices is a compelling idea until you sit down to do it. The concept is clear: group code by feature, not by technical type. But real projects are messier than toy examples. Features have nested sub-features. Some code is genuinely shared. Some decisions are not obvious.
This article goes beyond the basic Features/Orders/CreateOrder/ folder structure and looks at how to make real structure decisions: how deep to nest, when to share, how to handle cross-cutting concerns, and what a moderately complex project actually looks like after applying feature slicing throughout.
The Starting Point: A Feature-Sliced Project Root
The top-level structure of a feature-sliced .NET project follows a consistent pattern regardless of project size:
ProjectRoot/
Features/ <- All feature-specific code lives here
Shared/ <- Genuinely cross-feature code
Program.cs <- Entry point, registration, minimal wiring
appsettings.json
Everything under Features/ is organized by business capability. Everything under Shared/ is infrastructure and utilities used by multiple features. Program.cs wires it all together. This three-part structure works well across a wide range of .NET APIs. Very large modular systems may introduce additional top-level groupings -- module boundaries or bounded contexts -- but the Features/Shared/Program.cs pattern is a solid starting point for most projects.
The feature slice folder structure concept is central to vertical slice architecture in C#, which has a long track record in production .NET applications.
Structuring the Features Folder
Inside Features/, organize by domain area at the top level, then by use case within each area:
Features/
Tasks/
CreateTask/
CompleteTask/
DeleteTask/
GetTask/
GetTasks/
AssignTask/
Projects/
CreateProject/
GetProject/
GetProjects/
ArchiveProject/
Users/
RegisterUser/
UpdateProfile/
GetUserProfile/
Notifications/
SendTaskAssignedNotification/
SendProjectArchivedNotification/
Each leaf folder (CreateTask/, CompleteTask/, etc.) is a single use case -- one HTTP endpoint, one handler, one set of request/response models.
Inside a Feature Folder
A typical feature folder contains three to five files:
Features/Tasks/CreateTask/
CreateTaskEndpoint.cs <- HTTP route definition
CreateTaskHandler.cs <- Business logic
CreateTaskRequest.cs <- Input model
CreateTaskResponse.cs <- Output model (if needed)
CreateTaskValidator.cs <- Input validation (optional)
Every file is in the same namespace: YourApp.Features.Tasks.CreateTask. Navigation tools jump directly to the right folder. Searches for CreateTask return only the files relevant to that one use case.
Here is a complete feature folder in practice:
// Features/Tasks/CreateTask/CreateTaskRequest.cs
namespace TaskTracker.Features.Tasks.CreateTask;
public sealed record CreateTaskRequest(
string Title,
string? Description,
Guid ProjectId,
DateTimeOffset? DueDate);
// 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,
DueDate = request.DueDate,
CreatedAt = _time.GetUtcNow(),
IsCompleted = false
};
_db.Tasks.Add(task);
await _db.SaveChangesAsync(cancellationToken);
return new CreateTaskResponse(task.Id, task.Title, task.CreatedAt);
}
}
Note the use of TimeProvider rather than DateTimeOffset.UtcNow directly -- this makes the handler testable without mocking static methods.
When to Nest Features vs. When to Flatten
A common question when designing your feature slice folder structure is: when should you create sub-feature folders versus keeping everything at the top level?
The deciding factor is domain cohesion, not file count.
Nest when use cases belong to the same bounded domain area and share entity context:
Features/
Tasks/
CreateTask/
CompleteTask/
GetTask/
GetTasks/
Flatten when two features are in genuinely different domains even if they seem related:
Features/
Tasks/ <- Task CRUD and lifecycle
TaskReports/ <- Reporting and analytics on tasks (different domain concern)
TaskComments/ <- Discussion thread on tasks (different subdomain)
If TaskComments starts sharing handler logic with Tasks, that is a smell that they belong together or that the shared logic belongs in Shared/. If they evolve independently, keeping them separate is correct. From Chaos to Cohesion: How To Organize Code For Vertical Slices covers this cohesion thinking in more depth.
Structuring the Shared Folder
The Shared/ folder contains code that multiple unrelated feature slices genuinely depend on. It is not a dumping ground for "might be reused someday" code -- that code stays in the feature that currently uses it.
A well-organized Shared/ folder looks like this:
Shared/
Data/
AppDbContext.cs
Migrations/
Entities/
TaskEntity.cs
ProjectEntity.cs
UserEntity.cs
Auth/
CurrentUserAccessor.cs
AuthorizationPolicies.cs
Infrastructure/
GlobalExceptionHandler.cs
RequestLoggingMiddleware.cs
Extensions/
ServiceCollectionExtensions.cs
EndpointRouteBuilderExtensions.cs
Entities vs. DTOs
A critical distinction in the feature slice folder structure: entities live in Shared/; DTOs live in each feature.
Entities represent database rows. Multiple features need to read and write the same TaskEntity row, so the entity definition is shared. DTOs are the input and output shapes for a specific use case. CreateTaskRequest and GetTaskResponse are different shapes for the same underlying data -- they belong in their respective feature folders.
This distinction is what allows feature slices to evolve independently. When GetTask needs to add a CompletedAt field to its response, only GetTask/GetTaskResponse.cs changes. No shared DTO is affected.
A Real Example: Medium-Complexity Project
Here is what a project tracking API looks like with a full feature slice folder structure applied:
TaskTracker.Api/
Features/
Tasks/
CreateTask/
CreateTaskEndpoint.cs
CreateTaskHandler.cs
CreateTaskRequest.cs
CreateTaskResponse.cs
CreateTaskValidator.cs
CompleteTask/
CompleteTaskEndpoint.cs
CompleteTaskHandler.cs
CompleteTaskRequest.cs
GetTask/
GetTaskEndpoint.cs
GetTaskHandler.cs
GetTaskResponse.cs
GetTasks/
GetTasksEndpoint.cs
GetTasksHandler.cs
GetTasksQuery.cs
GetTasksResponse.cs
DeleteTask/
DeleteTaskEndpoint.cs
DeleteTaskHandler.cs
AssignTask/
AssignTaskEndpoint.cs
AssignTaskHandler.cs
AssignTaskRequest.cs
Projects/
CreateProject/
CreateProjectEndpoint.cs
CreateProjectHandler.cs
CreateProjectRequest.cs
CreateProjectResponse.cs
GetProjects/
GetProjectsEndpoint.cs
GetProjectsHandler.cs
GetProjectsResponse.cs
ArchiveProject/
ArchiveProjectEndpoint.cs
ArchiveProjectHandler.cs
Users/
RegisterUser/
RegisterUserEndpoint.cs
RegisterUserHandler.cs
RegisterUserRequest.cs
RegisterUserResponse.cs
GetUserProfile/
GetUserProfileEndpoint.cs
GetUserProfileHandler.cs
GetUserProfileResponse.cs
Shared/
Data/
AppDbContext.cs
Entities/
TaskEntity.cs
ProjectEntity.cs
UserEntity.cs
Auth/
CurrentUserAccessor.cs
Extensions/
ServiceCollectionExtensions.cs
Program.cs
appsettings.json
This structure has 19 feature use cases across 3 domain areas. Every use case has a dedicated folder. Finding "how does assigning a task work" means opening Features/Tasks/AssignTask/ -- there are only 3-4 files there.
Automating Registration
With a handful of features, explicit registration in Program.cs works well. At 20+ features, automation is worth the investment.
One approach: scan for all handler classes by convention using reflection.
// Shared/Extensions/ServiceCollectionExtensions.cs
namespace TaskTracker.Shared.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddFeatureHandlers(
this IServiceCollection services,
Assembly assembly)
{
// GetTypes() can throw ReflectionTypeLoadException if any type in the assembly
// fails to load. Catch it and use the types that did load successfully.
IEnumerable<Type> types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types.Where(t => t is not null)!;
}
var handlerTypes = types
.Where(t => t.IsClass
&& !t.IsAbstract
&& t.Name.EndsWith("Handler", StringComparison.Ordinal));
foreach (var handlerType in handlerTypes)
{
services.AddScoped(handlerType);
}
return services;
}
}
AOT/trimming note: Reflection scanning is incompatible with Native AOT publishing and may fail with .NET's linker trimming. If you publish with
<PublishTrimmed>true</PublishTrimmed>or as a Native AOT binary, use explicitAddScoped<T>()calls or a source-generator-based approach instead. See Source Generation vs Reflection in Needlr for a practical walkthrough of the difference.
A more structured approach uses Scrutor, which adds assembly scanning to IServiceCollection:
// Program.cs - using Scrutor for handler registration
builder.Services.Scan(scan => scan
.FromAssemblyOf<Program>()
.AddClasses(classes => classes.Where(t => t.Name.EndsWith("Handler")))
.AsSelf()
.WithScopedLifetime());
For endpoint mapping, a similar pattern works: define an IEndpoint interface and scan for implementations.
// Shared/IEndpoint.cs
namespace TaskTracker.Shared;
public interface IEndpoint
{
void Map(IEndpointRouteBuilder routes);
}
// Program.cs
var endpointTypes = Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.IsAssignableTo(typeof(IEndpoint)) && !t.IsAbstract);
foreach (var type in endpointTypes)
{
// Activator.CreateInstance requires a public parameterless constructor.
// Endpoints with constructor dependencies will throw at startup.
// For endpoints that need DI, resolve via app.Services instead.
var endpoint = (IEndpoint)Activator.CreateInstance(type)!;
endpoint.Map(app);
}
This keeps Program.cs short and ensures new features are automatically picked up when added.
Where Validation Belongs
Validation code for a feature's input belongs in that feature's folder, not in a shared validation layer. A CreateTaskValidator class lives in Features/Tasks/CreateTask/:
// Features/Tasks/CreateTask/CreateTaskValidator.cs
namespace TaskTracker.Features.Tasks.CreateTask;
public sealed class CreateTaskValidator
{
public ValidationResult Validate(CreateTaskRequest request)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(request.Title))
{
errors.Add("Title is required.");
}
if (request.Title?.Length > 200)
{
errors.Add("Title must be 200 characters or fewer.");
}
if (request.DueDate.HasValue && request.DueDate < DateTimeOffset.UtcNow)
{
errors.Add("Due date must be in the future.");
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
}
If you use FluentValidation, the AbstractValidator<CreateTaskRequest> subclass lives in the same folder. The validator is registered and invoked from the endpoint or handler -- it is not a shared pipeline artifact.
Note:
ValidationResultin the example above is a custom type, not a built-in .NET type. Here is a minimal implementation to put inShared/:// Shared/ValidationResult.cs namespace TaskTracker.Shared; public sealed class ValidationResult { public bool IsValid { get; private init; } public IReadOnlyList<string> Errors { get; private init; } private ValidationResult(bool isValid, IReadOnlyList<string> errors) { IsValid = isValid; Errors = errors; } public static ValidationResult Success() => new(true, []); public static ValidationResult Failure(IReadOnlyList<string> errors) => new(false, errors); }
Connecting This to the Broader Architecture
The feature slice folder structure you design will influence how other architectural decisions play out -- particularly around testing and CQRS. With a well-organized structure, each feature handler becomes a natural unit test target. The endpoint becomes an integration test target. For the full picture of how vertical slice development works for modern teams, including how team workflows map to slice boundaries, that article is worth reading alongside this one.
For larger systems considering how feature slices relate to clean architecture boundaries, C# Clean Architecture with MediatR is a useful comparison. Clean architecture and feature slices are not mutually exclusive -- slices often map to the use-case layer in clean architecture thinking.
The Plugin Architecture in C# Complete Guide is another example of feature-first organization applied to extensibility systems -- each plugin is its own independently loaded feature, sharing only an interface contract.
Summary: Feature Slice Folder Structure Decisions
| Decision | Guidance |
|---|---|
| Top-level structure | Features/, Shared/, Program.cs |
| Feature grouping | Domain area → use case (two levels) |
| File count per feature | 3-5 files (endpoint, handler, request, response, optional validator) |
| Entities | Live in Shared/Entities/ |
| DTOs | Live in each feature's folder |
| Shared code threshold | Extract when two unrelated features need the same thing |
| Handler registration | Explicit for small projects; convention scanning for large projects |
| Validation | Lives in the feature folder, not a shared pipeline |
Frequently Asked Questions
How do I decide when to create a new feature folder vs. adding to an existing one?
Each new HTTP endpoint should be its own feature folder. If you are adding new behavior to an existing endpoint (a new optional query parameter, a new response field), modify the existing feature folder. If you are adding a new action (complete, archive, reassign), create a new folder.
What happens when a feature folder gets large?
If a feature folder has more than six or seven files, consider whether the feature is actually two features. GetTasks with filtering, sorting, and pagination might split into GetTasks and SearchTasks. If the feature is genuinely that complex, a sub-folder with additional files is fine -- the goal is cohesion, not an arbitrary file limit.
Should endpoints be static classes or instance classes?
Either works. Static classes (as shown in this article) are simpler and require no registration. Instance classes implementing an IEndpoint interface are easier to scan and register automatically. Choose based on your registration strategy -- if you are using convention scanning, interfaces make discovery reliable.
Can feature slices work in a Blazor or Razor Pages project?
Yes. The same concept applies: group all code for a page or component together rather than splitting into separate Pages, Services, and Models folders. Razor Pages especially lends itself to this since each .cshtml.cs file already co-locates the page model with the page.
How should I handle background jobs or hosted services in a feature-sliced structure?
Background jobs that belong to a specific feature live in that feature's folder. A SendOverdueNotificationsJob lives in Features/Notifications/SendOverdueNotifications/. Shared infrastructure for running background jobs (job scheduling, hosting) lives in Shared/Infrastructure/.
What is the right way to share a lookup list between features?
If multiple features need the same lookup data (e.g., a list of project statuses), define a shared query in Shared/Queries/ or expose it through the database context directly. Avoid having one feature's handler call another feature's handler -- that creates hidden coupling between slices.
Is it worth having a Shared folder in a small project?
Yes, even in small projects. The Shared/ folder needs at minimum AppDbContext and your entity models. Starting with this separation from day one keeps the distinction clear as the project grows. Adding AppDbContext to Shared/Data/ takes thirty seconds and prevents the muddle that happens when shared infrastructure gets mixed into feature folders.

