
Clean Architecture
- 1.8k installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
clean-architecture is an agent skill that Clean Architecture principles and best practices from Robert C. Martin's book. This skill should be used when designing software systems, reviewing code structure, or ref.
About
The clean-architecture skill. Clean Architecture principles and best practices from Robert C. Martin's book. This skill should be used when designing software systems, reviewing code structure, or refactoring applications to achieve better separation of concerns. Triggers on tasks involving layers, boundaries, dependency direction, entities, use cases, or system architecture. Martin's "Clean Architecture: A Craftsman's Guide to Software Structure and Design." Contains 42 rules across 8 categories, prioritized by architectural impact. The workflow follows the source SKILL.md contract with progressive reference loading, clear trigger phrases, and practical steps developers can apply directly in agent sessions. The workflow follows the source SKILL.md contract with progressive reference loading, clear trigger phrases, and practical steps developers can apply directly in agent sessions. The workflow follows the source SKILL.md contract with progressive reference loading, clear trigger phrases, and practical steps developers can apply directly in agent sessions.
- Designing new software systems or modules
- Structuring dependencies between layers
- Defining boundaries between business logic and infrastructure
- Reviewing code for architectural violations
- Refactoring coupled systems toward cleaner structure
Clean Architecture by the numbers
- 1,828 all-time installs (skills.sh)
- +28 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #255 of 4,353 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
clean-architecture capabilities & compatibility
- Capabilities
- designing new software systems or modules · structuring dependencies between layers · defining boundaries between business logic and i · reviewing code for architectural violations · refactoring coupled systems toward cleaner struc
- Use cases
- testing · debugging · ci cd
What clean-architecture says it does
Martin's "Clean Architecture: A Craftsman's Guide to Software Structure and Design." Contains 42 rules across 8 categories, prioritized by architectural impact.
# Clean Architecture Best Practices Comprehensive guide to Clean Architecture principles for designing maintainable, testable software systems. Based on Robert
npx skills add https://github.com/pproenca/dot-skills --skill clean-architectureAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.8k |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I apply clean-architecture correctly using the SKILL.md workflows and reference files?
Clean Architecture principles and best practices from Robert C. Martin's book. This skill should be used when designing software systems, reviewing code structure, or refactoring applications to achie
Who is it for?
Developers and software engineers working with clean-architecture patterns from the skill documentation.
Skip if: Skip when cached docs are empty, boilerplate-only, or outside the skill documented scope.
When should I use this skill?
Clean Architecture principles and best practices from Robert C. Martin's book. This skill should be used when designing software systems, reviewing code structure, or refactoring applications to achieve better separation
What you get
Grounded clean-architecture guidance with highlights, triggers, and evidence quotes from SKILL.md.
- architecture rule checklist
- layer boundary recommendations
- refactor guidance
By the numbers
- Contains 42 Clean Architecture rules across 8 categories
Files
Clean Architecture Best Practices
Comprehensive guide to Clean Architecture principles for designing maintainable, testable software systems. Based on Robert C. Martin's "Clean Architecture: A Craftsman's Guide to Software Structure and Design." Contains 42 rules across 8 categories, prioritized by architectural impact.
When to Apply
Reference these guidelines when:
- Designing new software systems or modules
- Structuring dependencies between layers
- Defining boundaries between business logic and infrastructure
- Reviewing code for architectural violations
- Refactoring coupled systems toward cleaner structure
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Dependency Direction | CRITICAL | dep- |
| 2 | Entity Design | CRITICAL | entity- |
| 3 | Use Case Isolation | HIGH | usecase- |
| 4 | Component Cohesion | HIGH | comp- |
| 5 | Boundary Definition | MEDIUM-HIGH | bound- |
| 6 | Interface Adapters | MEDIUM | adapt- |
| 7 | Framework Isolation | MEDIUM | frame- |
| 8 | Testing Architecture | LOW-MEDIUM | test- |
Quick Reference
1. Dependency Direction (CRITICAL)
- `dep-inward-only` - Source dependencies point inward only
- `dep-interface-ownership` - Interfaces belong to clients not implementers
- `dep-no-framework-imports` - Avoid framework imports in inner layers
- `dep-data-crossing-boundaries` - Use simple data structures across boundaries
- `dep-acyclic-dependencies` - Eliminate cyclic dependencies between components
- `dep-stable-abstractions` - Depend on stable abstractions not volatile concretions
2. Entity Design (CRITICAL)
- `entity-pure-business-rules` - Entities contain only enterprise business rules
- `entity-no-persistence-awareness` - Entities must not know how they are persisted
- `entity-encapsulate-invariants` - Encapsulate business invariants within entities
- `entity-value-objects` - Use value objects for domain concepts
- `entity-rich-not-anemic` - Build rich domain models not anemic data structures
3. Use Case Isolation (HIGH)
- `usecase-single-responsibility` - Each use case has one reason to change
- `usecase-input-output-ports` - Define input and output ports for use cases
- `usecase-orchestrates-not-implements` - Use cases orchestrate entities not implement business rules
- `usecase-no-presentation-logic` - Use cases must not contain presentation logic
- `usecase-explicit-dependencies` - Declare all dependencies explicitly in constructor
- `usecase-transaction-boundary` - Use case defines the transaction boundary
4. Component Cohesion (HIGH)
- `comp-screaming-architecture` - Structure should scream the domain not the framework
- `comp-common-closure` - Group classes that change together
- `comp-common-reuse` - Avoid forcing clients to depend on unused code
- `comp-reuse-release-equivalence` - Release components as cohesive units
- `comp-stable-dependencies` - Depend in the direction of stability
5. Boundary Definition (MEDIUM-HIGH)
- `bound-humble-object` - Use humble objects at architectural boundaries
- `bound-partial-boundaries` - Use partial boundaries when full separation is premature
- `bound-boundary-cost-awareness` - Weigh boundary cost against ignorance cost
- `bound-main-component` - Treat main as a plugin to the application
- `bound-defer-decisions` - Defer framework and database decisions
- `bound-service-internal-architecture` - Services must have internal clean architecture
6. Interface Adapters (MEDIUM)
- `adapt-controller-thin` - Keep controllers thin
- `adapt-presenter-formats` - Presenters format data for the view
- `adapt-gateway-abstraction` - Gateways hide external system details
- `adapt-mapper-translation` - Use mappers to translate between layers
- `adapt-anti-corruption-layer` - Build anti-corruption layers for external systems
7. Framework Isolation (MEDIUM)
- `frame-domain-purity` - Domain layer has zero framework dependencies
- `frame-orm-in-infrastructure` - Keep ORM usage in infrastructure layer
- `frame-web-in-infrastructure` - Web framework concerns stay in interface layer
- `frame-di-container-edge` - Dependency injection containers live at the edge
- `frame-logging-abstraction` - Abstract logging behind domain interfaces
8. Testing Architecture (LOW-MEDIUM)
- `test-tests-are-architecture` - Tests are part of the system architecture
- `test-testable-design` - Design for testability from the start
- `test-layer-isolation` - Test each layer in isolation
- `test-boundary-verification` - Verify architectural boundaries with tests
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Rule Title in Imperative Form
Brief explanation (1-3 sentences) of WHY this matters for software architecture. Focus on the architectural implications and cascade effects.
Incorrect (description of problem/cost):
// Code example showing the anti-pattern
// Include comments on problematic lines explaining the costCorrect (description of benefit/solution):
// Code example showing the correct pattern
// Minimal diff from incorrect - same variable names, structureAlternative (when applicable):
// Alternative approach for specific contextsWhen NOT to use this pattern:
- Exception or edge case 1
- Exception or edge case 2
Benefits:
- Concrete benefit 1
- Concrete benefit 2
Reference: Source Title
{
"version": "1.0.6",
"organization": "Uncle Bob (Robert C. Martin)",
"technology": "Clean Architecture",
"date": "January 2026",
"abstract": "Comprehensive guide to Clean Architecture principles for designing maintainable, testable software systems. Based on Robert C. Martin's 'Clean Architecture: A Craftsman's Guide to Software Structure and Design.' Contains 42 rules across 8 categories, prioritized by impact from critical (dependency direction, entity design) to incremental (testing architecture). Each rule includes detailed explanations, language-agnostic code examples comparing incorrect vs. correct implementations, and specific impact descriptions.",
"references": [
"https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html",
"https://www.oreilly.com/library/view/clean-architecture-a/9780134494272/",
"https://github.com/serodriguez68/clean-architecture",
"https://www.milanjovanovic.tech/blog/screaming-architecture",
"https://martinfowler.com/bliki/AnemicDomainModel.html"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Dependency Direction (dep)
Impact: CRITICAL Description: The Dependency Rule is the architectural foundation. Source code dependencies must point inward only; violations cascade failures across all layers.
2. Entity Design (entity)
Impact: CRITICAL Description: Enterprise business rules must be framework-agnostic, stable, and completely independent of databases, UI, and external systems.
3. Use Case Isolation (usecase)
Impact: HIGH Description: Application-specific business rules orchestrate entities without leaking infrastructure details or depending on presentation concerns.
4. Component Cohesion (comp)
Impact: HIGH Description: Components grouped by business capability enable independent deployment, parallel team development, and controlled change propagation.
5. Boundary Definition (bound)
Impact: MEDIUM-HIGH Description: Architectural boundaries isolate volatile from stable elements; Humble Objects maximize testability by separating hard-to-test from easy-to-test code.
6. Interface Adapters (adapt)
Impact: MEDIUM Description: Controllers, presenters, and gateways translate between use cases and external systems without leaking implementation details.
7. Framework Isolation (frame)
Impact: MEDIUM Description: Frameworks are details, not architecture. Business logic must never import, reference, or depend on framework-specific types.
8. Testing Architecture (test)
Impact: LOW-MEDIUM Description: Tests are architectural components. Layer isolation enables fast unit tests, while boundaries enable targeted integration tests.
Build Anti-Corruption Layers for External Systems
When integrating with external systems that have different models, build an anti-corruption layer (ACL) that translates between their model and yours. Never let external concepts pollute your domain.
Incorrect (external model leaks into domain):
// Domain polluted with Stripe's model
public class Order {
private String id;
private List<OrderItem> items;
// Stripe-specific fields in domain
private String stripeCustomerId;
private String stripePaymentIntentId;
private String stripePaymentMethodId;
public void processPayment(Stripe stripe) {
// Domain directly calls Stripe
PaymentIntent intent = PaymentIntent.create(
PaymentIntentCreateParams.builder()
.setCustomer(this.stripeCustomerId)
.setPaymentMethod(this.stripePaymentMethodId)
.setAmount(this.calculateTotal().cents())
.setCurrency("usd")
.build()
);
this.stripePaymentIntentId = intent.getId();
}
}
// If we switch to PayPal, Order must changeCorrect (anti-corruption layer isolates external model):
// domain/Order.java - Pure domain, no external concepts
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderItem> items;
private PaymentStatus paymentStatus;
public Money calculateTotal() {
return items.stream()
.map(OrderItem::lineTotal)
.reduce(Money.zero(), Money::add);
}
public void markPaid(PaymentReference reference) {
this.paymentStatus = PaymentStatus.paid(reference);
}
}
// domain/ports/PaymentProcessor.java - Domain-defined port
public interface PaymentProcessor {
PaymentResult charge(CustomerId customer, Money amount);
}
// Domain-defined value objects
public record PaymentResult(PaymentReference reference, PaymentStatus status) {}
public record PaymentReference(String value) {}
// infrastructure/stripe/StripePaymentProcessor.java - ACL
public class StripePaymentProcessor implements PaymentProcessor {
private final CustomerMappingRepository customerMapping;
public PaymentResult charge(CustomerId customerId, Money amount) {
// Translate domain CustomerId to Stripe customer
String stripeCustomerId = customerMapping.getStripeId(customerId);
// Call Stripe with their model
PaymentIntent intent = PaymentIntent.create(
PaymentIntentCreateParams.builder()
.setCustomer(stripeCustomerId)
.setAmount(amount.cents())
.setCurrency("usd")
.setConfirm(true)
.build()
);
// Translate Stripe response back to domain model
return new PaymentResult(
new PaymentReference(intent.getId()),
translateStatus(intent.getStatus())
);
}
private PaymentStatus translateStatus(String stripeStatus) {
return switch (stripeStatus) {
case "succeeded" -> PaymentStatus.COMPLETED;
case "processing" -> PaymentStatus.PENDING;
case "requires_action" -> PaymentStatus.REQUIRES_ACTION;
default -> PaymentStatus.FAILED;
};
}
}Benefits:
- Domain model remains clean and business-focused
- Switch payment providers by implementing new ACL
- External API changes isolated to ACL
Reference: Anti-Corruption Layer Pattern
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}), 201Correct (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'}), 400Controller 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 teamIncorrect (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 independentlyCorrect (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 benefitDecision 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 requiredCorrect (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 choiceDecisions 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
Use Humble Objects at Architectural Boundaries
The Humble Object pattern separates hard-to-test behaviors from easy-to-test behaviors. The "humble" part contains minimal logic and is hard to test; the substantial logic goes in a testable component.
Incorrect (logic mixed with hard-to-test framework code):
// React component with business logic
function OrderSummary({ orderId }: Props) {
const [order, setOrder] = useState<Order | null>(null)
const [discount, setDiscount] = useState<number>(0)
useEffect(() => {
fetch(`/api/orders/${orderId}`)
.then(r => r.json())
.then(data => {
setOrder(data)
// Business logic in component - hard to test
if (data.items.length > 5) {
setDiscount(data.total * 0.1)
} else if (data.customer.tier === 'gold') {
setDiscount(data.total * 0.05)
}
})
}, [orderId])
const finalTotal = order ? order.total - discount : 0
return (
<div>
<span>Subtotal: ${order?.total}</span>
<span>Discount: ${discount}</span>
<span>Total: ${finalTotal}</span>
</div>
)
}
// Testing requires React Testing Library + mocked fetchCorrect (humble view + testable presenter):
// Presenter - pure function, easy to test
interface OrderViewModel {
subtotal: string
discount: string
total: string
hasDiscount: boolean
}
function presentOrder(order: Order): OrderViewModel {
const discount = calculateDiscount(order)
const finalTotal = order.total - discount
return {
subtotal: formatCurrency(order.total),
discount: formatCurrency(discount),
total: formatCurrency(finalTotal),
hasDiscount: discount > 0
}
}
function calculateDiscount(order: Order): number {
if (order.items.length > 5) return order.total * 0.1
if (order.customer.tier === 'gold') return order.total * 0.05
return 0
}
// Humble view - no logic, just renders data
function OrderSummary({ viewModel }: { viewModel: OrderViewModel }) {
return (
<div>
<span>Subtotal: {viewModel.subtotal}</span>
{viewModel.hasDiscount && <span>Discount: {viewModel.discount}</span>}
<span>Total: {viewModel.total}</span>
</div>
)
}
// Container handles data fetching - also humble
function OrderSummaryContainer({ orderId }: Props) {
const { data: order } = useQuery(['order', orderId], fetchOrder)
if (!order) return <Loading />
return <OrderSummary viewModel={presentOrder(order)} />
}
// Test presenter without React
test('applies bulk discount for 6+ items', () => {
const order = { items: [1,2,3,4,5,6], total: 100, customer: { tier: 'standard' } }
const vm = presentOrder(order)
expect(vm.discount).toBe('$10.00')
})Benefits:
- Business logic tested with simple unit tests
- UI tests only verify rendering, not logic
- 90%+ of logic covered by fast tests
Reference: Clean Architecture - Humble Object Pattern
Treat Main as a Plugin to the Application
The Main component is the lowest-level, dirtiest component. It creates all factories, strategies, and global facilities, then hands control to high-level abstractions. Treat it as a plugin that can be swapped.
Incorrect (Main mixed with application logic):
// main.go
func main() {
db := connectDatabase()
// Business logic in main
http.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
var order Order
json.NewDecoder(r.Body).Decode(&order)
// Validation logic
if order.Total < 0 {
http.Error(w, "Invalid total", 400)
return
}
// Direct database calls
db.Exec("INSERT INTO orders ...")
// Response formatting
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
http.ListenAndServe(":8080", nil)
}
// Cannot test without starting HTTP server
// Cannot run with different databaseCorrect (Main as composition root):
// main.go - Production entry point
func main() {
app := bootstrap(ProductionConfig{
DatabaseURL: os.Getenv("DATABASE_URL"),
Port: os.Getenv("PORT"),
})
app.Run()
}
// bootstrap.go - Composition root
func bootstrap(config Config) *Application {
// Create infrastructure
db := postgres.NewConnection(config.DatabaseURL)
// Create repositories (implement domain interfaces)
orderRepo := postgres.NewOrderRepository(db)
userRepo := postgres.NewUserRepository(db)
// Create use cases (depend on interfaces)
placeOrder := usecases.NewPlaceOrderUseCase(orderRepo)
getOrders := usecases.NewGetOrdersUseCase(orderRepo)
// Create controllers (call use cases)
orderController := controllers.NewOrderController(placeOrder, getOrders)
// Wire up HTTP routes
router := http.NewRouter()
router.POST("/orders", orderController.Create)
router.GET("/orders", orderController.List)
return &Application{
Router: router,
Port: config.Port,
}
}
// main_test.go - Test entry point
func TestMain(t *testing.T) {
app := bootstrap(TestConfig{
DatabaseURL: "memory://", // In-memory for tests
Port: "0", // Random port
})
// Test against app
}
// main_dev.go - Development entry point
func main() {
app := bootstrap(DevConfig{
DatabaseURL: "localhost:5432",
Port: "3000",
})
app.RunWithHotReload()
}Benefits:
- Swap database, logging, configuration per environment
- Integration tests use real wiring with test doubles
- Dev/staging/prod share same composition logic
Reference: Clean Architecture - The Main Component
Use Partial Boundaries When Full Separation is Premature
When you anticipate needing a boundary but the cost of full separation is too high, implement a partial boundary. This prepares for future separation without premature over-engineering.
Incorrect (no boundary when one might be needed):
# Tightly coupled - if we ever need to split, massive refactor
class ReportService:
def generate_sales_report(self, start_date, end_date):
# Direct database access
conn = psycopg2.connect(DATABASE_URL)
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM sales
WHERE date BETWEEN %s AND %s
""", (start_date, end_date))
# Direct PDF generation
pdf = FPDF()
for row in cursor.fetchall():
pdf.cell(200, 10, txt=str(row))
return pdf.output()
# Cannot split report generation from data access without rewritingCorrect (partial boundary - same component, prepared for split):
# Strategy 1: Facade pattern (simplest partial boundary)
class ReportingFacade:
"""Single entry point, internal implementation can be refactored later."""
def generate_sales_report(self, request: ReportRequest) -> Report:
data = self._fetch_data(request)
return self._format_report(data, request.format)
def _fetch_data(self, request):
# Could become a separate DataAccess component later
return self.repository.get_sales(request.start, request.end)
def _format_report(self, data, format):
# Could become a separate ReportRenderer component later
if format == 'pdf':
return PdfRenderer().render(data)
return CsvRenderer().render(data)
# Strategy 2: Interface with single implementation
class SalesDataProvider(Protocol):
def get_sales(self, start: date, end: date) -> list[Sale]: ...
class PostgresSalesProvider:
def get_sales(self, start: date, end: date) -> list[Sale]:
# Implementation here
# Both live in same component, but interface enables future split
# When split needed: move interface to one component, impl to another
# Strategy 3: One-dimensional boundary
# Skip reciprocal interface - simpler but weaker protection
class ReportGenerator:
def __init__(self, data_provider: SalesDataProvider):
self.data_provider = data_provider # Depends on abstraction
# SalesDataProvider implementation knows about ReportGenerator
# Not fully decoupled, but good enough for nowWhen to use partial boundaries:
- You suspect a boundary will be needed, but not yet
- Team is small and deployment is unified
- Cost of full boundary outweighs current benefits
Reference: Clean Architecture - Partial Boundaries
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 serviceCross-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
Group Classes That Change Together
Classes that change for the same reasons and at the same times should be in the same component. This is the Common Closure Principle (CCP) - the Single Responsibility Principle for components.
Incorrect (classes that change together are separated):
src/
├── entities/
│ └── Invoice.ts # Changes when billing rules change
├── repositories/
│ └── InvoiceRepository.ts # Changes when billing rules change
├── services/
│ └── InvoiceService.ts # Changes when billing rules change
├── validators/
│ └── InvoiceValidator.ts # Changes when billing rules change
└── mappers/
└── InvoiceMapper.ts # Changes when billing rules change
# A billing rule change touches 5 directories
# Risk of forgetting one, inconsistent changesCorrect (classes that change together are grouped):
src/
├── billing/
│ ├── domain/
│ │ ├── Invoice.ts
│ │ ├── InvoiceLine.ts
│ │ ├── InvoiceRules.ts
│ │ └── InvoiceValidator.ts
│ ├── application/
│ │ ├── CreateInvoiceUseCase.ts
│ │ └── ports/
│ │ └── InvoiceRepository.ts
│ └── infrastructure/
│ ├── PostgresInvoiceRepository.ts
│ └── InvoiceMapper.ts
├── payments/
│ └── ... # Changes for different reasons
└── shipping/
└── ... # Changes for different reasons
# Billing rule change isolated to billing/
# Single PR, single review, single deploymentHow to identify change reasons:
- "When X business rule changes, which classes change?"
- "When the Y team requests a feature, which code changes?"
- Group by actor/stakeholder, not by technical layer
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 dependenciesCorrect (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
Release Components as Cohesive Units
Classes grouped into a component should be releasable together. A component should have a version number, release notes, and clear documentation. This is the Reuse/Release Equivalence Principle (REP).
Incorrect (arbitrary grouping):
shared-lib/
├── src/
│ ├── auth/
│ │ ├── JwtValidator.ts
│ │ └── PermissionChecker.ts
│ ├── logging/
│ │ └── Logger.ts
│ ├── email/
│ │ └── EmailSender.ts
│ └── payment/
│ └── StripeClient.ts
└── package.json // version 1.2.3
# Version 1.2.4 fixes JWT bug
# But users of EmailSender must also upgrade
# Changelog unclear which features affect which usersCorrect (cohesive releasable components):
packages/
├── auth/
│ ├── src/
│ │ ├── JwtValidator.ts
│ │ ├── PermissionChecker.ts
│ │ └── index.ts
│ ├── CHANGELOG.md # Auth-specific changes
│ └── package.json # @company/auth v2.1.0
├── logging/
│ ├── src/
│ │ └── Logger.ts
│ ├── CHANGELOG.md
│ └── package.json # @company/logging v1.0.3
├── email/
│ ├── src/
│ │ └── EmailSender.ts
│ ├── CHANGELOG.md
│ └── package.json # @company/email v1.5.0
└── payments/
├── src/
│ └── StripeClient.ts
├── CHANGELOG.md
└── package.json # @company/payments v3.0.0
# Clear ownership: auth team owns @company/auth
# Independent versioning: JWT fix only bumps auth
# Semantic versioning: breaking change in payments doesn't affect othersBenefits:
- Teams release independently on their own schedule
- Version numbers communicate compatibility
- Changelogs specific to what users of each component care about
Reference: Clean Architecture - Reuse/Release Equivalence 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 breakCorrect (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 changesStability 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 independentlyCorrect (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_hashCorrect (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 dataWhen 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
Interfaces Belong to Clients Not Implementers
Interfaces should be defined in the layer that uses them, not the layer that implements them. The client owns the abstraction; the implementation adapts to it.
Incorrect (interface defined next to implementation):
// infrastructure/persistence/UserRepository.java
public interface UserRepository {
User findById(String id);
void save(User user);
}
// infrastructure/persistence/PostgresUserRepository.java
public class PostgresUserRepository implements UserRepository {
// Implementation
}
// application/usecases/CreateUserUseCase.java
import infrastructure.persistence.UserRepository; // Use case imports from infrastructure!
public class CreateUserUseCase {
private final UserRepository repository;
}Correct (interface defined where it's used):
// application/ports/output/UserRepository.java
public interface UserRepository {
User findById(String id);
void save(User user);
}
// application/usecases/CreateUserUseCase.java
import application.ports.output.UserRepository; // Same layer import
public class CreateUserUseCase {
private final UserRepository repository; // No infrastructure dependency
}
// infrastructure/persistence/PostgresUserRepository.java
import application.ports.output.UserRepository; // Infrastructure depends on application
public class PostgresUserRepository implements UserRepository {
// Implementation adapts to the port
}Note: This is the essence of the Dependency Inversion Principle. The high-level module defines what it needs; low-level modules conform to that contract.
Reference: Clean Architecture - Chapter 11: DIP
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
Depend on Stable Abstractions Not Volatile Concretions
The most flexible systems depend on abstractions, not concretions. Volatile concrete classes are under active development and change frequently; depending on them propagates instability.
Incorrect (depending on volatile concrete class):
// services/notification.go
package services
import (
"myapp/infrastructure/email/sendgrid"
"myapp/infrastructure/sms/twilio"
)
type NotificationService struct {
email *sendgrid.Client // Concrete SendGrid client
sms *twilio.Client // Concrete Twilio client
}
func (n *NotificationService) NotifyUser(userID string, message string) {
// When SendGrid API changes, this service must change
// When migrating to AWS SES, this service must change
n.email.SendWithTemplate("notify", message)
}Correct (depending on stable interface):
// domain/ports/notification.go
package ports
type EmailSender interface {
Send(to string, subject string, body string) error
}
type SMSSender interface {
Send(to string, message string) error
}
// services/notification.go
package services
import "myapp/domain/ports"
type NotificationService struct {
email ports.EmailSender // Stable abstraction
sms ports.SMSSender // Stable abstraction
}
func (n *NotificationService) NotifyUser(userID string, message string) {
// Service is immune to email provider changes
n.email.Send(userID, "Notification", message)
}
// infrastructure/sendgrid/client.go
package sendgrid
type Client struct { /* ... */ }
func (c *Client) Send(to, subject, body string) error {
// Concrete implementation can change freely
}Note: Depending on stable concretions (like standard library classes) is acceptable. Focus inversion on volatile, actively-developed modules.
Reference: Clean Architecture - Stable Abstractions Principle
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 codeCorrect (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 frameworksCorrect (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 librariesReference: Clean Architecture - Frameworks are Details
Abstract Logging Behind Domain Interfaces
Logging frameworks are infrastructure details. Define logging interfaces in the application layer; implement them in infrastructure. This enables switching loggers and keeps domain pure.
Incorrect (logging framework in domain/application):
package usecases
import (
"github.com/sirupsen/logrus" // Framework dependency
)
type CreateOrderUseCase struct {
repo OrderRepository
logger *logrus.Logger // Concrete logger
}
func (uc *CreateOrderUseCase) Execute(cmd CreateOrderCommand) (*Order, error) {
uc.logger.WithFields(logrus.Fields{
"customer_id": cmd.CustomerID,
"item_count": len(cmd.Items),
}).Info("Creating order")
order, err := uc.repo.Create(cmd)
if err != nil {
uc.logger.WithError(err).Error("Failed to create order")
return nil, err
}
uc.logger.WithField("order_id", order.ID).Info("Order created")
return order, nil
}
// Switching from logrus to zap requires changing all use casesCorrect (logging behind interface):
// application/ports/logger.go
package ports
type Logger interface {
Info(msg string, fields ...Field)
Error(msg string, err error, fields ...Field)
Debug(msg string, fields ...Field)
}
type Field struct {
Key string
Value interface{}
}
func F(key string, value interface{}) Field {
return Field{Key: key, Value: value}
}
// application/usecases/create_order.go
package usecases
import "myapp/application/ports"
type CreateOrderUseCase struct {
repo OrderRepository
logger ports.Logger
}
func (uc *CreateOrderUseCase) Execute(cmd CreateOrderCommand) (*Order, error) {
uc.logger.Info("Creating order",
ports.F("customer_id", cmd.CustomerID),
ports.F("item_count", len(cmd.Items)),
)
order, err := uc.repo.Create(cmd)
if err != nil {
uc.logger.Error("Failed to create order", err,
ports.F("customer_id", cmd.CustomerID),
)
return nil, err
}
uc.logger.Info("Order created", ports.F("order_id", order.ID))
return order, nil
}
// infrastructure/logging/logrus_logger.go
package logging
import (
"github.com/sirupsen/logrus"
"myapp/application/ports"
)
type LogrusLogger struct {
logger *logrus.Logger
}
func (l *LogrusLogger) Info(msg string, fields ...ports.Field) {
l.logger.WithFields(toLogrusFields(fields)).Info(msg)
}
func toLogrusFields(fields []ports.Field) logrus.Fields {
result := logrus.Fields{}
for _, f := range fields {
result[f.Key] = f.Value
}
return result
}
// infrastructure/logging/zap_logger.go - Alternative implementation
// Switch without touching use casesBenefits:
- Application code doesn't import logging frameworks
- Easy to switch logging backends
- Test logging by asserting on mock logger calls
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 caseCorrect (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
passBenefits:
- 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 ExpressCorrect (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
Verify Architectural Boundaries with Tests
Use automated tests to enforce that dependency rules are followed. Architecture tests catch violations before they become entrenched patterns.
Incorrect (no boundary verification):
// Over time, developers add shortcuts
// domain/Order.java
import org.springframework.stereotype.Component; // Framework in domain!
import javax.persistence.Entity; // JPA in domain!
// application/CreateOrderUseCase.java
import com.stripe.Stripe; // Direct payment vendor dependency!
import infrastructure.email.SendGridClient; // Infrastructure in application!
// No tests catch these violations
// They accumulate until refactoring becomes impossibleCorrect (architecture tests enforce boundaries):
// Using ArchUnit (Java) or similar tools
@AnalyzeClasses(packages = "com.myapp")
class ArchitectureTest {
@ArchTest
static final ArchRule domain_should_not_depend_on_infrastructure =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..");
@ArchTest
static final ArchRule domain_should_not_use_frameworks =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"javax.persistence..",
"jakarta.persistence.."
);
@ArchTest
static final ArchRule usecases_should_not_access_controllers =
noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAPackage("..interface..");
@ArchTest
static final ArchRule dependencies_point_inward =
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
.layer("Application").definedBy("..application..")
.layer("Infrastructure").definedBy("..infrastructure..")
.layer("Interface").definedBy("..interface..")
.whereLayer("Domain").mayNotAccessAnyLayer()
.whereLayer("Application").mayOnlyAccessLayers("Domain")
.whereLayer("Infrastructure").mayOnlyAccessLayers("Domain", "Application")
.whereLayer("Interface").mayOnlyAccessLayers("Domain", "Application");
}
// TypeScript equivalent using dependency-cruiser
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: 'domain-no-infra',
from: { path: '^src/domain' },
to: { path: '^src/infrastructure' }
},
{
name: 'domain-no-frameworks',
from: { path: '^src/domain' },
to: { path: 'node_modules/(express|prisma|typeorm)' }
}
]
};Run in CI:
# .github/workflows/ci.yml
- name: Check architecture
run: |
./gradlew archTest # Java with ArchUnit
npx depcruise src # TypeScript with dependency-cruiserBenefits:
- Violations caught immediately in CI
- New developers can't accidentally break boundaries
- Architecture documentation that stays accurate
Reference: ArchUnit
Test Each Layer in Isolation
Each architectural layer should be testable independently. Domain tests need no infrastructure. Use case tests need no web framework. This enables fast, focused testing.
Incorrect (everything tested through API):
// All tests go through HTTP - slow, imprecise failures
describe('Order API', () => {
let app: Express
let db: Database
beforeEach(async () => {
db = await Database.connect()
await db.migrate()
await db.seed()
app = createApp(db)
})
afterEach(async () => {
await db.clean()
await db.close()
})
it('rejects order with insufficient inventory', async () => {
// 500ms+ per test
const response = await request(app)
.post('/orders')
.send({ items: [{ productId: 'p1', quantity: 1000 }] })
expect(response.status).toBe(400)
expect(response.body.error).toBe('Insufficient inventory')
})
// 50 more tests like this - test suite takes 5 minutes
// When one fails, unclear if it's domain, use case, or HTTP issue
})Correct (layered testing):
// Domain layer tests - instant, no dependencies
describe('Order', () => {
it('calculates total from items', () => {
const order = new Order()
order.addItem(new Product('p1', Money.dollars(10)), 2)
order.addItem(new Product('p2', Money.dollars(5)), 1)
expect(order.total).toEqual(Money.dollars(25))
})
it('prevents adding item with zero quantity', () => {
const order = new Order()
expect(() => order.addItem(product, 0)).toThrow(InvalidQuantityError)
})
})
// Use case tests - fast, test doubles for ports
describe('PlaceOrderUseCase', () => {
it('rejects order when inventory insufficient', () => {
const orders = new InMemoryOrderRepository()
const inventory = new FakeInventory({ 'p1': 5 }) // Only 5 in stock
const useCase = new PlaceOrderUseCase(orders, inventory)
const result = useCase.execute({
items: [{ productId: 'p1', quantity: 10 }] // Want 10
})
expect(result.isFailure).toBe(true)
expect(result.error).toBe('INSUFFICIENT_INVENTORY')
})
})
// Infrastructure tests - verify adapters work correctly
describe('PostgresOrderRepository', () => {
it('persists and retrieves order', async () => {
const repo = new PostgresOrderRepository(testDb)
const order = createTestOrder()
await repo.save(order)
const retrieved = await repo.findById(order.id)
expect(retrieved).toEqual(order)
})
})
// API tests - few, verify wiring only
describe('POST /orders', () => {
it('returns 201 when order placed successfully', async () => {
// Most logic tested above; this verifies HTTP wiring
const response = await request(app)
.post('/orders')
.send(validOrderPayload)
expect(response.status).toBe(201)
expect(response.body).toHaveProperty('orderId')
})
})Test pyramid:
/\
/ \ E2E: ~5 tests (slow, expensive)
/----\
/ \ Integration: ~20 tests (medium)
/--------\
/ \ Unit: ~200 tests (fast, cheap)
/____________\Reference: The Test Pyramid
Design for Testability From the Start
Clean architecture makes testing easy by design. If something is hard to test, it's a sign of architectural coupling. Don't compromise architecture for testability; fix the coupling.
Incorrect (hard to test, requiring workarounds):
public class OrderProcessor {
public void process(Order order) {
// Hard to test: direct instantiation
PaymentService payment = new PaymentService();
// Hard to test: static call
if (InventoryChecker.isAvailable(order.getItems())) {
// Hard to test: singleton
payment.charge(order.getTotal());
NotificationService.getInstance().sendConfirmation(order);
// Hard to test: current time
order.setProcessedAt(new Date());
}
}
}
// Test requires PowerMock, static mocking, singleton reset
// Test is slow, brittle, and complicated
@Test
@PrepareForTest({InventoryChecker.class, NotificationService.class})
public void testProcess() {
PowerMockito.mockStatic(InventoryChecker.class);
PowerMockito.when(InventoryChecker.isAvailable(any())).thenReturn(true);
// ... 20 more lines of mock setup
}Correct (testable by design):
public class OrderProcessor {
private final InventoryChecker inventory;
private final PaymentGateway payment;
private final NotificationPort notification;
private final Clock clock;
// All dependencies injected - easy to substitute
public OrderProcessor(
InventoryChecker inventory,
PaymentGateway payment,
NotificationPort notification,
Clock clock
) {
this.inventory = inventory;
this.payment = payment;
this.notification = notification;
this.clock = clock;
}
public ProcessResult process(Order order) {
if (!inventory.isAvailable(order.getItems())) {
return ProcessResult.unavailable();
}
PaymentResult paymentResult = payment.charge(order.getTotal());
if (!paymentResult.isSuccessful()) {
return ProcessResult.paymentFailed(paymentResult.getError());
}
order.markProcessed(clock.now());
notification.sendConfirmation(order);
return ProcessResult.success(order);
}
}
@Test
void processesOrderWhenInventoryAvailable() {
var inventory = StubInventory.withAvailability(true);
var payment = FakePaymentGateway.alwaysSucceeds();
var notification = new SpyNotification();
var clock = Clock.fixed(Instant.parse("2024-01-15T10:00:00Z"), ZoneOffset.UTC);
var processor = new OrderProcessor(inventory, payment, notification, clock);
var result = processor.process(order);
assertThat(result.isSuccessful()).isTrue();
}Testability checklist:
- [ ] No
newfor services (inject dependencies) - [ ] No static method calls for behavior (use interfaces)
- [ ] No singletons (pass instances)
- [ ] No direct time/random access (inject Clock, Random)
- [ ] No hidden dependencies (everything in constructor)
Reference: Growing Object-Oriented Software, Guided by Tests
Tests Are Part of the System Architecture
Tests participate in the architecture like any other component. They follow the dependency rule, couple to stable APIs, and should be designed for maintainability.
Incorrect (tests as afterthought):
# tests/test_everything.py - Monolithic test file
import pytest
from unittest.mock import patch, MagicMock
class TestOrders:
@patch('app.services.order_service.db')
@patch('app.services.order_service.stripe')
@patch('app.services.order_service.email_sender')
@patch('app.services.order_service.inventory')
def test_create_order(self, mock_inv, mock_email, mock_stripe, mock_db):
# Testing implementation details
mock_db.query.return_value.filter.return_value.first.return_value = Customer(id=1)
mock_inv.check.return_value = True
mock_stripe.PaymentIntent.create.return_value = MagicMock(id='pi_123')
from app.services.order_service import create_order
result = create_order({'customer_id': 1, 'items': [...]})
# Asserting on internal calls, not behavior
mock_db.query.assert_called()
mock_stripe.PaymentIntent.create.assert_called_once()
# Tests coupled to implementation, break with refactoring
# Tests slow because they patch everythingCorrect (tests designed as architecture component):
# tests/unit/domain/test_order.py - Fast, stable, test domain rules
class TestOrder:
def test_calculates_total_from_line_items(self):
order = Order.create(customer_id="c1")
order.add_item(Product("p1", Money(100)))
order.add_item(Product("p2", Money(50)))
assert order.total == Money(150)
def test_rejects_negative_quantity(self):
order = Order.create(customer_id="c1")
with pytest.raises(InvalidQuantityError):
order.add_item(Product("p1", Money(100)), quantity=-1)
# tests/integration/application/test_create_order.py - Test use case
class TestCreateOrderUseCase:
def test_creates_order_and_reserves_inventory(self):
# Use test doubles, not mocks of internals
orders = InMemoryOrderRepository()
inventory = FakeInventoryService(available={"p1": 10})
use_case = CreateOrderUseCase(orders, inventory)
result = use_case.execute(CreateOrderCommand(
customer_id="c1",
items=[OrderItem("p1", quantity=2)]
))
# Assert on observable behavior
assert orders.find_by_id(result.order_id) is not None
assert inventory.reserved["p1"] == 2
# tests/e2e/test_order_flow.py - Full flow, few tests
class TestOrderFlow:
def test_complete_order_journey(self, api_client, test_db):
# Create order via API
response = api_client.post('/orders', json={...})
order_id = response.json['order_id']
# Verify order persisted
order = test_db.query(Order).get(order_id)
assert order.status == 'pending'Test architecture mirrors system:
tests/
├── unit/ # Fast, isolated
│ ├── domain/ # Entity business rules
│ └── application/ # Use case logic
├── integration/ # Component interaction
│ ├── persistence/ # Repository implementations
│ └── external/ # Gateway implementations
└── e2e/ # Full system
└── api/ # HTTP endpointsBenefits:
- Unit tests run in milliseconds
- Refactoring doesn't break tests
- Tests document intended behavior
Reference: Clean Architecture - The Test Boundary
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 brittleCorrect (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
Related skills
How it compares
Use clean-architecture for Uncle Bob layer and dependency rules instead of generic 'separate concerns' advice without concrete boundaries.
FAQ
Who is clean-architecture for?
Developers and software engineers working with clean-architecture patterns from the skill documentation.
When should I use clean-architecture?
Clean Architecture principles and best practices from Robert C. Martin's book. This skill should be used when designing software systems, reviewing code structure, or refactoring applications to achieve better separation of concerns. Triggers on tasks involving layers, boundaries
Is clean-architecture safe to install?
Review the Security Audits panel on this page before installing in production.