DEV Community

Chris
Chris

Posted on

1

Stop Overengineering in the Name of Clean Architecture

Clean Architecture is a great concept. It’s meant to help you write maintainable, modular, and scalable software.

But too many developers treat it like a religion. They follow it blindly, stuffing projects with unnecessary layers, abstractions, and patterns. All in the name of “clean code.”

Let’s be honest, Clean Architecture is often overused, not because the ideas are bad, but because people overengineer the implementation.


A Quick Joke (That Some Devs Write Unironically)

Here’s an example of multiplying two numbers implemented with an absurd number of patterns:

// overengineered-multiplier.ts

// Interface
interface IMultiplier {
  multiply(a: number, b: number): number;
}

// Singleton
class MultiplierService implements IMultiplier {
  private static instance: MultiplierService;

  private constructor() {}

  static getInstance(): MultiplierService {
    if (!MultiplierService.instance) {
      MultiplierService.instance = new MultiplierService();
    }
    return MultiplierService.instance;
  }

  multiply(a: number, b: number): number {
    return a * b;
  }
}

// Adapter
interface IMultiplierInput {
  x: number;
  y: number;
}

class InputAdapter {
  constructor(private readonly rawInput: [number, number]) {}

  adapt(): IMultiplierInput {
    return { x: this.rawInput[0], y: this.rawInput[1] };
  }
}

// Decorator
class LoggingMultiplierDecorator implements IMultiplier {
  constructor(private readonly inner: IMultiplier) {}

  multiply(a: number, b: number): number {
    console.log(`Logging: Multiplying ${a} * ${b}`);
    const result = this.inner.multiply(a, b);
    console.log(`Logging: Result is ${result}`);
    return result;
  }
}

// Proxy
class MultiplierProxy implements IMultiplier {
  constructor(private readonly target: IMultiplier) {}

  multiply(a: number, b: number): number {
    if (typeof a !== 'number' || typeof b !== 'number') {
      throw new Error('Invalid input types');
    }
    return this.target.multiply(a, b);
  }
}

// Abstract Factory
interface IMultiplierFactory {
  create(): IMultiplier;
}

class RealMultiplierFactory implements IMultiplierFactory {
  create(): IMultiplier {
    const singleton = MultiplierService.getInstance();
    const withLogging = new LoggingMultiplierDecorator(singleton);
    const withProxy = new MultiplierProxy(withLogging);
    return withProxy;
  }
}

// Usage
const rawInput: [number, number] = [6, 9];
const adapted = new InputAdapter(rawInput).adapt();

const factory = new RealMultiplierFactory();
const multiplier = factory.create();

const result = multiplier.multiply(adapted.x, adapted.y);
console.log(`Final Result: ${result}`);
Enter fullscreen mode Exit fullscreen mode

All of this just to calculate 6 * 9. This is what happens when design patterns become cosplay.


The Real Problem: Over-Abstraction

Clean Architecture promotes decoupling. That’s good. But too many devs interpret that as "abstract everything."

Example:

interface IUserRepository {
  findById(id: string): Promise<UserDTO>;
}

class UserRepositoryImpl implements IUserRepository {
  constructor(private db: PrismaClient) {}

  async findById(id: string): Promise<UserDTO> {
    return await this.db.user.findUnique({ where: { id } });
  }
}
Enter fullscreen mode Exit fullscreen mode

Then you add a use case on top:

class GetUserByIdUseCase {
  constructor(private repo: IUserRepository) {}

