The Dirty Code Pattern Developers Keep Rebuilding
The worst dirty code does not look dirty at first. It looks helpful, practical, reusable, and fast to ship, until the same business rule…
The Dirty Code Pattern Developers Keep Rebuilding
The worst dirty code does not look dirty at first. It looks helpful, practical, reusable, and fast to ship, until the same business rule starts living in six places and nobody knows which version is true anymore.

The Dirty Code Pattern Developers Keep Rebuilding
Dirty code is not always ugly.
Sometimes it has good names.
Sometimes it passes tests.
Sometimes it lives inside clean folders, behind helper functions, wrapped in services, protected by abstractions, and praised in code review because it “keeps things simple.”
That is what makes it dangerous.
The dirtiest code pattern developers keep rebuilding is not a long function, bad indentation, or a missing comment. Those are beginner-level problems. The real pattern is hidden policy scattered across the system.
A small rule starts in one place. Then another team needs almost the same rule. Then a background job needs a lighter version. Then the mobile API needs an exception. Then billing needs to override it. Then support needs an admin bypass. Then a retry path handles it slightly differently.
Nobody intentionally designs chaos.
They rebuild it one reasonable decision at a time.
I have seen systems where nobody could answer a simple question like, “Can this user cancel this subscription?” without checking the API controller, the billing service, the frontend button state, the admin override, the feature flag, the webhook handler, and an old migration nobody wanted to touch.
The code worked.
The system did not make sense.
That is the dirty pattern.
Not messy syntax.
Messy truth.
1. The Pattern Starts as a Harmless Shortcut

Most dirty architecture does not begin with arrogance. It begins with pressure.
A feature needs to ship. A condition is obvious. A developer adds it where the work is happening.
if (subscription.status === "active" && !subscription.hasPendingInvoice) {
allowCancel = true;
}
This looks fine. It is local. It is readable. It solves the immediate problem.
The pull request passes. The product manager is happy. Nobody complains because the rule is simple enough to understand in isolation.
Then another part of the system needs to decide whether cancellation is allowed. Instead of finding a shared policy, someone copies the condition and changes it slightly.
if (subscription.status !== "cancelled" && invoice.status !== "open") {
canCancel = true;
}
Still not dramatic.
Then support needs a forced cancellation. Then enterprise plans need a different rule. Then trials behave differently. Then payment failure introduces another exception. Then cancellation during migration becomes unsafe.
The original shortcut is no longer a shortcut. It is the first loose thread in a rule that now controls money, user access, support behavior, and billing integrity.
The mistake was not writing an if statement. The mistake was allowing a business decision to become local implementation detail.
This is where many developers misread cleanliness. A small condition near the code that uses it feels simple. But some conditions are not local. They represent policy. Policy belongs somewhere the system can recognize, test, and protect.
The better question is not, “Is this code readable?”
The better question is, “Is this decision allowed to live here?”
A mature codebase does not centralize everything. That creates its own mess. But high-impact business rules should not be casually rebuilt across controllers, jobs, UI states, and webhook handlers.
2. Developers Confuse Helpers With Ownership

Helpers are one of the easiest ways to make dirty code look clean.
A team notices repeated logic and extracts it:
function canCancelSubscription(subscription: Subscription) {
return subscription.status === "active" && !subscription.hasPendingInvoice;
}
This is better than copying the condition everywhere.
But it may still be weak design.
Why? Because a helper often answers a question without owning the consequence.
A helper can return true or false, but it usually does not explain why. It may not know which actor is making the request. It may not know whether this is a user action, admin action, system retry, billing webhook, or migration job. It may not create an audit trail. It may not protect state transitions. It may not describe what must happen next.
So the helper gets expanded:
function canCancelSubscription(subscription, user, options) {
if (options.force) return true;
if (user.role === "admin") return true;
if (subscription.status === "trialing") return true;
if (subscription.hasPendingInvoice) return false;
return subscription.status === "active";
}
Now the helper is not a helper anymore. It is an unofficial policy engine with no name, no boundary, and no lifecycle.
This is a common senior-level code smell. The function name sounds innocent, but the function is carrying business authority. It decides what the system permits. It changes money, access, state, audit requirements, and support workflows.
That kind of logic needs ownership.
A better design is often not a bigger helper. It is a named policy or domain service that makes the decision explicit and returns useful meaning.
type CancellationDecision =
| { allowed: true }
| { allowed: false; reason: "PENDING_INVOICE"
| "ALREADY_CANCELLED" | "LOCKED_PLAN" };
function evaluateCancellation(request: CancellationRequest): CancellationDecision {
// policy lives here intentionally
}
The point is not the exact pattern. Some teams use domain services. Some use policy objects. Some use state machines. Some use command handlers. The shape depends on the codebase.
The important part is that the decision has a home.
When a rule affects money, permissions, state, compliance, or user trust, it should not be hiding inside a casual helper.
Takeaway: helpers reduce duplication, but they do not automatically create ownership.
3. The Same Rule Gets Rebuilt for Different Callers

