The Clean Code Fetish: Why Your Perfect Abstractions Are Killing the Vibe (and the Product)
Your Strategy pattern is not a feature. The shipped flat function is.

TL;DR: Clean Code is a useful target for stable, mature systems. It is a velocity poison for indie hackers shipping their fifth pivot. The cost of premature abstraction isn't the lines you wrote — it's the four hours you spent debugging a Strategy pattern instead of the actual bug, and the test suite that breaks on every refactor. Inline first, abstract on the third repeat, and let your codebase look ugly until it earns its prettiness.
Somewhere between your second hackathon and your first paying customer, a voice started whispering in your ear: this code isn't clean enough. The function is too long. The naming is inconsistent. There's duplication. You haven't extracted an interface. The Cyclomatic Complexity is over 10. Robert C. Martin would not approve.
So you stop shipping features for a day. You introduce a Strategy pattern, a Factory, a Repository, an EventBus. You feel virtuous. Then your customer churns because the bug they reported is still in production, hidden behind three layers of indirection that your "clean" refactor created.
Sandi Metz nailed this in her seminal 2016 essay The Wrong Abstraction: "Duplication is far cheaper than the wrong abstraction." The Clean Code fetish — the unexamined religion of inheritance hierarchies, single responsibility per file, and obsessive DRY-ing — is one of the most expensive mistakes indie hackers make. Not because the underlying ideas are wrong. Because they're applied at the wrong time, by people who haven't earned the right to abstract yet.
This post is a deep dive into where Clean Code goes wrong for solopreneurs and small teams, with five concrete code examples — including the email-sending Strategy pattern that ate my Tuesday, and the flat function that replaced it in 90 minutes. Real services (Resend, Upstash Ratelimit, Prisma), real edge cases (Stripe-style idempotency keys, P2002 races, IEEE 754 money math), real test deletions.
Why does Clean Code break indie hacker velocity?
Premature abstraction encodes the wrong shape. Indie hackers iterate weekly; the "right" structure changes every pivot. Inline first; abstract after the third repeat — that's when you actually know which axis varies.
The original Clean Code book (Robert C. Martin, 2008) was written for enterprise Java teams maintaining systems that already had stable, well-understood requirements. The book's advice — small functions, single responsibility, DRY — is solid for that context. The problem is that indie hackers, side-project builders, and zero-to-one founders are not in that context.
You are in a context where:
- You don't know what the requirements actually are yet — you're guessing based on three customer interviews.
- The feature you're abstracting today might be deleted next sprint when the pivot lands.
- You're the only person who will read the code for the next six months.
- Speed-to-ship correlates more with revenue than code beauty does.
Joel Spolsky warned about this in his 2001 piece Don't Let Architecture Astronauts Scare You: developers who get high on abstractions tend to deliver Visio diagrams instead of features. Two decades later, the diagram tool is now Mermaid, but the pathology is identical.
The empirical case against premature abstraction got sharper in 2020 when Dan Abramov — co-author of Redux — published Goodbye, Clean Code. His argument, paraphrased: he once refactored a teammate's "duplicated" geometry code into a clever abstraction, and within a month the abstraction was actively obstructing every new feature, because the duplications were superficial and the actual variation lived elsewhere. Sound familiar?
Example 1: The over-abstracted email Strategy pattern that nobody asked for
Here's the kind of code I've written, you've written, and our junior selves are still writing somewhere. The setup: an indie SaaS needs to send three kinds of emails — welcome, password reset, billing receipt. The Clean Code response is, of course, a Strategy pattern with an interface, a factory, a registry, and a logger. Every piece is small. Every piece is "clean." The whole thing is a disaster.
// src/email/EmailStrategy.ts
import type { User } from '@prisma/client';
export interface EmailContext {
user: User;
metadata?: Record<string, unknown>;
}
export interface EmailStrategy {
readonly id: string;
build(ctx: EmailContext): Promise<{ subject: string; html: string }>;
}
// src/email/strategies/WelcomeEmailStrategy.ts
export class WelcomeEmailStrategy implements EmailStrategy {
readonly id = 'welcome';
async build({ user }: EmailContext) {
return {
subject: `Welcome, ${user.name}`,
html: `<p>Welcome aboard!</p>`,
};
}
}
// src/email/strategies/PasswordResetEmailStrategy.ts
export class PasswordResetEmailStrategy implements EmailStrategy {
readonly id = 'password-reset';
async build({ user, metadata }: EmailContext) {
const token = metadata?.token as string;
if (!token) throw new Error('Missing reset token');
return {
subject: 'Reset your password',
html: `<p>Click <a href="https://app.example.com/reset?t=${token}">here</a> to reset.</p>`,
};
}
}
// src/email/EmailStrategyRegistry.ts
import { WelcomeEmailStrategy } from './strategies/WelcomeEmailStrategy';
import { PasswordResetEmailStrategy } from './strategies/PasswordResetEmailStrategy';
import { BillingReceiptEmailStrategy } from './strategies/BillingReceiptEmailStrategy';
import type { EmailStrategy } from './EmailStrategy';
export class EmailStrategyRegistry {
private strategies = new Map<string, EmailStrategy>();
constructor() {
this.register(new WelcomeEmailStrategy());
this.register(new PasswordResetEmailStrategy());
this.register(new BillingReceiptEmailStrategy());
}
private register(s: EmailStrategy) {
this.strategies.set(s.id, s);
}
get(id: string): EmailStrategy {
const s = this.strategies.get(id);
if (!s) throw new Error(`Unknown email strategy: ${id}`);
return s;
}
}
// src/email/EmailService.ts
import { Resend } from 'resend';
import { EmailStrategyRegistry } from './EmailStrategyRegistry';
import type { EmailContext } from './EmailStrategy';
export class EmailService {
constructor(
private resend: Resend,
private registry: EmailStrategyRegistry,
) {}
async send(strategyId: string, ctx: EmailContext): Promise<void> {
const strategy = this.registry.get(strategyId);
const { subject, html } = await strategy.build(ctx);
await this.resend.emails.send({
from: 'noreply@example.com',
to: ctx.user.email,
subject,
html,
});
}
}Count the files: EmailStrategy.ts, three strategy files, EmailStrategyRegistry.ts, EmailService.ts. That's six files. Then there are the unit tests for each strategy, the registry, and the service. That's another six. Twelve files for an MVP that sends three emails.
And here's what this code doesn't handle:
- Idempotency. Hit "send welcome" twice in 50ms (because the user double-clicked sign up) and you send two emails.
- Rate limiting. A buggy webhook fires the receipt email in a loop and Resend bills you for the abuse.
- Unsubscribed users. The Strategy doesn't care; you spam someone who hit "unsubscribe" six months ago.
- Bounces. No retry, no DLQ, no logging beyond whatever Resend stores.
The Hidden Cost
The Clean Code fan looks at this and says: "Just add an IdempotencyDecorator and a RateLimitDecorator!" That's how you end up with sixteen files, three layers of decorators, and a debugger that needs a magnifying glass to find where the email actually gets sent. The original sin is that the abstraction was extracted before any of the cross-cutting concerns existed. Now every concern has to fit the abstraction's shape, even when it doesn't.
Example 2: The flat sendEmail function that ships and stays alive
Here's the rewrite. One file. One function. Inline error handling. Real idempotency via Stripe-style Idempotency-Key headers, real rate limiting via Upstash, real unsubscribe checks. Boring. Direct. Bug-resistant. And if next sprint we need to add SMS, we copy this file and swap the provider — we don't reshape an inheritance tree.
// src/email/sendEmail.ts
import { Resend } from 'resend';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { Prisma, PrismaClient } from '@prisma/client';
const resend = new Resend(process.env.RESEND_API_KEY!);
const prisma = new PrismaClient();
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, '1 m'), // 20 emails / user / minute
});
type EmailKind = 'welcome' | 'password-reset' | 'billing-receipt';
interface SendEmailInput {
kind: EmailKind;
userId: string;
// Stripe-style idempotency key. Same key = same email is only sent once.
idempotencyKey: string;
// Optional kind-specific payload (kept loose on purpose — a Zod schema in the
// route handler is where this gets validated, not in the email layer).
payload?: Record<string, unknown>;
}
export async function sendEmail(input: SendEmailInput): Promise<{ status: 'sent' | 'duplicate' | 'rate-limited' | 'unsubscribed' | 'no-email' }> {
// 1. Idempotency check first — fail fast, no provider call.
const existing = await prisma.emailLog.findUnique({
where: { idempotencyKey: input.idempotencyKey },
});
if (existing) return { status: 'duplicate' };
// 2. Load user. If they don't exist, log and return — never throw on a
// legit business state. (Throws are reserved for "this should be impossible".)
const user = await prisma.user.findUnique({
where: { id: input.userId },
select: { email: true, unsubscribedAt: true, name: true },
});
if (!user) return { status: 'no-email' };
if (!user.email) return { status: 'no-email' };
// 3. Honor unsubscribe — no exceptions, even for "transactional" emails.
// (Consult your lawyer; CAN-SPAM and GDPR have nuance here.)
if (user.unsubscribedAt && input.kind !== 'password-reset') {
return { status: 'unsubscribed' };
}
// 4. Rate limit per user. Stops a runaway webhook from exploding your bill.
const { success } = await ratelimit.limit(`email:${input.userId}`);
if (!success) return { status: 'rate-limited' };
// 5. Build content inline. No factory. No registry. If welcome and reset
// end up sharing markup, we'll extract a helper *then* — not now.
let subject: string;
let html: string;
switch (input.kind) {
case 'welcome':
subject = `Welcome, ${user.name ?? 'friend'}`;
html = `<p>Welcome aboard. <a href="https://app.example.com">Open the app</a>.</p>`;
break;
case 'password-reset': {
const token = input.payload?.token;
if (typeof token !== 'string' || token.length === 0) {
// This *is* a "should be impossible" — the route handler gates on it.
throw new Error('sendEmail(password-reset): missing token');
}
subject = 'Reset your password';
html = `<p>Click <a href="https://app.example.com/reset?t=${token}">here</a>. Expires in 30 minutes.</p>`;
break;
}
case 'billing-receipt': {
const cents = input.payload?.amountCents;
if (typeof cents !== 'number' || !Number.isInteger(cents) || cents < 0) {
throw new Error('sendEmail(billing-receipt): amountCents must be a non-negative integer');
}
// Money is integer cents end-to-end. Never trust IEEE 754 with currency.
const dollars = (cents / 100).toFixed(2);
subject = `Receipt: $${dollars}`;
html = `<p>Thanks for your payment of $${dollars}.</p>`;
break;
}
}
// 6. Send + log atomically (well, as atomically as we can without 2PC).
// The race condition matters: if Resend succeeds and Prisma fails, a
// retry will skip the duplicate. If Resend fails, we return the error
// and no log row exists, so a retry sends. This is the right tradeoff
// for "transactional but not financially critical" email.
try {
await resend.emails.send({
from: 'noreply@example.com',
to: user.email,
subject,
html,
});
await prisma.emailLog.create({
data: {
idempotencyKey: input.idempotencyKey,
userId: input.userId,
kind: input.kind,
sentAt: new Date(),
},
});
return { status: 'sent' };
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
// Race: another process won the idempotency key between our find and our
// create. Treat as duplicate — the other process is responsible for the send.
return { status: 'duplicate' };
}
throw err;
}
}That's 90 lines including comments and edge cases the Strategy version didn't cover. One file. One function signature. The reader doesn't need to grep through six classes to understand what happens when you call it. The error handling is right next to the code that produces the errors. The race condition between "find idempotency" and "create idempotency" is solved with Prisma's P2002 unique-violation code, exactly where it occurs.
Will this function survive forever? No. The day you add SMS or push notifications, you'll likely extract a tiny helper. The day a fourth email kind shares 80% of the markup with another, you'll pull a partial. Then you'll have earned the right to abstract — Fowler's Rule of Three from Refactoring 2nd edition (2018). Until then, ship.
How does premature abstraction wreck your tests?
Tests that pin internal abstractions break on every refactor. Tests that pin behavior at the boundary survive. Mock the network; never mock your own modules — that's tautological testing.
The Clean Code Strategy version had "100% unit test coverage." Here's what those tests actually looked like — and why deleting them was the highest-leverage thing I did all month.
Example 3: The brittle abstraction-bound test (delete this)
// src/email/__tests__/EmailService.test.ts (the bad version)
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EmailService } from '../EmailService';
import { EmailStrategyRegistry } from '../EmailStrategyRegistry';
import type { EmailStrategy } from '../EmailStrategy';
describe('EmailService', () => {
let resendMock: { emails: { send: ReturnType<typeof vi.fn> } };
let registryMock: EmailStrategyRegistry;
let strategyMock: EmailStrategy;
beforeEach(() => {
resendMock = { emails: { send: vi.fn().mockResolvedValue({ id: 'm_1' }) } };
strategyMock = {
id: 'welcome',
build: vi.fn().mockResolvedValue({ subject: 'S', html: '<p>H</p>' }),
};
registryMock = {
get: vi.fn().mockReturnValue(strategyMock),
} as unknown as EmailStrategyRegistry;
});
it('calls registry.get with the strategyId', async () => {
const svc = new EmailService(resendMock as never, registryMock);
await svc.send('welcome', { user: { id: 'u', email: 'a@b.c', name: 'A' } as never });
expect(registryMock.get).toHaveBeenCalledWith('welcome');
});
it('passes the built subject to resend.emails.send', async () => {
const svc = new EmailService(resendMock as never, registryMock);
await svc.send('welcome', { user: { id: 'u', email: 'a@b.c', name: 'A' } as never });
expect(resendMock.emails.send).toHaveBeenCalledWith(
expect.objectContaining({ subject: 'S', html: '<p>H</p>' }),
);
});
});These tests look fine. They are useless. They verify that EmailService calls registry.get with the right string and forwards the result to resend.emails.send. That's tautological testing — you've mocked everything except the wiring, then asserted that the wiring is wired correctly.
When you delete EmailStrategyRegistry, both tests blow up. Not because behavior changed. Because you renamed a class. That's the test suite preventing you from refactoring — the exact opposite of what tests are for. Hyrum's Law applies inside your own codebase too: every observable detail will be relied upon, including by your own test mocks.
Example 4: The behavior-bound test that survives any refactor
// src/email/__tests__/sendEmail.integration.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { sendEmail } from '../sendEmail';
import { prisma } from '../../prisma'; // real Prisma against a test DB
import { randomUUID } from 'node:crypto';
// Only the network boundary is mocked. Resend is the boundary; Prisma isn't.
const resendCalls: Array<{ to: string; subject: string }> = [];
const server = setupServer(
http.post('https://api.resend.com/emails', async ({ request }) => {
const body = (await request.json()) as { to: string; subject: string };
resendCalls.push({ to: body.to, subject: body.subject });
return HttpResponse.json({ id: 'm_test' });
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
beforeEach(async () => {
resendCalls.length = 0;
await prisma.emailLog.deleteMany();
await prisma.user.deleteMany();
});
describe('sendEmail (behavior)', () => {
it('sends a welcome email exactly once for a given idempotency key', async () => {
const user = await prisma.user.create({
data: { id: 'u1', email: 'a@b.c', name: 'Ana' },
});
const key = randomUUID();
const r1 = await sendEmail({ kind: 'welcome', userId: user.id, idempotencyKey: key });
const r2 = await sendEmail({ kind: 'welcome', userId: user.id, idempotencyKey: key });
expect(r1).toEqual({ status: 'sent' });
expect(r2).toEqual({ status: 'duplicate' });
expect(resendCalls).toHaveLength(1);
expect(resendCalls[0].subject).toBe('Welcome, Ana');
});
it('returns unsubscribed for non-transactional kinds', async () => {
await prisma.user.create({
data: { id: 'u2', email: 'a@b.c', unsubscribedAt: new Date() },
});
const r = await sendEmail({ kind: 'welcome', userId: 'u2', idempotencyKey: randomUUID() });
expect(r.status).toBe('unsubscribed');
expect(resendCalls).toHaveLength(0);
});
it('still sends password-reset to unsubscribed users (security override)', async () => {
await prisma.user.create({
data: { id: 'u3', email: 'a@b.c', unsubscribedAt: new Date() },
});
const r = await sendEmail({
kind: 'password-reset',
userId: 'u3',
idempotencyKey: randomUUID(),
payload: { token: 'tok_123' },
});
expect(r.status).toBe('sent');
expect(resendCalls[0].subject).toBe('Reset your password');
});
});Three integration tests. They mock the Resend HTTP boundary with MSW. They use a real Prisma client against a test database. They assert observable outcomes — "exactly one HTTP call", "status duplicate on second send", "unsubscribed users still get reset emails for security reasons."
When I rewrite sendEmail tomorrow, swap Prisma for Drizzle, change the rate-limiter, or split the function — these tests do not break. The contract under test is "what the user observes," not "which class calls which method." That's the difference between a test suite that protects you and a test suite that handcuffs you.
Example 5: When abstraction is earned — a preflight refactor done right
Eventually, three of your callers do something nearly identical: load the user, check unsubscribe, check rate limit, then dispatch. The third repeat is your trigger — Fowler's Rule of Three. Now the abstraction has earned itself.
// src/notify/preflight.ts
// Extracted only after we wrote it inline three times: sendEmail, sendSms,
// pushNotify. The shape was finally stable enough to name.
import { prisma } from '../prisma';
import { ratelimit } from '../ratelimit';
export type PreflightResult =
| { kind: 'ok'; user: { id: string; email: string | null; phone: string | null } }
| { kind: 'no-user' }
| { kind: 'unsubscribed' }
| { kind: 'rate-limited' };
interface PreflightInput {
userId: string;
channel: 'email' | 'sms' | 'push';
// 'transactional' bypasses unsubscribe (use with care; legal nuance applies).
transactional: boolean;
}
export async function preflight(input: PreflightInput): Promise<PreflightResult> {
const user = await prisma.user.findUnique({
where: { id: input.userId },
select: { id: true, email: true, phone: true, unsubscribedAt: true },
});
if (!user) return { kind: 'no-user' };
const contact = input.channel === 'sms' ? user.phone : user.email;
if (!contact) return { kind: 'no-user' };
if (user.unsubscribedAt && !input.transactional) {
return { kind: 'unsubscribed' };
}
const { success } = await ratelimit.limit(`${input.channel}:${user.id}`);
if (!success) return { kind: 'rate-limited' };
return { kind: 'ok', user: { id: user.id, email: user.email, phone: user.phone } };
}Notice what we did: we extracted only what we'd already written three times. The function returns a discriminated union that the caller pattern-matches on — no exceptions for normal business states. There's no PreflightStrategy interface, no factory, no registry. It's just a function. If we ever need a fourth channel, we'll add another branch. If the discriminated union grows past five variants, then we'll think about whether the right axis is "channel." Earned, not assumed.
Cost vs. payoff: when each "Clean Code" pattern actually pays
Patterns aren't universally good or bad. They're tradeoffs that pay off at certain sizes and stages. Here's the heuristic I now use for indie / small-team work:
| Pattern | Cost | Payoff | Pre-PMF verdict |
|---|---|---|---|
| Strategy + Registry | ~6 files, indirection | Plug-in third-party variants | Skip until 4+ variants |
| Repository pattern | DAO layer + interfaces | Swap ORM cleanly | Skip — Prisma already abstracts |
| Dependency Injection container | Wiring config, learning curve | Test isolation at scale | Skip — pass args directly |
| Decorator pattern | Stack-trace pain | Cross-cutting concerns | Inline in the function instead |
| Discriminated union state | Pattern matching boilerplate | Compile-time exhaustiveness | Use early — high payoff in TS |
| Pure function with explicit args | Slightly longer signature | Trivial to test, refactor, delete | Default to this everywhere |
| Service class with state | Lifecycle, mocking complexity | Stable shared dependencies | Avoid — push state to callers |
Kent Beck's Tidy First? (2023) reinforces the same idea from the other direction: tidyings (small refactors) are cheap and reversible; structural changes (introducing a Strategy pattern) are expensive and sticky. Default to tidyings. Reach for structural changes only when the tidyings stop helping.
Edge cases & gotchas the Clean Code books don't warn you about
- Money math and IEEE 754. Use integer cents end-to-end.
(0.1 + 0.2) === 0.3isfalsein JavaScript per IEEE 754. No abstraction protects you from this; only data type discipline does. - Timestamps. Always store
timestamptz(UTC), render in user TZ at the edge. The day you ship to Mexico City you'll be glad your "Clean" abstraction wasn't doing TZ math in the middle layer. - Idempotency keys. Stripe's Idempotency-Key header is the gold standard. Make the key the caller's responsibility, not the service's — only the caller knows whether two requests should be considered the same.
- Race conditions on unique keys. Two concurrent requests with the same idempotency key both pass the "does it exist?" check, then race to insert. One wins; the other gets
P2002. Handle that error code as "duplicate", not as failure. - Hyrum's Law inside your own codebase. The shape of every public symbol — class name, method name, error message, log line — gets relied upon. Keep your API surface small; expose functions, not classes, when you can.
- Real services in CI. Use Testcontainers for Postgres, MSW for HTTP. Don't mock Prisma. Don't mock your own modules. Mock the wire, not your own code.
Troubleshooting: symptoms that mean you've over-abstracted
Six failure modes I see repeatedly when I review indie codebases. Each one's diagnosis takes about 90 seconds; each one's fix takes a sprint.
- Symptom: Adding a feature touches 5+ files. Cause: The abstraction's axis of variation doesn't match the feature's axis. Fix: Inline the abstraction back into the call sites; re-extract along the right axis only when it repeats three times.
- Symptom: Stack traces have 8+ frames inside your own code before reaching the action. Cause: Decorators or middleware chains added speculatively. Fix: Move cross-cutting concerns into the body of the function or into route-handler wrappers, not into runtime decorators.
- Symptom: You can't describe what a module does without using the names of design patterns. Cause: The patterns are the design — you encoded mechanism, not domain. Fix: Rename modules after the noun they handle (
sendEmail,billing), not after the pattern (EmailStrategyRegistry). - Symptom: Tests break on every refactor without behavior changing. Cause: Tests assert internal structure (mock calls), not external behavior. Fix: Rewrite tests to assert on inputs/outputs at the boundary; delete the rest.
- Symptom: "100% coverage" but real bugs reach production. Cause: Coverage is over the abstraction, not over the integration. Fix: Add 5 integration tests against real services (Testcontainers + MSW); delete 50 unit tests against mocks.
- Symptom: Onboarding a contractor takes a week to ship one PR. Cause: The codebase's shape is illegible — you have to read 10 files to follow one feature. Fix: Inline before extracting; favor flat call graphs over deep hierarchies.
The vibe-coder's manifesto on Clean Code
I'm not saying delete Clean Code from your shelf. Robert C. Martin's book remains a useful reference for stable, mature systems. The mistake is treating it as scripture for code that hasn't earned its requirements yet. Indie hackers ship pivots, not architectures. Anecdotally — across the indie codebases I've reviewed in 2025 — the ones that ship 5x more features per quarter are the ones with longer functions, fewer files, and more integration tests.
Sandi Metz, again, with the line that should be in every indie hacker's editor footer: "Prefer duplication over the wrong abstraction." Inline first. Wince at the duplication on the second copy. Extract on the third — and only along the axis that's actually varying.
Your customers don't see your file tree. They see the feature you shipped this week, and the bug you fixed yesterday. Optimize for that. The Strategy pattern can wait.
Ready to ship your next project faster?
Desplega.ai helps indie hackers and solopreneurs ship and test faster with AI-powered QA tools that grade behavior, not architecture.
Get StartedFrequently Asked Questions
Is Clean Code actually bad?
No. Clean Code is a useful target for stable, well-understood domains. It becomes harmful when applied prematurely — when you abstract before three concrete uses exist, you encode the wrong shape. Refactor when patterns repeat, not when they might.
When should I extract a Strategy pattern or interface?
Apply Martin Fowler's Rule of Three: the third time you write similar code, then extract. The first time you guess the abstraction. The second time, you wince. The third time, you actually know which axis varies and which is incidental coupling.
Should indie hackers skip tests entirely?
No. Test behavior at the system boundary — HTTP handlers, queue workers, billing webhooks. Skip unit tests that pin internal abstractions in place. A real database, a real Resend mock URL, and three integration tests beat fifty mocked unit tests every time.
How do I know if I'm over-abstracting?
Three signs: you spend more time wiring DI than writing logic, you can't describe what the code does without using design pattern names, and a feature change touches five files instead of one. If yes, inline the abstraction and ship.
What about SOLID, DRY, KISS, and other principles?
Treat them as heuristics, not laws. KISS and YAGNI usually beat DRY for early-stage code. Sandi Metz: "duplication is far cheaper than the wrong abstraction." Optimize for deletability first, abstraction later — your future self will thank you.
Related Posts
Hot Module Replacement: Why Your Dev Server Restarts Are Killing Your Flow State | desplega.ai
Stop losing 2-3 hours daily to dev server restarts. Master HMR configuration in Vite and Next.js to maintain flow state, preserve component state, and boost coding velocity by 80%.
The Flaky Test Tax: Why Your Engineering Team is Secretly Burning Cash | desplega.ai
Discover how flaky tests create a hidden operational tax that costs CTOs millions in wasted compute, developer time, and delayed releases. Calculate your flakiness cost today.
The QA Death Spiral: When Your Test Suite Becomes Your Product | desplega.ai
An executive guide to recognizing when quality initiatives consume engineering capacity. Learn to identify test suite bloat, balance coverage vs velocity, and implement pragmatic quality gates.