Urban Renewal · Part 2 of 2

Urban Renewal #2: Tear Down or Retrofit? – The Bill for a Rewrite

With a coding agent, starting over has never looked cheaper, but the biggest item on the bill isn't written in the code. A blog that left WordPress and a PHP library whose upstream service changed: four questions behind the tear-down-or-retrofit call.

13 min read Software Architecture

This post is also available in Turkish →
Retrofitting instead of tearing down: a cutaway of an old, lived-in apartment block with steel jackets and braces on its columns, new plumbing inside the walls, and a rolled-up deed and spirit level on the site table.

Urban Renewal #1 ended with a map: eleven findings, an order to fix them in, and one row at the bottom of the table we deliberately put off. "http://, credentials in source, old SOAP address: with the tear-down-or-retrofit decision." We can't put that row off any longer, because the grid the building was wired to has changed. This chapter takes on the first big question in any legacy refactor: rewrite or refactor? Retrofit the old building, or tear it down and start again?

With a coding agent in the loop this question comes up more often than it used to. Starting over has never looked so cheap. Asking an agent for a clean class in an empty file takes less context than explaining a six-year-old file to it. The sticker price dropped. The bill didn't, because the biggest line on that bill isn't written in the code.

Here's the claim: the bill for starting over is everything the old code knew and never wrote down. The bill for retrofitting is everything the old code thinks it knows that is no longer true. I ran into both within a few weeks, in two different buildings.

A twenty-six-year-old warning#

The classic text on this is Joel Spolsky's Things You Should Never Do, Part I, dated 6 April 2000. He wrote it as Netscape 6.0 headed into its first public beta; there was never a 5.0, and 4.0 had shipped almost three years earlier. Spolsky calls the decision to rewrite the code from scratch the single worst strategic mistake a software company can make.

The core of his argument still holds. The odd-looking lines in old code are usually bug fixes, and each of those bugs, he says, took weeks of real-world use to find. A two-character fix can carry weeks of work behind it. Delete the code and you delete the work, and because the fix was never written down anywhere, you only learn it's gone when the bug comes back.

In Vibe Coder 5.1 we looked at Ward Cunningham's definition of technical debt, and he actually recommends paying off early shortcuts with a quick rewrite. The two don't contradict each other. Cunningham means an early rewrite, while the team still remembers what the code knows. Spolsky means tearing down a building that has accumulated years. The difference isn't the age of the building. It's whose head the building's knowledge lives in.

Two decisions under one roof#

Over the past five weeks this blog went through both.

Retrofit: in September we moved the server's PHP from 8.1 to 8.5 and wrote up the whole trip. The columns stayed, the code stayed. The bill for a four-version jump was a flood of deprecations, a logging trap, and a single # that fell off an .htaccess line.

Tear down: on 5 October 2026 the blog left WordPress. In its place came a framework-free platform in native PHP 8.3 and MariaDB. Posts are Markdown; publishing goes through a REST API and an MCP server opened up for agents. This draft was saved that way too.

Why wasn't a retrofit enough? Because the problem wasn't the columns, it was who the building was designed for. WordPress tries to meet the very different needs of millions of sites while keeping backward compatibility intact. Every user carries the weight of that effort: dependencies reaching outward, a codebase growing beyond my control, the bloat that years bring. My security concerns grow out of the same place. Clean up the theme, trim the plugins, and I'd still be permanently tied to someone else's code. I didn't want to be one more face in that crowd.

The second reason is more everyday. Even a small change meant coordinating several plugins, modules and the theme at once, like running an orchestra. I'm not a conductor. This blog exists to pass on the cases I run into in my daily work, and I can't give it my whole life the way a journalist would; life has to go on. What I wanted was a system that lets me sleep at night and simply flows: low upkeep, reliable, stable.

The line items#

The list in front of me on migration day was a small-scale rehearsal of Spolsky's warning. None of the items looked like a missing feature in the new platform. Every one was something the old system either handled on its own or never showed as a problem:

  • The numbers changed. Ninety-three posts and eight pages arrived with new IDs. Mapping old numbers to new ones meant trusting the slug. The field that carried translation pairs held a number that looked like the old post ID, but wasn't: it was the translation plugin's own group number.
  • Categories fell off. Sixteen posts arrived without a category, fifteen of them Vibe Coder chapters. In the new system a series and a category are separate things; being in a series doesn't put a post on a category page.
  • The ordering slipped. Some series positions came through scrambled; the index post had taken position "1".
  • Old addresses. Every URL search engines and other sites know has to keep living without colliding with the new routes. That's a title-deeds problem, and it gets its own chapter, number eight.
  • Filename collisions. The most instructive item. WordPress has a function that gives an uploaded file a collision-free name, wp_unique_filename(), since 2.5.0. I'd never given it a thought. On the new platform's first evening we thought a cover image had been deleted. The evidence said overwritten, not deleted: picking a name checked the disk, but there was a gap between picking the name and writing the file. Two uploads arriving together could get the same name.