The dirty pattern grows fastest when different callers need the same rule with slightly different context.
The frontend needs to know whether to show a button.
The backend needs to know whether to accept the request.
A background job needs to know whether to retry.
A webhook handler needs to know whether to apply an event.
An admin panel needs to know whether to bypass the normal flow.
A test needs to fake the decision.
Each caller adds its own version because each caller has a slightly different need.
That is how systems lose truth.
The frontend checks:
const showCancelButton =
subscription.status === "active" && !subscription.invoiceOpen;
The backend checks:
if (subscription.status !== "active") {
throw new Error("Cannot cancel subscription");
}
The webhook handler checks:
if (subscription.cancelledAt) return;
The admin panel checks:
if (user.isSupportAgent) allowCancel = true;
Individually, each decision makes sense. Together, they are a trap.
One screen hides the button, but the API allows the action. Another flow allows support to cancel, but the billing webhook reverses it later. A background job retries cancellation even after the subscription is locked. A test passes because it only covers one version of the rule.
This is not duplication in the beginner sense. It is semantic duplication. The same business meaning is being rebuilt using different fields, different assumptions, and different failure behavior.
That is much more dangerous than repeated code.
A better approach is to separate display decisions from authority decisions. The UI can ask for permission state. The backend remains the source of enforcement. Background jobs use the same transition rules. Admin overrides are explicit instead of hidden.
For example, an API can return a capability model:
{
"subscriptionId": "sub_123",
"status": "active",
"capabilities": {
"canCancel": true,
"canChangePlan": false,
"canRetryPayment": true
}
}
This does not mean the frontend becomes trusted. The backend must still enforce the rule. But the UI should not invent policy from raw status fields when the system already knows the answer.
The nuance matters. You do not need a capability object for every tiny feature. But when product rules become conditional and shared across clients, exposing intentional decisions is safer than forcing every caller to reverse-engineer them.
Takeaway: duplicated code is annoying. Duplicated meaning is dangerous.
4. Flags Turn Dirty Logic Into a Maze

Feature flags are valuable.
They also create some of the most confusing dirty code in modern systems.
A feature starts behind a flag:
if (flags.newBillingFlow) {
return runNewBillingFlow();
}
return runOldBillingFlow();
That is reasonable.
Then a region exception appears. Then enterprise customers are excluded. Then beta users get a special flow. Then old mobile clients need legacy behavior. Then the migration needs both flows to run in shadow mode. Then support needs a manual override.
The code becomes this:
if (
flags.newBillingFlow &&
user.region !== "EU" &&
!account.isEnterprise &&
client.version >= 42 &&
!subscription.isMigrating
) {
return runNewBillingFlow();
}
This still looks like one condition. It is not.
It is a release strategy, migration policy, customer segmentation rule, compatibility check, and operational safety switch compressed into one if.
That is dirty code wearing a feature flag costume.
The problem is not the existence of flags. The problem is leaving temporary rollout logic mixed with permanent business behavior. Over time, nobody knows which condition is still needed. Removing the wrong one feels risky, so the team leaves everything. New engineers copy the pattern because it seems official.
Flags become archaeology.
The better approach is to treat flags as lifecycle objects, not random booleans. A serious flag should have an owner, purpose, expected removal date, and known behavior. Release flags should not quietly become business rules. Business rules should not hide inside rollout checks.
Sometimes the cleanest move is to name the decision:
const billingFlow = resolveBillingFlow({
account,
user,
client,
subscription,
flags
});
Then put the ugly transition logic behind a boundary with tests that describe the matrix.
This does not make the complexity disappear. It makes the complexity visible.
That is the point.
A senior engineer does not pretend complexity is gone because it fits inside one condition. They ask whether the system can survive that condition changing later.
Takeaway: feature flags are not dirty by default, but unmanaged flags turn temporary decisions into permanent confusion.
5. Dirty Code Loves State That Is Not Modeled

