# Hexagonal Architecture (Ports & Adapters) Overlay

Load this **in addition to** the matching language reference when the project follows ports-and-adapters. Detection signals:

- A `domain/` (or `domains/`, `core/`) folder holding entities and value objects.
- A `ports/` folder, files matching `*.port.*`, or `abstract class` / `interface` files declared at a domain boundary.
- An `adapters/` (or `infrastructure/`) folder holding implementations of those ports.
- Build config / framework that names the pattern explicitly (e.g., NestJS module split into `domain` / `application` / `infrastructure`).

If unsure, ask the user once: *"Does this project use ports-and-adapters? What folder is the domain in?"* — and record the answer in one line so subsequent edits don't re-discover it.

These rules are language-agnostic. The examples use TypeScript/Java-ish syntax; translate to Rust traits, Kotlin interfaces, etc., as needed.

---

## Rule H1 — Ports stay thin

A port is the interface (or abstract class) at the domain/application boundary. The port declares **shape**, not behavior.

A port file's method body — or the interface contract itself — must **not** contain:

- `if` statements that branch on domain state.
- Validation that throws or returns errors based on input shape.
- Filtering (`.filter(...)`) over domain collections.
- Business calculations (math on amounts, date arithmetic that expresses a rule, etc.).

If the port file's method body does any of the above, the logic belongs in the domain (an invariant on the entity, a domain service) — or in the adapter if it's truly I/O-shaped.

Violations:

```ts
// bad: validation in a port body
abstract class UserRepository {
  async save(user: User) {
    if (!user.email) throw new Error('email required'); // rule check in port
    return this.persist(user);
  }
}

// bad: filtering in a port
abstract class OrderRepository {
  async findPending(orders: Order[]) {
    return orders.filter(o => o.status === 'pending'); // domain rule in port
  }
}
```

Not a violation:

