The Scoped Singleton DI Bug Your AI Just Suggested

The Scoped→Singleton DI Bug Your AI Just Suggested (and how to catch it)

AI 刚刚建议的 Scoped→Singleton 依赖注入 Bug(以及如何捕获它)

Of all the bugs that ship to production silently, the captured-dependency lifetime bug is one of the most expensive. It compiles. It passes your tests. It runs fine in dev. Then in production, under load, it starts corrupting data across requests. And AI assistants suggest it constantly. Here’s why — and the one Cursor rule that catches it before merge. 在所有悄无声息地进入生产环境的 Bug 中,捕获依赖项生命周期(captured-dependency lifetime)Bug 是代价最高昂的 Bug 之一。它能通过编译,能通过测试,在开发环境中运行良好。然而在生产环境的高负载下,它会开始跨请求破坏数据。而 AI 助手却经常建议这种写法。以下是原因,以及一条能在合并前捕获它的 Cursor 规则。

The bug, in 30 lines

30 行代码引发的 Bug

You ask Cursor to add caching to OrderService. It gives you this: 你要求 Cursor 为 OrderService 添加缓存,它给出了以下代码:

// OrderService.cs
public class OrderService : IOrderService {
    private readonly IMemoryCache _cache;
    private readonly OrderDbContext _db;

    public OrderService(IMemoryCache cache, OrderDbContext db) {
        _cache = cache;
        _db = db;
    }

    public async Task<Order?> GetAsync(int id, CancellationToken ct) {
        if (_cache.TryGetValue(id, out Order? cached)) return cached;
        var order = await _db.Orders.FindAsync(new object[] { id }, ct);
        if (order is not null) _cache.Set(id, order, TimeSpan.FromMinutes(5));
        return order;
    }
}

// Program.cs
builder.Services.AddDbContext<OrderDbContext>(...);
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddMemoryCache(); // ← registers IMemoryCache as Singleton

Looks correct. Compiles. Tests pass. Shipped. 看起来没问题。能编译,测试通过,已发布。

What actually happens at runtime

运行时实际发生了什么

IMemoryCache is registered as Singleton — one instance for the entire app’s lifetime. OrderService is registered as Scoped — one instance per HTTP request. IMemoryCache 被注册为单例(Singleton)——整个应用程序生命周期内只有一个实例。OrderService 被注册为作用域(Scoped)——每个 HTTP 请求一个实例。

On its own, that’s fine. The problem is what you cached: an Order entity, which is in turn attached to OrderDbContext — also Scoped. The cache, alive for the lifetime of the application, now holds a reference to an entity attached to a DbContext that was disposed when the original request ended. 单独来看这没问题。问题在于你缓存的内容:一个 Order 实体,它又关联到了同样是 Scoped 的 OrderDbContext。缓存的生命周期贯穿整个应用程序,现在它持有一个引用,指向了一个在原始请求结束时就已经被销毁(disposed)的 DbContext 中的实体。

Now request #2 comes in. It hits the cache, gets the order, mutates a property. Then request #3 hits the cache, sees the mutation, and decides to write something else based on it. Then request #4 wakes up the entity’s dispose-tracking and explodes with ObjectDisposedException — but only sometimes, depending on the GC pressure that day. Welcome to the longest debugging session of your year. 现在请求 #2 进来了。它命中缓存,获取订单,并修改了一个属性。接着请求 #3 命中缓存,看到了这个修改,并基于此决定写入其他内容。然后请求 #4 触发了实体的销毁追踪,并抛出 ObjectDisposedException ——但这种情况时有时无,取决于当天的 GC(垃圾回收)压力。欢迎来到你今年最漫长的调试之旅。

Why AI assistants suggest this constantly

为什么 AI 助手总是建议这样做

The patterns the AI has seen most often in its training data — short examples, blog tutorials, StackOverflow answers — almost always omit DI registration. A typical “caching with IMemoryCache” snippet looks like ten lines, with no reference to where the service is registered or with what lifetime. AI 在训练数据中最常看到的模式——简短的示例、博客教程、StackOverflow 答案——几乎总是忽略依赖注入(DI)的注册。一个典型的“使用 IMemoryCache 缓存”的代码片段通常只有十行,且没有提及服务是在哪里注册的,或者生命周期是什么。

The AI learned the surface pattern (“inject IMemoryCache, call .Set”) without the surrounding constraint (“…unless the consumer is Scoped and the cached value graph reaches into Scoped infrastructure”). When you ask it to add caching to your codebase, it pattern-matches against the surface form. The constraint is invisible to it. AI 学会了表面模式(“注入 IMemoryCache,调用 .Set”),却忽略了周围的约束(“……除非消费者是 Scoped,且缓存的值图涉及 Scoped 基础设施”)。当你要求它为代码库添加缓存时,它会根据表面形式进行模式匹配。而约束对它来说是不可见的。

This isn’t a “the AI is dumb” critique. Most senior developers ship this exact bug at least once. The patterns in the wild teach the wrong lesson. 这不是在批评“AI 很笨”。大多数资深开发人员至少都会犯一次这个 Bug。现有的模式教导了错误的经验。

The five lifetime traps to teach the AI

教给 AI 的五个生命周期陷阱

If you’re going to enforce one set of rules on AI-suggested .NET code, make it these: 如果你打算对 AI 建议的 .NET 代码强制执行一套规则,请务必包含以下这些:

  1. Scoped or Transient injected into Singleton: The classic. A Singleton constructor takes IRepository (Scoped). The Singleton captures it forever. Requests share state. Data corrupts. Scoped 或 Transient 注入到 Singleton 中:经典错误。单例构造函数接收 IRepository(Scoped)。单例会永久捕获它。请求共享状态,导致数据损坏。

  2. DbContext captured by anything Singleton: Special case of #1 but worth its own callout. DbContext is always Scoped — it has to be, it tracks per-request state. Any Singleton that captures a DbContext is a bug. If you need DB access from a Singleton, inject IServiceScopeFactory and create a scope per operation. DbContext 被任何 Singleton 捕获:这是第 1 点的特殊情况,但值得单独强调。DbContext 始终是 Scoped 的——它必须如此,因为它跟踪每个请求的状态。任何捕获 DbContext 的单例都是 Bug。如果你需要从单例中访问数据库,请注入 IServiceScopeFactory 并为每个操作创建一个作用域。

  3. Cached entities still attached to a DbContext: The bug from the example. The cache outlives the DbContext, but holds a graph that depends on it. The rule: what goes into long-lived caches must be either (a) AsNoTracking()’d, (b) projected to a DTO, or (c) detached explicitly. 缓存的实体仍关联在 DbContext 上:示例中的 Bug。缓存的生命周期长于 DbContext,但持有一个依赖于它的对象图。规则:进入长效缓存的内容必须是 (a) 使用 AsNoTracking(),(b) 投影为 DTO,或 (c) 显式分离。

  4. HttpClient instantiated with new: A long-running app that does new HttpClient() on every call leaks sockets — eventually exhausting the connection pool. Even worse: a Singleton that captures a single HttpClient reuses DNS forever. The rule: always inject IHttpClientFactory and call CreateClient(name). Never new HttpClient() outside of one-shot scripts. 使用 new 实例化 HttpClient:一个长期运行的应用程序如果在每次调用时都 new HttpClient(),会导致套接字泄漏,最终耗尽连接池。更糟糕的是:捕获单个 HttpClient 的单例会永久复用 DNS。规则:始终注入 IHttpClientFactory 并调用 CreateClient(name)。除非是一次性脚本,否则永远不要 new HttpClient()

  5. Hosted services touching Scoped dependencies directly: IHostedService is Singleton-by-construction. Inject a Scoped repo into one and it’ll be alive for the lifetime of the process — every “scoped” operation will share state. Worse, the DbContext will leak. The rule: in any BackgroundService or IHostedService, never inject Scoped dependencies directly. Inject IServiceScopeFactory and create a scope per unit of work. 托管服务直接接触 Scoped 依赖项IHostedService 本质上是单例的。将 Scoped 存储库注入其中,它将在进程生命周期内一直存活——每个“作用域”操作都将共享状态。更糟糕的是,DbContext 会泄漏。规则:在任何 BackgroundServiceIHostedService 中,永远不要直接注入 Scoped 依赖项。注入 IServiceScopeFactory 并为每个工作单元创建一个作用域。

The Cursor rule that catches all five

捕获这五个问题的 Cursor 规则

The dotnet-di.mdc rule in Agentic Architect codifies the above. When Cursor is editing a file where DI is happening — Program.cs, Startup.cs, ServiceCollectionExtensions.cs, any class constructor — the rule activates and audits suggestions for: Agentic Architect 中的 dotnet-di.mdc 规则将上述内容进行了规范化。当 Cursor 编辑涉及 DI 的文件(如 Program.csStartup.csServiceCollectionExtensions.cs 或任何类构造函数)时,该规则会激活并审计以下建议:

  • Lifetime mismatches between consumer and constructor parameters
  • 消费者与构造函数参数之间的生命周期不匹配
  • Captured Scoped dependencies inside hosted services or background workers
  • 托管服务或后台工作线程中捕获的 Scoped 依赖项
  • Direct HttpClient instantiation
  • 直接实例化 HttpClient
  • Captured tracked entities in long-lived caches
  • 长效缓存中捕获的被追踪实体
  • Static helpers reaching into scoped infrastructure
  • 访问 Scoped 基础设施的静态辅助类

The trick is the scoping: it loads only on files where DI is actually happening — not on every prompt. Your token budget stays sane. The AI stays sharp on the file you’re actually in. 诀窍在于作用域:它仅在实际发生 DI 的文件上加载,而不是在每个提示词上都加载。你的 Token 预算保持在合理范围内,AI 也能专注于你当前所在的文件。

The bigger pattern: enforce, don’t suggest

更大的模式:强制执行,而非建议

The reframe that took me a year of using AI assistants to internalize is this: generic prompts ask the AI to suggest good patterns. Scoped rules force it to enforce them. “Be careful with DI lifetimes” is a suggestion. The AI will agree, nod sagely… 我花了一年时间使用 AI 助手才领悟到的核心转变是:通用的提示词只是要求 AI 建议好的模式,而作用域规则则是强制它执行这些模式。“小心 DI 生命周期”只是一条建议,AI 会点头称是……