The fix is to make the check and the create a single step. PHP's fopen() x mode does exactly that:

PHP
<?php
​
declare(strict_types=1);
​
/**
 * Finds a free filename in a directory and claims it immediately.
 * Mode "x" refuses to open a file that already exists, so check and create
 * are one step: two simultaneous uploads cannot get the same name.
 */
function claimFreeName(string $dir, string $stem, string $ext): string
{
    for ($n = 0; $n < 100; $n++) {
        $name = $n === 0 ? "{$stem}.{$ext}" : "{$stem}-{$n}.{$ext}";
        $handle = @fopen("{$dir}/{$name}", 'xb'); // false if it exists: "File exists"
​
        if ($handle !== false) {
            fclose($handle);
            return $name; // the name is ours now; write the contents afterwards
        }
    }
​
    throw new RuntimeException("No free name for {$stem}.{$ext}");
}

Called three times on the same directory it returns kapak.webp, kapak-1.webp, kapak-2.webp; an fopen() on a taken name says "Failed to open stream: File exists" and returns false.

Hard truth: if we'd told an agent "write a blog to replace WordPress", it couldn't have produced this list either. The list wasn't in the code, it was in the old system's behaviour. You don't find it by reading. You find it by bumping into it during the move, or by writing the behaviour down before you move.

Building two: the grid changed#

Back to Altınkaynak. In #1 we saw that the SOAP address the library talks to no longer answers, and that Altınkaynak's services page now lists just two JSON endpoints. This morning (29 October 2026, the 11:33 update) I looked at both from a browser. The field names are the same: Kod, Aciklama, Alis, Satis, GuncellenmeZamani, plus a new IsMobile. The currency endpoint returns 14 items, the gold endpoint 53.

This is exactly where "same field names, just swap the URL" gets said. Three details break it.

1. The dot changed meaning. In the sample output of the 2020 post (in Turkish), the buy price for wholesale fine gold was 464.14: the dot is a decimal separator. Today the same code (HH_T) returns "6.450", and that isn't six point four five. It's six thousand four hundred and fifty lira. The dot is now a thousands separator. On the currency endpoint the comma is the decimal ("49,794"), gold items carry no decimals at all ("6.450", "3.208.928"), and silver has both ("19.908,45").

2. One method has no counterpart. The library has three public methods: GetCurrency, GetGold, GetMain. The new service has two endpoints. GetMain, the "most used gold and currencies" list, has nothing to map to.

3. A code changed. In the 2020 output the ounce was ONS. The new gold endpoint calls it XAUUSD. A caller looking for ONS gets an empty result, not an error.

Now let's try the fix that looks most reasonable to an agent, or to us on a tired evening. The old code uses floatval(); swapping the comma for a dot seems like enough. With this morning's real values, on PHP 8.3.6:

Item From the service floatval() Comma to dot Correct
USD buy 49,794 49 49.794 49.794
JPY buy 0,3110 0 0.311 0.3110
Fine gold buy 6.450 6.45 6.45 6450
500 g gold buy 3.208.928 3.208 3.208 3208928
250 g silver buy 19.908,45 19.908 19.908 19908.45

The reasonable fix rescues the currencies and makes gold and silver a thousand times cheaper. No error, no warning, no tests. In #1 we talked about "code that runs but prints the wrong price". This is worse: half of it looks fixed, so nobody looks twice.

The right move is to recognise the Turkish number format explicitly, and to fail loudly on anything it doesn't recognise:

PHP
<?php
​
declare(strict_types=1);
​
/**
 * Turns a price string from Altınkaynak's JSON endpoints into a decimal string.
 * Turkish format: dot for thousands, comma for decimals. "6.450" means six thousand four hundred fifty.
 * Deliberately not a float; do money maths with bcmath or integer kuruş.
 */
function parsePrice(string $text): string
{
    $text = trim($text);
​
    if (preg_match('/^(?:\d{1,3}(?:\.\d{3})+|\d+)(?:,\d+)?$/', $text) !== 1) {
        throw new UnexpectedValueException("Unexpected price format: '{$text}'");
    }
​
    return str_replace(['.', ','], ['', '.'], $text);
}

The last column of the table is this function's output. parsePrice('6.45') throws Unexpected price format: '6.45'; if the service changes format one day, it shouts instead of quietly printing a wrong price. Its limit is just as clear: a source that sends "6.450" in English format, meaning six point four five, will fool it. The format is part of the contract between us and the service, and it needs to be written down somewhere outside the code.