Many codebases suffer because they treat state as loose strings and scattered timestamps instead of modeling the lifecycle properly.
A subscription might have fields like this:
{
"status": "active",
"cancelledAt": null,
"pausedAt": null,
"paymentFailedAt": "2026-06-10T12:00:00Z",
"migrationLocked": false
}
This looks normal.
But what states are actually possible?
Can something be active and paused? Can it have cancelledAt but still be active? Can payment be failed during a trial? Can migration lock a cancelled subscription? Which field wins when they disagree?
If the system does not model state clearly, every caller starts guessing. One service checks status. Another checks cancelledAt. Another checks paymentFailedAt. Another checks migrationLocked. Each one believes it is doing the safe thing.
That is how dirty code spreads.
The database stores facts, but the application lacks a state model. So every developer builds their own mental model locally.
The better approach is to make lifecycle transitions explicit. Sometimes that means a state machine. Sometimes it means stricter enums. Sometimes it means transition functions. Sometimes it means database constraints. Sometimes it means all of them.
The goal is not to make architecture fancy. The goal is to prevent impossible states from becoming normal production data.
For example:
type SubscriptionState =
| "trialing"
| "active"
| "past_due"
| "paused"
| "cancelled"
| "locked";
Then transitions should be intentional:
function transitionSubscription(
current: SubscriptionState,
event: SubscriptionEvent
): SubscriptionState {
// only legal transitions live here
}
This is not basic enum cleanup. It is system protection. When state is unclear, dirty code becomes inevitable because every part of the system needs defensive interpretation.
There is nuance. Not every workflow needs a formal state machine. But when the business has real lifecycle rules, hidden state logic inside controllers and jobs becomes expensive fast.
Takeaway: when state is not modeled, every developer becomes a state machine by accident.
6. Dirty Code Hides in Error Handling

Error handling is where many clean-looking systems reveal their real quality.
A team starts with simple errors:
throw new Error("Payment failed");
Then the frontend needs to show a message. Then support needs a reason. Then retries need to know whether the failure is temporary. Then logs need request IDs. Then billing needs to distinguish card decline from provider timeout. Then security needs to avoid leaking sensitive details.
Suddenly, "Payment failed" is not enough.
So developers patch around it.
One caller checks the error message text. Another caller maps provider codes directly. Another caller catches everything and retries. Another caller swallows the error because the user should not see it. Another caller logs the full provider response, including details that should not be in logs.
Dirty code loves vague errors because vague errors force every caller to invent meaning.
A better error model separates human messages from system decisions.
type PaymentError = {
code: "CARD_DECLINED" | "PROVIDER_TIMEOUT" | "INVALID_STATE";
retryable: boolean;
message: string;
requestId: string;
};
This gives different parts of the system what they need. The UI can show a safe message. The retry worker can check retryable. Support can use the request ID. Logs can group by code. Tests can assert behavior without parsing text.
The exact fields are not the religion here. Some teams use problem-details responses. Some use internal error classes. Some use structured API error envelopes. That is fine. The mistake is letting random strings become system contracts.
This is high-level dirty code because it usually appears only when systems integrate with payment providers, authentication services, external APIs, queues, and background jobs. Simple apps can survive vague errors. Real systems cannot.
Takeaway: text is for humans. Codes and structure are for systems.
7. Rebuilding Policy in Tests Makes the Mess Look Safe

Tests can accidentally protect dirty code.
A team writes tests for each behavior, but the tests duplicate the same assumptions as the implementation. The test says cancellation should fail when status is not active. The implementation checks status is active. Both are wrong because neither knows about pending invoices, locked migrations, or admin overrides.
The test passes because it shares the same incomplete mental model.
This is why dirty code can feel stable. The test suite creates confidence around a broken boundary.
The problem becomes worse when every caller has its own tests. The frontend tests button visibility. The API tests request rejection. The job tests retry behavior. The webhook tests idempotency. But no test describes the policy as a whole.
The system has many tests and no single truth.
That is not strong coverage. That is distributed guessing with assertions.
A better testing strategy identifies the policy boundary and tests it directly. If cancellation rules matter, test the cancellation decision or command handler as the source of truth. Then test callers for integration behavior, not for reinvented business rules.
For example:
expect(evaluateCancellation({
status: "active",
hasPendingInvoice: true,
actor: "customer"
})).toEqual({
allowed: false,
reason: "PENDING_INVOICE"
});
Then the API test only verifies that it uses the decision correctly.
This keeps tests from becoming another place where dirty policy is rebuilt.
There is nuance here too. End-to-end tests still matter. Integration tests still matter. The point is not to test only pure functions. The point is to avoid spreading business truth across test files the same way it is spread across production code.
Takeaway: a test suite can pass while protecting the wrong design.
8. The Pattern Survives Because It Feels Faster