- A pure interface with no method bodies.
- An abstract method with no implementation.
- A port file that *imports* domain objects (that's the correct pattern).

---

## Rule H2 — Adapters do I/O, not business logic

Same principle as H1, on the adapter side. The adapter's job is to implement **just** the I/O — a SQL query, an HTTP call, a filesystem read. It is **not** the place to filter by domain state, branch on domain values, or compute business answers.

Smells in adapter methods:

- A `.filter(x => x.status === 'active')` predicate over domain state, *after fetching rows*.
- An `if` that rejects results based on a domain rule (*"skip if expired"*, *"exclude shared items"*).
- A method whose name encodes a business rule — `findValid*` / `findActive* `/ `findEligible*`. The adapter should fetch *things*; the caller (or the query itself) should apply the rule.
- Multiple joins and conditions assembled specifically to satisfy a compound domain rule — the adapter is answering a business question rather than offering a primitive fetch.

Judgment: an adapter **can** push filtering into SQL (`WHERE status = 'active'`) — that's still I/O-shaped. The smell is when:

- The filtering is *application code after the fetch*, or
- The method name itself encodes a domain rule that a reader cannot tell from the name alone.

A domain-flavored method name (`findValid*` / `findActive*` / `findEligible*`) is not automatically a violation. Accept it when **either** holds:

- The query body is still simple — a single `WHERE` clause on an obvious column, no compound predicate.
- Moving the predicate out would force an N+1 (the caller would have to fetch all rows and round-trip per row to decide which are valid).

Reject it when the query is complex enough that a reader can't tell what "valid"/"active"/"eligible" means without reverse-engineering the SQL. At that point the rule really is hiding in infrastructure — move the predicate to the domain and rename the adapter to `findAll` / `findByX`.

Not a smell:

- Simple `WHERE` clauses for *data-shape* reasons (`WHERE user_id = ?`) — that's scoping the fetch, not a business rule.
- Pagination, ordering, basic projection.
- Mapping raw rows to domain objects — that's the adapter's translation job.

Why it matters: when adapters hold business rules, the rules become invisible from the domain. A reviewer reading the domain sees a clean method; the rule is hidden two layers down in a repository. Changes to the rule require editing infrastructure code. Tests of the rule are forced through I/O. The whole point of ports-and-adapters collapses.

---

## Rule H3 — Domain objects live in the domain folder

Entities, value objects, domain types / enums, and domain errors must be declared under the project's `domain/` folder (or whatever this repo calls it). They must **not** be declared inside a port file or an adapter file.

Violations:

- `class Order { ... }` declared in `order.port.ts`.
- `type OrderStatus = 'pending' | 'shipped'` declared inside `order.repository.adapter.ts`.
- `class OrderNotFoundError extends Error {}` declared in a port file but never defined in `domain/`.
- An interface representing a domain concept (not a port contract) declared outside `domain/`.

Not a violation:

- Domain objects *imported* into a port or adapter file — that's the correct pattern.
- DTOs (data-transfer types used only by an adapter for wire shape) living next to the adapter.
- Utility types used only by an adapter's implementation.

The test: **is this type/class a thing the business talks about, or is it plumbing?** Business things belong in `domain/`. Plumbing can live near the plumbing.

---

## Rule H4 — Dependency direction: the domain imports nothing outward

The cardinal rule of ports and adapters: dependencies point inward. A file under the domain folder imports only from the domain itself and from pure utility code. Unlike H1–H3 this verdict is **mechanical** — read the import list of every changed domain file; no judgment about what the code means is required.

Violations — any import of:

- an ORM or DB layer (`sequelize`, `typeorm`, `prisma`, db model classes),
- a framework or its DI decorators (`@nestjs/*`, `express`),
- anything under adapters / repositories / infrastructure, DTO, or services.

Not a violation:

- imports from sibling domain files,
- pure utility libraries (`lodash`, `date-fns`),
- type-only imports of other *domain* types.

When the domain needs something the outside owns, invert it: declare the shape as a port and let the adapter implement it (H5).

---

## Rule H5 — A new boundary gets a port

When a change adds or modifies a service's dependency on a concrete adapter — constructor injection of a repository class, or direct model / DB access from a service — that boundary should be a port the adapter implements, so the caller depends on shape rather than on infrastructure.

The verdict is mechanical: this change adds or modifies such a dependency → finding; the injection sits entirely on lines this change did not touch → clean.

**An existing convention does not clear this one.** A codebase that injects concrete `*Repository` classes everywhere is precisely the debt this rule pays down, one touched injection at a time — matching the surrounding style is the condition being flagged, not a defence against it. Record it, say so in the note (*"matches existing convention — advisory"*), and propose the port: its name and the two or three method signatures it would declare — unless the project declares `architecture.style = hexagonal` in `.okstra/project.json`, where this item is blocking and that same port sketch is what you write rather than what you record: fix the injection before the write, never record-and-pass.

---

## Rule H6 — Business rules live in the domain, not in application services

A service orchestrates: fetch, delegate the decision to a domain function, persist, publish. Flag changed service code that *decides* a business outcome inline instead of calling into the domain.

Violations:

- An `if` / `else` chain over domain fields that decides an outcome — eligibility, validity, which branch of a business process runs.
- Arithmetic expressing a business formula: money, ranking, quota, date-based entitlement.
- A private service method whose name is a domain concept (`isEligible…`, `computeDowngrade…`, `resolve…Status`). The name is telling you where it belongs.

Not a violation:

- Orchestration control flow — early return on not-found, `try` / `finally` around a transaction, threading a transaction through repository calls.
- DTO → domain mapping.
- Calling a domain predicate and acting on its result. That IS the pattern.

The test: **if this decision changed, would the change be described as a business rule change or as a plumbing change?** A business rule change that lands in a service is the violation — the rule is now invisible to the domain's own tests, and the next caller that needs it writes its own copy.

Severity: `should-fix`, and blocking when the embedded rule is substantial — money, permissions, or a state machine. Under a declared `architecture.style = hexagonal` the substantial case is fixed before the write, never record-and-pass.

---

**How far a project convention reaches.** H5 above is the one rule in this overlay a project-local convention cannot clear. Elsewhere here, a documented convention does resolve the conflict in the project's favour — but that latitude stops at okstra's own gates. It never clears a check the implementation verifier's blocking list declares **mechanical**: a changed file under the domain folder importing an ORM / DB layer, a framework or its DI decorators, or anything under adapters / infrastructure / services is decided by reading the import list, and no convention argument reaches that verdict (`prompts/profiles/_implementation-verifier.md` §"Static design & test-quality review" → Hexagonal). Resolving a declared-mechanical finding as "project convention" is the failure this paragraph exists to prevent — a real run did exactly that, returned `clean`, and the team's own PR review then flagged the same import.

Severity: advisory in a project that has not declared `architecture.style = hexagonal` — a direction-of-travel rule there, not a correctness gate, so it never blocks on its own. Under that declaration it is blocking: the injection is fixed before the write.

---

## Quick checklist before declaring a port/adapter change complete

- [ ] No `if` / `.filter` / validation / business math inside any port body.
- [ ] Adapter methods are I/O only — any post-fetch JS filtering moved into the query or back to the caller.
- [ ] Adapter method names are neutral (`findAll`, `findByUserId`) unless the domain-flavored name passes the H2 judgment test.
- [ ] Every entity, value object, domain enum, and domain error is declared under `domain/`.
- [ ] Port files only declare shape and `import` domain types — they don't *define* them.
- [ ] No changed domain file imports an ORM, a framework, or anything under adapters / infrastructure / services.
- [ ] A service dependency this change adds or modifies goes through a port — or, unless the project declares `architecture.style = hexagonal` in `.okstra/project.json` (there this item is blocking: fix before write, never record-and-pass), the advisory is recorded with the port sketch.
