You don’t “fail fast” by abstracting early—you fail later, with interest. Premature generalization feels clean in the moment, but it quietly welds together parts of your system that haven’t earned a relationship yet. The result is a codebase that’s technically elegant and practically unchangeable.

The fix is not to avoid abstraction forever. It’s to delay it—until you’ve earned it. The rule of three is simple: write the code three times before you generalize. By then, you understand the variation space, and your abstraction has a fighting chance of being the right one instead of the most confident wrong thing.

The Real Enemy Isn’t Duplication—It’s Premature Certainty

Most developers treat duplication as a moral failing. That’s backwards. Duplication is often the symptom of a deeper truth: you don’t yet know what should vary.

When you extract too early, you’re not “DRYing up.” You’re making bets about future requirements using today’s assumptions. Those assumptions become constraints. And constraints become coupling. The coupling doesn’t always show up as bugs right away; it shows up as refactoring friction later, when you need to change one case and suddenly the “shared” code won’t fit.

A practical way to frame it: if you can’t explain, in plain language, what might differ between the cases you’re about to unify, you’re not ready to abstract. You’re ready to copy, at least temporarily.

Why “Second Time” Abstractions Create Coupling You Can’t Undo

The second time you encounter a pattern, you feel a little spark of “I’ve seen this before.” That’s the exact moment to resist.

Here’s a common scenario. You have two flows that both “process orders,” and they look similar enough to tempt you into a shared function:

  • Flow A: validates input, calculates totals, applies discounts, then writes an invoice.
  • Flow B: validates input differently, calculates totals with tax rules, may apply promotions, then writes a receipt.

On the surface, both flows perform “order processing.” If you abstract on the second occurrence, you’ll likely create an interface that works for A and B as you understand them today. The moment you add Flow C—say, for a subscription or a marketplace order—you discover the abstraction’s seams were in the wrong places. Now the shared function has become a funnel of conditional logic, special-case flags, or a sprawling parameter list that no one wants to call.

At that point, you don’t refactor an abstraction—you patch it. And patching abstractions is how teams end up with frameworks that only the original author can safely change.

Duplication, by contrast, is honest. When requirements diverge, the duplicated code can diverge too—without forcing everyone through the same narrow abstraction.

The Rule of Three: A Scheduling Strategy for Abstractions

The rule of three isn’t a religious commandment. It’s a workflow rule.

When you see repeated code, don’t immediately extract. Instead:

  1. Duplicate once more on purpose. Keep the code separate.
  2. Observe the differences across occurrences. Names, types, branching, side effects, performance needs—anything that forces divergence matters.
  3. Only then generalize. After you’ve written three versions and felt the shape of variation, extract the stable idea.

Why three? Because the first two instances often share a superficial similarity. The third usually reveals what was missing—what changes, what doesn’t, and what you were about to force into a one-size-fits-all mold.

Think of it like building a model. Two data points let you draw a line. Three points help you identify curvature. In software, the “curvature” is where business logic and system constraints start refusing to conform to your early abstractions.

Concrete Example: “Common” Validation Is Usually a Trap

Let’s say you’re building an API and you keep writing validation code:

  • Endpoint /users validates required fields, checks email format, and ensures username uniqueness.
  • Endpoint /admins validates required fields, checks email format, and enforces a different uniqueness rule.
  • Endpoint /invites validates required fields, checks email format, but also validates token expiry and rate limits.

If you abstract after the second endpoint, you might build something like:

  • validateUserCommon(data, rules, uniquenessService)

It will look great until /invites shows up. Now your “common” validator needs to understand token semantics and rate limiting, which are not user semantics. So you extend the interface with more parameters. Then you add optional callbacks. Then you sprinkle conditional branches. Eventually your abstraction becomes an orchestration layer with flags like isInvite and enforceRateLimit.

You’ve turned duplication into a framework. It’s not DRY—it’s DRY-ISH, and it’s fragile.

If you waited, you could identify the real stable core: “validate fields and normalize errors,” while allowing the rest to plug in. After three occurrences, you can design a clean boundary:

  • shared normalization and error formatting
  • separate strategy objects (or functions) for domain-specific checks

The abstraction becomes smaller and more correct, because it’s based on what truly varies—not on the similarity you observed too early.

When Duplication Is the Better Engineering Trade

It’s worth stating plainly: duplication is not inherently bad. The goal is not to eliminate repeated lines—it’s to manage change.

Duplication can be cheaper because:

  • It isolates blast radius. Changing one variant doesn’t force every caller through the same contract.
  • It preserves local intent. Readers see domain logic directly, rather than decoding a generic “framework” layer.
  • It makes variation visible. Differences become obvious instead of hidden behind abstraction parameters.

The trick is to be disciplined about the kind of duplication you tolerate. There’s a difference between “copying with care” and “copy-pasting chaos.”

A pragmatic guideline:

  • Duplicate logic that is likely to diverge.
  • Abstract mechanics that are clearly stable and well-understood (e.g., pure formatting utilities, trivial adapters, obvious invariants).
  • Don’t abstract business rules until you’ve seen them collide across multiple real cases.

In other words: it’s okay to repeat yourself while your understanding is still forming. It’s not okay to repeat unclear intent forever.

What “Write Three Times” Looks Like in Practice

You need a method that doesn’t collapse under its own effort. Here’s a pattern teams can adopt:

  • Create a tiny “compare window.” Keep the duplicated code close in your repository (same module or feature folder) so differences are easy to spot.
  • Name the duplicates as if they’re temporary. For example, validateUser_v1, validateUser_v2 feels awkward, but comments and commit history can serve the purpose. The point is to signal intent: “we’re learning.”
  • Add targeted tests for each occurrence. As you write the second and third versions, tests teach you what the system truly needs.
  • After the third, write down the shared intent. Ask: What concept repeats? What varies? What would an interface need to expose? If you can’t answer, you’re not ready.
  • Refactor only once. When you extract, do it decisively. Partial abstractions that half-bind everything are the worst of both worlds.

Also, be honest about scope. This rule is most valuable in the parts of your code where domain logic and requirements churn—request handling, business workflows, integrations, pricing rules, permissions. In low-change utilities, abstractions may be fine earlier because the variation space is smaller and more predictable.

Conclusion: Earn Abstractions, Don’t Claim Them

Premature abstraction is the architectural version of a false positive: it looks like progress, but it smuggles in assumptions that will cost you later. The rule of three keeps your codebase honest by delaying generalization until you’ve actually observed the variation space.

Duplicate with intent, learn through repetition, then abstract once the pattern is real. Your future self will thank you—not with compliments, but with the rarest gift in engineering: refactoring that doesn’t turn into surgery.