Hexagonal architecture (also known as Ports and Adapters) is a powerful way to design software that is independent of frameworks, databases, or UI layers. Its main goal is to isolate the core business logic from the outside world by enforcing clear boundaries. However, in many projects, this pattern is over-engineered to the point where the supposed simplicity turns into a maze of indirection, deeply nested folders, and needless abstractions.
The Problem: Overstructuring a Simple Concept
Many developers (including me) start with the intent of using hexagonal architecture but quickly spiral into the following:
/invoice
/application
/services
/mappers
/domain
/entities
/events
/errors
/services
/infrastructure
/adapters
/persistence
/externalAt first glance, this looks “well-organized,” but in practice it causes:
❌ Context Switching Overload
Navigating from a service to its related domain entity or mapper means jumping across multiple directories. The physical separation slows you down and fragments understanding.
❌ Boilerplate Inflation
Every small concept demands its own file and folder. A single feature ends up spread across dozens of microfiles. This is organization for the sake of organization, not efficiency.
❌ Loss of Clarity
The deeper and more granular the folder structure, the less obvious the intent becomes. You know where things are, but not why they exist.
❌ It Hides the Business Purpose
This might be the most important flaw: the structure doesn’t tell you what the module is actually doing. You open the domain, application, and infrastructure folders — and you’re still unsure. The only clue you have is the module name which most of the time does not reflect the actual module behaviour.
When opening a module directory, you should immediately see:
- What is this module responsible for?
- What are the key business concepts?
- Who is supposed to use its functionality, and how?
Deeply abstracted structure pushes this knowledge out of reach.
Keep the Domain Front and Center
Your domain is the heart of the application. It shouldn’t be buried under layers or directories like domain/entities, domain/services, and domain/errors.
A more approachable structure is:
/invoicing
customer.ts // recipient of the invoice
invoice.ts // entity representing the invoice
issuer.ts // entity issuing the invoice
invoicing.facade.ts // Public API of this module
tax-policy.ts // Tax calculation policy
due-date-calculator.ts // Domain service
infrastructure/ // Less important things
invoicing.module.ts // NestJS module wiring
knex-invoice.repository.ts // Knex adapter
knex-issuer.repository.ts // Knex adapterNo unnecessary directories. No needless ceremony. This just tells the whole story.
This aligns with hexagonal principles:
- Business logic is in dedicated concepts
- External interaction is through
knex-invoice.repository.ts - Communication with the outside world is handled by infrastructure components like controllers and NestJS modules.
- The facade orchestrates domain logic by coordinating aggregates, policies, and business rules..
- Infrastructure components call into the application layer via the module’s facade..
You may be wondering where all the supporting pieces went — the errors, events, identity objects, and so on. Here’s what a fully expressed Invoice domain concept might look like, with all related elements defined in a single file:
// invoicing/invoice.ts
// Value object for entity (aggregate) identity
class InvoiceId extends Uuid<'Invoice'> {}
// Entity domain events
type InvoiceEvents =
| Event<'InvoiceIssued', { issuedAt: string }>
| Event<'InvoicePaid', { paidAt: string }>
| Event<'InvoiceOverdue', { ... }>
// Business rule
class InvoiceDateCannotBeEarlierThanBillingPeriodStart extends BusinessRuleViolation {}
// Entity (aggregate) itself
class Invoice {
readonly id: InvoiceId;
// ...
}
interface InvoiceRepository {
load(invoiceId: InvoiceId): Promise<Invoice>
save(invoice: Invoice): Promise<void>
}At first glance, grouping all these definitions together might seem unconventional — even “messy.” But in fact, this structure reflects a key modeling principle: each of these types participates in defining the behavior and boundaries of the Invoice aggregate. They are part of a single cohesive concept.
Splitting them across multiple folders like domain/errors/, domain/events/, or domain/repositories/ introduces artificial distance between parts that are semantically close. Instead of improving clarity, it scatters the model and makes it harder to see the whole picture. By keeping them together, we reinforce the idea that the domain is organized by business meaning, not technical category.
The Facade as the Application Layer
Instead of creating an entire application/ folder with subfolders for use cases, services, DTOs, and so on – use a facade class as the boundary of your application layer. This facade can orchestrate domain logic and act as a public API for your module. One class, one file, clear purpose.
The Infrastructure Should Wrap Itself
Framework modules (e.g., NestJS modules), external adapters, and database persistence logic belong in the infrastructure. You can group them together – but don’t let them spill into your core. They’re replaceable, and your domain logic should never depend on them.
This keeps the framework where it belongs – on the edge.
Guiding Principles
- Flat is better than nested: Unless nesting adds real clarity, avoid it.
- Structure follows concept, not convention: Let each domain concept be its own file or directory.
- Only abstract when needed: Don’t introduce DTOs, mappers, or layers “just in case.” Add them when there’s actual complexity to manage – not before.
- Architecture should serve clarity: Hexagonal architecture is a tool, not a goal. If your structure doesn’t help you understand what the module does, it’s working against you.
Conclusion: Return to the Spirit of Hexagonal Architecture
The goal of hexagonal architecture is to reduce coupling and increase testability, not to impose a rigid directory structure. Simplicity is not the enemy of robustness – and overcomplicating your structure defeats the very purpose of this architectural pattern.
Stick to what matters: your domain and its behavior. Build from there.
