Why Your 'Clean' Architecture is Actually Technical Debt
The pursuit of architectural purity creates unmaintainable systems. Real engineering means embracing pragmatic complexity over aesthetic abstractions.
Your architecture is too clean. And it's killing your velocity.
Every senior engineer has seen this: the codebase that looks beautiful in diagrams but takes 3 PRs to add a simple feature. Perfect separation of concerns, pristine abstractions, and zero coupling between modules.
It's also impossible to ship anything.
The industry has developed an obsession with architectural purity that mirrors the UX community's obsession with visual minimalism. We sacrifice pragmatic functionality for theoretical elegance.
Real systems are messy. Deal with it.
The Clean Architecture Trap
Clean Architecture, Hexagonal Architecture, Onion Architecture — they all promise the same thing: perfect modularity through rigid separation.
// "Clean" architecture that looks good in tech talks
interface UserRepository {
findById(id: UserId): Promise<User>
save(user: User): Promise<void>
}
class CreateUserUseCase {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
private paymentService: PaymentService,
private auditService: AuditService
) {}
async execute(request: CreateUserRequest): Promise<CreateUserResponse> {
const user = new User(request.email, request.name)
await this.userRepository.save(user)
await this.emailService.sendWelcome(user.email)
await this.paymentService.createCustomer(user.id)
await this.auditService.logUserCreation(user.id)
return new CreateUserResponse(user.id)
}
}
Looks clean, right? Here's what actually happens:
- Adding email preferences requires touching 4 different modules
- A/B testing signup flows needs architectural changes
- Simple bug fixes cascade through multiple layers
- New developers spend weeks understanding the abstraction maze
- Feature development slows to a crawl
You've optimized for theoretical purity over practical velocity.
The Pragmatic Reality
Successful systems embrace controlled messiness:
// "Messy" but pragmatic architecture that actually ships
class UserService {
private db: Database
private email: EmailClient
private payments: PaymentProvider
private analytics: AnalyticsClient
async createUser(data: CreateUserData): Promise<User> {
// Start transaction for consistency
const tx = await this.db.begin()
try {
// Core user creation
const user = await tx.users.insert({
email: data.email,
name: data.name,
created_at: new Date(),
email_verified: false
})
// Immediate side effects that need to be atomic
await tx.user_preferences.insert({
user_id: user.id,
notifications: true,
marketing: data.marketing_opt_in || false
})
await tx.commit()
// Non-critical async operations
this.schedulePostCreationTasks(user)
return user
} catch (error) {
await tx.rollback()
throw error
}
}
private async schedulePostCreationTasks(user: User) {
// These can fail without breaking user creation
Promise.allSettled([
this.email.sendWelcome(user.email),
this.payments.createCustomer(user.id),
this.analytics.track('user_created', { user_id: user.id })
]).catch(error => {
// Log but don't throw - user creation already succeeded
this.logger.error('Post-creation tasks failed', { user_id: user.id, error })
})
}
}
This is better engineering:
- One place to understand user creation
- Clear transaction boundaries
- Graceful failure handling
- Easy to modify and extend
- New developers are productive immediately
"But it violates separation of concerns!"
No. It implements practical separation based on business boundaries, not theoretical purity.
The Abstraction Addiction
Engineers love abstractions. They make us feel smart. But every abstraction has cognitive overhead:
# Over-abstracted: Generic repository with query builder
class Repository(Generic[T]):
def __init__(self, entity_class: Type[T], query_builder: QueryBuilder):
self.entity_class = entity_class
self.query_builder = query_builder
def find_by_criteria(self, criteria: QueryCriteria) -> List[T]:
query = self.query_builder.build(criteria)
return query.execute()
# Usage requires understanding 4 different abstractions
user_repo = Repository(User, PostgresQueryBuilder())
criteria = QueryCriteria().where(Field('status'), Operator.EQUALS, 'active')
active_users = user_repo.find_by_criteria(criteria)
Versus:
# Pragmatic: Direct and obvious
class UserRepository:
def __init__(self, db: Database):
self.db = db
def find_active_users(self) -> List[User]:
return self.db.query("SELECT * FROM users WHERE status = 'active'")
def find_by_email(self, email: str) -> Optional[User]:
return self.db.query_one("SELECT * FROM users WHERE email = %s", [email])
The second version is:
- Easier to understand
- Easier to debug
- Easier to optimize
- Easier to test
- Easier to modify
"But what about reusability?"
YAGNI. You Aren't Gonna Need It. Build abstractions when you have 3+ concrete examples, not when you imagine you might need flexibility someday.
Conway's Law vs. Clean Architecture
Conway's Law: "Organizations design systems that mirror their communication structure."
Clean Architecture fights this. It creates artificial boundaries that don't match how teams actually work:
// Clean Architecture: Artificial boundaries
├── domain/
│ ├── entities/
│ ├── usecases/
│ └── repositories/
├── infrastructure/
│ ├── database/
│ ├── http/
│ └── email/
└── presentation/
├── controllers/
└── serializers/
// Team structure: Feature-based
├── user-team/
├── payment-team/
├── notification-team/
└── analytics-team/
The impedance mismatch kills productivity.
Smart architectures embrace Conway's Law:
// Feature-based architecture that matches teams
├── users/
│ ├── user.service.ts
│ ├── user.controller.ts
│ ├── user.repository.ts
│ └── user.types.ts
├── payments/
│ ├── payment.service.ts
│ ├── stripe.adapter.ts
│ └── payment.types.ts
└── shared/
├── database.ts
├── auth.ts
└── validation.ts
Each team owns their vertical slice. Cross-cutting concerns live in shared modules. Changes don't cascade across team boundaries.
This isn't "messy." It's organizationally aligned.
The Dependency Injection Cargo Cult
Dependency Injection became orthodoxy without critical thinking:
// "Clean" but ridiculous
@Service
public class OrderProcessor {
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final EmailService emailService;
private final AuditService auditService;
private final MetricsService metricsService;
public OrderProcessor(
PaymentService paymentService,
InventoryService inventoryService,
EmailService emailService,
AuditService auditService,
MetricsService metricsService
) {
this.paymentService = paymentService;
this.inventoryService = inventoryService;
this.emailService = emailService;
this.auditService = auditService;
this.metricsService = metricsService;
}
}
15 lines of boilerplate for 5 dependencies. Half the class is constructor noise.
Meanwhile, in languages that aren't architecturally constipated:
// Pragmatic dependency management
class OrderProcessor {
constructor(
private db: Database,
private payments: PaymentProvider,
private email: EmailClient
) {}
async processOrder(order: Order): Promise<void> {
// Actual business logic here
// No interface pollution
// No abstraction ceremony
}
}
DI has its place. But not everywhere. Static imports work fine for most dependencies. Save DI for things you actually need to swap or mock.
The Testing Pyramid Inversion
Clean Architecture promises testability. It delivers test complexity:
// "Clean" testing requires mocking everything
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase
let mockUserRepo: jest.Mocked<UserRepository>
let mockEmailService: jest.Mocked<EmailService>
let mockPaymentService: jest.Mocked<PaymentService>
let mockAuditService: jest.Mocked<AuditService>
beforeEach(() => {
mockUserRepo = createMock<UserRepository>()
mockEmailService = createMock<EmailService>()
mockPaymentService = createMock<PaymentService>()
mockAuditService = createMock<AuditService>()
useCase = new CreateUserUseCase(
mockUserRepo,
mockEmailService,
mockPaymentService,
mockAuditService
)
})
it('should create user', async () => {
// 50 lines of mock setup
// Test that mocks work correctly
// Zero actual business logic validation
})
})
You're testing mock interactions, not business behavior.
Better testing strategy:
// Integration tests that actually validate behavior
describe('User creation', () => {
it('creates user with correct data and sends welcome email', async () => {
const userData = { email: '[email protected]', name: 'Test User' }
const user = await userService.createUser(userData)
// Verify actual database state
const savedUser = await db.users.findById(user.id)
expect(savedUser).toMatchObject(userData)
// Verify actual email was queued
const emailJobs = await emailQueue.getJobs('waiting')
expect(emailJobs).toContainEqual(
expect.objectContaining({
data: { template: 'welcome', email: userData.email }
})
)
})
})
Test behavior, not implementation. Test realistic scenarios, not mock orchestration.
When Clean Architecture Actually Works
Clean Architecture isn't always wrong. It works when:
- Team size > 20 developers working on the same codebase
- Domain complexity requires strict invariant enforcement
- Regulatory requirements demand audit trails for every decision
- Multiple frontend/backend combinations sharing business logic
But for most projects:
- Team size < 10 developers
- Straightforward CRUD with some business rules
- Single deployment target
- Rapid iteration requirements
Pragmatic architecture wins.
The Pragmatic Principles
1. Optimize for Change Velocity Architecture should make common changes easy, not make all changes theoretically possible.
2. Co-locate Related Logic Things that change together should live together, regardless of abstract concerns.
3. Embrace Controlled Coupling Some coupling is good. Database entities coupled to their queries? That's logical cohesion.
4. Test Behavior, Not Structure Your tests should validate what the system does, not how it's organized internally.
5. Build Abstractions from Concrete Examples Extract patterns after you see them repeated, not before.
6. Choose Consistency Over Purity A consistent "impure" architecture beats an inconsistent "clean" one.
// Pragmatic architecture checklist
interface ArchitectureDecision {
team_size: number
change_frequency: 'hourly' | 'daily' | 'weekly' | 'monthly'
domain_complexity: 'simple' | 'moderate' | 'complex'
performance_requirements: 'standard' | 'high' | 'extreme'
team_experience: 'junior' | 'mixed' | 'senior'
}
function chooseArchitecture(context: ArchitectureDecision): string {
if (context.team_size < 5 && context.change_frequency === 'hourly') {
return 'monolith_with_feature_folders'
}
if (context.domain_complexity === 'complex' && context.team_size > 15) {
return 'bounded_contexts_with_clear_apis'
}
if (context.performance_requirements === 'extreme') {
return 'optimize_for_performance_first'
}
// Default: Start simple, evolve as needed
return 'pragmatic_layers_with_escape_hatches'
}
The Evolution Strategy
Start messy and evolve, don't start clean and constrain:
// Phase 1: Get it working
class UserService {
async createUser(data: CreateUserData): Promise<User> {
// All logic in one place
// Easy to understand and modify
// Ships features fast
}
}
// Phase 2: Extract patterns when they emerge
class UserService {
constructor(
private userRepository: UserRepository,
private postCreationTasks: PostCreationTaskRunner
) {}
async createUser(data: CreateUserData): Promise<User> {
// Core logic stable, extract reusable pieces
}
}
// Phase 3: Optimize for specific constraints
// Only when team size, complexity, or performance demands it
Evolution beats revolution. Refactor when you have real pain, not imagined complexity.
The best architecture is the one that gets out of your way and lets you ship valuable software.
Your users don't care about your dependency graphs. They care about features that work.
Build for them, not for architecture astronauts.
Need a system that doesn't fall apart in production?
Let's talk.
Clean architecture. Honest timelines. Software that ships on time and stays running. No fluff.
Keep reading
We're Outsourcing Our Brains and Calling It Progress
The abstraction addiction is turning us into users of systems we can't maintain, debug, or truly understand.
Your Dependencies Are Ticking Time Bombs
The WordPress backdoor incident proves our dependency model is broken. We audit our code religiously but trust random packages blindly.
Don't miss the next deep dive
Engineering insights, performance breakdowns, and real-world lessons from the trenches. Delivered when it's worth your time.
