I'll Be Backend
Principles · Rust

YAGNI: delete speculative options

Delete the settings nobody uses, so each new column is a two-line change.

15 steps5 chapters~5 min at 1.25×#yagni#dead code#over-engineering#configuration
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Rust video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–10

Payoff

Step 11

Rust bonus

Steps 12–13

Recap

Steps 14–15

The code: before and after

Before

/* config.toml
[export]
format = "csv"
delimiter = ","
header = true
*/
pub struct ExportConfig {
    pub format: String,
    pub delimiter: String,
    pub header: bool,
}

pub struct OrderExport {
    format: String,
    delimiter: String,
    header: bool,
}

impl OrderExport {
    pub fn new(cfg: &ExportConfig) -> Self {
        OrderExport {
            format: cfg.format.clone(),
            delimiter: cfg.delimiter.clone(),
            header: cfg.header,
        }
    }

    pub fn export(&self, orders: &[Order]) -> Result<String, String> {
        let d = &self.delimiter;
        let mut out = String::new();
        if self.header && self.format == "csv" {
            out.push_str(&format!("id{d}total\n"));
        }
        for o in orders {
            let row = match self.format.as_str() {
                "csv" => format!("{}{d}{:.2}", o.id, o.total),
                "json" => format!(r#"{{"id":"{}","total":{:.2}}}"#,
                    o.id, o.total),
                "xml" => format!(r#"<order id="{}" total="{:.2}"/>"#,
                    o.id, o.total),
                other => return Err(format!("bad format {other}")),
            };
            out.push_str(&row);
            out.push('\n');
        }
        Ok(out)
    }
}

pub struct NightlyJob {
    config: ExportConfig,
    orders: Box<dyn OrderRepo>,
    warehouse: Box<dyn Warehouse>,
}

impl NightlyJob {
    pub fn run(&self) -> Result<(), String> {
        let today = self.orders.today();
        let csv = OrderExport::new(&self.config).export(&today)?;
        self.warehouse.upload_csv(&csv)
    }
}

After

/* config.toml
*/
pub fn export_orders(orders: &[Order]) -> String {
    let mut out = String::new();
    out.push_str("id,total,postalCode\n");
    for o in orders {
        let row = format!("{},{:.2},{}",
            o.id, o.total, o.postal_code);
        out.push_str(&row);
        out.push('\n');
    }
    out
}

pub struct NightlyJob {
    orders: Box<dyn OrderRepo>,
    warehouse: Box<dyn Warehouse>,
}

impl NightlyJob {
    pub fn run(&self) -> Result<(), String> {
        let today = self.orders.today();
        let csv = export_orders(&today);
        self.warehouse.upload_csv(&csv)
    }
}

Transcript, step by step

Problem

Step 1: An export with three settings Before

Every night, this job exports today's orders and uploads them to the warehouse. Order Export copies three settings from the export table in the config file, a format, a delimiter and a header flag. It writes a header line, then one row per order in whichever format the config names. It can write CSV, JSON or XML, so it looks ready for whatever the business asks for next.

Step 2: One column, four edits Code smell

The warehouse now wants the postal code as a third column. So the header gets a new name, and every arm of the match on format gets a new value. A speculative option is a setting built for a need nobody has yet, and this struct holds three of them. You can see what they cost here in the header, here in the CSV arm, here in JSON, and here in XML.

Step 3: Two formats nobody reads Code smell

Yet the config file only ever says CSV, and the only caller uploads CSV to the warehouse. So the JSON and XML arms never run, and nobody would notice if one of them broke. One new column costs four edits, and two of them land in formats nothing reads. A missing quote in the XML template would still compile, and it would sit there until someone finally turns XML on. And every option multiplies the paths a reader has to check before trusting a change.

Step 4: Back it out, then delete Plan

Let's back that change out. YAGNI, you aren't gonna need it, says you build a feature when someone needs it, and these two formats have nobody. Kent Beck put it as make the change easy, then make the easy change. So we delete the options nobody uses, one small step at a time, and the file the warehouse gets stays the same after each one.

Refactor

Step 5: Delete the XML arm Remove Dead Code

No config value picks XML and no caller reads it, so the XML arm goes first. The match keeps CSV, JSON and the catch-all arm, and the file the warehouse gets is the same as before.

Step 6: Delete the JSON arm Remove Dead Code

JSON goes for the same reason, so the match is down to the CSV arm and a catch-all. That catch-all only returns an error for a format the config file never sets.

Step 7: Format can only be csv Remove Field

With one arm left, format can only ever be CSV, so it stops being a field. The match collapses into the CSV row, and the header test only checks the header flag. No format can be unknown now, so export returns a plain String, and run no longer needs the question mark after it. Format leaves Export Config and the config file too, because nothing reads it anymore.

Step 8: The delimiter is always a comma Remove Field

The delimiter was a comma in the config and nowhere else, so we write the comma in place. The field goes, so new copies only the header flag, and Export Config and the config file lose their delimiter too.

Step 9: The header is always on Remove Field

Header is always true, so the path that skips the header line has never run. We delete the if, then the field, so Order Export and Export Config are both unit structs with nothing inside. New still takes the config, and it reads nothing from it. The config file is down to an export table with nothing in it.

Step 10: Nothing left to configure Remove Parameter

New took a config and read nothing from it, so it goes, and the Export Config struct goes with it. The nightly job calls export on the unit struct in place, and it drops its config field. And the config file at the top is empty.

Payoff

Step 11: The postal code, in two edits Payoff

Now the postal code goes back in. It takes one new name in the header and one new value in the row, so that's two edits. The JSON and XML arms that took two of the four edits are gone, and there's one row where there were three arms. YAGNI fit here because the config always said CSV and the only caller uploads CSV, so those options cost an edit on every change and gave nothing back. The pushes into out and the nightly job didn't change.

Rust bonus

Step 12: A struct with nothing to hold Code smell

With the settings gone, Order Export is a unit struct, and export takes a self that it never reads. So the job names an empty value only to call its one method on it.

Step 13: A function is enough Rust bonus

In Rust a function can live in the module with no type around it, so the unit struct and its impl become Export Orders. The body is the same lines, and the job calls the function with today's orders. If the export ever needs state again, like a setting that really varies, a struct can come back with it.

Recap

Step 14: Where we started, where we ended Before / after

On the left, three fields copied a format, a delimiter and a header flag out of the config. The match on format had three arms, and only the CSV one ever ran. On the right, there's one short function, and the config file is empty. It already writes the postal code, and it's still shorter than the match was.

Step 15: When to reach for it Takeaways

Delete an option when the config only ever sets one value and no caller reaches the other branches. Keep it when a second user is real and dated, like a signed contract with a JSON partner, or when ops set the value per environment, because deleting it means writing it again against a deadline. You'll spot it by config values that never change, match arms no caller reaches, and defaults that nobody overrides.