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

Anti-patterns to recognize: patterns, pitfalls and fixes

Patterns that cause pain.

Expona Admin11 min read

Day 86 of my 100-day Design Patterns in Practice series brings us to a critical shift: moving from patterns that solve problems to anti-patterns to recognize—the architectural and coding choices that create them. Recognizing these painful patterns early in code review or refactoring saves countless hours of technical debt. Today I want to walk you through four patterns that cause real pain in production systems, and how to spot them before they metastasize.

Understanding anti-patterns

An anti-pattern isn't just bad code—it's a *recognizable* bad solution that appears repeatedly across projects. Where a design pattern solves a problem elegantly, an anti-pattern solves it in a way that creates more problems than it prevents. The insidious part is that anti-patterns often feel pragmatic in the moment. You're under deadline, the quick fix works, and nobody complains until three years later when the class has 8,000 lines and touches 40 different concerns.

Let me walk through the four most damaging ones I've encountered in production platforms.

God object — One class that knows and does everything

The God object is seductive because it starts small. You create a UserManager class to handle user creation. Then you add password reset. Then authentication. Then email notifications. Then payment processing. Then audit logging. Suddenly your class has 15 dependencies, 40 public methods, and every part of your system imports it.

I worked on an e-commerce platform where the OrderService had become exactly this:

typescript
class OrderService {
  constructor(
    private db: Database,
    private emailSvc: EmailService,
    private paymentGateway: PaymentGateway,
    private inventoryMgr: InventoryManager,
    private taxCalculator: TaxCalculator,
    private shippingProvider: ShippingProvider,
    private notificationBus: NotificationBus,
    private auditLog: AuditLog,
    private analyticsTracker: Analytics,
    private loyaltyProgram: LoyaltyProgram,
    private fraudDetector: FraudDetector,
    private priceOptimizer: PriceOptimizer
  ) {}

  async createOrder(dto: CreateOrderDto): Promise {
    // 200 lines: validation, payment, inventory, tax, shipping, notifications
    // This method does everything
  }

  async cancelOrder(id: string): Promise {
    // 150 lines: refund logic, inventory restoration, notifications, analytics
  }

  async getOrderMetrics(period: DateRange): Promise {
    // 100 lines: aggregating data from multiple concerns
  }

