DRY, KISS, YAGNI

Intermediate · 11 min read · ▶ live playground · ✦ checkpoint

A configuration parser needed to read one JSON file. Before the first release, it had a plug-in registry, a table that finds named extensions at runtime, and two factory contracts—but no second parser ever arrived.

Three principles expose what went wrong. DRY (Don't Repeat Yourself) says each piece of knowledge—a rule or decision the system must keep consistent—should have one authoritative home. KISS (Keep It Simple) says to prefer the plainest design that meets the known requirement. YAGNI (You Aren't Gonna Need It) says not to build capabilities for imagined futures.

These principles do not always push in the same direction. A rushed attempt to remove two similar lines can create a shared abstraction that couples unrelated callers. That may look DRY while making the design less simple and preparing for flexibility nobody needs.

Before: machinery waits for a future that never comes

The parser tower has a contract for making contracts, a registry for finding factories, and a plug-in interface for a single JSON implementation. Each layer is defensible alone. Together, they solve requirements such as dynamic format discovery that the application does not have.

class diagram

Before: a plug-in tower for one JSON parser

Before: a plug-in tower for one JSON parser
  1. 1ConfigLoader accepts a format even though the application has only one.
  2. 2A registry performs runtime discovery for a choice that never varies at runtime.
  3. 3The registry returns a factory contract instead of the parser the loader needs.
  4. 4One default factory implements the extension point built for hypothetical formats.
  5. 5The factory promises a general parser, adding another hop before any text is read.
  6. 6At the bottom sits the only concrete behavior the application currently uses.

This design is not wrong because interfaces or factories are bad. It is wrong-sized for the evidence. Supporting a new format would still require discovering its real differences—fields, errors, defaults, and source—so the speculative contracts have not even guaranteed useful flexibility.

Premature abstraction is a shared mechanism created before the common rule and its change pattern are understood. It charges a cost now: more names, object wiring, navigation, and tests. YAGNI asks for evidence before paying that cost; KISS asks whether today's flow can say what it does more directly.

After: one direct parser owns one real rule

The application does have shared knowledge: command-line startup and web startup must interpret the same configuration fields in the same way. Those field and validation rules deserve one owner. The unused plug-in machinery does not.

class diagram

After: one parser, two honest callers

After: one parser, two honest callers
  1. 1JsonConfigParser is the authoritative home for the known JSON field rules.
  2. 2Command-line startup uses that rule instead of copying its own validation.
  3. 3Web startup shares the same knowledge without a registry or factory layer.

The plain lines match the stored parser fields: both startup paths keep one direct collaborator. No registry sits between a caller and the rule it needs.

This version applies all three principles together. DRY gives the field and validation rules one home. KISS leaves a direct call from each known client. YAGNI removes format selection until a real second format reveals what must vary.

type AppConfig = {
  port: number;
  logLevel: "info" | "debug";
};
 
class JsonConfigParser {
  public parse(text: string): AppConfig {
    const raw = JSON.parse(text) as {
      port?: unknown;
      logLevel?: unknown;
    };
 
    if (
      typeof raw.port !== "number" ||
      !Number.isInteger(raw.port) ||
      raw.port < 1
    ) {
      throw new Error("port must be a positive integer");
    }
 
    if (raw.logLevel !== "info" && raw.logLevel !== "debug") {
      throw new Error("logLevel must be info or debug");
    }
 
    return {
      port: raw.port,
      logLevel: raw.logLevel,
    };
  }
}
 
class CliStartup {
  private parser: JsonConfigParser;
 
  public constructor(parser: JsonConfigParser) {
    this.parser = parser;
  }
 
  public start(text: string): string {
    const config = this.parser.parse(text);
    return `CLI listening on ${config.port}`;
  }
}
 
class WebStartup {
  private parser: JsonConfigParser;
 
  public constructor(parser: JsonConfigParser) {
    this.parser = parser;
  }
 
  public start(text: string): string {
    const config = this.parser.parse(text);
    return `Web listening on ${config.port}`;
  }
}

If YAML becomes a confirmed requirement, you can compare its behavior with JSON and choose a seam using facts. Perhaps both share an AppConfig result but have different parsing errors. Perhaps the application only needs a one-time conversion before startup. The requirement, not the acronym, should choose the shape.

Similar text is not always shared knowledge

Suppose customer references and coupon codes both begin by trimming spaces and converting letters to uppercase. A developer extracts one helper. Later, promotions allow people to type spaces inside a coupon, while customer references must preserve every internal character.

The shared helper now needs a flag that only one caller understands:

function normalizeLookupKey(
  value: string,
  removeInnerSpaces: boolean,
): string {
  // smell: one helper knows both customer and promotion policies.
  const normalized = removeInnerSpaces
    ? value.replaceAll(" ", "")
    : value.trim();
  return normalized.toUpperCase();
}
 
function customerReference(value: string): string {
  return normalizeLookupKey(value, false);
}
 
function couponCode(value: string): string {
  return normalizeLookupKey(value, true);
}

The helper removed repeated characters but combined two sources of knowledge. The next coupon rule may add dashes; the next customer rule may validate a prefix. Each change reopens the same function and risks the other caller.

Coincidental duplication is code that has the same shape today but represents rules that change for different reasons. Keeping those rules separate is not a DRY failure. It preserves their independent meaning.

The KISS version gives each rule a direct name and lets a little text repeat:

function customerReference(value: string): string {
  return value.trim().toUpperCase();
}
 
function couponCode(value: string): string {
  return value.replaceAll(" ", "").toUpperCase();
}
 
console.log(`Customer: ${customerReference(" ac-19 ")}`);
console.log(`Coupon: ${couponCode(" save 20 ")}`);

Customer: AC-19 Coupon: SAVE20

The two functions are short, but length is not the reason to keep them apart. Their names identify different rules and their callers can now evolve independently. If both later must enforce the same company-wide character policy, that policy is shared knowledge and may earn a shared owner.

When not to merge repetition

DRY is about knowledge, not textual deduplication. Do not merge code merely because today's tokens match. First ask whether one business decision should change both callers at the same time. If the answer is no, a shared helper recreates coupling across boundaries you meant to keep separate.

KISS does not mean the fewest lines, and YAGNI does not forbid every extension point. A well-named validator shared by five inputs may be plainer than five copies. A second confirmed parser may justify an interface. Both principles ask you to match complexity to present evidence and to leave room for ordinary refactoring when evidence changes.

Checkpoint

Answer all three to mark this lesson complete

You can now tell shared knowledge from matching text and size a design for evidence you have. Next, the Law of Demeter narrows what one object should know about its neighbors.

+50 XP on completion