๐ง๐ต๐ฒ ๐ฃ๐ฟ๐ฎ๐ด๐บ๐ฎ๐๐ถ๐ฐ ๐๐ฒ๐ ๐ฎ๐ด๐ผ๐ป: ๐ฆ๐ฐ๐ฎ๐น๐ถ๐ป๐ด ๐๐ฒ๐ฐ๐ผ๐๐ฝ๐น๐ถ๐ป๐ด ๐๐ถ๐๐ต๐ผ๐๐ ๐๐ผ๐บ๐ฝ๐น๐ฒ๐ ๐ถ๐๐
๐ง๐ต๐ฒ ๐ง๐ฒ๐ป๐๐ถ๐ผ๐ป ๐ผ๐ป ๐๐ต๐ฒ ๐ง๐ฟ๐ฎ๐ถ๐น
In a professional kitchen, there is a concept called mise en placeโeverything in its place. You donโt start searing the scallops until every herb is chopped and every sauce is whisked. If you skip the prep to โsave time,โ you end up adjusting the recipe mid-sautรฉ, usually resulting in a frantic mess, ruined ingredients, and a dish that takes twice as long to serve.
Modern software development has a similar โpopular choiceโ: start coding the logic immediately to show โprogress.โ But when we skip the architectural prepโthe interfaces and boundariesโwe arenโt moving fast; we are just building a kitchen weโll have to tear down while the customers are waiting. Iโve watched engineers lose sight of the goal in the pursuit of a โperfect flowโ that wasnโt grounded in discipline. If everyone says they want โclean code,โ why does the system feel like itโs fighting us the moment we add a new story?
๐ฆ๐๐๐๐ฒ๐บ ๐๐ฒ๐ผ๐บ๐ฒ๐๐ฟ๐
The environment of this experiment is a standard Kotlin and Spring Boot stack. The landscape is defined by three distinct zones designed to minimize the โweightโ of dependencies. To navigate this space, we use a rigid directory structure that acts as our map:
app
โโโ domain <-- THE HEART (POKOs only)
โ โโโ model
โ โ โโโ Data.kt <-- Pure Kotlin Data Class
โ โโโ ports
โ โโโ outgoing <-- Interfaces defining โWhatโ we need
โ โโโ DataPersistencePort.kt <- SQL db
โ โโโ DataStoragePort.kt <- Object storage
โโโ usecases <-- THE ORCHESTRATOR
โ โโโ StoreDataUseCase.kt <-- Feature logic
โโโ adapter <-- THE โHOWโ (Infrastructure)
โโโ web <-- Inbound Adapter
โ โโโ DataController.kt
โ โโโ dto <-- Request/Response DTOs
โ โโโ WebMapper.kt <-- DTO <-> Domain mapping
โโโ sqldb <-- Outbound Adapter
โ โโโ entity
โ โ โโโ DataJpaEntity.kt <-- @Entity + JPA annotation
โ โโโ DataRepository.kt <-- Spring Data/CrudRepository
โ โโโ PersistenceMapper.kt <-- Entity <-> Domain mapping
โ โโโ PersistenceAdapter.kt <-- Impl DataPersistencePort
โโโ cloud <-- Outbound Adapter
โโโ ObjectStorageAdapter.ktโค The Heart (Domain): Pure Kotlin Data Classes and business logic common to all usecases.
โค The Orchestrator (Usecases): Where feature-specific logic lives and adapters are coordinated.
โค The Infrastructure (Adapters): The โHowโ of the systemโweb controllers, JPA entities, and cloud storage clients.
The invisible boundary here is the Port. Itโs an interface that defines โwhatโ we need without caring โhowโ itโs done. In theory, this geometry should be light and flexible, yet many teams find it rigid because they misunderstand the direction of the signal.
๐๐บ๐ฝ๐ถ๐ฟ๐ถ๐ฐ๐ฎ๐น ๐๐
๐ฝ๐น๐ผ๐ฟ๐ฎ๐๐ถ๐ผ๐ป
I moved from the โtheoretical pathโ of perfect architecture to the โactual terrainโ of daily PRs. The system showed its breaking point not in a crash, but in a silent failure of discipline: the Domain Import Leak.
โค The Breaking Point: It usually starts when an engineer adds a domain service that directly imports an adapter: import app.adapter.NewAdapter.kt.
โค The Silent Failure: The code still passes tests. It still โworksโ. But the โPure Domainโ has been poisoned by infrastructure concerns.
โค The Result: When the time inevitably comes to move that service to a usecase, the system reacts with extreme fatigue. We end up with PRs requiring the renaming of tens of files, leading to typos, package mismatches, and a massive mental load on reviewers.
๐ ๐ฎ๐ป๐ฎ๐ด๐ถ๐ป๐ด ๐๐ต๐ฒ ๐ฆ๐ถ๐ด๐ป๐ฎ๐น
The handoff between layers is where the โspaghettiโ starts or ends. In my exploration, I found that the clarity of intent is often lost because teams are afraid of the โcomplexityโ of an extra interface.
โค Cognitive Load: Trying to refactor architecture in the middle of a feature story creates a โrefactoring nightmareโ.
โค Signal-to-Noise: If you are 100% sure a logic block belongs in the domain, put it there. If not, the โcleanerโ signal is to start in a Usecase and extract downward only when the need is proven.
โค Direct Translation: To keep the signal clear, Iโve found itโs even acceptable to call a Port directly from a controller for simple cases. This avoids 1:1 โpass-throughโ mapping while keeping the adapter decoupled through the interface.
๐ช๐ต๐ฎ๐ ๐๐ฎ๐ฟ๐ป๐ฒ๐ฑ ๐ง๐ฟ๐๐๐?
After the stress test of โno time to decouple,โ one principle remained standing: Mandatory Ports from the Start.
โค Stability: The โpriceโ of an interface at the start is effectively zero. It provides an immediate boundary that prevents the โimport leakโ and allows the domain to remain pure. โค The New Baseline: My trusted navigation strategy is now TDD-driven Hexagon.
โข Step 1: Define the Domain Model.
โข Step 2: Build the Adapter and verify it with Testcontainers (SQL or Object Storage).
โข Step 3: Finally, orchestrate it all in the Usecase or Controller using the Port interface.
๐๐ฐ๐๐ถ๐ผ๐ป๐ฎ๐ฏ๐น๐ฒ ๐๐ป๐๐ถ๐ด๐ต๐๐
โค Backlog (Failed the Stress Test):
โข โRefactoring-in-the-middleโ: Changing architecture while delivering a story leads to mess and typos.
โข Direct Adapter Imports: Any import app.adapter inside app.domain is a bug, not a feature.
โค Merged (Trusted Toolkit):
โข Ports First: Always create the interface for 3rd party services or repositories immediately.
โข Adapter-First Testing: Use Testcontainers to prove your โHowโ works before you worry about the โWhatโ in your orchestration.
โข Minimum Layers: Only add a Usecase layer if there is actual orchestration; otherwise, call the Port from the Controller.
Final Wisdom: Clean architecture isnโt about having the most layers; itโs about having the most resilient boundaries. The โpriceโ of an interface is nothing compared to the cost of a messy PR that no one wants to review.
