Organizing a project by feature
Program.cs now contains startup configuration, authentication, and the entire CRUD API. When you change a Todo feature, you have to search through unrelated code. This chapter splits the project by feature, also called feature-first organization: keep a feature's entities, DTOs, services, and endpoints in the same directory.
This is a refactor: endpoint paths, request fields, response content, and permissions stay the same. Here is the new entry-point file:
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
using TodoApi.Data;
using TodoApi.Features.Auth;
using TodoApi.Features.Todos;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
var connectionString = builder.Configuration.GetConnectionString("Todos")
?? throw new InvalidOperationException("Missing ConnectionStrings:Todos configuration.");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<TodoService>();
builder.Services.AddTodoAuthentication();
builder.Services.AddCors(options => options.AddPolicy("LocalFrontend", policy =>
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.WithExposedHeaders("Location")));
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRouting();
app.UseCors("LocalFrontend");
app.UseAuthentication();
app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
await TodoDatabase.InitializeAsync(app.Services);
app.MapAuthEndpoints();
app.MapTodoEndpoints();
app.Run();The other complete files are grouped by feature below. The example runs independently and does not reference the previous chapter's project.
Complete Todo feature files
using Microsoft.AspNetCore.Http.HttpResults;
namespace TodoApi.Features.Todos;
public static class TodoEndpoints
{
public static void MapTodoEndpoints(this WebApplication app)
{
var todos = app.MapGroup("/todos").WithTags("Todos").RequireAuthorization();
todos.MapGet("/", (TodoService service) => service.ListAsync());
todos.MapGet("/{id:int}", GetAsync);
todos.MapPost("/", CreateAsync).ProducesProblem(400).RequireAuthorization("CanWriteTodos");
todos.MapPut("/{id:int}", ReplaceAsync).ProducesProblem(400).RequireAuthorization("CanWriteTodos");
todos.MapDelete("/{id:int}", DeleteAsync).RequireAuthorization("CanWriteTodos");
}
private static async Task<Results<Ok<TodoResponse>, NotFound>> GetAsync(int id, TodoService service)
{
var todo = await service.FindAsync(id);
return todo is null ? TypedResults.NotFound() : TypedResults.Ok(todo);
}
private static async Task<Results<Created<TodoResponse>, ProblemHttpResult>> CreateAsync(CreateTodo input, TodoService service)
{
var todo = await service.CreateAsync(input);
return todo is null ? TypedResults.Problem(statusCode: 400, title: "Category not found")
: TypedResults.Created($"/todos/{todo.Id}", todo);
}
private static async Task<Results<NoContent, NotFound, ProblemHttpResult>> ReplaceAsync(int id, ReplaceTodo input, TodoService service) =>
await service.ReplaceAsync(id, input) switch
{
ReplaceOutcome.Updated => TypedResults.NoContent(),
ReplaceOutcome.TodoNotFound => TypedResults.NotFound(),
_ => TypedResults.Problem(statusCode: 400, title: "Category not found")
};
private static async Task<Results<NoContent, NotFound>> DeleteAsync(int id, TodoService service) =>
await service.DeleteAsync(id) ? TypedResults.NoContent() : TypedResults.NotFound();
}using Microsoft.EntityFrameworkCore;
using TodoApi.Data;
namespace TodoApi.Features.Todos;
public class TodoService(TodoDbContext db)
{
public Task<List<TodoResponse>> ListAsync() =>
db.Todos.AsNoTracking().OrderBy(t => t.Id)
.Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).ToListAsync();
public Task<TodoResponse?> FindAsync(int id) =>
db.Todos.AsNoTracking().Where(t => t.Id == id)
.Select(t => new TodoResponse(t.Id, t.Title, t.Done, t.CategoryId)).SingleOrDefaultAsync();
// Null means the target category does not exist; the endpoint determines the HTTP status code.
public async Task<TodoResponse?> CreateAsync(CreateTodo input)
{
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId)) return null;
var todo = new Todo { Title = input.Title, CategoryId = input.CategoryId };
db.Todos.Add(todo);
await db.SaveChangesAsync();
return new TodoResponse(todo.Id, todo.Title, todo.Done, todo.CategoryId);
}
public async Task<ReplaceOutcome> ReplaceAsync(int id, ReplaceTodo input)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return ReplaceOutcome.TodoNotFound;
if (!await db.Categories.AnyAsync(c => c.Id == input.CategoryId)) return ReplaceOutcome.CategoryNotFound;
todo.Title = input.Title;
todo.Done = input.Done;
todo.CategoryId = input.CategoryId;
await db.SaveChangesAsync();
return ReplaceOutcome.Updated;
}
public async Task<bool> DeleteAsync(int id)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return false;
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return true;
}
}using System.ComponentModel.DataAnnotations;
namespace TodoApi.Features.Todos;
public record CreateTodo(
[Required, StringLength(100)] string Title,
[Range(1, int.MaxValue)] int CategoryId);
public record ReplaceTodo(
[Required, StringLength(100)] string Title,
bool Done,
[Range(1, int.MaxValue)] int CategoryId);
public record TodoResponse(int Id, string Title, bool Done, int CategoryId);
public enum ReplaceOutcome { Updated, TodoNotFound, CategoryNotFound }namespace TodoApi.Features.Todos;
public class Todo
{
public int Id { get; set; }
public string Title { get; set; } = "";
public bool Done { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Todo> Todos { get; set; } = [];
}Complete authentication and database files
namespace TodoApi.Features.Auth;
public static class AuthConfiguration
{
public static IServiceCollection AddTodoAuthentication(this IServiceCollection services)
{
services.AddAuthentication("Bearer").AddJwtBearer();
services.AddAuthorization(options => options.AddPolicy("CanWriteTodos",
policy => policy.RequireAuthenticatedUser().RequireRole("editor")));
return services;
}
}using System.Security.Claims;
namespace TodoApi.Features.Auth;
public static class AuthEndpoints
{
public static void MapAuthEndpoints(this WebApplication app) =>
app.MapGet("/me", (ClaimsPrincipal user) => new { Name = user.Identity?.Name })
.RequireAuthorization();
}using Microsoft.EntityFrameworkCore;
using TodoApi.Features.Todos;
namespace TodoApi.Data;
public class TodoDbContext(DbContextOptions<TodoDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
public DbSet<Category> Categories => Set<Category>();
}using Microsoft.EntityFrameworkCore;
using TodoApi.Features.Todos;
namespace TodoApi.Data;
public static class TodoDatabase
{
public static async Task InitializeAsync(IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
await db.Database.EnsureCreatedAsync();
if (!await db.Categories.AnyAsync())
{
db.Categories.AddRange(new Category { Name = "Work" }, new Category { Name = "Life" });
await db.SaveChangesAsync();
}
}
}Run the tests before starting the app
Run from the repository root:
cd samples/22-project-structure
dotnet test --project Tests/TodoApi.Tests.csprojYou should still see 11 passed, 0 failed, 0 skipped. The tests and assertions are the same as in Chapter 21; because the types have moved into a namespace, the test project only adds an import:
global using TodoApi.Data;
global using TodoApi.Features.Todos;For a manual run, first stop services from other chapters, then create a development token for this chapter:
dotnet user-jwts create --name alice --role editor --valid-for 1h --output token
dotnet runIn another terminal, store the full token in the TOKEN variable as described in Chapter 18, then make a request:
curl -H "Authorization: Bearer $TOKEN" http://localhost:5080/todosOn the first run, this chapter's separate todos-22.db contains no tasks, so the response is []. The create, update, and delete commands from Chapters 17–20 still apply.
Find the feature, then its files
The main code is organized as follows; configuration and test files are also part of this chapter's project:
22-project-structure/
├── Program.cs
├── Features/
│ ├── Todos/
│ │ ├── Todo.cs
│ │ ├── TodoDtos.cs
│ │ ├── TodoService.cs
│ │ └── TodoEndpoints.cs
│ └── Auth/
│ ├── AuthConfiguration.cs
│ └── AuthEndpoints.cs
├── Data/
│ ├── TodoDbContext.cs
│ └── TodoDatabase.cs
└── Tests/For example, to add an editable field to Todo, start in Features/Todos to find the stored properties, input and output types, and handling logic. Do not start by looking for the entity in a global Models folder, then the service in Services, and finally the route in Endpoints.
This organization follows mini-store-api: entities, DTOs, services, and endpoints belong to their feature, while the shared context stays in Data. The categories here have only two fixed values and belong to the Todo feature, so they do not need a separate Categories module yet.
What belongs in each file?
| File | Contents | Example change |
|---|---|---|
Todo.cs | Todo and Category entities | Add a property to store in the database |
TodoDtos.cs | Request, response, and feature operation result types | Change which fields clients can submit |
TodoService.cs | Queries, category-existence checks, and save operations | Change a business rule for tasks |
TodoEndpoints.cs | Routes, permissions, parameter binding, and HTTP results | Change a path or write permission |
TodoDbContext.cs | Shared table access and database mappings | Configure an entity relationship |
Program.cs | Register services, arrange middleware, and mount features | Add a new feature entry point |
TodoService does not accept HttpContext or decide whether to return 400 or 404. For example, the replacement operation returns ReplaceOutcome, which the endpoint maps to an HTTP result. The enum simply names the three outcomes “updated,” “task not found,” and “category not found,” rather than making you guess what a number means.
Request-field validation still happens through the built-in validation on the endpoint; the service still queries the database to check whether a category exists. Moving files has not changed the responsibility of either check.
How MapTodoEndpoints connects back to Program
TodoEndpoints is a static class. Its MapTodoEndpoints method takes this WebApplication app as its first parameter. This is an extension method, so the entry point can call app.MapTodoEndpoints() while the implementation still uses familiar methods such as MapGroup, MapGet, and MapPost.
You can pass a handler method directly to methods such as MapGet instead of writing every handler as a long lambda. GetAsync on line 11 is a method in this file. See Minimal API route handlers.
namespace TodoApi.Features.Todos; declares a namespace that identifies where a type belongs. The entry point uses using TodoApi.Features.Todos to access these types and extension methods. Keeping directories and namespaces aligned makes code easier to find, though C# does not require them to match.
Technical detail
C# 14 also supports extension member blocks. This chapter keeps the supported extension-method syntax with a this parameter; only one method is needed here, so there is no reason to introduce another syntax structure.
Why keep the shared DbContext in Data?
Entities belong to a feature, but a request may change data in several features at once. A shared context gives those operations a chance to complete in one save or transaction. Organizing directories by feature does not require one database per directory.
TodoService is registered as Scoped, matching the context's default lifetime. This project has only one implementation, so inject the concrete class directly; this chapter does not add an ITodoService or Repository just for it.
There is no need to create an empty Common directory first. If code really becomes shared by several features, decide then whether it belongs in its own place. CORS configuration stays in the entry point because it applies to the whole API.
When is splitting worthwhile?
This Todo API also works with DbContext used directly in its endpoints. The Service is extracted here to demonstrate the organization in mini-store-api, where “the endpoint chooses the HTTP response and the service performs the business operation.” This is not a Minimal API requirement.
If a new feature has only two simple queries, start with its endpoints and related types in a feature directory. Extract operations into a Service when business rules grow or need to be reused. Directories should make code easier to find; every feature does not need the same number of files.
FastAPI comparison
This is similar to organizing routers, schemas, and business functions by domain, then registering the routers at the application entry point. In ASP.NET Core, extension methods mount endpoints and dependency injection supplies services.
Summary
- Feature-first organization keeps a feature's entities, DTOs, service, and endpoints together.
- Endpoints handle HTTP and permissions; services handle data and business rules; the shared context stays in Data.
- Extension methods connect feature routes to the entry point, while Program.cs keeps startup configuration and middleware order.
- Do not add empty files to satisfy a folder convention or add an interface layer for a single implementation by default.
- Verify the refactor with the same requests and assertions from the previous chapter to preserve API behavior.
Next: Publishing and deployment—publish the organized application. Previous: Testing.
