Every engineer learns DRY in their first year: don't repeat yourself, extract the common code, keep one source of truth. It is good advice, and it is also incomplete. DRY has a price tag that nobody mentions when they teach it to you, and the price is not paid in code. It is paid in meetings, release coordination, and abstractions that slowly rot because five teams depend on them and none of them owns them.

This post is about the other side of the trade: the situations where copying the code is the cheaper option, how to recognize them, and what the decision actually depends on.

What DRY actually costs

Removing duplication means creating something shared. A shared function, a shared gem, a shared service. And the moment something is shared, everyone who uses it is coupled to everyone else who uses it.

Inside a single codebase owned by a single team, that coupling is nearly free. You change the shared method, run the tests, ship. The feedback loop is minutes.

Across team boundaries the same change becomes a negotiation. You need design alignment before you touch the shared code, reviews from people on other teams, a versioning story, and a plan for the consumer that cannot upgrade this quarter. The parts of a system that need everybody in the room do not get faster when you add people. They get slower.

That is the real trade behind DRY. Sharing buys you consistency and a single place to fix bugs. Duplication buys you autonomy and independent evolution, at the risk of the copies drifting apart.

Incidental vs inherent duplication

The second thing DRY folklore gets wrong is treating all duplication as the same. Two pieces of code that look identical on screen are not necessarily the same logic. Sometimes they just happen to look alike today.

Consider two validations in a Rails app:

class DiscountForm
  include ActiveModel::Model

  attr_accessor :percentage

  validates :percentage, numericality: { greater_than: 0 }
end

class ParcelForm
  include ActiveModel::Model

  attr_accessor :weight_kg

  validates :weight_kg, numericality: { greater_than: 0 }
end

Both say "the number must be positive". A DRY reflex says extract a shared PositiveNumberValidator and use it in both places. But the two rules exist for completely different reasons. The discount rule comes from the pricing team and will change the day marketing wants negative adjustments or percentage caps. The parcel rule comes from the shipping domain and will change when the carrier adds minimum billable weight. The code is identical by coincidence, not by design.

This is incidental duplication: same shape, different reasons. Merging it welds two unrelated domains together, and the weld shows the first time one side needs to change. You end up with a shared validator sprouting options like allow_negative_for_discounts: true, which is worse than the duplication ever was.

Inherent duplication is the opposite case: the same business rule genuinely written down twice. If the definition of "active subscriber" lives in three queries and they disagree, that is not autonomy, that is a bug factory. Inherent duplication is the kind DRY was invented for, and it deserves to be extracted.

Three questions help you tell them apart. Who owns each call site, one team or two? Is the reason behind the logic the same on both sides, or does it just produce the same code today? And can you imagine a realistic future where one side changes and the other does not? Different owners, different reasons, or plausible divergence all point at incidental duplication, and incidental duplication is usually better left alone.

Shared library or separate service: Which one to pick

Suppose two services, payments and notifications, both need rate limiting. The logic is genuinely the same rule, so copying it means fixing every bug twice. You have two classic ways to share it.

A library is the low-ceremony option. The fixed cost is small: a build, a version number, a package repository, some docs. A bug fixed in the library reaches every consumer at their next release, and the code is right there to read when something behaves strangely. The catch is that a library couples you at the dependency level. It locks consumers to a language, and it drags its own dependencies along. If the rate limiter gem pins one version of the Redis client and your service needs another, you now have a fight that has nothing to do with rate limiting.

A dedicated service flips the trade. Consumers call an HTTP or gRPC endpoint instead of importing code, so the contract is clean, the implementation language is irrelevant, and the team that owns it can deploy and scale it independently. In exchange you pay network latency on a hot path, and you inherit a whole operational surface: monitoring, alerting, on call, capacity planning, and the awkward question of what your payment flow does when the rate limiting service is down.

There is no universal winner here. The simpler and more stable the logic, the more a library is enough. The more complex and independent the domain, and the more polyglot the consumers, the more a service earns its operational cost. Rate limiting for two Ruby services is a gem. Rate limiting as a platform capability for forty services in five languages is a service.

When inheritance starts to push back

Say you deliver notifications over email and over push, and both channels buffer messages, retry failures, and send in batches. The obvious DRY move is a parent class:

