concept

Port Granularity

also called Port Shape, Boundary Interface Granularity

How much of a need a single boundary interface can express — the property that decides whether the domain can ask one question or must assemble the answer one row at a time.

clean-architectureports-and-adaptersn-plus-onequery-shapelatency

A use case renders a basket of 200 lines. The repository port offers findById, so the use case loops. In the domain layer this reads as clean code with no infrastructure knowledge; in production it is 200 sequential round trips and 2.4 seconds at p99, while the database sits idle and the slow-query log is empty.

The defect is not in the loop and not in the database. It is in the shape of the port. A port with row-level methods can express exactly one access pattern, so every caller needing a set builds it from singles, and the query plan ends up encoded in the use case where nobody can see it.

Why it matters

Ports exist so the domain can state what it needs in its own vocabulary. A port that mirrors a table has abandoned that purpose and kept the ceremony: the domain still cannot see SQL, and it also cannot express "the priced lines for this basket", which is the thing it actually wants.

The failure is invisible to the usual instruments. Each query is fast, CPU is low, and the metric that would name the problem — calls through the port per request — does not exist, because the port looked like domain code rather than like I/O. This is the main reason clean architecture acquires a reputation for being slow, and the reputation is misplaced: the dependency rule does not forbid expressive interfaces, teams simply default to row accessors.

Implementation patterns

  • One method per access pattern the domain genuinely has, named in domain language: pricedLinesFor(basketId), overdueInvoicesForCustomer(id). No SQL, no table names, no driver types, and one round trip.
  • A specification object where callers vary: a domain-level description of criteria that the adapter translates into a query. It keeps one adapter method serving many needs without leaking the query language upward.
  • A batch form as the cheap interim step: findAllById(ids) turns 200 round trips into one without redesigning anything.
  • A fitness function on the boundary: fail the test suite when a 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.
  • Instrument the port, not the datastore. Count calls per request per port and alert on the ratio; it is the diagnostic that turns a two-day investigation into a two-minute one.
  • Let read-heavy projections bypass the domain. A list page served by a read model with its own query is a recognised shape and simpler than contorting ports to serve reporting.

Industry example

The vocabulary comes from Cockburn's hexagonal architecture (2005) and Martin's clean architecture (2012), both of which describe ports as the application's own interfaces rather than as thin wrappers over storage. The row-accessor habit comes from elsewhere: the repository implementations shipped with ORMs, which are generic by necessity and therefore row-shaped. The common failure is adopting a generic repository as if it were the port, inheriting an interface designed to know nothing about your domain into the one place that is supposed to be all domain.

Failure scenarios

  • The boundary N+1, as above: a loop over a collection calling a single-item port method.
  • Silent amplification after extraction. The same code moved behind a network call turns 200 in-process calls into 200 RPCs, so a latency problem becomes an outage.
  • A port that returns too much, the opposite error, where one coarse method fetches 40 fields and three joins because one caller needed them, and every other caller pays.
  • Leaked pagination semantics, where the port exposes offset and limit because the datastore does, and the domain inherits a cursor model it cannot reason about.
  • Adapter-specific behaviour depended upon, such as implicit ordering that one adapter provides and another does not, which turns a swap of implementations into a behavioural change.

Trade-offs

Choose Gains Pays
Coarse domain-shaped ports One round trip per need; I/O visible and controllable More interface surface; a method per use case; less reuse
Row-shaped generic ports Trivially reusable; matches ORM defaults Query plans leak into use cases; N+1 by construction
Specification objects Many queries through one method A small query language to build, test and bound

When not to use it

For a bounded collection of three or four items, the loop is fine and a specification object is ceremony. For a system where the datastore genuinely is the model — a thin CRUD service with no invariants — ports add indirection without buying anything, and a direct data-access layer is the honest design. And where a read path is pure projection, stop routing it through the domain at all rather than growing the port to serve it: the dependency rule earns its cost on the write path, where invariants live.

Interview question

Q: A team reports that their clean-architecture service is three times slower than the CRUD service it replaced, and insists the dependency rule forbids fixing it. How would you diagnose it, and what would you change without abandoning the architecture?

What a strong answer covers: instrumenting calls per request through each port before touching code · identifying port granularity rather than the loop or the database as the defect · the distinction between the domain depending on SQL (forbidden) and the domain expressing what it needs (required) · concrete remedies in increasing order of cost: batch method, domain-shaped method, specification object, read model · and the fitness function that prevents recurrence.

Quick check

Quiz: Why does a row-shaped repository port guarantee an N+1 for any set-oriented need? — Because a single-item method cannot express a set, so the caller must iterate, and each iteration is a separate round trip the adapter has no opportunity to combine.

Flashcard: Does naming a port method pricedLinesFor(basketId) violate the dependency rule? — No. It contains no SQL, table names or driver types; it states a domain need, which is what a port is for.