The decision table: retrofit or tear down?#

Put the two buildings side by side and these are the questions:

Question Points to retrofit Points to tear down Blog Altınkaynak
Is the foundation sound? (language, runtime, dependencies) Yes Rotten, or the direction changed Dependencies and upkeep php -l clean on PHP 8.3
Who holds the deeds? (whatever depends on you from outside) Many callers, a public contract Small contract, fully yours Old URLs, RSS, search engines Three public methods, the Currency object and its five fields
Is the old behaviour written down? No: write it down first Yes: tests, dumps, traffic records It wasn't; the line items came from here No; not a single test in the repo
What is changing? The plumbing The load-bearing structure Load-bearing structure Plumbing: SOAP out, JSON in

For Altınkaynak the answer is retrofit. The foundation is sound and what's changing is the plumbing. The most valuable thing to protect is the deeds: code that uses this library calls GetCurrency() and reads $Currency->buying, and any change that touches that is kicking in a tenant's door. The plan: a layer that reads JSON replaces the SOAP layer, and the public methods stay put. GetMain() is a contract with nothing behind it; rather than stuffing it with an invented list, it's more honest to deprecate it explicitly and throw a meaningful exception. A mapping goes in between ONS and XAUUSD. The type of buying and selling is the one item that changes the deeds: leave it a float and the money bug lives on, make it a string and callers may break. So that change should arrive with a major version bump and a written note.

A one-month-old system can't tell you whether tearing the blog down was the right call. What I can tell you is where the bill came from: every line item came out of the table's third row, behaviour nobody had written down.

How to get an agent to weigh in#

On this question the agent isn't a neutral consultant. It has two jobs to choose from: understand a six-year-old file, or write a clean class in an empty one. The second looks shorter and safer to it, because as long as it can't see what the old code knows, it can't see that knowledge being lost either. So we don't ask it "should we rewrite?". We ask for the inputs to the decision.

The task below is written to run in the read-only modes from #1: plan mode in Claude Code, --sandbox read-only in Codex, Ask in Cursor, the Plan agent in OpenCode. The differences between those modes and the disposable-copy rule are in #1.

Markdown
Task: Prepare input for a "rewrite from scratch or refactor step by step" decision
on this repository. Do not make the decision. Do not change any file, make no
network requests, write the report only in this chat.

1. Contract inventory: every public class, method, field, URL and command called
   from outside. For each: who might be calling it, how would we notice if it broke.
2. Implicit behaviour inventory: every line that looks odd but might be a bug fix
   (special cases, magic numbers, silently swallowed errors, format conversions).
   For each: file:line and "why might this be here".
3. External assumptions: services, data formats, locale, time zone, character
   encoding. Which can be read from the code, which only show up at runtime?
4. The five items most likely to be lost in a rewrite, with reasons.
​
Final section: questions you cannot answer by reading and need to ask me.

For Altınkaynak, item three alone sets the direction of the decision. The number format can't be read from the code; it only shows up when you look at the service. A good agent puts it under "what I couldn't learn by reading". A bad brief never asks for that section. I did not run this task inside any agent for this post; running the same task across different ecosystems and comparing the results is the job of chapter four, Illegal Floor.

When is tearing down right, and when isn't it?#

Right: when the foundation really is rotten, meaning a platform out of support or dependencies that can no longer be installed. When the contract is small and entirely in your hands. When you can write the old behaviour down first, with tests, dumps or recorded real traffic. Written-down behaviour is the photocopy of the deeds you take before the demolition.

Wrong: when the only reason is "the code is ugly". When nobody has written the old behaviour down and nobody will make time to. Because an agent suggested writing the new one before understanding the old.

Retrofitting can be wrong too. Holding up a building whose load-bearing structure needs replacing with a fresh coat of paint is just deferring the bill; Cunningham's interest keeps running.

Deeds stay, plumbing goes#

We didn't drive a single nail into the building this chapter either, but the two JSON responses pulled this morning are the series' first concrete material. In the next chapter, Urban Renewal #3: Scaffolding – Freeze the Behaviour, Then Touch It, we'll pin those responses down and turn the library's current behaviour, bugs included, into tests. The plumbing only comes out once the scaffolding is up. All chapters live on the Urban Renewal series page.

Demolition takes a day. If you don't learn what the old building knew before you knock it down, you'll learn it afterwards, from the error logs. Stay with the technology.

Comments

0 comments

Leave a comment

Replying to

Comments appear after approval. Your email address is never shared. How is your personal data processed?