The problem with "just ship it"

Early in my career, I wrote a lot of code that worked but was painful to change. Features were tangled together, business logic lived inside HTTP handlers, and testing meant spinning up an entire database.

Clean Architecture โ€” popularized by Robert C. Martin โ€” gave me a mental model to fix this.

The core idea

The dependency rule is simple: source code dependencies must point inward. Inner layers know nothing about outer layers.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚          Frameworks             โ”‚  โ† Outermost: Express, React, PostgreSQL
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚        Interface Adapters       โ”‚  โ† Controllers, Presenters, Gateways
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚          Use Cases              โ”‚  โ† Application-specific business rules
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚           Entities              โ”‚  โ† Enterprise-wide business rules
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

A real example

Let's say we're building a user registration feature. Here's how I'd structure it:

// entities/User.ts โ€” pure business logic, no dependencies
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly name: string,
    private passwordHash: string,
  ) {}

  static create(email: string, name: string, hash: string): User {
    if (!email.includes('@')) throw new Error('Invalid email');
    return new User(crypto.randomUUID(), email, name, hash);
  }
}

// usecases/RegisterUser.ts โ€” orchestrates the flow
export class RegisterUser {
  constructor(
    private userRepo: UserRepository,
    private hasher: PasswordHasher,
  ) {}

  async execute(input: { email: string; name: string; password: string }) {
    const existing = await this.userRepo.findByEmail(input.email);
    if (existing) throw new Error('Email already registered');

    const hash = await this.hasher.hash(input.password);
    const user = User.create(input.email, input.name, hash);
    await this.userRepo.save(user);
    return user;
  }
}

Notice how RegisterUser depends on interfaces (UserRepository, PasswordHasher), not concrete implementations. This makes testing trivial โ€” just pass in mocks.

Lessons learned

  1. Don't over-architect small projects. A weekend hackathon doesn't need four layers of abstraction.
  2. Start with use cases. Write the business logic first, then figure out the adapters.
  3. Interfaces are your best friend. They make the dependency rule easy to follow.
  4. It's okay to bend the rules. Pragmatism beats purity.

Wrapping up

Clean Architecture isn't a silver bullet, but it's a powerful tool for managing complexity. The key insight is simple: protect your business logic from infrastructure details.

If you're building something that needs to last longer than a sprint, it's worth the investment.