A use case renders a basket of up to 200 lines. It calls a repository port's findById once per line. In the domain layer this looks like a clean loop with no infrastructure knowledge, and p99 has climbed to 2.4 s. The dependency rule forbids the domain from knowing about SQL. What do you change?
Show the full answer Hide the answer
The first three things to look at
- The call count across the boundary, not inside it. Instrument the port, not the database. 200 calls at 10 ms each is 2 s of pure round trips, which matches the symptom exactly and is invisible in a query-time dashboard where every query is fast.
- The shape of the port's methods. A port that exposes
findById,findAllandsaveis a row accessor. It can express only one access pattern, so every caller that needs a set assembles it one row at a time. - Where the loop is. A loop in the use case is a loop in the architecture: the domain has encoded a query plan without being able to see it.
The diagnosis
The N+1 is not an infrastructure defect; it is a consequence of a port designed at the wrong granularity. The dependency rule says the domain must not depend on SQL. It does not say the domain must not express what it needs. A port method named pricedLinesFor(basketId) contains no SQL, no table names and no driver types, and it lets the adapter satisfy the whole need in one statement with one round trip.
The misleading signal is that every individual query is fast and the database is bored. CPU is low, the slow-query log is empty, and the dashboard that would show the problem — calls per request through the port — does not exist, because the port looked like domain code rather than like I/O.
The fix
- Make the port express the question, not the row. One method per access pattern the domain actually has, named in domain language. This is the point of a port: it is the domain's vocabulary for what it needs, and an interface that mirrors a table has given up that benefit for nothing.
- Where callers genuinely vary, pass a specification object — a domain-level description of the criteria that the adapter translates. It keeps the domain free of SQL while letting one adapter method serve many queries.
- Add a batch form rather than optimising the loop:
findAllById(ids)turns 200 round trips into one, and it is the smallest change if the port must stay row-shaped for now. - Put a fitness function on the boundary. Fail the test suite when a single use case exceeds a budget of port calls. This is the only control that stops the problem returning, because the next person will also write a clean-looking loop.
What it costs
Coarser ports are less reusable: a method that answers one question answers only that question, so the port grows a method per use case and the adapter grows with it. That is a real cost in a large codebase, and it is why the row-shaped port is so common. The trade you are making is more interface surface in exchange for the ability to see and control I/O, and at 200 lines per request there is no version of the row-shaped design that performs.
When this is the wrong answer
If the collection is small and bounded — three lines, not two hundred — the loop is fine and a specification object is ceremony. And if the real requirement is a read-heavy projection rendered on every page, the honest answer may be to stop routing it through the domain at all: a read model with its own query, bypassing the use-case layer, is a recognised shape and is simpler than contorting ports to serve reporting. The dependency rule earns its cost on the write path, where invariants live. Applying it uniformly to reads is where clean architecture gets its reputation for ceremony.