Pablo Hernandez
Why Your 'Clean' Architecture is Actually Technical Debt
May 3, 20267 min
Share
← All posts

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.

Pablo Hernandez
Pablo Hernandez
Founder & Principal Engineer
7 min read
Share

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.

typescript
// "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:

typescript
// "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:

python
# 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:

python
# 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:

typescript
// 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:

typescript
// 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:

java
// "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:

typescript
// 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:

typescript
// "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:

typescript
// 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.

typescript
// 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:

typescript
// 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.

Share

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.

Newsletter

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.

No spam, everUnsubscribe anytimeOnly the good stuff