A subtle but critical detail in Ports & Adapters (Hexagonal Architecture) is how to define ports. Three common breakdowns:
By entity / table
One port per logical entity (OrderPort, ItemPort, UserPort, …)
By use case
One port per use case (ForCreatingOrderPort, ForCancellingOrderPort, …)
By responsibility / conversational cluster
Ports reflect major interaction styles or tightly related responsibilities in the domain
The first two extremes produce dozens (or hundreds) of small ports. Any realistic use case then depends on 4–8 ports → constructor explosion, test-double bloat, wiring noise. The use-case-per-port variant also duplicates orchestration logic across ports.
The third approach — grouping by responsibility — is harder to discover but yields 4–8 ports total. Ports become coarse interfaces that cover what the application actually needs to say to / hear from the outside world.
How to cluster correctly
Group operations that:
- belong to the same consistency boundary (aggregate root + close collaborators)
- change together in transactions
- are replaced together when swapping technology (DB, queue, external service)
- reflect the same "conversation style" with an external actor
Heuristics to find natural clusters
- Start from use cases → collect all outgoing calls they make
- Merge ports whose methods are always used together or in the same transaction
- Split only when methods have clearly different lifecycles, failure modes, or replacement triggers (e.g. payment vs notification)
- Align with DDD aggregates: one repository port per major aggregate root (or small cluster of related aggregates)
- Aim for 5–15 methods per port max; beyond that, split by sub-responsibility (read vs write, query vs command)
Result: fewer dependencies per use case (often 1–3 driven ports), clean wiring, fast tests with 1–2 fakes, easy adapter swaps.
Example
# Bad (fragmented) @dataclass class CreateOrder: order_port: OrderPort item_port: ItemPort user_port: UserPort order_notification_port: OrderNotificationPort listener: ForPlacingOrders # Good (clustered) @dataclass class CreateOrder: persistence: OrderPersistence # covers order + items + user lookup + save listener: ForPlacingOrders
TLDR
Fewer ports = simpler, more maintainable hexagon. Cluster by how the domain actually talks.
Otherwise: death by a thousand ports.