  async execute(id: string): Promise<UserDTO> {
    return this.repo.findById(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

All of this for one database call. This isn’t clean it’s waste.


Common Overengineering Patterns

1. Interfaces and Implementations for Everything

Blindly creating an interface and a class for every service adds friction without real value.

Use it when:

  • You actually have multiple implementations
  • You’re building a plugin system or SDK

Don’t use it when:

  • You only have one implementation
  • You’re doing it "just in case"

2. Use Case Classes for Simple Logic

class CreatePostUseCase {
  execute(input: CreatePostDTO): Promise<PostDTO> {
    return this.postRepo.create(input);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a one-line call. Wrapping it in a class adds nothing. Use a service or method directly unless business logic really requires separation.


3. DTO Explosion

type Post = { id: string; title: string; content: string };
type CreatePostDTO = { title: string; content: string };
type PostResponseDTO = { id: string; title: string; content: string };
Enter fullscreen mode Exit fullscreen mode

If the structure is the same across layers, reuse the object. You don’t need a new DTO for every transition unless there’s a reason.


4. Domain Models with No Behavior

class User {
  constructor(public id: string, public name: string) {}
}
Enter fullscreen mode Exit fullscreen mode

If your "domain entity" is just a data wrapper, it’s not a domain model. Add behavior or don’t abstract.


5. Dependency Injection Overkill

@Module({
  providers: [
    { provide: IUserService, useClass: UserServiceImpl },
    { provide: IUserRepository, useClass: UserRepositoryImpl }
  ]
})
Enter fullscreen mode Exit fullscreen mode

DI containers are great. But if you’re not benefiting from polymorphism or dynamic injection, just instantiate the class.


What You Should Do Instead

  • Start simple. Build complexity only when needed.
  • Abstract with intent. Not everything needs to be swappable.
  • Refactor later. Let the structure grow organically as the app gets more complex.
  • Optimize for clarity. Not for pleasing architecture diagrams.

Clean Code Is Not More Code

Good code is:

  • Easy to read
  • Easy to test
  • Easy to change

It’s not defined by how many layers, decorators, or design patterns it uses.


Final Thoughts

Clean Architecture should serve your code, not the other way around.

It’s a tool, not a rulebook. Use it to solve complexity not to create it.


TLDR

Avoid This Do This Instead
Interface and Impl for everything Use one class until you actually need two
Use cases for CRUD logic Use simple services or methods
Separate DTOs for same shapes Reuse types where possible
DI for everything Instantiate directly when practical
Layers for layers’ sake Let structure grow with real needs

Build software, not monuments.

Of course, if your goal is job security, then by all means abstract everything. Write five layers per feature, name nothing clearly, and inject interfaces into factories into services into use cases. Once the codebase hits 100,000 lines, no one will know what’s going on but you. Congratulations, you’re now unfireable LOL.

Sentry image

Make it make sense

Make sense of fixing your code with straight-forward application monitoring.

Start debugging →

Top comments (1)

Collapse
 
xwero profile image
david duymelinck

Refactor later. Let the structure grow organically as the app gets more complex.

I think this is the most important sentence of the article.
The application only needs to be as complex as the current requirements.
Those requirements can warrant design patterns, and by thinking about how to structure the code it will be written more purposeful.

There is one thing where I differ from the post, and that is the use of dependency injection. By having a list of dependencies in the constructor or as method arguments it gives a indication of the tasks that are handled. It makes it easier to spot when code does too much, even when you haven't written the actual code.

Tiger Data image

🐯 🚀 Timescale is now TigerData: Building the Modern PostgreSQL for the Analytical and Agentic Era

We’ve quietly evolved from a time-series database into the modern PostgreSQL for today’s and tomorrow’s computing, built for performance, scale, and the agentic future.

So we’re changing our name: from Timescale to TigerData. Not to change who we are, but to reflect who we’ve become. TigerData is bold, fast, and built to power the next era of software.

Read more

👋 Kindness is contagious

Explore this insightful piece, celebrated by the caring DEV Community. Programmers from all walks of life are invited to contribute and expand our shared wisdom.

A simple "thank you" can make someone’s day—leave your kudos in the comments below!

On DEV, spreading knowledge paves the way and fortifies our camaraderie. Found this helpful? A brief note of appreciation to the author truly matters.

Let’s Go!