YAGNI: delete speculative options
Delete the settings nobody uses, so each new column is a two-line change.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Steps 5–9Payoff
Step 10Recap
Steps 11–12The code: before and after
Before
/* config/app.php
return [
'export' => [
'format' => 'csv',
'delimiter' => ',',
'header' => true,
],
];
*/
final readonly class ExportConfig
{
public function __construct(
public string $format,
public string $delimiter,
public bool $header,
) {}
}
final class OrderExport
{
private string $format;
private string $delimiter;
private bool $header;
public function __construct(ExportConfig $cfg)
{
$this->format = $cfg->format;
$this->delimiter = $cfg->delimiter;
$this->header = $cfg->header;
}
/** @param list<Order> $orders */
public function export(array $orders): string
{
$out = '';
if ($this->header && $this->format === 'csv') {
$out .= 'id' . $this->delimiter . "total\n";
}
foreach ($orders as $o) {
$row = match ($this->format) {
'csv' => $o->id . $this->delimiter . $o->total,
'json' => '{"id":"' . $o->id
. '","total":' . $o->total . '}',
'xml' => '<order id="' . $o->id
. '" total="' . $o->total . '"/>',
default => throw new InvalidArgumentException($this->format),
};
$out .= $row . "\n";
}
return $out;
}
}
final class NightlyJob
{
public function __construct(
private ExportConfig $config,
private OrderRepo $orders,
private Warehouse $warehouse,
) {}
public function run(): void
{
$export = new OrderExport($this->config);
$csv = $export->export($this->orders->today());
$this->warehouse->uploadCsv($csv);
}
}After
/* config/app.php
return [
];
*/
final class OrderExport
{
/** @param list<Order> $orders */
public function export(array $orders): string
{
$out = "id,total,postalCode\n";
foreach ($orders as $o) {
$row = $o->id . ',' . $o->total
. ',' . $o->postalCode;
$out .= $row . "\n";
}
return $out;
}
}
final class NightlyJob
{
public function __construct(
private OrderRepo $orders,
private Warehouse $warehouse,
) {}
public function run(): void
{
$export = new OrderExport();
$csv = $export->export($this->orders->today());
$this->warehouse->uploadCsv($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 config file at the top, 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 format match gets a new value. A speculative option is a setting built for a need nobody has yet, and this class copies 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 typo in the XML quotes would sit there unnoticed 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 still has CSV, JSON and the default 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 default. That default only throws 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. Format leaves the config file and Export Config 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, along with its line in the constructor, its Export Config property and its line in the config file.
Step 9: The header is always on Remove Field, Parameter
Header is always true, so the path that skips the header line has never run. We delete the if, so the output starts with the header, and the header field goes because nothing reads it anymore. That leaves a constructor that reads nothing from its config, and PHPStan rejects an unused parameter, so it goes in the same step along with the Export Config class. The nightly job builds Order Export with no arguments now, and it drops its config property. And the config file at the top returns an empty array.
Payoff
Step 10: 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 append, the return and the nightly job didn't change.
Recap
Step 11: 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 format match had three arms, and only the CSV one ever ran. On the right, there's one short method, and the config file returns an empty array. It already writes the postal code, and it's still shorter than the match was.
Step 12: 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.