Clean Architecture
Apply Clean Architecture principles to define layer boundaries, identify dependency violations, and structure domain vs infrastructure code. Use when designing service boundaries, separating business logic from infrastructure, evaluating hexagonal/onion/ports-and-adapters architecture, structuring module layout, or resolving dependency inversion and circular dependency issues.
npx skills add github:pantheon-org/tekhne --skill software-engineering--design-principles--clean-architectureStrategic architecture principles for boundaries, dependencies, and layered system design.
When to Use
- Designing service boundaries or module structure
- Evaluating dependency direction and circular dependencies
- Structuring new applications or refactoring monoliths
- Assessing where to place entities, use cases, adapters, and frameworks
- Deciding when to introduce architectural boundaries
When Not to Use
- Class-level design decisions (use solid-principles)
- Choosing structural patterns like Factory or Adapter (use design-patterns)
- Testing strategy configuration (use testable-design)
Architecture Layers
Dependency Rule: Dependencies point inward only. Inner layers know nothing about outer layers.
Layers (inner → outer): Entities → Use Cases → Interface Adapters → Frameworks & Drivers
See references/dep-inward-only.md for layer definitions and examples.
Workflow
Step 1: Identify Decision Type
Output: Classify as entity, use case, adapter, or framework decision.
Ask:
- Does this logic belong to core business rules? → Entity
- Does it orchestrate business workflows? → Use Case
- Does it translate between layers? → Adapter
- Is it external infrastructure? → Framework
Step 2: Apply Dependency Direction Checks
Output: Dependency violations with corrective actions.
Checklist:
- Dependencies point inward (no outer layers importing inner layers)
- No circular dependencies between modules
- Clear ownership of interfaces (defined by inner layers, implemented by outer layers)
- Entities remain pure (no framework or infrastructure imports)
Example:
Violation: Entity imports ORM decorator from infrastructure.
Refactor: Define entity as plain object; map to ORM in repository (adapter layer).
Step 3: Design Use Cases
Output: Use case interface with input/output ports.
Template:
// Use Case (application layer)
interface CreateOrderUseCase {
execute(input: CreateOrderInput): Promise<CreateOrderOutput>
}
// Input/Output ports (defined by use case)
interface CreateOrderInput {
userId: string
items: OrderItem[]
}
interface CreateOrderOutput {
orderId: string
status: string
}
Use cases:
- Have one responsibility (single workflow)
- Depend on abstractions (ports), not concrete implementations
- Orchestrate entities and gateways
- Contain no presentation logic (formatting, HTTP, UI)
Step 4: Define Boundaries with Adapters
Output: Adapter interfaces (ports) and implementations.
- Controllers: HTTP requests → use case input
- Presenters: Use case output → HTTP response
- Gateways: Use case calls → database/API operations
- Repositories: Entity persistence → database operations
Example:
// Port (defined by use case layer)
interface IOrderRepository {
save(order: Order): Promise<void>
findById(id: string): Promise<Order | null>
}
// Adapter (infrastructure layer)
class PostgresOrderRepository implements IOrderRepository {
async save(order: Order): Promise<void> {
// Map entity to ORM, persist
}
}
Step 5: Place Framework Code at Edges
Output: Framework integrations isolated in outermost layer.
Keep frameworks (Express, NestJS, TypeORM, React) in the infrastructure/adapter layer:
- DI container at application edge
- ORM configurations in infrastructure
- Web server in infrastructure
- Domain remains framework-free
Example:
BAD: Entity uses @Entity decorator from TypeORM.
GOOD: Entity is plain TypeScript; repository maps to TypeORM in infrastructure.
Step 6: Document Boundary Decisions
Output: ADR with rationale, alternatives, and risks.
Template:
Decision: Extract authentication into separate bounded context.
Rationale: Auth has independent lifecycle and team ownership.
Alternatives:
- Keep in monolith (simpler, tightly coupled)
- Partial boundary (YAGNI, easier to extract later)
Chosen: Full bounded context (clear ownership, independent deployment)
Risks: Network calls add latency; requires distributed transaction handling.
Anti-Patterns
NEVER allow circular dependencies
BAD: Module A imports B and B imports A.
GOOD: Extract shared contract/module and invert dependencies.
WHY: a cycle means neither module can be understood, tested, or deployed independently — changing one always risks breaking the other, and most build/bundler tools cannot even guarantee a deterministic load order across the cycle.
NEVER let entities depend on frameworks
BAD: Entity imports ORM decorators, framework types, or infrastructure.
GOOD: Entities are plain objects; adapters handle framework mapping.
WHY: once an entity carries an @Entity decorator or a framework base class, every business-rule test must boot that framework too, and swapping the ORM means rewriting the domain model instead of one adapter.
NEVER put business logic in controllers
BAD: Controller validates, calculates, and persists data.
GOOD: Controller calls use case; use case orchestrates business logic.
WHY: business logic trapped in a controller can only be exercised through an HTTP request — it MUST be re-implemented for a CLI, a queue consumer, or a test that wants to skip the transport layer entirely.
NEVER bypass interface contracts
BAD: Use case instantiates concrete PostgresRepository.
GOOD: Use case depends on IRepository interface; DI provides implementation.
WHY: a use case that names a concrete repository class can never be unit-tested without a real (or heavily mocked) database, and swapping storage engines means editing every use case instead of one adapter.
NEVER design boundaries for imagined future requirements
BAD: Add full hexagonal architecture “in case” of future DB migration.
GOOD: Solve current need; refactor when trigger appears (YAGNI).
WHY: a boundary drawn for a migration that never happens is pure ongoing tax — every future change must thread through an abstraction layer that protects against a scenario nobody triggered.
NEVER let a repository interface leak persistence details into the domain
BAD:
interface IOrderRepository {
findByRawSql(query: string): Promise<OrderRow[]>
}
GOOD:
interface IOrderRepository {
findById(id: string): Promise<Order | null>
}
WHY: a port that exposes SQL, ORM query builders, or database row shapes ensures the use case layer is coupled to the storage engine even though it depends only on an “abstraction” — the interface must be defined entirely in domain terms, never in persistence terms.
Quick Commands
# Find dependency direction violations
rg -n "import.*infrastructure.*from.*domain|import.*adapter.*from.*entity" src
# Find circular dependencies
nx graph
# Find framework leakage into domain
rg -n "@Entity|@Injectable|@Component" src/domain
References
Dependencies: inward-only · acyclic · data crossing boundaries · no framework imports
Components: screaming architecture · stable dependencies
Boundaries: cost awareness · defer decisions · service internal architecture
Entities: purity · rich not anemic · encapsulate invariants · value objects · no persistence awareness
Use Cases: isolation · explicit dependencies · orchestrates not implements · input/output ports · no presentation logic · transaction boundary
Adapters: gateway abstraction · thin controller · mapper translation · presenter formats
Frameworks: DI at edge · domain purity · ORM in infrastructure · web in infrastructure
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
No eval scenarios available.
Keep Controllers Thin
Controllers should only handle HTTP concerns: parsing requests, validating input format, calling use cases, and formatting responses. No business logic.
Incorrect (fat controller with business logic):
class OrderController:
@app.route('/orders', methods=['POST'])
def create_order(self):
data = request.json
# Input validation - OK in controller
if not data.get('items'):
return jsonify({'error': 'Items required'}), 400
# Business logic - WRONG in controller
customer = db.query(Customer).get(data['customer_id'])
if not customer.is_active:
return jsonify({'error': 'Inactive customer'}), 400
total = 0
for item in data['items']:
product = db.query(Product).get(item['product_id'])
if product.stock < item['quantity']:
return jsonify({'error': f'{product.name} out of stock'}), 400
total += product.price * item['quantity']
# More business logic
if total > customer.credit_limit:
return jsonify({'error': 'Exceeds credit limit'}), 400
order = Order(
customer_id=customer.id,
items=data['items'],
total=total
)
db.session.add(order)
db.session.commit()
return jsonify({'order_id': order.id}), 201
Correct (thin controller delegates to use case):
class OrderController:
def __init__(self, create_order_use_case: CreateOrderUseCase):
self.create_order = create_order_use_case
@app.route('/orders', methods=['POST'])
def create(self):
# Parse HTTP request
data = request.json
# Validate request format (not business rules)
if not data.get('items'):
return jsonify({'error': 'Items required'}), 400
# Build command
command = CreateOrderCommand(
customer_id=data['customer_id'],
items=[
OrderItemCommand(p['product_id'], p['quantity'])
for p in data['items']
]
)
# Delegate to use case
try:
result = self.create_order.execute(command)
return jsonify({'order_id': result.order_id}), 201
except CustomerInactiveError:
return jsonify({'error': 'Customer account inactive'}), 400
except InsufficientStockError as e:
return jsonify({'error': f'{e.product_name} out of stock'}), 400
except CreditLimitExceededError:
return jsonify({'error': 'Order exceeds credit limit'}), 400
Controller responsibilities:
- Parse HTTP request to command/query objects
- Validate request format (required fields present)
- Call appropriate use case
- Map exceptions to HTTP status codes
- Format response
Use case responsibilities:
- Business validation (credit limits, stock)
- Business logic (calculations, state changes)
- Orchestrate entities and repositories
Reference: Clean Architecture - Controllers
Gateways Hide External System Details
Database gateways, API gateways, and service gateways are polymorphic interfaces that hide external system details. The use case layer talks to abstractions; infrastructure implements them.
Incorrect (use case knows external system details):
func (uc *ProcessRefundUseCase) Execute(orderId string) error {
order := uc.db.QueryRow("SELECT * FROM orders WHERE id = $1", orderId)
// Direct Stripe API knowledge in use case
stripe.Key = os.Getenv("STRIPE_KEY")
params := &stripe.RefundParams{
PaymentIntent: stripe.String(order.PaymentIntentId),
Amount: stripe.Int64(order.Total),
}
_, err := refund.New(params)
if err != nil {
// Stripe-specific error handling
if stripeErr, ok := err.(*stripe.Error); ok {
if stripeErr.Code == stripe.ErrorCodeChargeAlreadyRefunded {
return ErrAlreadyRefunded
}
}
return err
}
uc.db.Exec("UPDATE orders SET status = 'refunded' WHERE id = $1", orderId)
return nil
}
Correct (gateway abstracts external system):
// application/ports/PaymentGateway.go
type PaymentGateway interface {
Refund(paymentId string, amount Money) (RefundResult, error)
}
type RefundResult struct {
RefundId string
Status RefundStatus
}
// application/ports/OrderRepository.go
type OrderRepository interface {
FindById(id OrderId) (*Order, error)
Save(order *Order) error
}
// application/usecases/ProcessRefundUseCase.go
type ProcessRefundUseCase struct {
orders OrderRepository
payments PaymentGateway
}
func (uc *ProcessRefundUseCase) Execute(orderId string) error {
order, err := uc.orders.FindById(OrderId(orderId))
if err != nil {
return err
}
result, err := uc.payments.Refund(order.PaymentId, order.Total)
if err != nil {
return err // Gateway translates Stripe errors to domain errors
}
order.MarkRefunded(result.RefundId)
return uc.orders.Save(order)
}
// infrastructure/StripePaymentGateway.go
type StripePaymentGateway struct {
client *stripe.Client
}
func (g *StripePaymentGateway) Refund(paymentId string, amount Money) (RefundResult, error) {
params := &stripe.RefundParams{
PaymentIntent: stripe.String(paymentId),
Amount: stripe.Int64(amount.Cents()),
}
refund, err := g.client.Refunds.New(params)
if err != nil {
return RefundResult{}, g.translateError(err)
}
return RefundResult{
RefundId: refund.ID,
Status: g.translateStatus(refund.Status),
}, nil
}
func (g *StripePaymentGateway) translateError(err error) error {
// Convert Stripe-specific errors to domain errors
if stripeErr, ok := err.(*stripe.Error); ok {
switch stripeErr.Code {
case stripe.ErrorCodeChargeAlreadyRefunded:
return ErrAlreadyRefunded
}
}
return ErrPaymentFailed
}
Benefits:
- Switch from Stripe to Adyen without touching use cases
- Test use cases with mock gateways
- External API changes isolated to gateway implementation
Reference: Clean Architecture - Database Gateways
Use Mappers to Translate Between Layers
Mappers translate between domain entities and external representations (database rows, API responses, DTOs). Each layer has its own model; mappers bridge them.
Incorrect (domain entity used everywhere):
// Domain entity directly serialized to JSON and stored in DB
class User {
id: string
email: string
passwordHash: string // Exposed in API response!
createdAt: Date
lastLogin: Date
preferences: UserPreferences
roles: Role[]
toJSON() {
return { ...this } // Leaks everything
}
}
// Controller returns entity directly
app.get('/users/:id', (req, res) => {
const user = userRepo.findById(req.params.id)
res.json(user) // passwordHash in response!
})
// ORM couples domain to database schema
@Entity()
class User {
@PrimaryColumn() id: string
@Column() email: string
// Domain entity is now a database entity
}
Correct (dedicated models per layer with mappers):
// domain/User.ts - Pure domain entity
class User {
constructor(
readonly id: UserId,
private email: Email,
private passwordHash: PasswordHash,
private roles: Set<Role>
) {}
hasRole(role: Role): boolean {
return this.roles.has(role)
}
}
// infrastructure/persistence/UserEntity.ts - Database model
interface UserRow {
id: string
email: string
password_hash: string
roles: string // JSON array in DB
created_at: string
updated_at: string
}
// infrastructure/persistence/UserMapper.ts
class UserMapper {
toDomain(row: UserRow): User {
return new User(
new UserId(row.id),
Email.create(row.email),
new PasswordHash(row.password_hash),
new Set(JSON.parse(row.roles))
)
}
toPersistence(user: User): UserRow {
return {
id: user.id.value,
email: user.email.value,
password_hash: user.passwordHash.value,
roles: JSON.stringify([...user.roles]),
updated_at: new Date().toISOString()
}
}
}
// interface/dto/UserResponse.ts - API response model
interface UserResponse {
id: string
email: string
roles: string[]
// No passwordHash!
}
// interface/mappers/UserResponseMapper.ts
class UserResponseMapper {
toResponse(user: User): UserResponse {
return {
id: user.id.value,
email: user.email.value,
roles: [...user.roles]
}
}
}
Benefits:
- Database schema changes don’t affect domain
- API can evolve independently of domain model
- Sensitive data never accidentally exposed
Reference: Data Mapper Pattern
Presenters Format Data for the View
Presenters accept data from use cases and format it for presentation. They create view models with strings, booleans, and pre-formatted values - nothing left for the view to compute.
Incorrect (view does formatting):
// View component does formatting - logic spread across UI
function InvoiceView({ invoice }: { invoice: Invoice }) {
// Formatting logic in view
const formattedDate = new Date(invoice.dueDate).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
const formattedTotal = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: invoice.currency
}).format(invoice.total / 100)
const statusColor = invoice.status === 'paid' ? 'green'
: invoice.status === 'overdue' ? 'red'
: 'yellow'
const daysOverdue = invoice.status === 'overdue'
? Math.floor((Date.now() - new Date(invoice.dueDate).getTime()) / 86400000)
: 0
return (
<div>
<span style={{ color: statusColor }}>{invoice.status.toUpperCase()}</span>
<span>Due: {formattedDate}</span>
<span>Total: {formattedTotal}</span>
{daysOverdue > 0 && <span>{daysOverdue} days overdue</span>}
</div>
)
}
Correct (presenter formats, view renders):
// presenter/InvoicePresenter.ts
interface InvoiceViewModel {
invoiceNumber: string
status: string
statusColor: 'green' | 'yellow' | 'red'
dueDate: string
total: string
overdueMessage: string | null
}
class InvoicePresenter {
present(invoice: InvoiceResult, locale: string): InvoiceViewModel {
return {
invoiceNumber: `INV-${invoice.id.padStart(6, '0')}`,
status: invoice.status.toUpperCase(),
statusColor: this.getStatusColor(invoice.status),
dueDate: this.formatDate(invoice.dueDate, locale),
total: this.formatMoney(invoice.total, invoice.currency, locale),
overdueMessage: this.getOverdueMessage(invoice)
}
}
private getStatusColor(status: string): 'green' | 'yellow' | 'red' {
const colors = { paid: 'green', pending: 'yellow', overdue: 'red' }
return colors[status] || 'yellow'
}
private formatDate(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, {
month: 'long', day: 'numeric', year: 'numeric'
}).format(date)
}
private getOverdueMessage(invoice: InvoiceResult): string | null {
if (invoice.status !== 'overdue') return null
const days = this.calculateDaysOverdue(invoice.dueDate)
return `${days} day${days !== 1 ? 's' : ''} overdue`
}
}
// view/InvoiceView.tsx - Humble, just renders
function InvoiceView({ vm }: { vm: InvoiceViewModel }) {
return (
<div>
<span style={{ color: vm.statusColor }}>{vm.status}</span>
<span>Due: {vm.dueDate}</span>
<span>Total: {vm.total}</span>
{vm.overdueMessage && <span>{vm.overdueMessage}</span>}
</div>
)
}
Benefits:
- Formatting logic tested without UI framework
- Same use case serves different locales via different presenter configs
- View components trivially simple, easy to redesign
Reference: Clean Architecture - Presenters and Humble Objects
Weigh Boundary Cost Against Ignorance Cost
Boundaries are expensive to create and maintain, but ignoring needed boundaries becomes very expensive later. Continuously evaluate where the cost of implementing is less than the cost of ignoring.
Incorrect (boundary everywhere - over-engineering):
// Overkill for a simple CRUD app with 3 entities
// 9 interfaces for 3 entities
interface UserRepository { }
interface UserService { }
interface UserPresenter { }
interface ProductRepository { }
interface ProductService { }
interface ProductPresenter { }
interface OrderRepository { }
interface OrderService { }
interface OrderPresenter { }
// 9 implementations
class SqlUserRepository implements UserRepository { }
class UserServiceImpl implements UserService { }
class UserPresenterImpl implements UserPresenter { }
// ... 6 more classes
// 3 factories
class UserFactory { }
class ProductFactory { }
class OrderFactory { }
// Result: 100 files for functionality that could be 20 files
// Maintenance burden exceeds benefit for small team
Incorrect (no boundaries - under-engineering):
// Dangerous for a complex domain with multiple teams
class GodService {
public void handleEverything(Request req) {
// 2000 lines mixing:
// - User authentication
// - Order processing
// - Payment handling
// - Email notifications
// - Report generation
}
}
// Result: Every change risks breaking unrelated features
// No team can work independently
Correct (boundaries where they matter):
// Evaluate each potential boundary:
// HIGH VALUE BOUNDARY: External payment provider
// - Changes frequently (provider updates API)
// - Risk of vendor lock-in
// - Different team might own this
interface PaymentGateway {
PaymentResult charge(Money amount, PaymentMethod method);
}
// MEDIUM VALUE BOUNDARY: Database access
// - Might migrate databases
// - Enables testing without DB
interface OrderRepository {
void save(Order order);
Order findById(OrderId id);
}
// LOW VALUE - SKIP FOR NOW: Presenter/View split
// - Single frontend, single team
// - No plans to support multiple UIs
// Just put formatting in React components for now
// Add boundary later if needed
// SKIP: Separate microservices
// - Team is 5 people
// - Deployment is monolithic anyway
// - Network boundary adds latency and complexity for no benefit
Decision Framework:
| Factor | Add Boundary | Skip Boundary |
|---|---|---|
| Change frequency | High | Low |
| Team ownership | Multiple teams | Single team |
| External dependency | Yes | No |
| Testing difficulty | Hard without boundary | Easy anyway |
| Current pain | Evident | Hypothetical |
Reference: Clean Architecture - The Cost of Boundaries
Defer Framework and Database Decisions
A good architecture allows major decisions about frameworks, databases, and delivery mechanisms to be deferred until the last responsible moment. The longer you wait, the more information you have.
Incorrect (early commitment to specifics):
// Day 1: "Let's use Prisma with PostgreSQL and Next.js"
// domain/Order.ts - Coupled to Prisma from the start
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function createOrder(data: OrderInput) {
// Business logic intertwined with Prisma
const order = await prisma.order.create({
data: {
items: {
create: data.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.price
}))
},
total: data.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
},
include: { items: true }
})
return order
}
// 6 months later: "PostgreSQL doesn't scale for our read patterns,
// we need DynamoDB" - Massive rewrite required
Correct (defer database decision):
// Day 1: Focus on business rules, defer database choice
// domain/Order.ts - Pure business logic
export class Order {
private items: OrderItem[] = []
addItem(product: Product, quantity: number): void {
if (quantity <= 0) throw new InvalidQuantityError()
this.items.push(new OrderItem(product, quantity))
}
calculateTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.lineTotal()),
Money.zero()
)
}
}
// application/ports/OrderRepository.ts - Interface only
export interface OrderRepository {
save(order: Order): Promise<void>
findById(id: OrderId): Promise<Order | null>
}
// For now: Simple in-memory implementation for testing
// infrastructure/InMemoryOrderRepository.ts
export class InMemoryOrderRepository implements OrderRepository {
private orders = new Map<string, Order>()
async save(order: Order): Promise<void> {
this.orders.set(order.id.value, order)
}
}
// Later, when you know more: Add real database
// infrastructure/PrismaOrderRepository.ts
// OR
// infrastructure/DynamoOrderRepository.ts
// OR
// infrastructure/MongoOrderRepository.ts
// The domain never changes regardless of database choice
Decisions worth deferring:
- Database vendor (PostgreSQL vs MongoDB vs DynamoDB)
- ORM choice (Prisma vs TypeORM vs raw SQL)
- Web framework (Express vs Fastify vs Hono)
- Message queue (RabbitMQ vs Kafka vs SQS)
- Cache provider (Redis vs Memcached)
Benefits:
- Learn requirements before committing
- Prototype faster with simple implementations
- Switch vendors without domain rewrites
Services Must Have Internal Clean Architecture
Breaking a monolith into microservices doesn’t solve architectural problems. Each service still needs internal architecture. Services are a deployment option, not an architecture.
Incorrect (microservices as architecture replacement):
"We use microservices architecture"
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ order-service │ │ user-service │ │ payment-service │
│ │ │ │ │ │
│ routes.js │ │ routes.js │ │ routes.js │
│ database.js │ │ database.js │ │ database.js │
│ helpers.js │ │ helpers.js │ │ helpers.js │
│ │ │ │ │ │
│ (No layers, │ │ (No layers, │ │ (No layers, │
│ no boundaries,│ │ no boundaries,│ │ no boundaries,│
│ just smaller │ │ just smaller │ │ just smaller │
│ messes) │ │ messes) │ │ messes) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
# Result: Distributed monolith
# All the downsides of microservices (network, deployment, consistency)
# None of the benefits (each service still a tangled mess)
Correct (clean architecture within each service):
┌───────────────────────────────────────────────────────────┐
│ order-service │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ domain/ │ │
│ │ Order.ts OrderLine.ts OrderStatus.ts │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ application/ │ │
│ │ PlaceOrderUseCase.ts │ │
│ │ ports/ │ │
│ │ OrderRepository.ts │ │
│ │ PaymentGateway.ts ← calls payment-service │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ infrastructure/ │ │
│ │ PostgresOrderRepository.ts │ │
│ │ HttpPaymentGateway.ts → payment-service API │ │
│ │ KafkaEventPublisher.ts │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ interface/ │ │
│ │ OrderController.ts │ │
│ │ OrderEventHandler.ts │ │
│ └─────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
# Each service has:
# - Domain layer with business rules
# - Application layer with use cases and ports
# - Infrastructure layer implementing ports
# - Clean dependency direction within service
Cross-service communication:
// application/ports/PaymentGateway.ts
interface PaymentGateway {
charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>
}
// infrastructure/HttpPaymentGateway.ts
class HttpPaymentGateway implements PaymentGateway {
async charge(amount: Money, method: PaymentMethod): Promise<PaymentResult> {
// Calls payment-service over HTTP
// Service boundary is an infrastructure detail
const response = await fetch('http://payment-service/charge', { ... })
return this.mapResponse(response)
}
}
Benefits:
- Services testable without other services
- Can extract or merge services without rewriting business logic
- Network boundaries don’t compromise internal architecture
Avoid Forcing Clients to Depend on Unused Code
Classes used together should be in the same component. Classes not used together should be in separate components. Forcing a client to depend on things it doesn’t use creates unnecessary coupling.
Incorrect (monolithic utility package):
// utils/src/main/java/com/app/utils/
├── StringUtils.java
├── DateUtils.java
├── FileUtils.java
├── HttpUtils.java
├── CryptoUtils.java
├── ImageUtils.java // Depends on ImageMagick
└── PdfUtils.java // Depends on iText
// pom.xml for utils module
<dependencies>
<dependency>imagemagick</dependency> <!-- 50MB -->
<dependency>itext-pdf</dependency> <!-- 20MB -->
<dependency>bouncycastle</dependency> <!-- 10MB -->
</dependencies>
// A service that only needs StringUtils
// must now depend on all 80MB of transitive dependencies
Correct (split by usage pattern):
// string-utils/
├── StringUtils.java
└── pom.xml // No heavy dependencies
// date-utils/
├── DateUtils.java
└── pom.xml
// image-processing/
├── ImageUtils.java
├── ImageResizer.java
└── pom.xml // ImageMagick dependency only here
// pdf-generation/
├── PdfUtils.java
├── PdfBuilder.java
└── pom.xml // iText dependency only here
// crypto/
├── CryptoUtils.java
├── HashingService.java
└── pom.xml // BouncyCastle dependency only here
// Services import only what they need
// order-service depends on string-utils, date-utils (2MB)
// report-service depends on string-utils, pdf-generation (22MB)
Benefits:
- Clients only pull dependencies they actually use
- Smaller deployment artifacts
- Changes to image processing don’t redeploy order service
Reference: Clean Architecture - Common Reuse Principle
Structure Should Scream the Domain Not the Framework
The folder structure should communicate what the system does, not what framework it uses. Looking at the top-level directories should reveal the business domain.
Incorrect (framework-oriented structure):
src/
├── controllers/
│ ├── UserController.ts
│ ├── OrderController.ts
│ └── ProductController.ts
├── services/
│ ├── UserService.ts
│ ├── OrderService.ts
│ └── ProductService.ts
├── repositories/
│ ├── UserRepository.ts
│ ├── OrderRepository.ts
│ └── ProductRepository.ts
├── models/
│ ├── User.ts
│ ├── Order.ts
│ └── Product.ts
└── utils/
└── helpers.ts
# This screams "MVC framework" not "e-commerce system"
Correct (domain-oriented structure):
src/
├── ordering/
│ ├── domain/
│ │ ├── Order.ts
│ │ ├── OrderLine.ts
│ │ └── OrderStatus.ts
│ ├── application/
│ │ ├── PlaceOrderUseCase.ts
│ │ ├── CancelOrderUseCase.ts
│ │ └── ports/
│ │ ├── OrderRepository.ts
│ │ └── PaymentGateway.ts
│ └── infrastructure/
│ ├── PostgresOrderRepository.ts
│ └── StripePaymentGateway.ts
├── inventory/
│ ├── domain/
│ ├── application/
│ └── infrastructure/
├── customers/
│ ├── domain/
│ ├── application/
│ └── infrastructure/
└── shared/
└── kernel/
├── Money.ts
└── EntityId.ts
# This screams "e-commerce with ordering, inventory, customers"
Benefits:
- New developers understand the domain immediately
- Related code lives together, enabling focused changes
- Frameworks become implementation details, not organizing principles
Reference: Screaming Architecture
Depend in the Direction of Stability
Components should depend on components that are more stable than themselves. A stable component (many dependents, few dependencies) should not depend on an unstable component (few dependents, many dependencies).
Incorrect (stable component depends on unstable):
┌─────────────────────────────────────────────────────┐
│ core-domain │
│ (Used by 50 services, should be very stable) │
│ │
│ import { formatDate } from 'ui-helpers' // WRONG! │
│ import { sendMetrics } from 'analytics' // WRONG! │
└──────────────────────┬──────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
ui-helpers analytics reporting
(Changes (Changes (Changes
weekly) monthly) often)
# When ui-helpers changes, core-domain might break
# When core-domain breaks, all 50 services break
Correct (depend toward stability):
┌─────────────────────────────────────────────────────┐
│ core-domain │
│ (Used by 50 services, zero external dependencies) │
│ │
│ - Only depends on language primitives │
│ - Defines interfaces, not implementations │
└─────────────────────────────────────────────────────┘
▲
│
┌───────────────┼───────────────┐
│ │ │
ui-helpers analytics reporting
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ external-dependencies │
│ (date-fns, axios, lodash - maintained externally) │
└─────────────────────────────────────────────────────┘
# Unstable components depend on stable core
# Core never breaks due to UI changes
Stability Metric:
Instability = Outgoing Dependencies / (Incoming + Outgoing)
- I = 0: Maximally stable (many dependents, no dependencies)
- I = 1: Maximally unstable (no dependents, many dependencies)
Dependencies should flow from high I to low I.
Reference: Clean Architecture - Stable Dependencies Principle
Eliminate Cyclic Dependencies Between Components
The dependency graph must be a Directed Acyclic Graph (DAG). Cycles create ripple effects where changes propagate unpredictably through the system.
Incorrect (cyclic dependency):
// modules/orders/OrderService.ts
import { CustomerService } from '../customers/CustomerService'
export class OrderService {
constructor(private customers: CustomerService) {}
async createOrder(customerId: string) {
const customer = await this.customers.findById(customerId)
// ...
}
}
// modules/customers/CustomerService.ts
import { OrderService } from '../orders/OrderService' // Cycle!
export class CustomerService {
constructor(private orders: OrderService) {}
async getCustomerWithOrders(customerId: string) {
const orders = await this.orders.findByCustomer(customerId)
// ...
}
}
// Neither module can be deployed or tested independently
Correct (break cycle with dependency inversion):
// modules/orders/ports/CustomerProvider.ts
export interface CustomerProvider {
findById(id: string): Promise<Customer>
}
// modules/orders/OrderService.ts
import { CustomerProvider } from './ports/CustomerProvider'
export class OrderService {
constructor(private customers: CustomerProvider) {}
async createOrder(customerId: string) {
const customer = await this.customers.findById(customerId)
// ...
}
}
// modules/customers/CustomerService.ts
// No import from orders module
export class CustomerService implements CustomerProvider {
// Implements the interface defined in orders module
}
// modules/customers/adapters/OrderAdapter.ts
import { OrderService } from '../../orders/OrderService'
export class CustomerOrderAdapter {
constructor(private orders: OrderService) {}
async getOrdersForCustomer(customerId: string) {
return this.orders.findByCustomer(customerId)
}
}
Alternative (extract shared abstraction):
Create a new component that both depend on, breaking the cycle into a DAG.
Reference: Clean Architecture - Acyclic Dependencies Principle
Use Simple Data Structures Across Boundaries
Data crossing architectural boundaries should be simple, isolated data structures. Never pass entities, database rows, or framework objects across boundaries.
Incorrect (entity crosses boundary):
# domain/entities/user.py
class User:
def __init__(self, id, email, password_hash, created_at):
self.id = id
self.email = email
self.password_hash = password_hash # Sensitive data
self.created_at = created_at
# interface_adapters/controllers/user_controller.py
class UserController:
def get_user(self, user_id):
user = self.use_case.get_user(user_id)
return jsonify(user.__dict__) # Entity exposed to HTTP layer, leaks password_hash
Correct (DTOs cross boundaries):
# application/dto/user_response.py
@dataclass
class UserResponse:
id: str
email: str
member_since: str # Formatted for presentation
# application/usecases/get_user.py
class GetUserUseCase:
def execute(self, user_id: str) -> UserResponse:
user = self.repository.find_by_id(user_id)
return UserResponse(
id=user.id,
email=user.email,
member_since=user.created_at.strftime("%B %Y")
)
# interface_adapters/controllers/user_controller.py
class UserController:
def get_user(self, user_id):
response = self.use_case.execute(user_id)
return jsonify(asdict(response)) # Only safe, formatted data
When NOT to use this pattern:
- Within the same architectural layer, entities can flow freely
- Performance-critical paths may need optimized data transfer
Reference: Clean Architecture - Crossing Boundaries
Source Dependencies Point Inward Only
The Dependency Rule states that source code dependencies can only point inward toward higher-level policies. Inner circles must never reference outer circles.
Incorrect (inner layer imports from outer layer):
// domain/entities/Order.ts - ENTITY LAYER
import { OrderRepository } from '../../infrastructure/OrderRepository'
import { EmailService } from '../../infrastructure/EmailService'
export class Order {
constructor(
private repo: OrderRepository, // Changes to repo implementation break Order
private email: EmailService
) {}
async complete() {
await this.repo.save(this)
await this.email.notify(this.customerId) // Cannot test without email server
}
}
Correct (inner layer defines interface, outer layer implements):
// domain/entities/Order.ts - ENTITY LAYER
export interface OrderPersistence {
save(order: Order): Promise<void>
}
export interface NotificationPort {
notify(customerId: string): Promise<void>
}
export class Order {
constructor(
private repo: OrderPersistence,
private email: NotificationPort
) {}
async complete() {
await this.repo.save(this)
await this.email.notify(this.customerId)
}
}
// infrastructure/OrderRepository.ts - INFRASTRUCTURE LAYER
import { Order, OrderPersistence } from '../domain/entities/Order'
export class OrderRepository implements OrderPersistence {
async save(order: Order): Promise<void> { /* DB implementation */ }
}
Benefits:
- Inner layers remain stable when outer layers change
- Business rules can be tested without infrastructure
- Infrastructure can be swapped without touching domain code
Reference: The Clean Architecture
Avoid Framework Imports in Inner Layers
Entities and use cases must never import framework-specific types. Framework dependencies in inner layers create tight coupling that makes testing slow and migration impossible.
Incorrect (use case imports framework types):
// Application/UseCases/ProcessPaymentUseCase.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
public class ProcessPaymentUseCase
{
private readonly DbContext _context; // EF Core dependency
private readonly IHttpContextAccessor _http; // ASP.NET dependency
public async Task Execute(PaymentRequest request)
{
var userId = _http.HttpContext.User.Identity.Name;
var payment = JsonConvert.DeserializeObject<Payment>(request.Data);
_context.Payments.Add(payment);
await _context.SaveChangesAsync();
}
}
Correct (use case depends only on abstractions):
// Application/UseCases/ProcessPaymentUseCase.cs
// No framework imports
public class ProcessPaymentUseCase
{
private readonly IPaymentRepository _payments;
private readonly ICurrentUserProvider _currentUser;
public async Task Execute(PaymentCommand command)
{
var userId = _currentUser.GetUserId();
var payment = new Payment(command.Amount, command.Currency, userId);
await _payments.Save(payment);
}
}
// Infrastructure/Persistence/EfPaymentRepository.cs
using Microsoft.EntityFrameworkCore;
public class EfPaymentRepository : IPaymentRepository
{
private readonly DbContext _context;
// Framework usage isolated to infrastructure
}
Benefits:
- Use case tests run without framework bootstrapping
- Framework can be upgraded or replaced independently
- Business logic remains readable without framework noise
Reference: Clean Architecture - Frameworks are Details
Encapsulate Business Invariants Within Entities
Business rules that must always be true (invariants) should be enforced by the entity itself. External code should not be able to put an entity into an invalid state.
Incorrect (invariants scattered outside entity):
# application/usecases/transfer_money.py
class TransferMoneyUseCase:
def execute(self, from_id, to_id, amount):
from_account = self.repo.find(from_id)
to_account = self.repo.find(to_id)
# Validation scattered in use case
if from_account.balance < amount:
raise InsufficientFundsError()
if amount <= 0:
raise InvalidAmountError()
if from_account.status != 'active':
raise AccountInactiveError()
from_account.balance -= amount # Direct field mutation
to_account.balance += amount
# Another use case might forget these checks!
Correct (invariants enforced by entity):
# domain/entities/account.py
class Account:
def __init__(self, id: AccountId, balance: Money, status: AccountStatus):
self._id = id
self._balance = balance
self._status = status
def withdraw(self, amount: Money) -> None:
self._ensure_active()
self._ensure_positive_amount(amount)
self._ensure_sufficient_funds(amount)
self._balance = self._balance.subtract(amount)
def deposit(self, amount: Money) -> None:
self._ensure_active()
self._ensure_positive_amount(amount)
self._balance = self._balance.add(amount)
def _ensure_active(self) -> None:
if self._status != AccountStatus.ACTIVE:
raise AccountInactiveError(self._id)
def _ensure_positive_amount(self, amount: Money) -> None:
if amount.is_zero_or_negative():
raise InvalidAmountError(amount)
def _ensure_sufficient_funds(self, amount: Money) -> None:
if self._balance.less_than(amount):
raise InsufficientFundsError(self._id, self._balance, amount)
# application/usecases/transfer_money.py
class TransferMoneyUseCase:
def execute(self, from_id, to_id, amount):
from_account = self.repo.find(from_id)
to_account = self.repo.find(to_id)
from_account.withdraw(amount) # Invariants guaranteed
to_account.deposit(amount)
Benefits:
- Impossible to create invalid account state
- Validation rules documented in one place
- Every use case gets consistent validation automatically
Reference: Domain-Driven Design - Aggregates
Entities Must Not Know How They Are Persisted
Entities should have no awareness of databases, ORMs, or storage mechanisms. Persistence is an infrastructure detail that must not leak into the domain.
Incorrect (entity aware of persistence):
// domain/entities/Product.java
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_name", nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@Version
private Long version;
// Entity is coupled to JPA, cannot exist without Hibernate
}
Correct (persistence-ignorant entity):
// domain/entities/Product.java
public class Product {
private final ProductId id;
private String name;
private CategoryId categoryId;
private Money price;
public Product(ProductId id, String name, CategoryId categoryId, Money price) {
this.id = id;
this.name = validateName(name);
this.categoryId = categoryId;
this.price = price;
}
public void updatePrice(Money newPrice, PricingPolicy policy) {
if (!policy.allowsPrice(newPrice)) {
throw new InvalidPriceException(newPrice);
}
this.price = newPrice;
}
private String validateName(String name) {
if (name == null || name.isBlank()) {
throw new InvalidProductNameException();
}
return name.trim();
}
}
// infrastructure/persistence/jpa/JpaProductEntity.java
@Entity
@Table(name = "products")
class JpaProductEntity {
@Id private Long id;
@Column private String name;
// ORM mapping isolated to infrastructure
}
// infrastructure/persistence/jpa/ProductMapper.java
class ProductMapper {
Product toDomain(JpaProductEntity entity) { /* ... */ }
JpaProductEntity toJpa(Product product) { /* ... */ }
}
Benefits:
- Switch from SQL to NoSQL without touching domain
- Entity tests don’t require database setup
- Domain model expresses business concepts, not database schema
Reference: Clean Architecture - Database is a Detail
Entities Contain Only Enterprise Business Rules
Entities encapsulate enterprise-wide business rules that would exist regardless of automation. They must not contain application-specific logic, persistence code, or UI concerns.
Incorrect (entity mixed with infrastructure concerns):
class Invoice {
id: string
items: LineItem[]
status: InvoiceStatus
dueDate: Date
calculateTotal(): Money {
return this.items.reduce((sum, item) => sum.add(item.amount), Money.zero())
}
isOverdue(): boolean {
return this.status === InvoiceStatus.Unpaid && new Date() > this.dueDate
}
markPaid(): void {
this.status = InvoiceStatus.Paid
database.invoices.update(this) // Entity cannot be tested without database
emailService.send(this.customerEmail, 'Payment received') // Coupled to email system
}
}
Correct (entity contains only business rules):
class Invoice {
id: string
items: LineItem[]
status: InvoiceStatus
dueDate: Date
calculateTotal(): Money {
return this.items.reduce((sum, item) => sum.add(item.amount), Money.zero())
}
isOverdue(): boolean {
return this.status === InvoiceStatus.Unpaid && new Date() > this.dueDate
}
markPaid(): void {
if (this.status !== InvoiceStatus.Unpaid) {
throw new InvalidOperationError('Invoice already processed')
}
this.status = InvoiceStatus.Paid
}
}
Benefits:
- Entity can be used in billing system, reporting system, mobile app
- Business rules tested without database or email setup
- Rules documented in one place, not scattered across application
Reference: Clean Architecture - Entities
Build Rich Domain Models Not Anemic Data Structures
Entities should contain behavior, not just data. Anemic domain models push business logic into services, scattering rules and duplicating validation.
Incorrect (anemic domain model):
// domain/entities/Subscription.java
public class Subscription {
private String id;
private String planId;
private LocalDate startDate;
private LocalDate endDate;
private String status;
// Only getters and setters - no behavior
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDate getEndDate() { return endDate; }
public void setEndDate(LocalDate endDate) { this.endDate = endDate; }
}
// application/services/SubscriptionService.java
public class SubscriptionService {
public void cancel(Subscription sub) {
if (!sub.getStatus().equals("active")) {
throw new IllegalStateException("Cannot cancel");
}
sub.setStatus("cancelled");
sub.setEndDate(LocalDate.now());
}
public void renew(Subscription sub, int months) {
if (!sub.getStatus().equals("active")) {
throw new IllegalStateException("Cannot renew");
}
sub.setEndDate(sub.getEndDate().plusMonths(months));
}
// Logic scattered across many services
}
Correct (rich domain model):
// domain/entities/Subscription.java
public class Subscription {
private final SubscriptionId id;
private final PlanId planId;
private final LocalDate startDate;
private LocalDate endDate;
private SubscriptionStatus status;
public void cancel() {
ensureActive();
this.status = SubscriptionStatus.CANCELLED;
this.endDate = LocalDate.now();
}
public void renew(Period extension) {
ensureActive();
if (extension.getMonths() < 1) {
throw new InvalidExtensionPeriodException(extension);
}
this.endDate = this.endDate.plus(extension);
}
public boolean isExpired() {
return LocalDate.now().isAfter(this.endDate);
}
public boolean canUpgradeTo(Plan newPlan) {
return this.status == SubscriptionStatus.ACTIVE
&& newPlan.isUpgradeFrom(this.planId);
}
private void ensureActive() {
if (this.status != SubscriptionStatus.ACTIVE) {
throw new InactiveSubscriptionException(this.id);
}
}
}
// application/usecases/CancelSubscriptionUseCase.java
public class CancelSubscriptionUseCase {
public void execute(SubscriptionId id) {
Subscription sub = repository.findById(id);
sub.cancel(); // Business logic in entity
repository.save(sub);
}
}
Benefits:
- Business rules live with the data they operate on
- Impossible to forget validation when manipulating data
- Entity documents its own capabilities and constraints
Reference: Anemic Domain Model Anti-pattern
Use Value Objects for Domain Concepts
Replace primitive types with value objects that encapsulate validation and behavior. Value objects are immutable, compared by value, and self-validating.
Incorrect (primitive obsession):
class Customer {
constructor(
public id: string,
public email: string,
public phone: string,
public postalCode: string
) {}
changeEmail(newEmail: string): void {
// Validation scattered or missing
if (!newEmail.includes('@')) {
throw new Error('Invalid email')
}
this.email = newEmail
}
}
// Callers can pass any string
const customer = new Customer('123', 'not-an-email', '123', 'invalid')
Correct (value objects for domain concepts):
class Email {
private constructor(private readonly value: string) {}
static create(value: string): Email {
if (!Email.isValid(value)) {
throw new InvalidEmailError(value)
}
return new Email(value.toLowerCase())
}
private static isValid(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
equals(other: Email): boolean {
return this.value === other.value
}
toString(): string {
return this.value
}
}
class PhoneNumber {
private constructor(
private readonly countryCode: string,
private readonly number: string
) {}
static create(countryCode: string, number: string): PhoneNumber {
// Validation and normalization
return new PhoneNumber(countryCode, number.replace(/\D/g, ''))
}
}
class Customer {
constructor(
public readonly id: CustomerId,
private email: Email,
private phone: PhoneNumber
) {}
changeEmail(newEmail: Email): void {
this.email = newEmail // Already validated
}
}
// Compile-time type safety + runtime validation
const customer = new Customer(
CustomerId.create('123'),
Email.create('user@example.com'),
PhoneNumber.create('+1', '555-1234')
)
Benefits:
- Invalid values cannot exist in the system
- Domain concepts explicitly named in code
- Validation logic centralized and reusable
- Type system prevents mixing up string parameters
Reference: Value Object Pattern
Dependency Injection Containers Live at the Edge
DI containers are infrastructure. Use them at composition root (main/startup) to wire dependencies, but don’t let them invade domain or application code.
Incorrect (DI container throughout codebase):
// Domain depends on DI container
using Microsoft.Extensions.DependencyInjection;
public class Order
{
public void Process()
{
// Service locator anti-pattern
var emailService = ServiceLocator.Current.GetService<IEmailService>();
var logger = ServiceLocator.Current.GetService<ILogger>();
// ...
}
}
// Application layer decorated with DI attributes
public class CreateOrderUseCase
{
[Inject]
public IOrderRepository Orders { get; set; }
[Inject]
public IPaymentGateway Payments { get; set; }
// Properties injected by container - hidden dependencies
}
// Switching DI containers requires touching domain code
Correct (DI container only at composition root):
// Domain - no DI container knowledge
public class Order
{
public void Process(IEmailService email, ILogger logger)
{
// Dependencies passed explicitly
}
}
// Application - constructor injection, no container knowledge
public class CreateOrderUseCase
{
private readonly IOrderRepository _orders;
private readonly IPaymentGateway _payments;
// Plain constructor - works with any DI container or manual wiring
public CreateOrderUseCase(
IOrderRepository orders,
IPaymentGateway payments)
{
_orders = orders;
_payments = payments;
}
}
// Composition root - only place that knows about DI container
// Program.cs or Startup.cs
public class CompositionRoot
{
public static IServiceProvider ConfigureServices()
{
var services = new ServiceCollection();
// Infrastructure
services.AddScoped<IOrderRepository, PostgresOrderRepository>();
services.AddScoped<IPaymentGateway, StripePaymentGateway>();
services.AddScoped<IEmailService, SendGridEmailService>();
// Application
services.AddScoped<CreateOrderUseCase>();
services.AddScoped<GetOrdersUseCase>();
// Interface
services.AddScoped<OrderController>();
return services.BuildServiceProvider();
}
}
// For tests - manual wiring without container
[Test]
public void CreatesOrder()
{
var orders = new InMemoryOrderRepository();
var payments = new FakePaymentGateway();
var useCase = new CreateOrderUseCase(orders, payments);
useCase.Execute(command);
Assert.That(orders.All(), Has.Count.EqualTo(1));
}
Benefits:
- Domain and application code portable across DI containers
- Tests don’t need DI container setup
- Dependencies explicit in constructors
Reference: Composition Root Pattern
Domain Layer Has Zero Framework Dependencies
The domain layer (entities and domain services) should have zero dependencies on frameworks, ORMs, or external libraries. Only language primitives and domain-specific code.
Incorrect (domain depends on framework):
// Domain entity with framework dependencies
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
namespace Domain.Entities
{
[Table("products")]
public class Product
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
[MaxLength(100)]
[JsonProperty("product_name")]
public string Name { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal Price { get; set; }
// EF Core navigation property
public virtual Category Category { get; set; }
// Framework-specific validation
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (Price < 0)
yield return new ValidationResult("Price cannot be negative");
}
}
}
// Domain now depends on: EF Core, DataAnnotations, Newtonsoft.Json
// Cannot exist without these frameworks
Correct (pure domain):
// Domain entity - zero framework dependencies
namespace Domain.Entities
{
public class Product
{
public ProductId Id { get; }
public string Name { get; private set; }
public Money Price { get; private set; }
public CategoryId CategoryId { get; private set; }
public Product(ProductId id, string name, Money price, CategoryId categoryId)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidProductNameException();
if (name.Length > 100)
throw new ProductNameTooLongException(name.Length, 100);
if (price.IsNegative)
throw new NegativePriceException(price);
Id = id;
Name = name;
Price = price;
CategoryId = categoryId;
}
public void UpdatePrice(Money newPrice)
{
if (newPrice.IsNegative)
throw new NegativePriceException(newPrice);
Price = newPrice;
}
}
}
// Infrastructure - framework dependencies isolated here
namespace Infrastructure.Persistence
{
using Microsoft.EntityFrameworkCore;
[Table("products")]
internal class ProductEntity
{
[Key] public int Id { get; set; }
[MaxLength(100)] public string Name { get; set; }
[Column(TypeName = "decimal(18,2)")] public decimal Price { get; set; }
public int CategoryId { get; set; }
}
internal class ProductMapper
{
public Product ToDomain(ProductEntity entity) => /* ... */
public ProductEntity ToEntity(Product product) => /* ... */
}
}
How to check domain purity:
# Domain project should have no package references
dotnet list Domain.csproj package
# Should return: No packages found
# Or in package.json
# "dependencies": {} should be empty or only contain domain libraries
Reference: Clean Architecture - Frameworks are Details
Keep ORM Usage in Infrastructure Layer
ORM-specific code (entities, mappings, queries) belongs in the infrastructure layer. The domain and application layers should not know which ORM (or if any ORM) is being used.
Incorrect (ORM in application layer):
# application/usecases/get_orders.py
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, or_
class GetOrdersUseCase:
def __init__(self, session: Session): # SQLAlchemy session in use case
self.session = session
def execute(self, customer_id: str, filters: OrderFilters):
# SQLAlchemy query in use case
query = self.session.query(Order).options(
joinedload(Order.items),
joinedload(Order.customer)
)
if filters.status:
query = query.filter(Order.status == filters.status)
if filters.date_range:
query = query.filter(and_(
Order.created_at >= filters.date_range.start,
Order.created_at <= filters.date_range.end
))
return query.order_by(Order.created_at.desc()).all()
# Use case is now coupled to SQLAlchemy
# Switching to Peewee or raw SQL requires rewriting use case
Correct (ORM isolated in infrastructure):
# application/ports/order_repository.py
from abc import ABC, abstractmethod
class OrderRepository(ABC):
@abstractmethod
def find_by_customer(
self,
customer_id: CustomerId,
filters: OrderFilters
) -> list[Order]:
pass
# application/usecases/get_orders.py
class GetOrdersUseCase:
def __init__(self, orders: OrderRepository): # Interface, not Session
self.orders = orders
def execute(self, customer_id: str, filters: OrderFilters) -> list[Order]:
return self.orders.find_by_customer(
CustomerId(customer_id),
filters
)
# infrastructure/persistence/sqlalchemy_order_repository.py
from sqlalchemy.orm import Session, joinedload
class SqlAlchemyOrderRepository(OrderRepository):
def __init__(self, session: Session):
self.session = session
def find_by_customer(
self,
customer_id: CustomerId,
filters: OrderFilters
) -> list[Order]:
query = self.session.query(OrderEntity).options(
joinedload(OrderEntity.items)
).filter(OrderEntity.customer_id == customer_id.value)
if filters.status:
query = query.filter(OrderEntity.status == filters.status.value)
entities = query.order_by(OrderEntity.created_at.desc()).all()
return [self._to_domain(e) for e in entities]
def _to_domain(self, entity: OrderEntity) -> Order:
# Map ORM entity to domain entity
pass
Benefits:
- Use case tests don’t need database or ORM setup
- Can switch ORMs without touching business logic
- Complex queries encapsulated in repository
Reference: Repository Pattern
Web Framework Concerns Stay in Interface Layer
HTTP-specific code (requests, responses, headers, cookies, sessions) belongs in the interface adapters layer. Use cases should be callable from any delivery mechanism.
Incorrect (use case coupled to HTTP):
// application/usecases/LoginUseCase.ts
import { Request, Response } from 'express'
import { sign } from 'jsonwebtoken'
export class LoginUseCase {
async execute(req: Request, res: Response) {
const { email, password } = req.body
const user = await this.users.findByEmail(email)
if (!user || !user.verifyPassword(password)) {
return res.status(401).json({ error: 'Invalid credentials' })
}
// Set HTTP-only cookie
const token = sign({ userId: user.id }, process.env.JWT_SECRET)
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
})
return res.json({ user: { id: user.id, email: user.email } })
}
}
// Cannot call from CLI, message queue, or test without Express
Correct (use case independent of delivery):
// application/usecases/LoginUseCase.ts
export interface LoginCommand {
email: string
password: string
}
export interface LoginResult {
userId: string
email: string
authToken: string
}
export class LoginUseCase {
constructor(
private users: UserRepository,
private tokenService: TokenService
) {}
async execute(command: LoginCommand): Promise<LoginResult> {
const user = await this.users.findByEmail(command.email)
if (!user) {
throw new InvalidCredentialsError()
}
if (!user.verifyPassword(command.password)) {
throw new InvalidCredentialsError()
}
const token = this.tokenService.generate({ userId: user.id.value })
return {
userId: user.id.value,
email: user.email.value,
authToken: token
}
}
}
// interface/http/AuthController.ts
import { Request, Response } from 'express'
export class AuthController {
constructor(private loginUseCase: LoginUseCase) {}
async login(req: Request, res: Response) {
try {
const result = await this.loginUseCase.execute({
email: req.body.email,
password: req.body.password
})
// HTTP concerns in controller
res.cookie('auth_token', result.authToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
})
return res.json({
user: { id: result.userId, email: result.email }
})
} catch (error) {
if (error instanceof InvalidCredentialsError) {
return res.status(401).json({ error: 'Invalid credentials' })
}
throw error
}
}
}
// interface/cli/AuthCli.ts - Same use case, different delivery
export class AuthCli {
async login(email: string, password: string) {
const result = await this.loginUseCase.execute({ email, password })
console.log(`Logged in as ${result.email}`)
fs.writeFileSync('.auth_token', result.authToken)
}
}
Benefits:
- Same use case for HTTP, CLI, WebSocket, queue consumers
- Easy to test without HTTP mocking
- Web framework upgrades isolated to interface layer
Reference: Clean Architecture - The Web is a Detail
Declare All Dependencies Explicitly in Constructor
Use cases should receive all dependencies through their constructor. Hidden dependencies (service locators, singletons, static calls) make testing difficult and hide coupling.
Incorrect (hidden dependencies):
public class PlaceOrderUseCase {
public OrderConfirmation execute(PlaceOrderCommand command) {
// Hidden dependencies - impossible to test or trace
var customer = CustomerRepository.getInstance().find(command.customerId());
var inventory = InventoryService.getInventory();
for (var item : command.items()) {
if (!inventory.isAvailable(item)) {
throw new OutOfStockException(item);
}
}
var order = new Order(customer, command.items());
// More hidden dependencies
Database.getConnection().save(order);
EmailService.send(customer.email(), "Order placed");
EventBus.publish(new OrderPlacedEvent(order));
return new OrderConfirmation(order.id());
}
}
// Testing requires mocking static methods - complex and brittle
Correct (explicit constructor dependencies):
public class PlaceOrderUseCase {
private final CustomerRepository customers;
private final InventoryChecker inventory;
private final OrderRepository orders;
private final NotificationPort notifications;
private final EventPublisher events;
public PlaceOrderUseCase(
CustomerRepository customers,
InventoryChecker inventory,
OrderRepository orders,
NotificationPort notifications,
EventPublisher events
) {
this.customers = customers;
this.inventory = inventory;
this.orders = orders;
this.notifications = notifications;
this.events = events;
}
public OrderConfirmation execute(PlaceOrderCommand command) {
var customer = customers.find(command.customerId());
for (var item : command.items()) {
if (!inventory.isAvailable(item)) {
throw new OutOfStockException(item);
}
}
var order = new Order(customer, command.items());
orders.save(order);
notifications.orderPlaced(customer, order);
events.publish(new OrderPlacedEvent(order));
return new OrderConfirmation(order.id());
}
}
// Testing is straightforward
@Test
void placesOrderWhenInventoryAvailable() {
var customers = mock(CustomerRepository.class);
var inventory = mock(InventoryChecker.class);
var orders = mock(OrderRepository.class);
// ... configure mocks
var useCase = new PlaceOrderUseCase(customers, inventory, orders, ...);
var result = useCase.execute(command);
verify(orders).save(any(Order.class));
}
Benefits:
- Dependencies visible in constructor signature
- Tests substitute any dependency with test doubles
- Coupling is explicit and measurable
Reference: Dependency Injection Principles
Define Input and Output Ports for Use Cases
Use cases should define their own input (request) and output (response) data structures. These ports isolate the use case from the delivery mechanism (HTTP, CLI, queue).
Incorrect (use case coupled to HTTP):
public class RegisterUserUseCase
{
public IActionResult Execute(HttpRequest request) // Coupled to ASP.NET
{
var email = request.Form["email"];
var password = request.Form["password"];
if (string.IsNullOrEmpty(email))
return new BadRequestResult(); // HTTP-specific response
var user = new User(email, password);
_repository.Save(user);
return new OkObjectResult(new { id = user.Id }); // JSON response
}
}
Correct (use case with defined ports):
// application/ports/input/RegisterUserCommand.cs
public record RegisterUserCommand(
string Email,
string Password,
string Name
);
// application/ports/output/RegisterUserResult.cs
public record RegisterUserResult(
string UserId,
string Email,
DateTime CreatedAt
);
// application/usecases/RegisterUserUseCase.cs
public class RegisterUserUseCase
{
private readonly IUserRepository _repository;
private readonly IPasswordHasher _hasher;
public RegisterUserResult Execute(RegisterUserCommand command)
{
if (string.IsNullOrEmpty(command.Email))
throw new ValidationException("Email required");
var hashedPassword = _hasher.Hash(command.Password);
var user = new User(command.Email, hashedPassword, command.Name);
_repository.Save(user);
return new RegisterUserResult(
user.Id.Value,
user.Email.Value,
user.CreatedAt
);
}
}
// interface_adapters/controllers/UserController.cs
[ApiController]
public class UserController : ControllerBase
{
[HttpPost]
public IActionResult Register([FromBody] RegisterRequest request)
{
var command = new RegisterUserCommand(
request.Email,
request.Password,
request.Name
);
var result = _useCase.Execute(command);
return Ok(new { userId = result.UserId });
}
}
Benefits:
- Same use case callable from HTTP, CLI, message queue, tests
- Request/response format changes don’t affect use case
- Use case testable without HTTP infrastructure
Reference: Clean Architecture - Input/Output Ports
Use Cases Must Not Contain Presentation Logic
Use cases return domain data, not formatted strings, HTML, or UI-specific structures. Presentation logic belongs in the interface adapters layer.
Incorrect (presentation logic in use case):
class GetUserProfileUseCase {
execute(userId: string): UserProfileResponse {
const user = this.repo.findById(userId)
const orders = this.orderRepo.findByUser(userId)
return {
displayName: `${user.firstName} ${user.lastName}`, // Formatting
memberSince: user.createdAt.toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
}), // Locale-specific formatting
avatarHtml: `<img src="${user.avatarUrl}" alt="${user.firstName}"/>`, // HTML!
orderSummary: orders.length > 0
? `${orders.length} orders totaling $${this.sumOrders(orders).toFixed(2)}`
: 'No orders yet', // UI text
recentOrders: orders.slice(0, 5).map(o => ({
...o,
statusBadgeColor: this.getStatusColor(o.status) // UI styling
}))
}
}
private getStatusColor(status: string): string {
const colors = { pending: 'yellow', shipped: 'blue', delivered: 'green' }
return colors[status] || 'gray'
}
}
Correct (use case returns domain data):
// application/usecases/GetUserProfileUseCase.ts
class GetUserProfileUseCase {
execute(userId: string): UserProfileResult {
const user = this.repo.findById(userId)
const orders = this.orderRepo.findByUser(userId)
return {
user: {
id: user.id.value,
firstName: user.firstName,
lastName: user.lastName,
email: user.email.value,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt
},
orders: orders.map(o => ({
id: o.id.value,
total: o.total.amount,
currency: o.total.currency,
status: o.status,
createdAt: o.createdAt
})),
orderCount: orders.length,
totalSpent: this.sumOrders(orders)
}
}
}
// interface_adapters/presenters/UserProfilePresenter.ts
class UserProfilePresenter {
present(result: UserProfileResult, locale: string): UserProfileViewModel {
const formatter = new Intl.DateTimeFormat(locale, {
month: 'long',
year: 'numeric'
})
return {
displayName: `${result.user.firstName} ${result.user.lastName}`,
memberSince: formatter.format(result.user.createdAt),
avatarUrl: result.user.avatarUrl,
orderSummary: this.formatOrderSummary(result, locale),
recentOrders: result.orders.slice(0, 5).map(o =>
this.formatOrder(o, locale)
)
}
}
}
Benefits:
- Same use case serves web, mobile, API consumers
- Locale/format changes don’t touch business logic
- UI redesigns don’t affect use case tests
Reference: Clean Architecture - Presenters
Use Cases Orchestrate Entities Not Implement Business Rules
Use cases coordinate the flow of data to and from entities. Business rules belong in entities; use cases should not duplicate or implement them.
Incorrect (business rules in use case):
func (uc *ApplyDiscountUseCase) Execute(orderId string, code string) error {
order := uc.repo.Find(orderId)
discount := uc.discounts.Find(code)
// Business rules implemented in use case
if order.Status != "pending" {
return errors.New("cannot apply discount to processed order")
}
if discount.ExpiresAt.Before(time.Now()) {
return errors.New("discount expired")
}
if order.Total.LessThan(discount.MinimumOrder) {
return errors.New("order total below minimum")
}
if discount.UsageCount >= discount.MaxUses {
return errors.New("discount fully redeemed")
}
// Calculate discount - more business rules
var discountAmount Money
if discount.Type == "percentage" {
discountAmount = order.Total.MultiplyBy(discount.Value / 100)
} else {
discountAmount = discount.Value
}
order.DiscountAmount = discountAmount
order.Total = order.Total.Subtract(discountAmount)
uc.repo.Save(order)
return nil
}
Correct (use case orchestrates, entities implement rules):
func (uc *ApplyDiscountUseCase) Execute(orderId string, code string) error {
order := uc.repo.Find(orderId)
discount := uc.discounts.Find(code)
// Use case orchestrates the interaction
if err := order.ApplyDiscount(discount); err != nil {
return err
}
uc.repo.Save(order)
return nil
}
// domain/entities/order.go
func (o *Order) ApplyDiscount(discount *Discount) error {
if o.status != OrderStatusPending {
return ErrOrderNotPending
}
if !discount.IsValidFor(o.total) {
return discount.ValidationError(o.total)
}
o.discount = discount
o.discountAmount = discount.CalculateFor(o.total)
return nil
}
// domain/entities/discount.go
func (d *Discount) IsValidFor(orderTotal Money) bool {
return !d.IsExpired() &&
!d.IsFullyRedeemed() &&
orderTotal.GreaterThanOrEqual(d.minimumOrder)
}
func (d *Discount) CalculateFor(total Money) Money {
if d.discountType == PercentageDiscount {
return total.MultiplyBy(d.value).DivideBy(100)
}
return d.value
}
Benefits:
- Business rules tested once in entity, not in every use case
- Rules cannot diverge between use cases
- Use case clearly shows workflow, not implementation details
Reference: Clean Architecture - Use Cases
Each Use Case Has One Reason to Change
Each use case class should orchestrate exactly one application-specific workflow. Combining multiple use cases creates coupling that forces unrelated changes together.
Incorrect (multiple use cases in one class):
class OrderService:
def __init__(self, repo, payment, shipping, notification):
self.repo = repo
self.payment = payment
self.shipping = shipping
self.notification = notification
def create_order(self, items, customer_id):
# Use case 1: Create order
order = Order.create(items, customer_id)
self.repo.save(order)
return order
def process_payment(self, order_id, payment_method):
# Use case 2: Process payment
order = self.repo.find(order_id)
self.payment.charge(order.total, payment_method)
order.mark_paid()
self.repo.save(order)
def ship_order(self, order_id, address):
# Use case 3: Ship order
order = self.repo.find(order_id)
tracking = self.shipping.create_shipment(order, address)
order.mark_shipped(tracking)
self.notification.send_shipped(order)
self.repo.save(order)
# Changes to shipping logic force retest of payment logic
Correct (separate use case classes):
class CreateOrderUseCase:
def __init__(self, repo: OrderRepository):
self.repo = repo
def execute(self, command: CreateOrderCommand) -> OrderId:
order = Order.create(command.items, command.customer_id)
self.repo.save(order)
return order.id
class ProcessPaymentUseCase:
def __init__(self, repo: OrderRepository, payment: PaymentGateway):
self.repo = repo
self.payment = payment
def execute(self, command: ProcessPaymentCommand) -> None:
order = self.repo.find(command.order_id)
self.payment.charge(order.total, command.payment_method)
order.mark_paid()
self.repo.save(order)
class ShipOrderUseCase:
def __init__(
self,
repo: OrderRepository,
shipping: ShippingService,
notification: NotificationPort
):
self.repo = repo
self.shipping = shipping
self.notification = notification
def execute(self, command: ShipOrderCommand) -> TrackingNumber:
order = self.repo.find(command.order_id)
tracking = self.shipping.create_shipment(order, command.address)
order.mark_shipped(tracking)
self.notification.send_shipped(order)
self.repo.save(order)
return tracking
Benefits:
- Changes to shipping don’t affect payment tests
- Teams can work on different use cases in parallel
- Each use case has minimal dependencies
Reference: Clean Architecture - Use Cases
Use Case Defines the Transaction Boundary
The use case is the natural transaction boundary. All operations within a use case should succeed or fail atomically. Infrastructure manages the transaction; use case defines the scope.
Incorrect (transaction managed inside repository):
class OrderRepository:
def save(self, order):
with self.db.transaction(): # Transaction per operation
self.db.insert('orders', order)
class InventoryRepository:
def reserve(self, items):
with self.db.transaction(): # Separate transaction
for item in items:
self.db.update('inventory', item.sku, decrement=item.qty)
class PlaceOrderUseCase:
def execute(self, command):
order = Order.create(command.items)
self.orders.save(order) # Commits
self.inventory.reserve(command.items) # If this fails, order is orphaned!
self.payments.charge(order.total) # If this fails, inventory is reserved but unpaid
Correct (use case defines transaction boundary):
# application/ports/UnitOfWork.py
class UnitOfWork(Protocol):
def begin(self) -> None: ...
def commit(self) -> None: ...
def rollback(self) -> None: ...
# application/usecases/PlaceOrderUseCase.py
class PlaceOrderUseCase:
def __init__(self, uow: UnitOfWork, orders, inventory, payments):
self.uow = uow
self.orders = orders
self.inventory = inventory
self.payments = payments
def execute(self, command):
self.uow.begin()
try:
order = Order.create(command.items)
self.inventory.reserve(command.items)
self.payments.charge(order.total)
self.orders.save(order)
self.uow.commit() # All or nothing
return order.id
except Exception as e:
self.uow.rollback() # Clean rollback
raise
# Alternative using context manager
class PlaceOrderUseCase:
def execute(self, command):
with self.uow: # Transaction spans entire use case
order = Order.create(command.items)
self.inventory.reserve(command.items)
self.payments.charge(order.total)
self.orders.save(order)
return order.id
Note: For distributed systems, consider saga patterns or eventual consistency rather than distributed transactions.
Reference: Unit of Work Pattern