Skip to content
Go back

Claude Code 改 .NET 端点:先划清边界

「给这个端点加个分页校验。」你把这句话丢给 Claude Code,它读了几个文件,交回一个 diff:加了范围判断,也补了测试。看起来对,问题是你凭什么合并它。

Diff 小不等于边界清楚。一个看似局部的改动会同时碰到好几层:查询参数绑定、默认值、校验错误格式、授权、租户过滤、响应形状、下游查询是否被执行、以及测试到底证明了什么。这些层里有一层被顺手改掉,客户端就会发现。

Nick Cosentino 在 Dev Leader 上这篇讲的就是怎么把这种改动框成一次可审查的边界。在这篇文章里,Claude Code 的角色就是 coding agent:它能在仓库里读代码、改文件、执行命令,价值不是替你设计系统,而是找出相关契约、做出最小的完整改动。

一次改动能不能合并,看的是端点契约有没有被写清、被证据覆盖、被 diff 保住,而不是 diff 有多小。

下面按「先划边界,再让它动手」这条线重述,并把原文散在各节的判断整理成六步。第三步的七次 HTTP 检查,我在本机用 .NET 10 SDK 复现了一遍,其中有一项结果值得单独说。

前置条件

如果你只是想读懂这篇文章,不需要装 Claude Code;但想复现第三步的表格,必须有 .NET 10 SDK。

第一步:把改动写成可验收的契约

「加分页校验」听起来精确,其实留了一堆没定的问题:默认值是多少?零允许吗?上限在哪?API 的错误格式是什么?非法输入会不会真的打到数据库?

示例端点的初始代码如下(OrderCatalog 和 OrderPage 在第三步的完整示例里定义)。缺陷落在 ReadPage 那一行:所有解析成功的整数都会直接进查询。

app.MapGet("/orders", (int? pageSize, OrderCatalog catalog) =>
{
    var resolvedPageSize = pageSize ?? 20;
    return Results.Ok(new OrderPage(
        resolvedPageSize,
        catalog.ReadPage(resolvedPageSize)));
});

示例把契约收得很窄:

输入期望结果要保住的既有行为
省略 pageSize200,pageSize 为 20默认值不变
pageSize=1200,返回 1 条下边界包含在内
pageSize=100200,pageSize 报告为 100上边界包含在内
0、101、负数400,沿用现有错误格式不触达查询服务
abc400绑定失败的路径不被改变

这三个数字(20、1、100)是示例的业务验收条件,不是 ASP.NET Core 的默认值。框架不知道你的分页策略;真实仓库里的默认值和范围要从已批准的需求和现有行为里推出来。

边界还要覆盖那些「不该被这次改动碰到」的东西:授权、租户过滤、排序、取消行为、成功响应的字段和类型。如果这些无法从仓库里确认,让 agent 先报告不确定,而不是先改。

原文有个说法值得记住:比「只改 handler 文件」更好的边界定义,是「这个端点的契约」。一次完整的行为改动可能同时涉及 handler、回归测试和接口文档。范围单位是契约,不是文件数。

第二步:先 trace,再让它动手

Claude Code 的官方文档列出的能力是仓库读取、文件编辑和命令执行,并在常见工作流里建议先定位相关代码、理清交互,再动手改。

对现有端点,第一件事是要求一份「从路由到查询」的追踪:参数在哪绑定、默认值在哪产生、哪一层做校验、哪个服务执行查询。这是一次只读发现,提示词里要把禁止事项写死:

Set-Location 'C:\repos\OrderService'
claude "追踪 GET /orders:从 query binding 到 query service,以及它的 HTTP 测试。指出当前 pageSize 的默认值、授权、租户过滤、错误格式和排序,并给出相关文件名和行号。不要修改文件、安装包、运行构建或测试、连接数据库、提交或发布。"

换成英文提示词也可以,要点不在语言,而在这四条:只读发现、指出文件位置、覆盖既有契约、明确禁止执行。

期望的输出是一段带文件引用的具体说明。一段「Minimal API 通常这样绑定参数」的通用介绍不算,一个绕过你还没看过的校验过滤器的实现方案也不算。

仓库里的说明文件(例如 CLAUDE.md)能提供约定,但官方 memory 文档把它定位成上下文,而不是强制约束。所以权限要单独配,尤其在环境里有凭据或连了外部工具的时候。

还有一条容易混:读测试文件和运行测试是两件事。后者可能启动服务、连上配置好的依赖,属于验证环境该做的决定,不该混在发现阶段顺手做掉。

第三步:分清绑定失败和校验失败

ASP.NET Core 通过参数绑定把请求数据转成 handler 参数。一个数字型查询参数因此引出两个不同的问题:框架能不能把输入解析成声明的类型?解析成功之后,值是否满足业务规则?

pageSize=abc 和 pageSize=-1 不是同一条路径。前者是绑定失败,后者是绑定成功但被业务规则拒绝。通过其中一个的测试,不能证明另一个。

省略输入是第三条路径。把可选的 int? 改成必填的 int,会让「省略就取默认 20」变成「绑定失败、400」,这是很容易被顺手改掉的客户端行为。

还有一层:查询返回空列表,和拒绝请求不是一个东西。对负数返回空结果,客户端看到的是 200 加空数组;契约要求的是 400。客户端可见的状态码和下游是否被执行,都属于契约。

这次复现:七次 HTTP 检查

要跑这七次检查,先有一个能跑的最小项目。两个文件就够,不需要额外的 NuGet 包:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

改动后的完整 Program.cs:

using System.Collections.Immutable;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<OrderCatalog>();

var app = builder.Build();

app.MapGet("/orders", IResult (int? pageSize, OrderCatalog catalog) =>
{
    var resolvedPageSize = pageSize ?? 20;

    if (resolvedPageSize < 1 || resolvedPageSize > 100)
    {
        return Results.BadRequest(new ApiError(
            "invalid_page_size",
            "pageSize must be between 1 and 100."));
    }

    return Results.Ok(new OrderPage(
        resolvedPageSize,
        catalog.ReadPage(resolvedPageSize)));
});

app.Run();

public sealed class OrderCatalog
{
    private readonly ImmutableArray<OrderSummary> _orders =
    [
        new(1, "First order"),
        new(2, "Second order"),
        new(3, "Third order")
    ];

    public ImmutableArray<OrderSummary> ReadPage(int pageSize)
    {
        return _orders.Take(pageSize).ToImmutableArray();
    }
}

public sealed record OrderSummary(int Id, string Description);

public sealed record OrderPage(
    int PageSize,
    ImmutableArray<OrderSummary> Items);

public sealed record ApiError(string Code, string Message);

写成 IResult 是为了让返回类型一眼可见;Results.Ok 和 Results.BadRequest 本来就返回 IResult,这个 handler 也可以走类型推断。官方文档还提供了 TypedResults 和结果联合类型,类型信息更强,但一次范围校验任务不需要顺手做这个重构。

这不是生产脚手架:它故意没有认证、租户模型、数据库、日志提供者,也没有联网依赖。别把它整段套用到现有服务上。

把项目文件命名为 OrdersBounded.csproj,和 Program.cs 放在同一个目录,编译后指定端口启动:

dotnet build
dotnet run --urls http://127.0.0.1:5099

看到 Now listening on: http://127.0.0.1:5099 之后,在另一个终端跑下面的检查。我这个工程放在临时目录,用 .NET 10 SDK 10.0.402 编译(0 警告 0 错误),然后跑了七次检查:

$base = "http://127.0.0.1:5099"
$cases = @(
  @{ Name = "omitted"; Url = "/orders" },
  @{ Name = "1"; Url = "/orders?pageSize=1" },
  @{ Name = "100"; Url = "/orders?pageSize=100" },
  @{ Name = "0"; Url = "/orders?pageSize=0" },
  @{ Name = "101"; Url = "/orders?pageSize=101" },
  @{ Name = "-1"; Url = "/orders?pageSize=-1" },
  @{ Name = "abc"; Url = "/orders?pageSize=abc" }
)
foreach ($c in $cases) {
  $r = Invoke-WebRequest -Uri ($base + $c.Url) -SkipHttpErrorCheck
  "{0,-8} {1} {2}" -f $c.Name, $r.StatusCode, $r.Content
}

实际输出:

输入状态码响应体说明
省略200{"pageSize":20,"items":[…3 条…]}默认值生效
1200{"pageSize":1,"items":[…1 条…]}下边界包含
100200{"pageSize":100,"items":[…3 条…]}上边界包含,见下方限制
0400{"code":"invalid_page_size","message":"…"}业务规则拒绝
101400同上业务规则拒绝
-1400同上原缺陷在这里被修掉
abc400空绑定失败,走了另一条路径

最后一行是这次复现里最有用的一处观察:abc 确实返回 400,但响应体是空的,没有走示例自定义的 ApiError。原因很直接——参数绑定失败发生在进入 handler 之前,框架直接回了 400,你的错误格式根本没机会执行。

这正好说明为什么「区分绑定失败和校验失败」不是学术问题:如果客户端依赖 {"code":"invalid_page_size", …} 这个结构去提示用户,那么 pageSize=abc 这一路它拿到的是空 body,前端解析会失败。原文提醒这个示例「不承诺绑定失败也用同一个错误形状」,实测把它变成了可见的现象。

这次复现证明什么,没证明什么

证明了:默认值、两个边界都被接受(item 数量见下)、三种越界拒绝、绑定失败的状态码,都与预期一致。

没证明:真实应用的授权、租户过滤、持久化、错误中间件的行为。

还有个细节:pageSize=100 返回 pageSize: 100 但只有 3 条数据,因为示例目录里只塞了 3 行。这个结果证明不了 100 行的边界,要证明 item 数量,fixture 里得有足够的数据,或者断言只能落在「接受的 pageSize」上。原文专门提醒了这一点。