  // ... 35 more methods touching every part of the system
}
```

<p>The pain becomes acute when:</p>

<ul>
<li>You can't test <code>createOrder</code> without mocking a dozen dependencies</li>
<li>Changing tax calculation logic requires understanding payment logic first</li>
<li>Adding fraud detection breaks shipping updates</li>
<li>The class becomes a bottleneck—everyone needs to touch it, nobody wants to</li>
</ul>

<p><strong>How to recognize it:</strong> Count the constructor dependencies. If it's more than 4-5, you probably have a God object. Check the methods—if they're longer than 30 lines, if they touch many different business concepts, if they're hard to name concisely, you're consolidating too much.</p>

<p><strong>The fix:</strong> Break responsibilities into single-purpose services. Use composition and dependency injection, not aggregation of concerns:</p>

<pre><code class="language-typescript">// Split into focused services
class OrderCreationService {
  constructor(
    private db: Database,
    private paymentSvc: PaymentService,
    private inventorySvc: InventoryService
  ) {}

  async createOrder(dto: CreateOrderDto): Promise {
    // 40 lines: focused on creation only
  }
}

class OrderFulfillmentService {
  constructor(
    private db: Database,
    private shippingSvc: ShippingService,
    private notificationSvc: NotificationService
  ) {}

  async fulfillOrder(orderId: string): Promise {
    // 30 lines: focused on fulfillment
  }
}

class OrderAnalyticsService {
  constructor(private db: Database) {}

  async getMetrics(period: DateRange): Promise {
    // Simple, testable
  }
}

Spaghetti code — No structure, everything calls everything

Spaghetti code is what happens when there's no clear dependency direction. Components call each other bidirectionally. Modules import circularly. There's no layer separation. You trace one function call and end up following a tangled path through 12 files.

I inherited a CRM system like this. The Customer class called Order, which called Invoice, which called back to Customer for validation, which imported Notification, which tried to get customer data, creating a circular chain impossible to refactor.

typescript
// customer.ts
import { Order } from './order';
export class Customer {
  async update(data: UpdateDto) {
    const order = new Order(); // Direct instantiation, tight coupling
    if (await order.hasUnpaidInvoices(this.id)) {
      throw new Error('Cannot update customer with unpaid invoices');
    }
    // ...
  }
}

// order.ts
import { Customer } from './customer';
export class Order {
  async hasUnpaidInvoices(customerId: string) {
    const customer = new Customer(customerId);
    const profile = await customer.getProfile(); // Circular call
    return profile.invoices.some(inv => !inv.paid);
  }
}

// invoice.ts
import { Order } from './order';
export class Invoice {
  async send() {
    const order = new Order(); // Another path back to Order
    // Impossible to test, impossible to change
  }
}

The damage compounds because:

  • You can't change one module without understanding all its dependents
  • Testing requires loading the entire dependency graph
  • Refactoring creates cascading failures across the codebase
  • New developers get lost immediately

How to recognize it: Run a dependency graph visualization tool. If you see cycles, crossing arrows, or tangled lines instead of clear layers, you have spaghetti code. Check if modules import each other. Look for circular dependencies in your build logs.

The fix: Establish clear dependency direction. Use dependency injection, not direct instantiation. Create layers—domain models, application services, infrastructure—with one-way dependencies:

typescript
// customer.domain.ts (no imports of Order, Invoice, etc.)
export class Customer {
  constructor(public id: string, public name: string) {}
}

// order.application.ts (depends on domain only)
import { Customer } from './customer.domain';
export class OrderService {
  constructor(
    private orderRepository: OrderRepository,
    private invoiceRepository: InvoiceRepository
  ) {}

  async createForCustomer(customer: Customer): Promise {
    // Clean, one-way dependency
  }
}

// customer.application.ts (does NOT import OrderService)
export class CustomerService {
  constructor(
    private customerRepository: CustomerRepository,
    private publishEvent: (event: Event) => Promise
  ) {}

  async updateCustomer(id: string, data: UpdateDto): Promise {
    const customer = await this.customerRepository.getById(id);
    customer.update(data);
    await this.publishEvent(new CustomerUpdated(customer));
    // OrderService listens to event, no tight coupling
  }
}

Golden hammer — Using the favorite tool for every problem

The golden hammer is what happens when a team becomes deeply comfortable with one technology and applies it everywhere, regardless of fit. You learn Kafka, so every problem becomes "let's use Kafka." You love React, so you try to build real-time dashboards in React when D3 or a charting library would be 10x better.

On a ride-hailing platform I worked on, we'd built an event-driven architecture around Kafka. It was brilliant for order processing, driver matching, real-time tracking. Then someone proposed putting *session data* into a Kafka topic, then querying it for analytics. Another engineer wanted to use Kafka for caching user preferences. A third wanted to stream every API request as a Kafka event.

We ended up with:

  • A Kafka cluster processing 40 topics, 30 of which had one consumer
  • Operational complexity tripled without corresponding benefit
  • Debugging latency issues became impossible—is it Kafka? The consumer? The serializer?
  • Onboarding new engineers took weeks just to understand the unnecessary event infrastructure

How to recognize it: Listen for "we always use X" or "X solved our problem before, so let's use it here too." Check if the tool's strengths actually map to the problem. If you're using a distributed message broker for something that fits in a cache, you have a golden hammer.

The fix: Match tool selection to requirements. Create a simple decision matrix:

Requirement Best Fit Alternative
Session data (fast reads, occasional writes) Redis / In-memory cache Not Kafka
Async order processing (eventual consistency) Message queue (Kafka, RabbitMQ) Event bus for same-service events
Real-time analytics (streaming aggregation) ClickHouse, specialized analytics DB Not Kafka directly to app code
User preference caching Redis or CDN Not Kafka

The principle: use the simplest tool that solves the specific problem. Kafka is powerful, but it's not the answer to every throughput question.

Lava flow — Dead code nobody dares to delete

Lava flow is the quiet accumulator. It's code that's not executed, not tested, not understood, and nobody wants to touch it. Maybe it handled an old payment provider. Maybe it was for a deprecated feature. Maybe someone might need it someday. It sits there, ossified, making the codebase heavier and riskier with every change.

I found a travel platform still carrying 3,000 lines of code for XML parsing that had been replaced by JSON 5 years prior. It imported libraries that were themselves deprecated. When we refactored authentication, we had to cautiously work around it because nobody was confident it wasn't secretly used somewhere.

typescript
// In a 8,000 line file, 2,000 lines of this:
class LegacyXMLPaymentParser {
  parse(xmlString: string): PaymentData {
    // ... 50 lines of XSLT logic, unmaintained
  }
  
  validateLegacy(data: any): boolean {
    // ... 30 lines, calls deprecated libraries
  }
  
  convertToV2(data: PaymentData): PaymentDataV2 {
    // ... 40 lines, never called (we migrated in 2019)
  }
  
  // ... 1,880 more lines nobody touches
}

// Somewhere in the codebase, it's imported but never used:
const parser = new LegacyXMLPaymentParser(); // eslint-disable-line

The problem:

  • It adds lines of code developers must mentally parse
  • It creates false complexity—is this method important? Is it used?
  • When you refactor, you have to account for it even if it's dead
  • It makes onboarding harder—new developers spend time understanding code that doesn't matter
  • It's a potential security risk if deprecated dependencies have unpatched vulnerabilities

How to recognize it: Run code coverage tools. Use your IDE's "find usages" feature. If a class or function has zero callers, it's likely lava flow. Check git history—if it hasn't been modified in 18+ months, it's probably dead. Commit logs mentioning "legacy" or "deprecated" are red flags.

The fix: Be brave and delete it. If you're truly uncertain:

  1. Check git blame and history—understand why it exists
  2. Search the entire codebase for references, including strings
  3. Ask your team in a code review
  4. If still uncertain, create a ticket for removal and set a deadline
  5. If nobody claims it in 30 days, delete it
  6. If it was needed, it's in git history—you can recover it

In the travel platform, we removed that parser class and 12 other lava flow modules in one refactor. The codebase became 15% smaller and significantly clearer. Nothing broke.

Common mistakes

  • Thinking anti-patterns are always obvious: They creep in gradually. The God object doesn't become problematic until it's huge. Code review early and often.
  • Tolerating them because "it works": Working code with poor structure is more expensive to change than code that's both functional and clean. Refactor before it becomes critical.
  • Blaming developers for anti-patterns: They're usually systemic—lack of architecture governance, missing code reviews, unclear guidelines. Address the system, not the person.
  • Refactoring anti-patterns without tests: Before you untangle spaghetti code or break up a God object, add tests. They're your safety net and often expose whether code is truly alive or dead.

Key takeaways

  • A God object happens when one class accumulates too many responsibilities—fix it by breaking into focused, single-purpose services with clear dependency injection.
  • Spaghetti code emerges from circular dependencies and bidirectional calls—establish clear layers (domain, application, infrastructure) with one-way dependency flow.
  • The golden hammer occurs when teams apply their favorite tool to every problem—match tools to requirements, use the simplest solution that fits.
  • Lava flow is dead code that accumulates silently—be aggressive about deletion; git history preserves everything if you need it back.
  • Anti-patterns aren't character flaws; they're system signals. When you spot one, look at process and architecture, not blame.

Tomorrow (Day 87) we'll move into strategies for actually refactoring these patterns out of existing systems—the practical steps for untangling what's already tangled. See you then.

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