Dirty code wins because it is locally faster.
It is faster to add one condition than to name a policy.
It is faster to copy a rule than to create a boundary.
It is faster to add a flag than to clean up a rollout.
It is faster to patch an error case than to design error semantics.
It is faster to handle state locally than to model lifecycle transitions.
And sometimes that is the right tradeoff. Shipping matters. Teams have deadlines. Not every rule deserves architecture. Not every duplicated condition is a disaster.
The senior mistake is not taking shortcuts. The senior mistake is forgetting which shortcuts are now carrying system meaning.
A local shortcut becomes dangerous when it is copied, depended on, exposed to clients, used in jobs, tied to money, or involved in security. At that point, it stops being local.
This is the difference between basic clean code advice and real engineering judgment. Basic advice says, “Don’t duplicate code.” Real judgment asks, “What kind of duplication is this, and what will break if two versions disagree?”
Duplicated formatting logic is annoying.
Duplicated permission logic is a security risk.
Duplicated billing rules are financial risk.
Duplicated retry rules can create incidents.
Duplicated state interpretation can corrupt data.
The better habit is to review shortcuts after they survive. A quick patch may be acceptable on day one. If the same logic appears in three places one month later, it is no longer a patch. It is an unnamed design.
Takeaway: dirty code survives because it feels cheaper than design, until the system starts charging interest.
9. Better Engineers Do Not Just Clean Code. They Protect Meaning.

The solution is not to make every codebase abstract.
That is another trap.
Some developers respond to messy policy by building giant frameworks, generic rule engines, overdesigned domain layers, and configuration systems nobody understands. That is not maturity. That is fear disguised as architecture.
The better move is more practical: protect meaning where meaning matters.
Start by identifying decisions that should not be scattered. Permissions. Billing eligibility. State transitions. Retry behavior. Error classification. Data visibility. Feature availability. Workflow progression. External provider handling. These are not just lines of code. They are system rules.
Then give those decisions a home.
That home may be a simple function, a policy module, a domain service, a command handler, a state machine, or a database constraint. The right answer depends on the size and risk of the rule.
The test is simple: when a new developer asks, “Where does the system decide this?” there should be a real answer.
Not five files.
Not “it depends.”
Not “check the frontend too.”
Not “there is a helper somewhere.”
A real answer.
This is what makes codebases easier to maintain. Not perfect architecture. Not fashionable patterns. Not endless refactors. Just fewer places where the same truth is rebuilt differently.
Better engineers understand that clean code is not only about how code looks. It is about whether the system has one reliable place to express important meaning.
That is why the dirty pattern keeps returning. Developers clean syntax but leave decisions scattered. They extract helpers but do not define ownership. They add tests but duplicate assumptions. They add flags but forget lifecycle. They model data but not state.
The code gets prettier.
The truth stays messy.
Conclusion
The dirty code pattern developers keep rebuilding is not a long method or a badly named variable.
It is scattered truth.
It is the same business rule hiding in helpers, controllers, jobs, UI checks, tests, flags, and webhook handlers. It looks harmless because each piece makes sense alone. It becomes painful because the system no longer has one answer to important questions.
Good engineering is not about removing every shortcut. It is about knowing which shortcuts have become policy.
Because the code that hurts teams most is rarely the code that looks ugly.
It is the code that quietly teaches the system to disagree with itself.
Call to Action
👏 Found it useful? Clap. 💬 Got thoughts? Comment. 🔔 Follow for more insights, practical lessons, and ideas that help you grow professionally and personally.
메타데이터
- post_id
- 462fd69024af
- slug
- the-dirty-code-pattern-developers-keep-rebuilding-462fd69024af
- url
- https://medium.com/skillstuff/the-dirty-code-pattern-developers-keep-rebuilding-462fd69024af
- canonical_url
- https://medium.com/skillstuff/the-dirty-code-pattern-developers-keep-rebuilding-462fd69024af
- author_url
- https://medium.com/@masaood
- status
- ok
- fetched_at
- 2026-07-08 16:17:31