Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 088 / 100

When patterns become over-engineering in Design Patterns

More abstractions, more problems.

Expona Admin8 min read

When patterns become over-engineering

I've watched countless production systems slow down under the weight of abstractions nobody needed. The irony: they were added with the best intentions—to make code more testable, flexible, and maintainable. But after five years shipping features across e-commerce, CRM, and SaaS platforms, I've learned that when patterns become over-engineering, they do the opposite. They introduce complexity without solving a real problem, making the codebase harder to change, not easier.

This is Day 88 of our Design Patterns in Practice series, and today we're going to talk about when to not use patterns. Because sometimes the most valuable decision you can make is leaving the abstraction off the table.

Interfaces with one implementation — Forever

I've seen this pattern dozens of times. A developer writes an interface, implements it once, and then never touches it again. Ever. Five years later, the codebase still has it.

typescript
// UserService.ts
export interface IUserService {
  getUser(id: string): Promise<User>;
  updateUser(id: string, data: Partial<User>): Promise<User>;
  deleteUser(id: string): Promise<void>;
}

export class UserService implements IUserService {
  constructor(private db: Database) {}

  async getUser(id: string): Promise<User> {
    return this.db.users.findById(id);
  }

  async updateUser(id: string, data: Partial<User>): Promise<User> {
    return this.db.users.update(id, data);
  }

  async deleteUser(id: string): Promise<void> {
    await this.db.users.delete(id);
  }
}

Now ask yourself: why is this interface here? The answer I usually hear is "for testing." But then I look at the tests.

typescript
// UserService.test.ts
describe('UserService', () => {
  it('should return a user', async () => {
    const db = {
      users: {
        findById: jest.fn().mockResolvedValue({ id: '1', name: 'Alice' })
      }
    };
    const service = new UserService(db as any);
    const user = await service.getUser('1');
    expect(user.name).toBe('Alice');
  });
});

Notice something? We're not using the interface in the test at all. We're mocking the Database directly. The interface exists, but it's not driving any tests, it's not being swapped out for a fake implementation, and it's not enabling any real flexibility.

Interfaces have value when you have multiple implementations or when you need to swap behavior at runtime. A mock in a test doesn't count—that's what dependency injection is for. But if you're only ever going to use UserService in production and mock its dependencies, the interface adds zero value and one more thing to maintain.

The cost: every time you add a method to the service, you update the interface. Every time a new developer reads the code, they wonder which implementations exist. Every refactoring has to touch the interface file even if no implementations change. That's friction.

Generic repositories over ORMs — An abstraction over an abstraction

This one still surprises me because it's so prevalent. The pattern goes like this: use a generic repository to hide the ORM, so later you can swap ORMs without changing application code. In practice, you've written a worse ORM on top of your actual ORM.

typescript
// Generic repository pattern
export interface IRepository<T> {
  findById(id: string): Promise<T>;
  findAll(filters?: any): Promise<T[]>;
  create(data: T): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}

export class UserRepository implements IRepository<User> {
  constructor(private db: DataSource) {}

  async findById(id: string): Promise<User> {
    return this.db.getRepository(User).findOneBy({ id });
  }

  async findAll(filters?: any): Promise<User[]> {
    let query = this.db.getRepository(User).createQueryBuilder('u');
    if (filters?.name) {
      query = query.where('u.name LIKE :name', { name: `%${filters.name}%` });
    }
    return query.getMany();
  }

  // ...rest of CRUD operations
}

Now look at your actual queries. The ones that matter. The ones that drive your product.

typescript
// Real business logic
async function getActiveUsersInRegion(region: string, offset: number) {
  const repo = container.get(UserRepository);
  const users = await repo.findAll({
    region,
    status: 'active',
    limit: 50,
    offset
  });
  // ...
}

Your business logic is now fighting the generic interface. You need pagination, joins, aggregations, subqueries—things that don't fit neatly into a generic CRUD mold. So you either:

  1. Extend the interface constantly, making it less generic
  2. Add special methods like findActiveUsersInRegion(), which defeats the purpose
  3. Bypass the repository and use the ORM directly, making the layer pointless

I've worked on systems where we ended up with findAll(filters?: any) being passed nested objects, and the repository had to deserialize them into query builders. The abstraction had become a bottleneck, not a buffer.

