The Law of Leaky Abstractions: Learn One Layer Below

By Sergey Nosov

15 October 2026

This loop prints the customer’s name for every order, using Entity Framework Core:

using var db = new ShopContext();
var orders = db.Orders.ToList();

foreach (var order in orders)
{
    Console.WriteLine(order.Customer.Name);
}

Nothing in the loop mentions a database. Run it against 1,000 orders, each placed by a different customer, and it can send 1,001 SQL queries. Remove one line of configuration, and it sends one query and then throws a NullReferenceException. The loop did not change; a setting elsewhere did.

Joel Spolsky named this kind of behavior in an essay on his blog, Joel on Software, on 11 November 2002. His Law of Leaky Abstractions reads: “All non-trivial abstractions, to some degree, are leaky.”

This article works through the law with that loop as the main example: why it leaks, how to see the leak, and why the obvious fix leaks too. Then it follows the law through three more abstractions and turns to the habit I take from it: learn one layer below the abstraction you use every day. The samples were checked on .NET 10 with EF Core 10 and SQLite.

What Spolsky Meant

Spolsky had cofounded Fog Creek Software in 2000 and later created Stack Overflow with Jeff Atwood. His essay opens with TCP, which he calls “a way to transmit data that is reliable,” built on IP, which promises nothing of the kind. TCP hides lost and reordered packets so well that most programmers never think about them. That is what an abstraction is, in his words: “a simplification of something much more complicated that is going on under the covers.”

Then he admits to a “little fib”: TCP does not guarantee delivery after all. “If your pet snake has chewed through the network cable leading to your computer, and no IP packets can get through, then TCP can’t do anything about it and your message doesn’t arrive.” On a congested network, “TCP will work, but everything will be really slow.” The program sees a timeout or a crawl, symptoms that make sense only one layer down. In Spolsky’s words, “the network leaks through the abstraction and you feel the things that the abstraction can’t quite protect you from.”

His other examples span the stack. With virtual memory, the direction in which you walk a large two-dimensional array can decide how many page faults you take. With SQL, “certain SQL queries are thousands of times slower than other logically equivalent queries.” Network file systems such as NFS and SMB let you treat remote files “as if” they were local, until the connection slows or drops.

The law is not an argument against abstractions; Spolsky credits them with letting programmers take on GUI and network programming. It is a statement about their cost, which he put in one sentence: “So the abstractions save us time working, but they don’t save us time learning.”

Anatomy of a Leak: The N+1 Query

The opening loop sends its extra queries only when lazy loading is on. EF Core documents three common ways to load related data: eagerly, with the original query; explicitly, when you ask; or lazily. Lazy loading, in the documentation’s word, happens “transparently” the moment code reads a navigation property.

In EF Core, lazy loading is opt-in. The simplest way to opt in is to install the Microsoft.EntityFrameworkCore.Proxies package and call UseLazyLoadingProxies. EF Core then lazy-loads every navigation property that is virtual and on a class it can inherit from. Entity Framework 6, the predecessor of EF Core, makes lazy loading opt-out. Its API reference states that “lazy loading is enabled by default,” so there a virtual navigation property is all the loop needs to leak.

In the model behind the opening loop, Order.Customer is virtual and the context calls UseLazyLoadingProxies. To run, it also needs the SQLite provider, Microsoft.EntityFrameworkCore.Sqlite, and a shop.db that already holds orders and customers:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public virtual Customer Customer { get; set; } = null!;
}

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

public class ShopContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnConfiguring(
        DbContextOptionsBuilder options) =>
        options
            .UseSqlite("Data Source=shop.db")
            .UseLazyLoadingProxies()
            .LogTo(Console.WriteLine,
                [RelationalEventId.CommandExecuted]);
}

The LogTo call prints each SQL command EF Core executes. The first query reads the orders, and every customer the context has not seen yet costs one more:

