Anti-Patterns in Software Design: What NOT to Do
Anti-patterns are solutions that seem smart but create more problems than they solve. Recognize them early—God Objects, Anemic Models, Big Ball of Mud, Spaghetti Code—and you'll save yourself weeks of debugging and refactoring later.
What Anti-Patterns Are
Anti-patterns are solutions that seem clever but create more problems than they solve. They're the dark side of software design — so common they have names. Recognizing them early saves enormous pain.
God Object
The Problem: One class does everything.
// God Object: Order knows about everything
class Order {
// Core responsibility: order logic
constructor(id, customer, items) {
this.id = id;
this.customer = customer;
this.items = items;
}
placeOrder() { /* ... */ }
calculateTotal() { /* ... */ }
// Persistence (shouldn't be here)
save() {
database.insert('orders', this.toJSON());
}
// Notifications (shouldn't be here)
sendConfirmationEmail() {
emailService.send(this.customer.email, 'Order Confirmed');
}
// Reporting (shouldn't be here)
generateInvoice() {
return new Invoice(this.id, this.customer, this.items);
}
// Payments (shouldn't be here)
processPayment(paymentInfo) {
paymentService.charge(paymentInfo, this.getTotal());
}
// Shipping (shouldn't be here)
scheduleShipment() {
shippingService.schedule(this.id);
}
// Dozens more...
}Why It Happens: It starts innocent. "Just add this one thing to Order." Then another. Then another. Before you realize it, Order is a thousand-line monster.
Why It's Bad:
- Hard to test (needs everything: database, email, payment service)
- Hard to modify (change one thing, break three others)
- Hard to understand (what does Order actually do?)
- Hard to reuse (can't use Order without all its dependencies)
How to Fix It: Extract responsibilities into separate classes.
// Fixed: Clear separation of concerns
class Order {
constructor(id, customer, items) {
this.id = id;
this.customer = customer;
this.items = items;
}
placeOrder() { /* order logic only */ }
calculateTotal() { /* calculation only */ }
}
class OrderRepository {
save(order) {
database.insert('orders', order.toJSON());
}
}
class OrderNotificationService {
sendConfirmation(order) {
emailService.send(order.customer.email, 'Order Confirmed');
}
}
class InvoiceGenerator {
generate(order) {
return new Invoice(order.id, order.customer, order.items);
}
}
class OrderPaymentProcessor {
process(order, paymentInfo) {
paymentService.charge(paymentInfo, order.getTotal());
}
}
// Usage
const order = new Order(1, customer, items);
order.placeOrder();
orderRepository.save(order);
notificationService.sendConfirmation(order);Order now has one responsibility: managing order data and logic. Everything else is delegated.
Anemic Domain Model
The Problem: Objects have no behavior, only data.
// Anemic: Order is just a data container
class Order {
constructor(id, customerId, items, status) {
this.id = id;
this.customerId = customerId;
this.items = items;
this.status = status;
}
}
// All logic lives in services
class OrderService {
placeOrder(order) {
if (order.items.length === 0) {
throw new Error("Order must have items");
}
if (order.total > this.getCustomerCreditLimit(order.customerId)) {
throw new Error("Order exceeds credit limit");
}
order.status = 'placed'; // Mutating the anemic object
}
calculateTotal(order) {
return order.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}
}Why It Happens: It's easy. Separate data from logic. But it violates the fundamental principle of object-oriented programming — and it's the exact opposite of what Domain-Driven Design advocates.
Why It's Bad:
- Business logic is scattered across many services
- Objects don't defend their invariants (anyone can mutate them)
- Hard to understand what an Order actually does
- Testing requires mocking dozens of services
How to Fix It: Move behavior into the object.
// Rich domain model: Order knows how to validate itself
class Order {
constructor(id, customerId, items) {
this.id = id;
this.customerId = customerId;
this.items = items;
this.status = 'created';
}
addItem(product, quantity) {
this.items.push({ product, quantity });
}
canPlace(creditLimit) {
return this.getTotal() <= creditLimit;
}
place(creditLimit) {
if (this.items.length === 0) {
throw new Error("Order must have items");
}
if (!this.canPlace(creditLimit)) {
throw new Error("Order exceeds credit limit");
}
this.status = 'placed';
}
getTotal() {
return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
}
}
// Service is thin: just orchestration
class OrderService {
placeOrder(order, customer) {
order.place(customer.creditLimit);
this.repository.save(order);
}
}Now Order encapsulates its logic. It's testable, understandable, and self-defending.
Big Ball of Mud
The Problem: No clear structure. Everything is tangled together.
// Spaghetti: Hard to trace what depends on what
function processUserRegistration(userData) {
// Database call
const connection = mysql.createConnection(config);
const user = connection.query(`SELECT * FROM users WHERE email = ?`, [userData.email]);
// Email validation
if (!userData.email.includes('@')) return error;
// Password hashing
const hashedPassword = crypto.hash(userData.password);
// Database insertion
connection.query(`INSERT INTO users VALUES (?, ?)`, [userData.email, hashedPassword]);
// Send email
nodemailer.send({
to: userData.email,
subject: 'Welcome',
body: `Welcome ${userData.name}`
});
// Update cache
redis.set(`user:${userData.email}`, userData);
// Log analytics
analytics.track('user_registered', { email: userData.email });
// Call external API
const response = fetch(`https://external-api.com/notify`, {
email: userData.email,
timestamp: Date.now()
});
return response;
}Everything mixed together. Hard to test. Hard to change. Hard to understand.
How to Fix It: Create clear structure and separation of concerns.
class UserRegistration {
constructor(repository, emailService, validator) {
this.repository = repository;
this.emailService = emailService;
this.validator = validator;
}
register(userData) {
// Validate
this.validator.validate(userData);
// Create user
const user = new User(userData.email, userData.name, userData.password);
// Persist
this.repository.save(user);
// Notify
this.emailService.sendWelcome(user);
// Publish event for other systems
eventBus.publish(new UserRegisteredEvent(user));
return user;
}
}
// Each responsibility is clear and testable
class UserValidator {
validate(userData) {
if (!userData.email.includes('@')) throw new Error('Invalid email');
}
}
class UserRepository {
save(user) {
database.insert('users', user.toJSON());
}
}
class WelcomeEmailService {
sendWelcome(user) {
emailService.send(user.email, 'Welcome', `Welcome ${user.name}`);
}
}Clear structure. Each class has one job. Easy to test, change, and understand.
Spaghetti Code
The Problem: Tangled control flow. Hard to follow.
function processOrder(order) {
if (order.items.length > 0) {
if (order.customer.isActive) {
if (order.total < order.customer.creditLimit) {
order.status = 'processing';
if (paymentService.charge(order.customer.id, order.total)) {
inventory.allocate(order.items);
if (shipping.canShip(order.items)) {
shipping.schedule(order.id);
notification.send(order.customer.email, 'Order confirmed');
if (order.customer.loyaltyPoints > 100) {
rewards.applyDiscount(order.customer.id, 10);
}
} else {
order.status = 'waiting_for_stock';
}
} else {
order.status = 'payment_failed';
}
} else {
throw new Error('Credit limit exceeded');
}
} else {
throw new Error('Customer account inactive');
}
} else {
throw new Error('Order is empty');
}
}Nested ifs. Hard to follow. Easy to mess up.
How to Fix It: Use early returns (guard clauses).
function processOrder(order) {
// Guard clauses: fail fast
if (order.items.length === 0) {
throw new Error('Order is empty');
}
if (!order.customer.isActive) {
throw new Error('Customer account inactive');
}
if (order.total > order.customer.creditLimit) {
throw new Error('Credit limit exceeded');
}
// Now happy path is clear
order.status = 'processing';
const charged = paymentService.charge(order.customer.id, order.total);
if (!charged) {
order.status = 'payment_failed';
return;
}
inventory.allocate(order.items);
if (!shipping.canShip(order.items)) {
order.status = 'waiting_for_stock';
return;
}
shipping.schedule(order.id);
notification.send(order.customer.email, 'Order confirmed');
if (order.customer.loyaltyPoints > 100) {
rewards.applyDiscount(order.customer.id, 10);
}
}Much clearer. Guard clauses at the top, happy path in the middle.
Golden Hammer
The Problem: Using the same solution for every problem.
// "We built this with REST APIs, so let's use REST for everything"
// Including real-time notifications that need bidirectional communication
// Including analytics that should be asynchronous
// Including heavy batch processing that should be events-driven
// Or: "We use Kubernetes for everything"
// Including the simple internal admin tool
// Including the one-off data migration script
// Including the scheduled report generator
// Or: "Everything is a microservice"
// Including tightly-coupled business logic that should be in one context
// Including data that should be transactionally consistentUsing a technology because you like it, not because it solves the problem. The classic example: forcing microservices on a system that would be better served by a modular monolith.
How to Fix It: Choose the right tool for each problem. Use architectural tradeoff frameworks instead of hype.
// APIs: good for synchronous request-response
class OrderAPI {
post('/orders', (req, res) => {
// Create order synchronously
});
}
// Message queue: good for asynchronous events
eventBus.publish(new OrderPlacedEvent(...));
class InventoryService {
onOrderPlaced(event) {
// Allocate stock asynchronously
}
}
// Scheduled jobs: good for batch processing
scheduler.schedule('0 2 * * *', () => {
generateDailyReport();
});
// Monolith: good for tightly-coupled logic
class OrderContext {
// Order, Payment, Customer all in same transaction
}Different tools for different jobs.
Premature Optimization
The Problem: Optimizing before you know it's needed.
// Premature optimization: Complex caching nobody needs
class UserService {
constructor() {
this.cache = new LRUCache(1000);
this.cacheHits = 0;
this.cacheMisses = 0;
}
getUser(id) {
const key = `user:${id}`;
if (this.cache.has(key)) {
this.cacheHits++;
return this.cache.get(key);
}
this.cacheMisses++;
const user = database.query(`SELECT * FROM users WHERE id = ?`, [id]);
this.cache.set(key, user);
return user;
}
clearCache() { /* ... */ }
invalidateUserCache(id) { /* ... */ }
// ...
}
// Turns out: users are loaded rarely
// Cache adds complexity without benefitHow to Fix It: Make it work first. Optimize only if profiling shows a problem.
// Start simple
class UserService {
getUser(id) {
return database.query(`SELECT * FROM users WHERE id = ?`, [id]);
}
}
// Later, if profiling shows performance issues:
// "90% of requests are for the same 10 users"
// THEN add caching, with confidence it's neededCargo Cult Programming
The Problem: Doing something because everyone else does, without understanding why.
// Cargo cult: "Everyone uses Docker, so we put everything in Docker"
// Including local development scripts that run once
// "Everyone uses microservices, so let's split our monolith into 50 services"
// Creating more coordination problems than we solved
// "Everyone tests everything, so we test getters and setters"
// Spending time on low-value tests
// "Everyone uses dependency injection, so we DI everything"
// Including things that don't need flexibility
class Logger {
constructor(injectedConsole = console) {
this.console = injectedConsole;
}
// Pointless DI. Logger will never change.
}Following practices without understanding the reasoning.
How to Fix It: Understand the why. Apply thoughtfully.
// Use Docker where it matters: production consistency, dependency isolation
// Use microservices where there's genuine independent scaling or team ownership
// Test what matters: business logic, complex behavior, edge cases
// Use DI where flexibility actually helps: external dependencies, things that change
class PaymentProcessor {
constructor(paymentGateway) {
// Makes sense: different payment gateways in different environments
this.gateway = paymentGateway;
}
}
class OrderCalculator {
// No DI needed: this logic never changes
calculateTotal(items, taxRate) {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0) * (1 + taxRate);
}
}How AI Introduces Anti-Patterns
AI code generation is particularly prone to certain anti-patterns:
Anemic Models: AI defaults to data classes with service layers because it's predictable.
God Objects: Without clear boundaries, AI accumulates functionality in one class.
Copy-Paste Code: AI generates similar code for different cases instead of extracting patterns.
Over-Engineering: AI adds abstractions for flexibility that never materializes.
At Bitloops, we combat these by encoding design patterns and principles into code generation, so AI-generated code starts with good design.
FAQ
How do I know if I have an anti-pattern?
Warning signs: hard to test, hard to change, confusing to read, mixing many responsibilities, duplicated logic.
Should I refactor to remove anti-patterns immediately?
Only if they're causing pain. Small anti-patterns in low-risk code are fine. Major anti-patterns in critical paths should be fixed.
Can patterns become anti-patterns?
Yes. A pattern is right for its context. Use it in the wrong context and it becomes an anti-pattern. MVC works for web apps. It's overkill for a script.
How do I convince my team to fix anti-patterns?
Show concrete costs: "Changing this takes a week because the God Object touches everything." "Tests fail randomly because of spaghetti code."
Primary Sources
- Definitive pattern language covering Big Ball of Mud and other architectural anti-patterns from 1997. Big Ball of Mud
- Microsoft's guide to software anti-patterns, their causes, consequences, and solutions. Anti-Patterns
- Martin Fowler's comprehensive reference for refactoring techniques and code smell recognition. Refactoring
- Domain-driven design fundamentals for modeling complex business domains correctly. Domain-Driven Design
- Practical patterns for enterprise application architecture and distributed systems design. EAA Patterns
- Kent Beck's foundational guide to test-driven development methodology. TDD by Example
More in this hub
Anti-Patterns in Software Design: What NOT to Do
9 / 10Previous
Article 8
SOLID Principles: Five Rules for Better Code
Next
Article 10
Designing for Change and Evolution: Building Flexible Systems
Also in this hub
Get Started with Bitloops.
Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.
curl -sSL https://bitloops.com/install.sh | bash