The golden rule: dependencies point inward. The domain does not know HTTP, SQL, framework. Clean, hexagonal, and onion describe the same design with different vocabularies.
DDD fills the center: ddd. Modules: modular-monolith. UI: mvvm-architecture (View/ViewModel are presentation adapters).
Map of names
| Name | Emphasis |
|---|---|
| Clean | Rings: Enterprise → Application → Interface adapters → Frameworks |
| Hexagonal | Ports (domain API) and adapters (HTTP, DB, bus) |
| Onion | Domain at the core; infra in the shell |
Do not implement the three in parallel folders. Choose the names of the repo.
Rings
┌─────────────────────────────────────┐
│ Frameworks & drivers (HTTP, DB) │
│ ┌───────────────────────────────┐ │
│ │ Interface adapters │ │
│ │ (controllers, presenters, │ │
│ │ repository impl, gateways) │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ Application (use cases) │ │ │
│ │ │ ┌───────────────────┐ │ │ │
│ │ │ │ Domain / entities │ │ │ │
│ │ │ └───────────────────┘ │ │ │
│ │ └─────────────────────────┘ │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
- Domain: entities, VOs, invariants. No
importfrom web/ORM. - Application: use cases (one command / one transaction). Orchestrates ports.
- Adapters: REST, GraphQL, CLI, UI,
sqlx/EF/Prisma, queues. - Framework: the host (Gin, Axum, ASP.NET, Nest) only in the outer ring.
Arrow: adapter → application → domain. Never domain → Prisma/Gin.
Ports
- Output port:
trait/ interfaceOrderRepository,Clock,EventPublisher. - Input port: the use case (
PlaceOrder) called by the controller. - One adapter per port and technology. Do not mix SQL into the use case “just this once”.
HTTP handler → PlaceOrder (app) → Order (domain)
↓
OrderRepository (port) ← Postgres adapter
Use cases
- Intention name (
PlaceOrder,CancelShipment), notOrderService.do. - Application input/output ≠ database row ≠ JSON DTO if that leaks infra. Map at the edges.
- Transaction in the application (or in the repository impl), not in the controller.
- Authz: the use case requires identity; the ownership rule can live in the domain —
authentication-authorization.
Tests
- Use cases with fake ports: the value of the architecture.
- Adapters: integration tests (DB, HTTP) separately.
- If nobody tests the use case without Docker, the arrows probably point the wrong way.
When not to apply
- Script, 50-line lambda, generated CRUD.
- Do not create 8 .NET projects (
*.Domain.Abstractions.Core) for two screens. - Vertical slice inside a module can glue adapter+use case of the feature without global rings — even so the domain does not import the framework. See
modular-monolith.
Anti-patterns
- Entity Framework / Prisma in the “domain”
- Use case that returns
HttpResponse/gin.Context - Interface for every class (
IOrder+Order) with no second implementation - Single DTO crossing all rings
- “Clean” only in the folder name
Integration with other skills
- Decision:
software-architecture - Rich model:
ddd - Modules / slices:
modular-monolith - CQRS (read vs write port):
cqrs - HTTP:
api-design+ stack (aspnet-core,golang-apis,axum,nestjs-apis) - Persistence: stays adapter (
entity-framework,sqlx,golang-sqlc,database-prisma)
Completion criteria
- Domain/application compile without web/ORM packages
- Use cases testable with fakes
- Mapping at the edges (HTTP/DB)
- One naming convention (Clean or ports) consistent in the repo
- No extra rings without testing/evolution benefit