-- First, the orders
SELECT "o"."Id", "o"."CustomerId"
FROM "Orders" AS "o"

-- Then, for each new customer
SELECT "c"."Id", "c"."Name"
FROM "Customers" AS "c"
WHERE "c"."Id" = @p
LIMIT 1

With 1,000 orders from 1,000 different customers, the loop sends 1,001 queries. With 1,000 orders from 100 customers, it sends 101: once EF Core loads a customer, it fills in the Customer property of every tracked order that refers to it. So here the N in N+1 counts customers, not orders. Only tracked orders get that help: read them with AsNoTracking, and the same loop sends 1,001 queries for 100 customers.

Microsoft’s performance guide shows the pattern with blogs and posts. In its example, reading each blog’s posts costs another query and round trip, and the guide warns that the pattern “can cause very significant performance issues.” If each round trip takes 2 milliseconds, 1,000 of them add 2 seconds to the page.

Without UseLazyLoadingProxies, the loop sends one query, every order.Customer is null, and the first iteration throws. That holds unless the context already tracks some of those customers. EF Core fixes up navigation properties to entities loaded earlier, so those orders arrive with their customers attached. The same loop can print every name, throw at once, or throw partway through. A navigation property looks like a field, but underneath it is a question for the context: is lazy loading on, and what has it already loaded?

Two Fixes and the SQL They Send

Both fixes move the decision into the query, where a reader can see it. Include loads each order’s customer in the same query, with a JOIN:

var orders = db.Orders
    .Include(o => o.Customer)
    .ToList();
SELECT "o"."Id", "o"."CustomerId",
       "c"."Id", "c"."Name"
FROM "Orders" AS "o"
INNER JOIN "Customers" AS "c"
    ON "o"."CustomerId" = "c"."Id"

A projection asks only for the columns it names:

var rows = db.Orders
    .Select(o => new { o.Id, o.Customer.Name })
    .ToList();
SELECT "o"."Id", "c"."Name"
FROM "Orders" AS "o"
INNER JOIN "Customers" AS "c"
    ON "o"."CustomerId" = "c"."Id"

Each sends one query, with lazy loading on or off. The performance guide recommends this direction: “Because lazy loading makes it extremely easy to inadvertently trigger the N+1 problem, it is recommended to avoid it.”

Notice what the fixes required. Nobody wrote SQL; both stay in LINQ. But seeing the problem took reading the SQL log, and choosing a fix took knowing what a JOIN returns.

The Fix Leaks Too

Include on a reference such as Customer adds one JOIN and keeps one row per order. Include two collections side by side, such as a blog’s posts and its contributors, and the rows multiply. Microsoft’s documentation gives the arithmetic: “if a given blog has 10 posts and 10 contributors, the database returns 100 rows for that single blog.” It calls this Cartesian explosion, and EF Core warns when a query loads several collections without a chosen strategy. A log filtered to executed commands, like the one in this article’s model, would not show that warning.

The other strategy, AsSplitQuery, loads each included collection with a query of its own. Split queries bring back what the JOIN removed. Each costs its own round trip, the documentation notes, and “while most databases guarantee data consistency for single queries, no such guarantees exist for multiple queries.” It concludes: “Unfortunately, there isn’t one strategy for loading related entities that fits all scenarios.”

Each fix trades one property of the layer below for another. Choosing well requires knowing that layer, which is the law at work.

Three More Abstractions That Leak

The same shape appears wherever a layer hides a cost or a failure.

The Network behind HttpClient

Spolsky’s TCP example still applies. In .NET, HttpClient presents a request and a response, but the handler underneath it keeps a pool of TCP connections. Microsoft’s guidelines for HttpClient describe two opposite ways that pool leaks through.

Create a client with a new handler for each request, and every request pays for a new connection. Worse, the guidelines warn, closed connections do not release their TCP ports at once, so a high request rate can exhaust them. Keep one client forever, and you meet the other leak: “HttpClient only resolves DNS entries when a connection is created.” A connection that never closes keeps using the old address after a DNS change. For a shared client, Microsoft’s fix is a setting from the layer below:

// Create once, share everywhere
var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),
};
var sharedClient = new HttpClient(handler);

The guidelines give 2 minutes as an example. The right value depends on how often the addresses behind your dependencies change: a fact about DNS, not HTTP. The guidelines’ other option is short-lived clients from IHttpClientFactory, which reuses handlers and their connection pools.

The network between services leaks as well. AWS documents that “if a connection that’s using a NAT gateway is idle for 350 seconds or more, the connection times out.” After that, the gateway answers with a reset instead of a normal close, so a connection that looked healthy fails on its next use.

HttpClient’s defaults stay clear of that limit, because a pooled connection is retired after 1 minute of idleness. An idle timeout of 350 seconds or more, in HttpClient or another client, brings the limit into play. AWS suggests sending more traffic over the connection or enabling TCP keepalive with a value under 350 seconds, a setting well below the code that opened the connection.

Surviving such failures takes timeouts and retries. RFC 9110, the standard for HTTP semantics, advises against retrying automatically unless the client knows that the operation is idempotent or that the first attempt was never applied. The idempotency article on this site shows how to give clients that knowledge.

When a leak costs enough, the industry sometimes rebuilds the layer. RFC 9114, published in June 2022, explains why HTTP/3 runs over QUIC instead of TCP. HTTP/2 multiplexes many requests over one TCP connection, but TCP’s loss recovery cannot tell them apart. One lost or reordered packet stalls all active requests, even those it did not carry.

Serverless Still Has Servers

A serverless function promises code that runs per request, with the servers somebody else’s problem. AWS’s Lambda documentation describes the machinery underneath. Before a function runs, Lambda may have to download the code and set up an execution environment: a cold start. AWS says cold starts typically affect under 1 percent of invocations and last from under 100 milliseconds to over 1 second, and that you pay for that time. Since 1 August 2025, the charge also covers on-demand functions packaged as ZIP files on managed runtimes, whose initialization had been free.

When start times must be predictable, AWS recommends provisioned concurrency, which initializes execution environments in advance. Even that remedy is measured in the layer below: you choose how many environments to keep initialized.

After a request, Lambda freezes the environment and may reuse it. That helps: objects created outside the handler stay initialized, so a database connection can survive between invocations. It also surprises. AWS warns that “background processes or callbacks that were initiated by your Lambda function and did not complete when the function ended resume if Lambda reuses the execution environment.” And reuse cannot be relied on: Lambda terminates environments every few hours, even for functions invoked continuously. A correct function knows what runs once per environment, what runs per request, and what may still be running from the last request.

The Garbage Collector Decides What Garbage Is

Microsoft’s garbage collection documentation says automatic memory management can eliminate problems such as “forgetting to free an object and causing a memory leak.” The collector frees objects “no longer being used by the application,” but it judges use by reachability from the application’s roots. Roots include static fields and local variables on a thread’s stack. An object you are done with, but still reference, stays alive.

Two common ways to keep one alive are a static cache that never evicts and an event handler that is never removed. The WPF documentation explains the second: subscribing “creates a strong reference from the event source to the event listener,” so a long-lived publisher keeps every forgotten subscriber in memory. The collector works exactly as designed. The leak lives in the gap between unreachable and no longer needed.

Reclaiming memory also costs time, some of it with the application paused. Background collection, on by default, can collect generation 2 while the application runs, but generations 0 and 1 are still collected with all managed threads suspended. Server garbage collection and GCSettings.LatencyMode change those trade-offs. Using them well takes what the abstraction hid: generations, allocation rates, and pause times.

The dotnet-counters tool shows time in GC and heap size as a process runs. The dotnet-gcdump tool captures what the heap holds, but its documentation warns that it triggers a full collection, “which can suspend the runtime for a long time.”

Learn One Layer Below

Relying solely on abstractions creates a fragile skill set. The habit I take from the law is modest: learn one layer below the abstraction you use every day. If you use an ORM, learn SQL. If you use a web framework, understand HTTP. For the abstractions in this article, the layers below are these:

Why one layer and not all of them? In most examples here, one layer down was enough: SQL explained the N+1 problem, connection pooling the stale address, and the execution lifecycle the leftover callback. When a leak points further down, as the NAT gateway’s does, follow it, and learn the next layer then.

Spolsky saw one more consequence. When a new code-generation tool promises to make everyone more efficient, he wrote, people advise: “learn how to do it manually first, then use the wizzy tool to save time.” Such tools, he added, “like all abstractions, leak.”

Today’s newest code generators are AI assistants, and their code runs on the same layers. An assistant can write the lazy-loading loop as readily as the projection, and both look plausible in a diff. A reviewer who has read a SQL log knows which one can send 1,001 queries. My book Code You Did Not Write covers reviewing unfamiliar code for what it actually does, not just what it appears to do.

Catch Leaks Earlier

Make the layer below visible. In development, turn on the log of the layer under your code: EF Core’s LogTo, your HTTP client’s connection events, your platform’s startup metrics.

Count, then assert. A leak like N+1 is a number: queries per page. Because LogTo accepts any method that takes a string, a test can count CommandExecuted events instead of printing them. Run the query and the loop in a fresh context; customers left over from setup would hide the lazy loads. Then fail the test if the count exceeds one. Monitoring can track the same number in production.

Test against the real dependency at the boundary. A test double hides exactly the layer that leaks. Microsoft’s EF Core testing guidance warns that “tests which pass against a test double don’t guarantee that your program works when running against the real external resource.” Its example: SQL Server compares strings case-insensitively by default, while SQLite is case-sensitive. The guidance calls using the in-memory provider as a database fake “highly discouraged.” The article on testing layers covers the tools that make such tests cheap enough for every build.

Read the documentation and the source. Almost every leak in this article is documented by its vendor. Go through the pages on performance, limits, and troubleshooting before an incident sends you there. Where they stop, read the code: EF Core and the .NET runtime are open source.

Write the leak down. Leave it where the next developer will trip over it: a comment beside the query, a test that pins the query count, a line in the runbook. A leak found twice is a leak that was never shared.

When You Build the Abstraction

Anyone who writes a library, a service client, or an internal framework builds abstractions too. The entry on this law in my Software Development Principles series states the goal: not a perfect abstraction that hides everything, but an honest one that acknowledges its limits. The libraries in this article show what that looks like.

Fail loudly at the boundary. Before version 3.0, EF Core ran any part of a query it could not translate on the client, and by default it logged at most a warning. For a Where filter, that could mean fetching the whole table. The 3.0 breaking-change notes explain that this “can easily go undetected if the table contains only a few rows in development.” The warnings “also proved too easy to ignore.” EF Core now throws when such a part lies outside the final projection. An exception on the first run beats a warning that waits for production data.

Document the costs and expose the settings. EF Core’s lazy-loading page warns about N+1, and the library warns when a query loads several collections. No default fits every workload, and the documentation for PooledConnectionLifetime, AsSplitQuery, and the garbage collector’s latency modes says when to change each one. Document each setting you expose with its trade-off.

Provide an escape hatch. EF Core lets you “drop down to SQL queries,” which its documentation calls useful, among other cases, “if a LINQ query causes EF to generate inefficient SQL.” An escape hatch keeps users from routing around the abstraction in worse ways.

Keep the lower layer’s evidence. When you wrap an error, keep the original as the inner exception and say which operation failed. A generic error that swallows the lock timeout beneath it makes the next person reproduce the failure to learn what the original already said.

A Checklist for Leaky Abstractions

Further Reading