Fundamentals First · Part 4

Small Doors, Deep Rooms

· Fundamentals First · AI · AI Skills · Software Design

You hold a map of your codebase in your head. Authentication lives over here; the order flow runs down through there; these modules belong together even if they’re scattered across a dozen folders. The AI has none of that. It steps into your repo like the man in Memento — no memory, no history, blinking at a wall of modules that can all import from each other, with nothing on the file system telling it which ones actually belong together. The map is in your head, and your head is the one place the AI can’t read.

The temptation is to fix it from the outside — write the map down. A longer AGENTS.md, a richer prompt, a fatter context window: hand the AI the thing it’s missing. It helps less than you’d hope, because the structure of the codebase is the document the AI reads, and it reads that one far more closely than anything you write about it. Pour your intentions into Markdown and the code still says something else — the imports still run everywhere, the four plausible homes for “place an order” are all still there — and the AI believes the code, not the commentary. The map can’t be bolted on from outside. It has to be the structure.

If you write .NET, you’ve already reached for the tool you trust most for exactly this. You give every layer an interface. The trigger hands off to an IOrderOrchestrator, which calls an IOrderHandler, which talks to an IOrderRepository — each one its own seam, its own mock, its own unit test, all wired together through dependency injection. This is what good structure is supposed to look like, and it’s the most disciplined-looking version of the exact problem we started with. You didn’t draw the map; you added three more rooms to the maze, gave each a door, and labelled every door. The AI now has four importable surfaces where there should be one — and it still can’t tell you where an order gets placed, because the honest answer is a little bit in each of them.

The word doing the damage is interface, and you mean two different things by it without noticing. There’s interface the keyword — IOrderRepository, a type contract the IDE will scaffold in a keystroke and you can mock in a test. And there’s interface in the older, deeper sense: everything a caller must understand to use a piece of code. The method names, yes, but also what it does, what it touches, what it throws, what order you’re allowed to call it in. The first kind is nearly free to mint; you can stamp out a dozen before lunch. The second kind is the only one that costs the reader anything to learn — and so it’s the only one that counts. Every I-prefixed type you added was a contract. Not one of them made the thing behind it smaller to understand. You multiplied the cheap interface and left the expensive one exactly as sprawling as it was.

Once you can hold the two interfaces apart, the thing worth measuring comes into focus. Take any module and weigh one against the other: how much you must understand to use it, against how much it does for you once you do. Call that ratio its depth. A deep module hides a great deal behind a small surface; a shallow one hides almost nothing, its interface nearly as large as the work it does. You already trust the deepest modules in .NET without a second thought — JsonSerializer.Serialize(order) and its mirror Deserialize, two methods, the whole interface you have to know, sitting on top of reflection, source-generated converters, UTF-8 handling, escaping, streaming, polymorphism, cycle detection. An enormous machine behind a door so small you’ve never once looked for the handle. That shape has a name and a pedigree: it’s the spine of John Ousterhout’s A Philosophy of Software Design, and it’s the exact inverse of what your layered stack produced. An IOrderHandler that forwards to a repository hides almost nothing and calls itself a module anyway. The door is the size of the room.

So collapse it. The trigger, the orchestrator, the handler, the repository don’t have to vanish — they have to stop being the interface and start being the room. You give the whole feature one public door, and nothing else:

// The module's entire public surface — all a caller, or the AI, has to read.
public interface IOrdering
{
    Task<OrderResult> PlaceOrderAsync(PlaceOrder command, CancellationToken ct = default);
}

public sealed record PlaceOrder(Guid CustomerId, IReadOnlyList<LineItem> Items, string IdempotencyKey);
public sealed record OrderResult(Guid OrderId, OrderStatus Status);

Everything that used to be a public layer is now hidden inside, reachable only through that one method. The keyword doing the hiding is internal — visible inside this module’s assembly, invisible to the rest of the app, so the compiler flatly refuses any import from outside:

internal sealed class Ordering : IOrdering   // internal: it does not exist outside this module
{
    public async Task<OrderResult> PlaceOrderAsync(PlaceOrder cmd, CancellationToken ct)
    {
        // the room: validation, idempotency, inventory reservation, payment capture,
        // retry and compensation, persistence, event publishing. The orchestrator,
        // the handler, the repository, the retry policy all live in here as `internal`
        // types — none of them part of the interface.
    }
}

The binding from door to room happens exactly once, in the one place that’s allowed to know the room exists:

public static class OrderingModule
{
    public static IServiceCollection AddOrdering(this IServiceCollection services)
    {
        services.AddScoped<IOrdering, Ordering>();
        // the orchestrator, handler, repository, retry policy register here too — all internal.
        return services;
    }
}

// The trigger asks for the door and never learns what's behind it.
public class PlaceOrderFunction(IOrdering ordering)
{
    [Function("PlaceOrder")]
    public Task<OrderResult> Run(PlaceOrder cmd, CancellationToken ct) =>
        ordering.PlaceOrderAsync(cmd, ct);
}

Program.cs calls AddOrdering() once; everyone else only ever names IOrdering. PlaceOrderAsync hides more than Serialize does, and the caller still has exactly one thing to learn: hand it a command, get a result back. The four importable surfaces from before are now one — and the other three can’t be imported at all.