class DeliveryChannel
  MAX_BUFFER = 500

  def initialize
    @buffer = []
  end

  def enqueue(message)
    raise BufferOverflow if @buffer.size >= MAX_BUFFER

    @buffer << message
    flush if @buffer.size >= batch_size
  end

  def flush
    with_retries { deliver_batch(@buffer.shift(batch_size)) }
  end
end

class EmailChannel < DeliveryChannel
  def batch_size = 50
  def deliver_batch(messages) = EmailGateway.send_bulk(messages)
end

class PushChannel < DeliveryChannel
  def batch_size = 200
  def deliver_batch(messages) = PushGateway.send_bulk(messages)
end

This looks great until the channels stop being the same. Marketing wants push campaigns with an unbounded buffer, while email must stay bounded because the gateway bills per queued message. Now MAX_BUFFER is wrong for one child, so you make it overridable. Then push needs backoff between batches and email does not, so with_retries grows a flag. Every divergence leaks one more channel specific detail into the parent, and after a year the base class is a museum of special cases that nobody can change safely, because every child depends on some corner of its behavior.

Composition handles divergence better because each concern is a separate piece you plug in:

class ChannelPipeline
  def initialize(buffer:, retry_policy:, transport:)
    @buffer = buffer
    @retry_policy = retry_policy
    @transport = transport
  end

  def enqueue(message)
    @buffer.push(message)
    flush if @buffer.full?
  end

  def flush
    @retry_policy.run { @transport.deliver(@buffer.drain) }
  end
end

email = ChannelPipeline.new(
  buffer: BoundedBuffer.new(limit: 500, batch: 50),
  retry_policy: SimpleRetry.new(attempts: 3),
  transport: EmailGateway.new
)

push = ChannelPipeline.new(
  buffer: UnboundedBuffer.new(batch: 200),
  retry_policy: BackoffRetry.new(attempts: 5),
  transport: PushGateway.new
)

Email keeps its bounded buffer, push gets its unbounded one, and neither decision contaminates the other. The cost is honest and visible: more classes, more wiring, more pieces the reader has to hold in their head. For two channels that really are identical, the base class was fine. Composition wins when the channels are similar but not the same, which is what most "similar" code turns into given enough time.

When to duplicate and when to share

The most useful fact in this whole trade is that the two mistakes are not equally expensive to undo. Merging two copies that turned out to be the same rule is a mechanical refactor: diff them, reconcile, extract, done in an afternoon. Splitting a shared abstraction that a dozen callers depend on is an archaeology project, because every caller has quietly grown to rely on some corner of its behavior.

So the practical rules of thumb look like this. Inside one codebase owned by one team, DRY almost always wins, because coordination is free and drift is real. Across team or service boundaries, duplication deserves the benefit of the doubt, because coordination is the dominant cost. Extract inherent duplication, the same business rule written twice, aggressively. Leave incidental duplication, same shape with different reasons, alone. And when you genuinely cannot tell which kind you are looking at, let the copies live a little longer. Either the third occurrence shows up and the right abstraction becomes obvious, or the copies diverge and prove they were never the same thing. Both outcomes beat committing to the wrong abstraction early.

Is duplicated code always bad?

No. Duplication is a cost, not a sin. It trades a single source of truth for independent evolution. Inside one team's codebase the trade is usually bad and DRY wins. Across team or service boundaries the trade is often good, because the alternative is coordination overhead on every change.

What is the rule of three in programming?

Wait until a piece of logic appears a third time before extracting an abstraction. Two occurrences can be coincidence; three give you real evidence about which parts are actually common and which only looked common. The third case is usually the one that breaks your assumed abstraction, and it is much cheaper to learn that before you extract.

Should microservices share code?

Share contracts and genuinely domain free utilities, and be suspicious of everything else. A shared library between services couples their dependency trees and release schedules, which is exactly the coupling microservices were supposed to remove. Copying a hundred lines of glue code into each service is often cheaper than maintaining a shared gem that every team must upgrade in lockstep.

Why is premature abstraction worse than duplication?

Because of the asymmetry in fixing each mistake. Copies that should have been shared can be merged with a mechanical refactor. A wrong abstraction with many callers must be carefully unpicked, caller by caller, while it keeps accumulating special case flags. The wrong abstraction also actively misleads readers about what the system means, which duplication never does.

Happy duplicating!