Unfolding Architecture
Progressive architectural decisions: start simple, add complexity only when it reduces complexity.
Architecture is not a blueprint drawn before construction. It unfolds from working code under concrete pressure. Each dimension of architecture starts at its simplest form (Level 0) and only moves to a higher level when a specific, demonstrable problem forces the change.
Central creed: code quality = maintaining long-term speed of development. Every architectural decision — unfolding a dimension, applying a design pattern, or removing one — is judged by whether it keeps the code easy to understand and change tomorrow.
Default Starting Point
Level 0 in all dimensions. No deviation without a concrete reason backed by a violated constraint, or measurable complexity increase. "It might be useful later" is never a valid reason to unfold.
Dimensions
1. Data vs Logic
| Level | Style | Description | |-------|------------------------------------|-------------------------------------------------------------------------------------------------------------------| | 0 | OOP: data + logic together | Data and the logic that operates on it live in the same class. Logic hides data. This is the natural OOP default. | | 1 | DOP: separate data from algorithms | Stable data structures with varying algorithms applied externally. Data is transparent; logic is separate. |
Unfold to Level 1 when:
- Multiple independent algorithms need to operate on the same data
- Data structures are stable but processing varies significantly
- Serialization/deserialization concerns conflict with behavior encapsulation
Do NOT unfold when:
- Data and logic change together (they belong together)
- There is only a hand full of algorithms per data structure that are closely related to the data
- The separation would scatter related behavior across files
2. Indirection
| Level | Style | Description | |-------|------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | 0 | Direct dependencies | Everything knows about everything it uses. No interfaces, no layers, no DTOs. Persistence lives next to domain. | | 1 | Targeted abstraction | Interface or anti-corruption layer at a specific pain point — persistence, external API, or unstable component. | | 2 | Ports & adapters (hexagonal) | Full separation of domain from infrastructure via IoC: the domain defines ports (interfaces), adapters implement them, dependencies point inward. Persistence is just one adapter among many. A composition root wires adapters to ports. |
Unfold to Level 1 when:
- A component has multiple concrete implementations that must be swappable
- A dependency crosses a deployment boundary (e.g., external service)
- Testing requires replacing a real dependency with a test double, e.g. calling an external service
- An external system's model leaks into and distorts the domain model
- An external API returns data in a shape that does not match domain concepts
- Changes in one component frequently force changes in an unrelated component
- Persistence technology needs to change and the domain should not be affected
Unfold to Level 2 when:
- Multiple adapters exist for the same port (e.g., database + in-memory for tests,
but there is no suitable abstraction layer available in the platform, e.g. an EntityManager)
- It becomes too complex to test the domain in isolation from real infrastructure
- The system has many distinct infrastructure concerns (persistence, messaging, external APIs, etc.)
that would clutter the domain
Do NOT unfold when:
- Only one implementation exists, and it can be seen as an implementation or model detail of the domain
- The "interface" would mirror the class 1:1 (interface bloat)
- The coupling is between genuinely related concepts (cohesion, not coupling)
- DTOs or DB Entities would be identical copies of domain objects (except for some annotations, etc.)
- The "boundary" is between two internal components that change together
- The application has a single persistence mechanism and no external integrations
Note: Even hexagonal architecture can evolve step-by-step. You do not need to introduce all ports and adapters at once. Start with a single targeted abstraction (Level 1) where the pressure is highest, then add more ports as concrete needs arise. Full hexagonal (Level 2) is the result of multiple incremental unfoldings, not a single architectural decision.
3. Communication
| Level | Style | Description | |-------|----------------------------|---------------------------------------------------------------------------------------------------| | 0 | Direct method calls | Components call each other directly. Simple, traceable, debuggable. | | 1 | Domain events (in-process) | Components publish events that other components react to, but within the same process. | | 2 | Async messaging (broker) | Communication through a message broker. Full decoupling of sender and receiver in time and space. |
Unfold to Level 1 when:
- An action must trigger multiple independent side effects (e.g., send email AND update audit log)
that change independently of the triggering action
- Circular dependencies emerge between components that cannot easily be resolved by other means
Unfold to Level 2 when:
- Components must be deployed and scaled independently
- Temporal decoupling is required (sender must not wait for receiver)
- The system spans multiple processes or services
- Exactly-one guarantees are necessary (mostly using Two-Phase-Commits)
Do NOT unfold when:
- There is only one consumer for each action
- Traceability and debuggability are more important than decoupling
- The indirection would obscure a simple, linear flow
4. Package Structure Guarantees
| Level | Style | Description | |-------|--------------------------|------------------------------------------------------------------------------------------------------------| | 0 | Manual | No enforced dependency rules between packages. Any class can use any other class (as long as it's public). | | 1 | Package dependency tests | Packages exist with clear responsibilities. Dependency rules between packages are enforced by tests. | | 2 | Modulith | Separate modules within one deployable unit, each with explicit API boundaries and enforced encapsulation. | | 3 | Microservices | Independent services, separately built and deployed, communicating over network boundaries. |
Unfold to Level 1 when:
- The codebase grows beyond a handful of packages and accidental cross-package dependencies start appearing
- Changes in one package unexpectedly break another
- Package responsibilities become blurred because nothing enforces boundari