第四步:先写会失败的测试,再让它实现

写代码之前先把验收矩阵定下来。每一行要写清:期望状态码、响应契约、以及下游查询是否允许执行。

矩阵最小覆盖:省略输入(证明默认值兼容)、1 和 100(证明闭合边界)、0 和 101(证明邻近越界被拒)、一个负数(让原缺陷显式化)、一个非数字(证明绑定路径没被忽略)。

不要换成「让 Claude Code 写七个测试」。一个参数化测试能覆盖好几组输入,而授权和下游行为可能需要单独的测试。重要的是断言的行为,不是测试的数量。

在已有仓库里,顺序应该是:先写回归测试,在基线上证明它失败;再让 agent 实现改动,证明同一个测试通过。没有基线失败这一步,绿灯可能来自一个根本没戳中缺陷的测试。

选测试类型同样有讲究:

还有一类测试很容易看起来很强:只在响应成功时才执行的断言。如果成功是前提,就无条件先断言成功再检查 body;如果拒绝是要求,就直接断言拒绝。在失败路径上跳掉关键断言的测试,给不出回归覆盖。

本文不提供完整的测试工程:示例本身没有测试宿主,加 xUnit 会引入它自己的依赖。真正要固定下来的是断言清单——第三步那段 PowerShell 循环就是最小可跑的集成检查,换到真实仓库里,把它变成已有测试框架的参数化用例即可。

第五步:把构建、测试、发布留在编辑请求之外

官方的 permissions 文档讲的是运行时强制的规则和模式差异。这些控制应该反映你允许的工作范围,而不是把所有开发者命令当成可互换的东西。

一个构建可以触发自定义 target;一次测试可能启动带外部依赖的 fixture;一次迁移会改持久化状态;一条发布命令会把结果暴露给用户。一个分页校验任务并不隐含地授权这四件事。

如果验证在远端跑,记录四样东西:确切的 revision、命令、环境、结果。并且保证「被验证的版本」就是「被审查的版本」——上一个 commit 的绿灯不证明最终代码的行为。

第六步:把 diff 当契约变更来审

Claude Code 实现完之后,别只看那个 if。按这张清单过一遍:

检查项要看什么
参数形状是否仍是可选 int?,默认值是否还是 20
响应契约成功响应的字段名、类型、集合形状有没有变
路由上下文路由是否还在原来的授权分组里,没被搬出去
测试有效性测试是否真的发了 0、101、负数和 abc,是否断言了响应体
数据充分性fixture 的数据量够不够支撑 item 数量的断言
隐藏失败有没有用 skip 或宽泛的异常处理把无关失败藏起来
文件范围新增包、schema 变更、日志重写、路由搬移,有没有具体理由

最后一项最容易放水。一次范围校验不需要迁移,也不需要新包。如果实现里出现了 schema 或依赖变更,那是一个重新确认范围的信号,而不是顺手批准的「清理」。日志和追踪同理:校验改动不该变成一次无关的日志配置重写。

常见问题

为什么先 trace 而不是直接让 agent 生成代码?

Trace 确立了输入、默认值、校验和查询各自住在哪一层。没有这一步,一个看起来合理的实现很容易绕过既有契约,或者把规则重复到错误的层。

pageSize 默认 20 是 ASP.NET Core 的默认值吗?

不是。它是这篇文章里的示例业务要求。真实端点的默认值和允许范围必须来自它自己的契约。

非数字和 0 走的是同一条路径吗?

不是。非数字在参数绑定阶段就失败了,走框架的 400 且不经过 handler;0 是绑定成功的整数,被业务规则拒绝。这次的实测输出把它们区分得很清楚:前者 body 为空,后者是自定义错误载荷。

示例可以直接盖到生产端点上吗?

不行。示例用匿名访问、内存数据隔离了校验逻辑,它的 HTTP 检查覆盖不到你的生产契约。真实应用的认证、租户、持久化、可观测性和错误约定都要保留。

handler 单元测试能证明 HTTP 契约吗?

不能完全证明。路由、绑定、中间件、授权、序列化需要对应的集成证据;隔离测试仍然适合更小的逻辑边界。

验证没跑的时候,agent 应该怎么报告?

它应该说清「代码已写但未执行」、列出缺了哪些检查、并且不要声称成功。这样审查才能把代码阅读和运行时证据分开。

下一步

下次把端点改动交给 coding agent,先把契约表和七种输入的断言写好,再让它 trace、再让它实现。它交回 diff 之后,按第六步那张表过一遍。

验证没跑过就别合并。契约写清、证据可复现、边界被 diff 保住,三样都在,这次改动才算有边界——diff 是三行还是三十行并不重要。

如果你也在把 coding agent 接进日常开发,Aide Hub 会继续写 .NET、开发工具和 AI 助手的落地经验。

参考


Tags


Previous

ASP.NET Core 中同时支持多个认证方案

Next

带 coding agent:先分清卡在哪,再决定给什么