TypeScript and modern ORMs like TypeORM and Prisma are already strongly typed and designed for this. Use them directly. When you eventually need to migrate (which is rare), you'll rewrite queries—it's not the catastrophe people fear. The cost of maintaining a custom repository layer is higher than the cost of a well-planned migration.

Factories for simple objects — new is not a code smell

I see factory patterns applied to objects that have straightforward constructors and no complex logic. The reasoning is always the same: "We might need to instantiate this differently later."

typescript
// Unnecessary factory pattern
export interface IUserFactory {
  createUser(data: CreateUserInput): User;
}

export class UserFactory implements IUserFactory {
  createUser(data: CreateUserInput): User {
    return new User({
      id: uuid(),
      email: data.email,
      name: data.name,
      createdAt: new Date()
    });
  }
}

// Usage
const factory = container.get(UserFactory);
const user = factory.createUser({ email: 'alice@example.com', name: 'Alice' });

Compare that to:

typescript
// Direct instantiation
const user = new User({
  id: uuid(),
  email: 'alice@example.com',
  name: 'Alice',
  createdAt: new Date()
});

Factories shine when creation logic is genuinely complex. When you need to coordinate dependencies, validate state, or choose between implementations. But if you're just wrapping a constructor call, you've added indirection without solving a problem.

The burden: every time you create a user, you reach into a container to get a factory. The code is harder to read because the instantiation is hidden. If someone wants to understand how a User is created, they have to hunt through the factory. And if you never actually use the factory pattern—if users are only ever created one way—you've maintained this extra layer for nothing.

Factories have their place. Serializers, builders for complex objects, parallel implementations—those justify the pattern. But a wrapper around new? That's over-engineering.

The test — Does this pattern make the next change easier?

Here's the honest question I ask before adding any pattern: Will this make the next change easier?

Not "is this architecturally pure?" Not "could this theoretically be useful?" But will it actually reduce friction on the next refactor, test, or feature?

For interfaces on single implementations, the answer is usually no. The next change is still a direct edit to the class. But now you're updating two places instead of one.

For generic repositories, the answer is no. The next query that doesn't fit the CRUD model forces you to rethink the layer.

For unnecessary factories, the answer is almost always no. The next time you need to change how a user is created, you're editing the factory anyway, but now you also have to understand the factory's indirection.

But there are legitimate cases where the answer is yes:

  • Multiple implementations actually exist. Your app legitimately supports two databases, or you have a real mock implementation your tests prefer. The interface enables a real swap.
  • The pattern solves a concrete pain point today. You're doing circular dependency injection that the factory pattern cleanly breaks. You're testing two code paths that genuinely need different setups. You're migrating from one system to another and the abstraction provides breathing room.
  • The team needs a convention. On large teams, patterns enforce consistency. If every service follows the same structure, onboarding is easier. But this is about team scale, not code quality.

Run the test. Ask your team. If the pattern doesn't make the next change easier, leave it out of the codebase. You can always add it later when the problem is real.

Common mistakes

  • Confusing "could be useful" with "is useful now." Speculative abstraction is the enemy. Build what you need today; refactor when you need it tomorrow.
  • Thinking mocks equal multiple implementations. Mocking a dependency in a test doesn't justify an interface if there's only one real implementation. The interface's job is different.
  • Measuring success by "how clean the code looks" instead of "how easy is it to change." An interface might look beautiful but create friction when you add a new method. Friction is the real cost.
  • Adding patterns to code that doesn't yet need them, then never revisiting. The interface stays, the factory persists, the repository layer ossifies. Technical debt compounds.
  • Treating all abstractions equally. Sometimes you need an abstraction layer—when you're genuinely swapping implementations, or hiding complexity. But don't abstract for abstraction's sake.

Key takeaways

  • An interface with one implementation adds maintenance burden without enabling flexibility. Skip it unless you have multiple implementations or your tests actually swap behavior.
  • Generic repository patterns are an abstraction over an abstraction. Use your ORM directly. When business logic demands complex queries, the repository layer becomes a bottleneck, not a buffer.
  • Factories for simple object creation hide straightforward logic behind indirection. Reserve factories for complex instantiation or when you genuinely need to choose between implementations.
  • Before adding any pattern, ask: Will this make the next change easier? If the answer is no, leave it out. You can always add it when the problem becomes real.
  • Over-engineering doesn't fail spectacularly—it fails slowly, through accumulated friction in every refactor, every test, and every feature.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles