按功能组织项目
现在 Program.cs 里有启动配置、认证和整套 CRUD。修改 Todo 时,要在这些不相关的代码之间来回找。这一章按功能拆分,也叫 feature-first(按功能组织):同一功能的实体、DTO、服务和端点放在同一个目录。
这是一次重构(refactoring):接口路径、请求字段、响应内容和权限都不变。拆分后的入口文件如下:
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();其余完整文件按功能列在下面;示例可独立运行,不引用上一章的项目。
Todo 功能的完整文件
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; } = [];
}认证与数据库的完整文件
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();
}
}
}先跑测试,再启动应用
从仓库根目录执行:
cd samples/22-project-structure
dotnet test --project Tests/TodoApi.Tests.csproj仍然应是 11 个通过、0 个失败、0 个跳过。测试请求和断言与第 21 章相同;因为类型搬进了命名空间,测试只增加了导入:
global using TodoApi.Data;
global using TodoApi.Features.Todos;手动运行时,先停止其他章的服务,为本章生成开发令牌:
dotnet user-jwts create --name alice --role editor --valid-for 1h --output token
dotnet run在另一个终端中,按第 18 章的方式将完整令牌存入 TOKEN 变量,再请求:
curl -H "Authorization: Bearer $TOKEN" http://localhost:5080/todos首次运行时,本章独立的 todos-22.db 没有任务,返回 []。第 17~20 章的创建、修改、删除命令仍然适用。
先找功能,再找文件
主要代码的目录如下,配置和测试文件也保留在本章项目中:
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/例如给 Todo 增加一个可编辑字段,先打开 Features/Todos,在这里找到存储属性、输入输出类型和处理逻辑。不要先到全局 Models 找实体,再到 Services 找服务,最后到 Endpoints 找路由。
组织方式参考 mini-store-api:实体、DTO、Service、Endpoints 归所属功能,共享上下文留在 Data。这里的分类只有两个固定值,属于 Todo 功能,还不需要单独创建一个 Categories 模块。
每个文件负责什么
| 文件 | 放什么 | 修改示例 |
|---|---|---|
Todo.cs | Todo 和 Category 实体 | 增加要存入数据库的属性 |
TodoDtos.cs | 请求、响应以及本功能的操作结果类型 | 调整客户端可以提交的字段 |
TodoService.cs | 查询、分类存在性检查和保存操作 | 修改任务的业务规则 |
TodoEndpoints.cs | 路由、权限、参数接收和 HTTP 结果 | 调整路径或写入权限 |
TodoDbContext.cs | 共享的表入口和数据库映射 | 配置实体关系 |
Program.cs | 注册服务、安排中间件、挂载功能 | 添加一个新的功能入口 |
TodoService 不接收 HttpContext,也不决定返回 400 还是 404。例如替换操作返回 ReplaceOutcome,端点再映射为 HTTP 结果。这里的 enum(枚举) 只是给“更新成功、任务不存在、分类不存在”三个结果起名字,避免用数字猜含义。
请求字段校验仍由端点上的内置校验完成;分类是否存在仍由服务查询数据库。移动文件没有改变这两项检查的职责。
MapTodoEndpoints 怎样接回 Program
TodoEndpoints 是一个静态类。MapTodoEndpoints 的第一个参数写成 this WebApplication app,这是扩展方法(extension method):于是入口可以写 app.MapTodoEndpoints(),里面仍然是熟悉的 MapGroup、MapGet、MapPost。
处理方法可以直接作为参数传给 MapGet 等方法,不必都写成长 lambda。第 11 行的 GetAsync 就是本文件里的方法名。Minimal API 路由处理方法
namespace TodoApi.Features.Todos; 是命名空间(namespace)声明,用来区分类型所属位置。入口通过 using TodoApi.Features.Todos 使用这些类型和扩展方法。目录与命名空间保持对应,便于查找;C# 不要求二者必须一致。
技术细节
C# 14 也支持扩展成员块。本章保留 this 参数的扩展方法写法,它仍然受支持,而且这里只需定义一个方法,不必额外引入新的语法结构。
为什么共享 DbContext 留在 Data
业务实体归功能所有,但一个请求可能同时修改多个功能的数据。共享上下文让这些操作有机会在一次保存或同一个事务中完成。按功能分目录,并不要求一个目录配一个数据库。
TodoService 注册为 Scoped,和上下文的默认生命周期一致。当前项目只有一个实现,直接注入这个具体类即可;本章没有为它额外增加 ITodoService 或 Repository。
也不需要先建立一个空的 Common 目录。确实出现多个功能共用的代码时,再判断它是否适合提取。本章保留 CORS 配置在入口,是因为它服务于整个 API。
拆分是否值得
这个 Todo API 直接在端点里使用 DbContext 也能工作。这里提取 Service,是为了演示 mini-store-api 中“端点决定 HTTP 响应,服务完成业务操作”的组织方式;它不是 Minimal API 的硬性要求。
一个新功能只有两条简单查询时,可以先把端点和相关类型放在功能目录里。业务规则变多、需要复用时,再把操作提取到 Service。目录是为了缩小查找范围,不是要求每个功能必须凑齐相同数量的文件。
FastAPI 对照
这类似按业务包组织 router、schema 和业务函数,再在应用入口注册 router。ASP.NET Core 这里通过扩展方法挂载端点,通过依赖注入提供服务。
总结
- feature-first 把同一功能的实体、DTO、服务和端点放在一起。
- 端点负责 HTTP 和权限,服务处理数据与业务规则,共享上下文留在 Data。
- 扩展方法把功能路由接回入口;Program.cs 保留启动配置和中间件顺序。
- 不为目录形式补空文件,也不强制给单个实现增加接口层。
- 用上一章相同的请求与断言验证重构,保持接口行为不变。
