During the final SDLC iteration of the Poor Man's Queue (Building a Poor Man's Queue with Cloudflare Workers), I encountered a subtle trap in applying SOLID principles: misguided abstraction.
While SOLID principles are designed to guide us toward maintainable code, I've observed—in my own work and others'—that we can sometimes violate their very intent by misunderstanding them. The result? Either over-engineered solutions that add complexity without value, or over-simplified solutions that sacrifice maintainability.
The original Poor Man's Queue suffered from over-abstraction, featuring three layers where two would suffice:
- Queue → Queue Service → Durable Object Queue
By eliminating the unnecessary middle Queue Service layer, I created a cleaner, more maintainable architecture while still faithfully adhering to SOLID principles.
So how do we find this balance? The answer lies in understanding what SOLID actually requires versus what we think it requires.
What is SOLID?
SOLID comprises five principles that help us write maintainable, flexible code:
- Single Responsibility: A class should have one reason to change
- Open/Closed: Open for extension, closed for modification
- Liskov Substitution: Subtypes must be substitutable for their base types
- Interface Segregation: Many specific interfaces beat one general interface
- Dependency Inversion: Depend on abstractions, not concretions
These principles are powerful. But they're often misinterpreted in two directions: building abstractions we don't need, or avoiding necessary structure entirely. Let's explore how to follow SOLID while keeping our code appropriately simple.
Single Responsibility: Focus on Change
The Principle: Each class should have one reason to change.
The Misunderstanding: Creating a class for every single action or extracting every function.
The Reality: Group related operations that change together.
Imagine a pizza delivery system. The driver delivers pizzas. But what constitutes "one responsibility"? How the responsibilities of delivery guy would change in the future ?
Over-Abstracted Version
interface IVehicleOperator {
operate(): void;
}
interface INavigationStrategy {
navigate(destination: Address): Route;
}
interface IDeliveryExecutor {
execute(delivery: IDelivery): void;
}
class PizzaDeliveryVehicleOperatorService implements IVehicleOperator {
constructor(
private navigationStrategy: INavigationStrategy,
private deliveryExecutor: IDeliveryExecutor
) {}
operate(): void {
// Orchestrates navigation and delivery
}
}
Problems with this approach:
- Unnecessary abstraction: No actual variation exists in vehicle operation
- Cognitive overhead: Three interfaces to deliver a pizza
- Testing complexity: Mock two dependencies just to test delivery logic
- Violates YAGNI: Building for scenarios that don't exist
Under-Abstracted Version
class DeliveryDriver {
async deliver(pizza: Pizza, address: Address): Promise<void> {
// Navigate
const lat = this.geocode(address);
const route = this.calculateRoute(lat);
// Drive
await this.startEngine();
await this.followRoute(route);
// Hand off
await this.knockOnDoor();
await this.getPizzaFromBag(pizza);
await this.collectPayment();
}
private geocode(address: Address): LatLng { /* ... */ }
private calculateRoute(coords: LatLng): Route { /* ... */ }
private startEngine(): Promise<void> { /* ... */ }
private followRoute(route: Route): Promise<void> { /* ... */ }
private knockOnDoor(): Promise<void> { /* ... */ }
private getPizzaFromBag(pizza: Pizza): Promise<void> { /* ... */ }
private collectPayment(): Promise<void> { /* ... */ }
}
Problems with this approach:
- Multiple responsibilities: Navigation, driving, and payment handling will change for different reasons
- Hard to test: Can't test routing without the entire delivery flow
- Hard to extend: Want to add GPS-based routing? Must modify the class
- Violates SRP: This class has at least three reasons to change
Balanced Version
interface DeliveryService {
deliver(pizza: Pizza, address: Address): Promise<void>;
}
class DeliveryDriver implements DeliveryService {
constructor(
private navigator: (address: Address) => Route
) {}
async deliver(pizza: Pizza, address: Address): Promise<void> {
const route = this.navigator(address);
await this.drive(route);
await this.handOffPizza(pizza);
}
private async drive(route: Route): Promise<void> {
// Driving logic that changes when vehicle mechanics change
}
private async handOffPizza(pizza: Pizza): Promise<void> {
// Handoff logic that changes when delivery protocols change
}
}
// Simple function-based dependencies
const simpleNavigator = (address: Address): Route => {
return new Route(address);
};
// Easy to instantiate, easy to test, easy to extend
const driver = new DeliveryDriver(simpleNavigator);
Why this is better:
- Single Responsibility: The class has one reason to change: how deliveries are executed
- Testable: Inject a test navigator, verify driving and handoff behavior
- Extensible: Need GPS navigation? Inject a different function
- Simple: No interface explosion, just focused abstractions
The key insight: Single Responsibility doesn't mean "one function per class." It means "one reason to change." Navigation algorithms change independently of delivery execution, so we separate them. But driving and handoff are part of the delivery execution responsibility.
Keep it balanced: Extract dependencies that change independently. Keep related operations together.
Open/Closed: Extend Thoughtfully
The Principle: Add new behavior without changing existing code.
The Misunderstanding: Never use concrete types, or conversely, never create abstractions.
The Reality: Encapsulate behavior that varies; use appropriate structures.
Think about a coffee shop adding drinks to their menu. They don't rebuild the kitchen each time, but they also don't just write sticky notes.
Over-Abstracted Version
interface IDrinkFactory {
create(): IDrink;
}
interface IDrink {
prepare(): void;
}
abstract class AbstractDrinkCreator {
abstract createDrink(): IDrink;
public serveDrink(): IDrink {
const drink = this.createDrink();
drink.prepare();
return drink;
}
}
class CoffeeFactory extends AbstractDrinkCreator implements IDrinkFactory {
createDrink(): IDrink {
return new Coffee();
}
create(): IDrink {
return this.createDrink();
}
}
Problems:
- Redundant abstraction: Factory interface and abstract creator do the same thing
- Maintenance burden: Adding tea requires a factory class, concrete class, and boilerplate
Under-Abstracted Version
const coffee = {
name: 'Coffee',
price: 3,
prepare: () => {
console.log('Grinding beans...');
console.log('Brewing coffee...');
}
};
const tea = {
name: 'Tea',
price: 2,
prepare: () => {
console.log('Boiling water...');
console.log('Steeping tea...');
}
};
function serve(drink) {
drink.prepare();
console.log(`Serving ${drink.name}`);
}
Problems:
- Violates Open/Closed: Adding loyalty discounts means modifying each object literal
- No encapsulation: Business logic (pricing, preparation) is scattered
- Hard to extend: Want to add size options? Must modify every drink object
- Fragile: Typos in property names won't be caught
Balanced Version
interface Customer {
hasLoyalty: boolean;
}
interface PrepareOptions {
size?: 'small' | 'medium' | 'large';
}
interface Drink {
readonly name: string;
getPrice(customer?: Customer): number;
prepare(options?: PrepareOptions): Promise<void>;
}
class Coffee implements Drink {
readonly name = 'Coffee';
getPrice(customer?: Customer): number {
const base = 3;
return customer?.hasLoyalty ? base * 0.9 : base;
}
async prepare(options?: PrepareOptions): Promise<void> {
console.log('Grinding beans...');
console.log(`Brewing ${options?.size || 'medium'} coffee...`);
}
}
class Tea implements Drink {
readonly name = 'Tea';
getPrice(customer?: Customer): number {
const base = 2;
return customer?.hasLoyalty ? base * 0.9 : base;
}
async prepare(options?: PrepareOptions): Promise<void> {
console.log('Boiling water...');
console.log(`Steeping ${options?.size || 'medium'} tea...`);
}
}
// Open for extension: Add new drinks without modifying existing ones
class Espresso implements Drink {
readonly name = 'Espresso';
getPrice(customer?: Customer): number {
const base = 4;
return customer?.hasLoyalty ? base * 0.9 : base;
}
async prepare(options?: PrepareOptions): Promise<void> {
console.log('Pulling espresso shot...');
}
}
async function serve(drink: Drink, customer?: Customer): Promise<void> {
const price = drink.getPrice(customer);
await drink.prepare();
console.log(`${drink.name} ready! Price: $${price}`);
}
Why this is better:
- Open/Closed: Add new drinks by creating new classes, no existing code changes
- Encapsulated: Each drink handles its own pricing and preparation logic
- Type-safe: TypeScript catches errors at compile time
- Extensible: Adding features (sizes, customizations) doesn't break existing code
For simple initialization with dependencies:
function createCappuccino(milkFrother: MilkFrother): Drink {
return new class implements Drink {
readonly name = 'Cappuccino';
getPrice(customer?: Customer): number {
return customer?.hasLoyalty ? 4.05 : 4.5;
}
async prepare(options?: PrepareOptions): Promise<void> {
console.log('Brewing espresso...');
await milkFrother.froth();
console.log('Combining espresso and foam...');
}
};
}
The key insight: Use classes when behavior needs to be extended. Use simple objects for configuration data. The distinction matters.
Keep it balanced: Use appropriate structures for varying behavior. Don't force everything into objects, but don't avoid classes when they solve real problems.
Liskov Substitution: Behavioral Consistency
The Principle: Subclasses should work wherever the parent class works.
The Misunderstanding: This is just about inheritance hierarchies.
The Reality: Any implementation of an interface must behave consistently with others.
Think of data stores—they all save, load, and delete data with consistent semantics.
Over-Abstracted Version
abstract class AbstractDataStore {
abstract save(data: any): void;
abstract load(id: string): any;
abstract delete(id: string): void;
protected validate(data: any): boolean {
return data !== null && data !== undefined;
}
}
class DatabaseStore extends AbstractDataStore {
save(data: any): void {
if (!this.validate(data)) throw new Error('Invalid data');
// database logic
}
load(id: string): any {
// database logic
}
delete(id: string): void {
// database logic
}
}
class FileStore extends AbstractDataStore {
save(data: any): void {
if (!this.validate(data)) throw new Error('Invalid data');
// file logic
}
load(id: string): any {
// file logic
}
delete(id: string): void {
// file logic
}
}
Problems:
- Fragile inheritance: Changes to AbstractDataStore ripple everywhere
- Hidden coupling: Subclasses must know about parent's validate method
- Testing complexity: Can't test implementations independently
Under-Abstracted Version
const databaseStore = {
save: (data) => {
if (!data) throw new Error('Invalid');
// save logic
},
load: (id) => { /* load logic */ },
delete: (id) => { /* delete logic */ }
};
const fileStore = {
save: (data) => {
// Oops! Forgot validation
// save logic
},
load: (id) => { /* load logic */ },
delete: (id) => { /* delete logic */ }
};
Problems:
- LSP violation: Stores behave differently (one validates, one doesn't)
- No type safety: Typos and inconsistencies won't be caught
- No contract: Unclear what behavior is guaranteed
Balanced Version
interface DataStore<T> {
save(data: NonNullable<T>): Promise<void>;
load(id: string): Promise<T | null>;
delete(id: string): Promise<void>;
}
class DatabaseStore<T> implements DataStore<T> {
private data = new Map<string, T>();
async save(data: NonNullable<T>): Promise<void> {
const id = data( as any).id || crypto.randomUUID();
this.data.set(id, data);
}
async load(id: string): Promise<T | null> {
return this.data.get(id) || null;
}
async delete(id: string): Promise<void> {
this.data.delete(id);
}
}
class FileStore<T> implements DataStore<T> {
async save(data: NonNullable<T>): Promise<void> {
const id = (data as any).id || crypto.randomUUID();
console.log(`Saving to file: ${id}`);
// File system logic
}
async load(id: string): Promise<T | null> {
console.log(`Loading from file: ${id}`);
return null; // File system logic
}
async delete(id: string): Promise<void> {
console.log(`Deleting file: ${id}`);
// File system logic
}
}
// Composition for shared behavior
function createCachedStore<T>(
store: DataStore<T>,
maxSize: number = 100
): DataStore<T> {
const cache = new Map<string, T>();
return {
save: async (data) => {
await store.save(data);
const id = (data as any).id;
if (id) cache.set(id, data);
},
load: async (id) => {
if (cache.has(id)) return cache.get(id)!;
const data = await store.load(id);
if (data) cache.set(id, data);
return data;
},
delete: async (id) => {
await store.delete(id);
cache.delete(id);
}
};
}
Why this is better:
- LSP compliant: Every DataStore behaves consistently
- Type-safe: TypeScript enforces the contract (NonNullable)
- Testable: Each implementation can be tested independently
- Composable: Add features through composition, not inheritance
The key insight: LSP applies to any abstraction (interface, abstract class, base class). All implementations must behave consistently. Type systems help enforce this.
Keep it balanced: Use interfaces with type constraints. Share implementation through composition, not inheritance. Ensure behavioral consistency.
Interface Segregation: Focused Contracts
The Principle: Don't force implementations to depend on methods they don't use.
The Misunderstanding: Create an interface for every method, or conversely, one giant interface for everything.
The Reality: Create interfaces based on client needs, not implementation details.
Think of designing a logger. Different parts of your application need different logging capabilities.
Over-Abstracted Version
interface IBasicLogger {
log(msg: string): void;
}
interface IErrorLogger {
logError(error: Error): void;
}
interface IDebugLogger {
logDebug(msg: string): void;
}
interface IWarningLogger {
logWarning(msg: string): void;
}
interface IInfoLogger {
logInfo(msg: string): void;
}
class ProductionLogger implements
IBasicLogger, IErrorLogger, IDebugLogger, IWarningLogger, IInfoLogger {
log(msg: string): void { /* */ }
logError(error: Error): void { /* */ }
logDebug(msg: string): void { /* */ }
logWarning(msg: string): void { /* */ }
logInfo(msg: string): void { /* */ }
}
Problems:
- Interface explosion: Five interfaces for one cohesive concept
- Maintenance nightmare: Adding a level means creating a new interface everywhere
- Violates cohesion: Logging levels aren't separate responsibilities
Under-Abstracted Version
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
interface Logger {
log(level: LogLevel, message: string): void;
}
const errorOnlyLogger: Logger = {
log: (level, message) => {
if (level === 'error') {
console.error(message);
}
// Silently ignores all other levels
}
};
function reportError(logger: Logger, error: Error): void {
logger.log('error', error.message);
}
Problems:
-
ISP violation:
errorOnlyLogger
claims to implement Logger but ignores most of it - Hidden behavior: Callers don't know this logger ignores non-errors
- LSP violation: Not truly substitutable with other Logger implementations
- Testing confusion: How do you test that it correctly ignores info/debug/warn?
Balanced Version
// Segregated interfaces for different client needs
interface ErrorLogger {
logError(message: string, error?: Error): void;
}
interface InfoLogger {
logInfo(message: string): void;
}
interface DebugLogger {
logDebug(message: string): void;
}
// Full logger combines capabilities
interface Logger extends ErrorLogger, InfoLogger, DebugLogger {
logWarning(message: string): void;
}
// Production: Full-featured logger
class ConsoleLogger implements Logger {
logError(message: string, error?: Error): void {
console.error(message, error);
}
logInfo(message: string): void {
console.info(message);
}
logDebug(message: string): void {
console.debug(message);
}
logWarning(message: string): void {
console.warn(message);
}
}
// Specialized: Error tracking only needs errors
class ErrorTrackingService implements ErrorLogger {
logError(message: string, error?: Error): void {
// Send to Sentry, Rollbar, etc.
console.error('Tracking:', message, error);
}
}
// Clients depend only on what they need
function handleCriticalError(logger: ErrorLogger, error: Error): void {
logger.logError('Critical error occurred', error);
// Only depends on error logging capability
}
function logUserAction(logger: InfoLogger, action: string): void {
logger.logInfo(`User action: ${action}`);
// Only depends on info logging capability
}
function debugPerformance(logger: DebugLogger, timing: number): void {
logger.logDebug(`Operation took ${timing}ms`);
// Only depends on debug logging capability
}
Why this is better:
- ISP compliant: Clients depend only on the methods they use
- LSP compliant: Each implementation honestly fulfills its contract
- Flexible: Full logger or specialized logger, both work correctly
- Clear contracts: No hidden behavior, no silent ignoring
The key insight: Interface Segregation is about client needs, not implementation convenience. Different parts of your application have different requirements. handleCriticalError
doesn't need info/debug logging, so it shouldn't depend on those methods.
Keep it balanced: Create interfaces based on how clients use them, not how implementations provide them. Implementations can support multiple interfaces.
Dependency Inversion: Abstract Appropriately
The Principle: High-level code shouldn't depend on low-level details.
The Misunderstanding: Inject everything through complex IoC containers, or conversely, new everything directly.
The Reality: Identify true dependencies and inject appropriate abstractions.
Think of a notification system. The email provider might change, but the notification logic stays the same.
Over-Abstracted Version
interface IEmailServiceFactory {
create(): IEmailService;
}
interface IEmailService {
send(email: IEmail): Promise<void>;
}
interface IEmail {
to: string;
from: string;
subject: string;
body: string;
}
interface IEmailBuilder {
setTo(to: string): IEmailBuilder;
setFrom(from: string): IEmailBuilder;
setSubject(subject: string): IEmailBuilder;
setBody(body: string): IEmailBuilder;
build(): IEmail;
}
class EmailServiceFactory implements IEmailServiceFactory {
create(): IEmailService {
return new SendGridEmailService();
}
}
class NotificationService {
private emailService: IEmailService;
constructor(
private emailFactory: IEmailServiceFactory,
private emailBuilder: IEmailBuilder
) {
this.emailService = emailFactory.create();
}
async notifyUser(email: string, message: string): Promise<void> {
const emailMessage = this.emailBuilder
.setTo(email)
.setFrom('noreply@app.com')
.setSubject('Notification')
.setBody(message)
.build();
await this.emailService.send(emailMessage);
}
}
Problems:
- Over-injection: Factory and builder injected when one interface suffices
- Cognitive load: Five interfaces to send an email
- Testing nightmare: Mock factory, mock builder, verify service creation
Under-Abstracted Version
class NotificationService {
private sendGridClient = new SendGridClient();
async notifyUser(email: string, message: string): Promise<void> {
await this.sendGridClient.send({
to: email,
from: 'noreply@app.com',
subject: 'Notification',
body: message
});
}
}
Problems:
- Hard dependency: Tightly coupled to SendGrid
- Untestable: Can't test without hitting SendGrid API
- Inflexible: Changing providers requires modifying NotificationService
Balanced Version
interface EmailSender {
send(to: string, subject: string, body: string): Promise<void>;
}
interface SmsSender {
send(to: string, message: string): Promise<void>;
}
class NotificationService {
constructor(
private emailSender: EmailSender,
private smsSender: SmsSender
) {}
async notifyUser(
email: string,
phone: string,
message: string
): Promise<void> {
await Promise.all([
this.emailSender.send(email, 'Notification', message),
this.smsSender.send(phone, message)
]);
}
async notifyByEmail(email: string, message: string): Promise<void> {
await this.emailSender.send(email, 'Notification', message);
}
}
// Production implementation
const sendGridSender: EmailSender = {
send: async (to, subject, body) => {
// SendGrid API call
console.log(`SendGrid: Sending to ${to}`);
}
};
const twilioSender: SmsSender = {
send: async (to, message) => {
// Twilio API call
console.log(`Twilio: SMS to ${to}`);
}
};
// Test implementation
const testEmailSender: EmailSender = {
send: async (to, subject, body) => {
console.log(`Test: Would send "${subject}" to ${to}`);
}
};
// Resilient implementation with fallback
function createResilientEmailSender(
providers: EmailSender[]
): EmailSender {
return {
send: async (to, subject, body) => {
let lastError: Error | undefined;
for (const provider of providers) {
try {
await provider.send(to, subject, body);
return; // Success!
} catch (error) {
lastError = error as Error;
console.log('Provider failed, trying next...');
}
}
throw new Error(`All providers failed: ${lastError?.message}`);
}
};
}
// Simple instantiation
const mailgunSender: EmailSender = {
send: async (to, subject, body) => {
console.log(`Mailgun: Sending to ${to}`);
}
};
const resilientSender = createResilientEmailSender([
sendGridSender,
mailgunSender
]);
const service = new NotificationService(resilientSender, twilioSender);
Why this is better:
- DIP compliant: High-level NotificationService depends on abstractions
- Testable: Inject test implementations easily
- Flexible: Swap providers without changing NotificationService
- Simple: No factory hierarchies or IoC containers required
- Composable: Build complex behavior (resilience) through simple functions
The key insight: Dependency Inversion is about depending on abstractions, not about injecting everything. Identify true seams in your architecture—points where implementation details genuinely vary or need isolation.
Keep it balanced: Inject dependencies that vary or need testing isolation. Use simple factory functions for complex construction. Don't inject configuration or utilities that don't vary.
Why Balance Matters
Both over-abstraction and under-abstraction have costs. Finding the right balance is crucial.
The Cost of Over-Abstraction
Cognitive Load: Every abstraction layer is a mental hop. Five interfaces to send an email means five things to understand before you can change how emails are sent.
Debugging Complexity: Stack traces become useless when filled with factory methods, abstract base classes, and delegation layers. Simple bugs hide in complex architecture.
False Flexibility: That elaborate factory pattern you built? You'll probably never swap implementations. The abstraction meant to enable change becomes an impediment to change.
Slower Development: Adding features requires touching multiple interfaces, updating factories, modifying abstraction layers. What should take 10 minutes takes an hour.
The Cost of Under-Abstraction
Rigidity: Object literals with hardcoded logic can't be extended without modification. You violate Open/Closed every time requirements evolve.
Tight Coupling: Direct instantiation of dependencies (like new SendGridClient()
) makes testing impossible and providers unchangeable.
Behavioral Inconsistency: Without interfaces and type constraints, implementations drift. One store validates, another doesn't—LSP violations everywhere.
Fragility: No compile-time safety means runtime errors. Typos in property names, missing methods, inconsistent behavior—all discovered too late.
The Sweet Spot
Appropriate abstraction provides:
- Clarity: Code reveals intent without unnecessary indirection
- Flexibility: Extensions don't require modifications
- Testability: Dependencies can be swapped for testing
- Type Safety: Compiler catches errors before runtime
- Maintainability: Changes are localized and predictable
When to Abstract
Don't guess. Let pain guide you. Abstract when you feel:
1. Testing Pain
Symptom: Can't test business logic without hitting external services or databases.
Solution: Extract an interface for the dependency, inject it.
// Before: Untestable
class OrderService {
async processOrder(order: Order): Promise<void> {
const payment = new StripePaymentService();
await payment.charge(order.total);
}
}
// After: Testable
interface PaymentService {
charge(amount: number): Promise<void>;
}
class OrderService {
constructor(private payment: PaymentService) {}
async processOrder(order: Order): Promise<void> {
await this.payment.charge(order.total);
}
}
2. Extension Pain
Symptom: Adding new behavior requires modifying existing code in multiple places.
Solution: Extract an interface, make implementations independent.
// Before: Must modify drink objects to add features
const coffee = {
name: 'Coffee',
price: 3,
prepare: () => console.log('Brewing...')
};
// After: Extend by adding classes
class Coffee implements Drink {
readonly name = 'Coffee';
getPrice(customer?: Customer): number {
return customer?.hasLoyalty ? 2.7 : 3;
}
async prepare(): Promise<void> {
console.log('Brewing...');
}
}
3. Duplication Pain
Symptom: Same code repeated with minor variations.
Solution: Extract the variation point, inject or parameterize it.
// Before: Duplication
async function sendWelcomeEmail(user: User): Promise<void> {
await sendGrid.send({
to: user.email,
subject: 'Welcome!',
body: 'Welcome to our app!'
});
}
async function sendPasswordResetEmail(user: User): Promise<void> {
await sendGrid.send({
to: user.email,
subject: 'Password Reset',
body: 'Click here to reset...'
});
}
// After: Extract variation
interface EmailTemplate {
subject: string;
body: string;
}
async function sendEmail(
sender: EmailSender,
user: User,
template: EmailTemplate
): Promise<void> {
await sender.send(user.email, template.subject, template.body);
}
const welcomeTemplate = {
subject: 'Welcome!',
body: 'Welcome to our app!'
};
const resetTemplate = {
subject: 'Password Reset',
body: 'Click here to reset...'
};
4. Coupling Pain
Symptom: Changes ripple unexpectedly. Modifying A requires modifying B, C, and D.
Solution: Identify the coupling point, introduce an interface to break it.
5. Multiple Implementations Today
Symptom: You're shipping with multiple real implementations right now (not hypothetical futures).
Solution: Abstract immediately. This is the clearest signal that abstraction provides value.
The Path Forward: Context-Driven Design
SOLID principles are guides, not goals. The goal is code that's easy to understand, easy to change, and easy to maintain. Abstraction is a tool for achieving these goals, not an end in itself.
Ask These Questions:
Before adding abstraction:
- Do I have two real, different implementations right now?
- Is this dependency causing testing pain?
- Will requirements vary here in ways I can articulate today?
Before avoiding abstraction:
- Am I violating Open/Closed by modifying existing code for extensions?
- Are my implementations behaviorally consistent (LSP)?
- Are different clients being forced to depend on interfaces they don't use (ISP)?
For both:
- Would a new developer understand this in under two minutes?
- Can I test this easily?
- Does this make changes easier or harder?
The Decision Matrix
Use this matrix to guide your abstraction decisions:
Context | Abstraction Level | Rationale |
---|---|---|
Early Prototype/MVP | Minimal | Learn the domain first, refactor later |
Production System | Appropriate | Balance maintainability with clarity |
Solo Developer | Minimal-Moderate | Less coordination overhead |
Large Team | Moderate-High | Contracts prevent coupling |
Stable Domain | Moderate-High | Encode proven patterns |
Volatile Requirements | Moderate | Flexibility through composition |
Performance Critical | Minimal | Every layer costs nanoseconds |
Standard Business App | Moderate | Clarity over micro-optimization |
Follow SOLID By:
Single Responsibility
- Writing focused classes that group operations changing together
- Separating concerns that change independently
- Using simple dependency injection for varying behavior
Open/Closed
- Using classes for behavior that extends (not just data)
- Keeping abstractions focused and purposeful
- Extending through subclassing or composition, not modification
Liskov Substitution
- Ensuring all implementations behave consistently
- Using type systems to enforce contracts
- Testing substitutability explicitly
Interface Segregation
- Creating interfaces based on client needs, not implementation details
- Allowing implementations to support one or many interfaces
- Ensuring clients depend only on what they use
Dependency Inversion
- Identifying true seams where implementations vary
- Injecting dependencies through simple interfaces
- Using factory functions for complex construction, not factory classes
The Minimalist's Process
- Start Concrete: Write the working code first. Solve the immediate problem.
-
Feel the Pain: Wait until you experience actual problems:
- Can't test without external dependencies
- Adding features requires modifying multiple existing files
- Same code repeated with minor variations
- Changes ripple unexpectedly
-
Extract Minimally: Add only the abstraction needed to resolve that specific pain:
- Testing pain → Extract interface for dependency
- Extension pain → Create classes implementing interface
- Duplication pain → Parameterize or extract the variation
- Coupling pain → Introduce interface at coupling point
-
Verify SOLID: Check that your abstraction:
- Has a single, clear responsibility (SRP)
- Allows extension without modification (OCP)
- Supports true substitutability (LSP)
- Serves focused client needs (ISP)
- Depends on abstractions, not concretions (DIP)
- Repeat: Let new pain guide further refinement. Don't pre-solve tomorrow's problems.
Real-World Examples: The Balance in Practice
Example 1: E-Commerce Order Processing
Context: Production system, stable domain, team of 8 developers
Initial Version (Too Simple):
class OrderProcessor {
async processOrder(order: Order): Promise<void> {
// Payment
await fetch('https://stripe.com/api/charge', {
method: 'POST',
body: JSON.stringify({ amount: order.total })
});
// Inventory
await fetch('https://api.inventory.com/reserve', {
method: 'POST',
body: JSON.stringify({ items: order.items })
});
// Email
await fetch('https://sendgrid.com/api/send', {
method: 'POST',
body: JSON.stringify({ to: order.email })
});
}
}
Problems: Untestable, tightly coupled, violates SRP/OCP/DIP
Balanced Version:
interface PaymentService {
charge(amount: number, currency: string): Promise<PaymentResult>;
}
interface InventoryService {
reserve(items: OrderItem[]): Promise<ReservationResult>;
release(reservationId: string): Promise<void>;
}
interface NotificationService {
sendOrderConfirmation(order: Order): Promise<void>;
}
class OrderProcessor {
constructor(
private payment: PaymentService,
private inventory: InventoryService,
private notifications: NotificationService
) {}
async processOrder(order: Order): Promise<OrderResult> {
// Reserve inventory first
const reservation = await this.inventory.reserve(order.items);
try {
// Charge payment
const payment = await this.payment.charge(
order.total,
order.currency
);
// Send confirmation
await this.notifications.sendOrderConfirmation(order);
return { success: true, orderId: order.id };
} catch (error) {
// Release inventory on failure
await this.inventory.release(reservation.id);
throw error;
}
}
}
Why This Works:
- Testable: Inject test implementations for each service
- SRP: Each service has one responsibility
- OCP: Can swap payment/inventory/notification providers
- DIP: OrderProcessor depends on abstractions
- Still Simple: Three focused interfaces, no factory hierarchies
Example 2: Real-Time Analytics Dashboard
Context: Prototype, volatile requirements, solo developer
Right Approach (Start Simple):
interface MetricValue {
timestamp: number;
value: number;
}
class MetricsCollector {
private metrics: Map<string, MetricValue[]> = new Map();
record(name: string, value: number): void {
const values = this.metrics.get(name) || [];
values.push({ timestamp: Date.now(), value });
this.metrics.set(name, values);
}
getMetrics(name: string, since?: number): MetricValue[] {
const values = this.metrics.get(name) || [];
if (since) {
return values.filter(v => v.timestamp >= since);
}
return values;
}
}
// Simple, concrete, no premature abstraction
const collector = new MetricsCollector();
collector.record('api_latency', 150);
When to Abstract: After requirements stabilize and you need:
- Multiple storage backends (memory, Redis, TimescaleDB)
- Multiple collection strategies (sampling, aggregation)
- Complex querying (percentiles, aggregations)
Then Extract:
interface MetricsStore {
record(name: string, value: number): Promise<void>;
query(name: string, options: QueryOptions): Promise<MetricValue[]>;
}
interface AggregationStrategy {
aggregate(values: MetricValue[]): AggregatedMetric;
}
class MetricsCollector {
constructor(
private store: MetricsStore,
private aggregator: AggregationStrategy
) {}
// Implementation using abstractions
}
Example 3: Content Management System
Context: Production, large team, stable domain
Wrong Approach (Over-Abstracted):
interface IContentFactory { /* ... */ }
interface IContentRepository { /* ... */ }
interface IContentValidator { /* ... */ }
interface IContentSerializer { /* ... */ }
interface IContentRenderer { /* ... */ }
abstract class AbstractContentManager { /* ... */ }
class ContentManagerFactory { /* ... */ }
Right Approach (Appropriately Abstracted):
interface Content {
id: string;
type: 'article' | 'page' | 'post';
title: string;
body: string;
metadata: Record<string, any>;
}
interface ContentStore {
save(content: Content): Promise<void>;
load(id: string): Promise<Content | null>;
delete(id: string): Promise<void>;
search(query: SearchQuery): Promise<Content[]>;
}
interface ContentRenderer {
render(content: Content): Promise<string>;
}
class ContentManager {
constructor(
private store: ContentStore,
private renderer: ContentRenderer
) {}
async publish(content: Content): Promise<void> {
await this.validate(content);
await this.store.save(content);
}
async getRenderedContent(id: string): Promise<string> {
const content = await this.store.load(id);
if (!content) throw new Error('Content not found');
return this.renderer.render(content);
}
private validate(content: Content): void {
if (!content.title) throw new Error('Title required');
if (!content.body) throw new Error('Body required');
}
}
Why This Works:
- Two key abstractions: Storage and rendering (the variation points)
- No factory explosion: Simple constructor injection
- Team-friendly: Clear contracts, easy to understand
- Testable: Mock store and renderer
- Extensible: Add new content types, storage backends, renderers
Common Antipatterns to Avoid
1. The "Just In Case" Interface
// ❌ Bad: Interface that might be useful someday
interface IConfigurationProvider {
get(key: string): any;
set(key: string, value: any): void;
reload(): Promise<void>;
watch(key: string, callback: Function): void;
}
// ✅ Good: Start with what you need
const config = {
apiKey: process.env.API_KEY,
apiUrl: process.env.API_URL
};
// Extract interface only when you need multiple sources
2. The Abstract Base Class Disease
// ❌ Bad: Abstract classes for shared implementation
abstract class AbstractService {
protected abstract logger: Logger;
protected abstract config: Config;
protected log(msg: string): void {
this.logger.log(msg);
}
}
// ✅ Good: Composition over inheritance
class ServiceBase {
constructor(
protected logger: Logger,
protected config: Config
) {}
}
// Or even better: just inject what you need
class OrderService {
constructor(
private logger: Logger,
private config: Config
) {}
}
3. The Godzilla Interface
// ❌ Bad: One interface for everything
interface UserService {
authenticate(credentials: Credentials): Promise<User>;
register(data: RegistrationData): Promise<User>;
updateProfile(userId: string, data: ProfileData): Promise<void>;
deleteAccount(userId: string): Promise<void>;
sendPasswordReset(email: string): Promise<void>;
verifyEmail(token: string): Promise<void>;
listUsers(filters: Filters): Promise<User[]>;
banUser(userId: string, reason: string): Promise<void>;
}
// ✅ Good: Segregated by client needs
interface AuthenticationService {
authenticate(credentials: Credentials): Promise<User>;
sendPasswordReset(email: string): Promise<void>;
}
interface RegistrationService {
register(data: RegistrationData): Promise<User>;
verifyEmail(token: string): Promise<void>;
}
interface UserManagementService {
updateProfile(userId: string, data: ProfileData): Promise<void>;
deleteAccount(userId: string): Promise<void>;
}
interface AdminUserService {
listUsers(filters: Filters): Promise<User[]>;
banUser(userId: string, reason: string): Promise<void>;
}
4. The Premature Abstraction
// ❌ Bad: Abstracting before you understand the domain
interface IPaymentStrategy {
execute(context: IPaymentContext): Promise<IPaymentResult>;
}
interface IPaymentContext {
getAmount(): number;
getCurrency(): string;
getMetadata(): Map<string, any>;
}
// ✅ Good: Start concrete, extract patterns later
class PaymentProcessor {
async processPayment(
amount: number,
currency: string,
paymentMethod: PaymentMethod
): Promise<PaymentResult> {
// Concrete implementation
// Extract abstraction when second payment provider appears
}
}
Measuring Success
How do you know if you've found the right balance? Use these metrics:
Code Health Indicators
Good Signs:
- ✅ New features touch 1-3 files, not 10+
- ✅ Tests don't require elaborate setup
- ✅ New team members understand code in < 1 hour
- ✅ Bugs are usually in one obvious place
- ✅ Refactoring feels safe and localized
Warning Signs:
- ⚠️ "Where do I make this change?" is a common question
- ⚠️ Tests break when unrelated code changes
- ⚠️ Stack traces are 20+ levels deep
- ⚠️ Developers say "I don't want to touch that code"
- ⚠️ Simple features take days instead of hours
The Two-Minute Rule
Can a competent developer:
- Understand what the code does in < 2 minutes?
- Identify where to make a change in < 2 minutes?
- Make a simple change in < 10 minutes?
If no, you may have too much abstraction. If changes require modifying multiple files, you may have too little.
The Testing Litmus Test
- Too much abstraction: Setting up a test requires mocking 5+ dependencies
- Too little abstraction: Can't test without hitting external services
- Just right: Tests use 1-2 simple test doubles and run fast
Conclusion: The Courage to Be Appropriate
The hardest part of applying SOLID isn't technical—it's cultural and contextual. We've been trained to equate abstraction with professionalism, and simplicity with naivety. But the opposite can be equally problematic: assuming all abstraction is over-engineering.
The truth is more nuanced:
- Abstraction is not evil; premature or excessive abstraction is
- Concreteness is not virtuous; rigid, untestable concreteness is
- SOLID principles are guides, not commandments
- Context matters: team size, domain stability, project phase, performance requirements
The mark of a senior engineer isn't how many design patterns they can apply, nor how few abstractions they can get away with. It's knowing when to abstract and when not to—and having the judgment to distinguish between the two.
Key Takeaways
- Let Pain Guide You: Don't abstract until you feel testing pain, extension pain, duplication pain, or coupling pain
- Verify SOLID Compliance: Any abstraction you add should genuinely satisfy SOLID principles, not just add layers
- Context Drives Decisions: The right amount of abstraction depends on your specific situation—prototype vs. production, solo vs. team, stable vs. volatile
- Both Extremes Hurt: Over-abstraction creates cognitive load and false flexibility; under-abstraction creates rigidity and untestability
- Refactor Fearlessly: Simple code refactors into abstracted code easily; over-abstracted code is hard to simplify
- Measure Outcomes: Judge your abstractions by clarity, testability, and changeability—not by adherence to patterns
The Balanced Creed
- Start concrete: Write working code that solves today's problem
- Feel actual pain: Wait for real problems, not imagined futures
- Extract appropriately: Add abstraction that genuinely satisfies SOLID
- Verify the balance: Check for clarity, testability, and maintainability
- Repeat: Let new challenges guide further refinement
Be balanced. Be clear. Be SOLID.
The best code is neither minimal nor maximal—it's appropriate. It does what it says. It changes easily when requirements change. It has exactly as much abstraction as the context demands—no more, no less.
This is the art of software engineering: finding the right balance between simplicity and structure, between today's needs and tomorrow's flexibility, between rigid concreteness and abstract complexity.
Master this balance, and you'll master SOLID.
Top comments (0)