And once the room is sealed behind a single door, the door turns out to be portable. Pull Ordering into its own class library — the interface, the DTOs, the internal room, and AddOrdering, with no reference to ASP.NET or Azure Functions anywhere inside it — and any number of hosts can mount it. A web API references the library, calls AddOrdering() in its Program.cs, and serves ordering over HTTP. A Function App references the same library, calls the same AddOrdering(), and fires it off a queue trigger. The orchestrator, the handler, the retry policy, the idempotency — written once, shared, still sealed. The trigger and the controller aren’t part of the module at all; they’re thin host adapters, one per front door, each doing nothing but taking IOrdering and calling PlaceOrderAsync. The same property that lets the AI read one door instead of four — everything load-bearing behind the interface, nothing leaking out — is exactly what lets you hang that door on two hosts without copying a line of logic. A clean interface is a navigation seam, a test seam, and a reuse seam. They were always the same seam.

That seam is also where the AI earns its keep. You designed IOrdering — you chose the door, you applied taste to it, you decided that placing an order takes a command and returns a result and that idempotency is the caller’s key to own. The room behind it you can now hand over wholesale: let the AI write the orchestrator, restructure the handler, swap the retry policy, rearrange all of it, and you never read a line — as long as the tests through PlaceOrderAsync still pass. You own the door; the AI owns the room. Ousterhout would call this a deep module; the practical name for what it buys you with an AI is a gray box — you trust the surface and the behaviour, and you decline to care about the inside until you have a reason to.

That’s also the test of whether you got depth or just rearranged the maze, and it’s an unforgiving one: could you delete the orchestrator and the handler tomorrow, fold their logic together a completely different way, and change zero callers and zero tests? If yes, the layers were genuinely inside the room. If touching them breaks a caller or forces you to rewrite a test, they were never hidden — they were public surfaces wearing internal as a costume, and your tests were quietly reaching past the door.

And lest this read as a .NET sermon — the idea isn’t .NET’s, and the frontend has it worse. A spaghetti React codebase is the same web exactly: features importing straight into each other’s guts, twenty-prop components, every sub-component exported just in case, only rendered in JSX. The fix is identical — a feature exposes one barrel, index.ts, handing out an OrdersPage and a useOrders hook while the rows, the filters, the local reducer stay sealed inside. What you can’t port is the enforcement: TypeScript has no internal, so nothing in the language stops a deep import, and the fence the C# compiler gave you for free is now yours to build — a barrel as the declared surface, an ESLint boundary rule that turns reaching past it into an error, package exports for the closest thing to a real seam. That gap is exactly why frontends rot so reliably: the principle holds, but enforcement moves from the compiler to you. (Matt makes the same point, and reaches for Effect to win some of the boundary back.)

None of this is automatic, and that’s the catch. Drawing the door in the right place — deciding that IOrdering is one module and payments is another, that idempotency belongs to the caller while retries belong in the room — is taste, and taste is the first thing you skip when you’re moving fast. So I don’t leave it to good intentions; I keep two skills that force the question. codebase-design carries the vocabulary from this post — deep and shallow, surface and room, where a seam goes — and turns it into something the AI and I run against a change before it’s written, at PRD and issue time, exactly where the talk says the thinking belongs. design-an-interface does the harder half: handed a new module, it spins up several genuinely different designs for the door in parallel and makes me choose, instead of letting me keep the first signature that happened to compile. Design it twice, keep the better one. The skill is what turns “I should think about the boundary” into a thing that happens every session, rather than the step I always mean to take and don’t.

So that’s part four. The failure was a codebase the AI couldn’t navigate — not for want of structure, but buried under too much of it: a web of shallow modules dressed up as discipline, an interface on every layer and a map in none of them. The fundamental, older than AI by twenty years, is the deep module — a few simple doors over deep rooms, the cheap interface refused in favour of the expensive one made small. The skill is codebase-design and design-an-interface, which keep me drawing the door on purpose instead of letting it set by accident. And the artifact is the one we built right here: the trigger, orchestrator, handler, and repository you’d have left as four public layers, folded into a single IOrdering you can mount on two hosts and hand the insides to an AI.

But notice what the whole gray box rests on. You trust the room because the tests lock the door — as long as the tests through PlaceOrderAsync still pass. Pull that thread and a new question drops out: it isn’t enough that the tests exist, it matters how fast they answer. A boundary the AI can change safely is only as safe as the speed at which it learns it broke something. That speed — the rate of feedback — turns out to be the real ceiling on how fast any of this goes. Next time: feedback loops, and why the rate of feedback is your speed limit.


The deep-module framing — and the picture of the AI as a memoryless new starter wandering your codebase — is Matt Pocock’s; he lays it out in this video. The deep module itself is John Ousterhout’s, from A Philosophy of Software Design*. I’ve only translated it into the .NET I reach for.*

Nālukettu — the traditional Kerala homestead built as four wings around a single open courtyard, the nadumuttam*. Every room opens onto the same still centre; you never quite learn the house, only the courtyard — and the courtyard tells you where everything is.*

Reactions

Comments

No comments yet.