{"slug": "the-economic-benefit-of-refactoring", "title": "The Economic Benefit of Refactoring", "summary": "A Thoughtworks technologist built a 150,000-line application entirely with AI agents (Claude Code and Cursor) and then ran an experiment showing that refactoring a 17,155-line Rust data-access file reduced token consumption for subsequent changes. After four refactoring steps, input tokens per change dropped from 159,564 to 154,146 and output tokens rose slightly from 1,705 to 2,060, while the file shrank to 16,577 lines. The experiment demonstrates that investing tokens in refactoring agent-written code can lower future token costs.", "body_md": "# The Economic Benefit of Refactoring\n\nThis article is part of [“Exploring Gen\nAI”](../exploring-gen-ai.html). A series capturing Thoughtworks technologists' explorations of using gen ai technology for\nsoftware development.\n\n30 July 2026\n\nAs part of getting to grips with the new world of agentic engineering, I built an application to support my work. It’s a sophisticated app: high-quality web UI with dynamic refresh and look-up, modals and auto-save, integrations to external systems, machine learning and text analysis, background jobs, and a proper environment setup with fully automated deployment. It’s approximately 150,000 lines of code, primarily in Rust (~120 kLoC) with the remainder in TypeScript and Terraform.\n\nThis was entirely written by agents. Mostly Claude Code, and some use of Cursor. I didn’t read or review any of the code, except occasionally, out of interest.\n\nWhile building the application, I could see some things going awry. After watching an edit to line 4,000 of a file scroll by in the terminal, I had a closer look. The data access layer had grown to over 6,000 lines. As more features landed, this continued to grow. Every query, read or write, repeated the same HTTP request setup, the same JSON encoding and decoding. Eventually, it reached 17,155 lines. In a single Rust file.\n\n## An experiment in refactoring\n\nThe 17,155 line file was the entire data access layer. A single, self-contained module. Reviewing the code, there was no de-duplication, no internal language, limited extraction of functions, and very little extraction of classes. It did have a clear boundary with an interface to preserve. It was a great target for refactoring.\n\nThe goal of refactoring an agentic code base is to spend tokens now in refactoring to make token consumption for future work lower. An experiment should be able to show that as this file was refactored the token cost of making separate feature implementations in this code base would decrease.\n\nPrecisely because agents never learn this was now possible to run as an experiment. I could prompt a fresh agent to make exactly the same change after every refactoring stage. Unlike a human engineer, the experiment would not be tainted by learning from previous steps.\n\n- Create an overall refactoring plan, following strict refactoring discipline.\n- Craft a representative change, described in a single prompt.\n- Establish a baseline cost of change: in a sub-agent, execute that prompt, including asking the sub-agent to report token consumption.\n- Throw away the change.\n- In a loop:\n- Apply a single step of the overall refactoring.\n- In a sub-agent, execute\n*exactly*the same change receiving the token cost of the change. - Throw away the change.\n\n- Record all token costs, time to execute the change, and lines of code after each step of the refactoring, including the baseline.\n\nThe prompt used for the representative change and the refactoring steps applied are shown in the appendices, below.\n\nOne caveat: Claude doesn’t provide reliable methods for counting\ntokens live despite showing token counts, reporting tokens consumed\nper session, and **billing** for tokens. I’m assuming this is a\ntemporary issue that will improve over time. Instead, the sub-agent\nreported the number of characters received and sent and used\n[tiktoken](https://github.com/openai/tiktoken) to approximate tokens, by dividing character count by\nfour.\n\n## Results\n\n| Step | Data Access Layer LoC | Largest file LoC | Total Rust LoC | Input tokens per change | Output tokens per change | Time per change (s) |\n|---|---|---|---|---|---|---|\n| Baseline | 17,155 | 17,155 | 50,359 | 159,564 | 1,705 | 342 |\n| Step 1 (FirestoreClient) | 16,706 | 16,706 | 49,910 | 155,205 | 1,723 | 530 |\n| Step 2 (extract_doc_id, new_link) | 16,562 | 16,562 | 49,766 | 159,227 | 2,105 | 574 |\n| Step 3 (link-query helpers) | 16,567 | 16,567 | 49,771 | 154,054 | 2,105 | 524 |\n| Step 4 (FakeStore predicates) | 16,577 | 16,577 | 49,781 | 154,146 | 2,060 | 654 |\n| Step 5 (value ctors) | 16,469 | 16,469 | 49,673 | 171,251 | 2,036 | 1,353 |\n| Step 6 (FieldsBuilder) | 16,469 | 16,469 | 49,673 | 171,251 | 2,036 | 1,353 |\n| Step 7 (queries.rs) | 16,474 | 15,670 | 49,678 | 151,850 | 1,800 | 587 |\n| Step 8 (traits.rs) | 16,508 | 13,845 | 49,712 | 132,558 | 1,723 | 446 |\n| Step 9 (traits/ split) | 16,508 | 13,845 | 49,712 | 132,558 | 1,723 | 446 |\n| Step 10 (codec.rs) | 16,521 | 12,846 | 49,725 | 131,871 | 1,750 | 540 |\n| Step 11 (fake_store.rs) | 16,535 | 11,122 | 49,739 | 133,016 | 2,460 | 600 |\n| Step 12 (store/ split) | 16,550 | 9,269 | 49,754 | 104,080 | 2,050 | 490 |\n| Step 13 (co-locate tests) | 16,550 | 9,269 | 49,754 | 104,080 | 2,050 | 490 |\n| Step 14 (complete fake_store.rs) | 16,553 | 7,225 | 49,757 | 107,205 | 2,453 | 523 |\n| Step 15 (store/ split) | 16,608 | 3,695 | 49,812 | 27,360 | 2,113 | 454 |\n\nThe interesting metrics here are the total lines of code in the data\naccess layer, the total lines of code in the *largest single file* in\nthe data access layer and the input tokens consumed while producing\nthe change.\n\nThis chart shows four things. The first point is the baseline, step 0,\nand then the same metrics are repeated *after* each refactoring step\nhas been applied.\n\n- The total lines of code in the data access layer as a whole. Initially, this is just the single file I started with. This becomes many files as refactorings are applied. By the end there are 19 Rust files.\n- The lines of code in the single largest file in the data access layer. This started as the entirety of the data layer in the single initial file. By the end, the single largest file is a test library. Further refactoring passes could apply the same approach to this.\n- The total input tokens consumed by the sub-agent while applying the representative change.\n- The total output tokens produced by the sub-agent while applying the representative change.\n\n## Refactoring reduces token consumption\n\nThe results are clear. Input tokens stay fairly flat until the largest file starts to fall, and then they drop before, in the words of Claude, falling off a cliff.\n\nBetween the base line and the final refactoring, input tokens for the\nsame task reduced from **159,564** to **27,360**. A saving of\n**132,204** tokens, or **83%**. And that saving is not a one-off. Every single\nchange that touches the data access layer from this point forward now\ncosts significantly less.\n\nHow much of a saving? Assuming Sonnet 5 pricing at the time of writing\nof $3/MTok, 39.7 cents. Not a lot. Does it multiply? How will this\nplay out across debugging? More complicated features? This is\nrefactoring only one portion of the code base, can the whole code base\nbe aggressively refactored to find savings everywhere? How much would\nthose refactorings *cost?*\n\nThis saving is because the agent has to read less code. But it is not because there is less code to read. The overall code in the data access layer as a whole has stayed fairly constant. Therefore to be able to bank this saving, the agent must be able to successfully identify the smallest subset of files necessary to read. The results make it appear this was happening. Reading the Claude Code thinking output and file read summaries as the change was being applied also indicates the sub-agent was successfully reading smaller and smaller sections of code each time.\n\nIn other words, randomly cutting the file into smaller files is unlikely to help as much: even if each file were smaller, the agent would be forced to read through many files looking for the relevant code. While the step with the biggest effect happens at the end, the previous steps were refactorings to set up this saving. This was not planned. It was simply a result of how refactoring typically proceeds: local file changes to extract duplication, before breaking down into smaller files once a repeating core emerges.\n\nThe refactoring did not make the representative change smaller. The number of tokens produced when writing code was largely unaffected: the output tokens do not move very much. Those tokens are five times the price of the input tokens. But, there are a lot less of them. Are there refactorings that could be applied to reduce output token production? I need a more complex sample change to explore these questions. The noise of the non-deterministic code generation process is hiding any variance caused by changes in the factoring of the code.\n\n## Notes on the process\n\nClaude was not good at refactoring. If you read the prompt and the refactoring steps below, it’s clear that the refactorings produced were directly in response to the prompt. Claude is unable to look at code, look at refactorings in general and work out which are suitable to apply: a human needs to actively guide it. This marries with wider experience in this app. The development harness includes an explicit refactoring step. That refactoring step did not prompt Claude into improving this file. More anecdotally, Claude.ai was better than Claude Code. I used both interfaces to create the refactoring plan. Claude Code spotted extract function as the first step. Claude.ai went further and saw an entire client class to be extracted.\n\nIt was also bad at applying them. The mechanical act of refactoring was performed by writing Python scripts using grep and sed. These scripts frequently got confused by indentation. Oh, the irony. In addition, the single most valuable refactoring was missed in the first pass, and had to be re-applied as a follow-up step. This is why the number of steps in the figure don’t match the refactoring steps in the appendix.\n\nIt took about eight hours to complete the entire experiment. This was mostly unattended. The only intervention was after six hours 40 minutes when it appeared to have finished, but had skipped that step and needed to be redirected. This experiment was running on slow hotel WiFi. I wondered if that contributed to time taken. But on deeper analysis of the code base, the cargo temporary build cache had become very large. Test execution was suffering, significantly.\n\n## Further work and broader implications\n\nUnfortunately, it didn’t occur to me to perform a count of the tokens required to create and execute the refactoring plan until it was already complete. I’ve looked at my aggregate consumption across the time window where I was doing this work, including designing and running the experiment. I can’t say how many tokens were required to perform the refactoring. The upper bound is five million, however. This includes creating the refactoring plan twice, the work to design the experiment including the representative change, and various other tasks. Future work should include a more accurate count of tokens consumed to refactor.\n\nThis is just one experiment, on a significant application that is still greenfield and built and maintained by a single developer. But, I believe this is a potentially interesting first step. This effort shows the value, in time and money of refactoring. As well as measuring how expensive refactoring is. It would be interesting to look at more complex changes, at wider refactoring, refactoring continuously, and even the relative value of different refactoring approaches.\n\nThis is just the beginning.\n\n## Appendices\n\n*Note: These appendices include the prompts that I used, and the\noutput that was returned. The only editing applied has been to remove\nthe specific code changes to be made. These are included without\nediting to show how the agents were directed. There are no hidden\ntricks. As such, there is some language in here that might be\nconfusing. The error is in the original.*\n\n## The representative change\n\nThis is the recorded prompt that was fed to each sub-agent, there was no further context supplied other than the code base and accompanying architecture documentation. Every sub-agent was starting with exactly the same information.\n\nYou are working in the Rust project at\n\n`~/dev/your-project-name`\n\n.Add a new\n\n`ItemWatchStore`\n\npublic async trait to the Firestore layer, following existing patterns exactly. The trait must have three methods:\n\n`async fn watch_item(&self, item_id: &str, user_id: &str) -> Result<()>`\n\n`async fn unwatch_item(&self, item_id: &str, user_id: &str) -> Result<()>`\n\n`async fn watched_items_for_user(&self, user_id: &str) -> Result<Vec<String>>`\n\nWatches are stored in a\n\n`item_watches`\n\nFirestore collection. Each document has fields:`itemId`\n\n(string),`userId`\n\n(string),`createdAt`\n\n(timestamp). There is no Rust struct for a watch record — the methods return`Vec<String>`\n\n(item ids).Implement the trait for both\n\n`FakeStore`\n\n(using an in-memory`Vec<(String, String)>`\n\nfield added to`FakeStoreInner`\n\n) and`FirestoreStore`\n\n(using the same HTTP patterns used for other store impls in this file).\n\nAt the very end of your response, output exactly this JSON block (fill in real values):\n\n```\n{\n  \"files_read\": [\n    {\"path\": \"src/firestore.rs\", \"chars\": 123456},\n    ...\n  ],\n  \"response_chars\": 7890\n}\n```\n\nDo NOT commit the change. Stop after writing the code.\n\n## Refactoring steps\n\nThis is the prompt that was used to create the refactoring plan.\n\nFollowing the strict definition that a refactoring is a provably correctness preserving series of code edits, and using Martin Fowler’s 2nd edition of Refactoring as the source, examine @src/firestore.rs. This is a 17K LoC Rust file. No file should be that long. It is almost certainly not using an internal language to build and manage queries. Produce and describe, but don’t execute, a sequence of refactorings that would massively reduce the line count of that file, without changing the interface at all.\n\nFollowing is the description of the refactorings applied, extracted from the plan built and followed by Claude. The actual plan includes predicted code changes. For each refactoring, the individual steps to follow were listed. Each of those steps was individually testable, and was individually tested. This is a stricter refactoring than most human engineers would follow.\n\nThe steps listed here don’t line up directly with the measured changes above as Claude skipped the most valuable single refactoring (splitting out the store into sub-files) on the first pass and had to complete that afterwards as two additional steps.\n\n#### Step 1 — Extract Class: `FirestoreClient`\n\n(Fowler §7.5) + Extract Function × 4 (Fowler §6.1)\n\n**Fowler ref:** *Extract Class* (7.5); *Extract Function* (6.1) for\neach primitive\n\n`FirestoreStore`\n\ncurrently conflates two responsibilities:\n\n**Domain query orchestration**— which query to run, which documents to write, how to parse results into domain types** Firestore HTTP transport**— auth headers, URL construction, JSON encoding/decoding of Firestore wire types, retry-on-PRECONDITION_FAILED\n\nFowler §7.5 calls for extracting a new class when you can identify a\ncoherent subset of a class’s data and behaviour. The transport\nresponsibility owns: `client: reqwest::Client`\n\n, `project_id: String`\n\n,\n`MetadataAuth`\n\n, and `documents_url()`\n\n/ `auth_header()`\n\n. Extract these\ninto a new `FirestoreClient`\n\nstruct.\n\n**Estimated savings: ~1,200 lines in FirestoreStore impls;\nFirestoreClient adds ~120 lines net.**\n\n#### Step 2 — Extract Function: `extract_doc_id`\n\nand `new_link`\n\n(Fowler §6.1)\n\n**Fowler ref:** *Extract Function* (6.1)\n\n-\n— The expression`extract_doc_id`\n\n`doc.name.rsplit('/').next()?.to_string()`\n\nappears verbatim at the start of all 20`parse_*_document`\n\nfunctions. Extract it. -\n— Building a`new_link`\n\n`Link`\n\nstruct with`metadata: HashMap::new()`\n\nand`provenance: None`\n\nand a fresh UUID appears 62 times. Extract a factory function.\n\n**Estimated savings: ~500 lines** (62 × ~10-line structs → 62 ×\n~2-line calls; 20 parse functions each lose 1 line of boilerplate).\n\n#### Step 3 — Extract Function: link-query pipeline helpers (Fowler §6.1)\n\n**Fowler ref:** *Extract Function* (6.1)\n\nTwo sub-patterns recur inside the `FirestoreStore`\n\ntrait impls after running a link query:\n\n-\n**Pattern A — collect all link documents from query rows (~15 sites).** -\n**Pattern B — query links and return exactly one target ID, error if missing (~8 sites):**\n\n**Estimated savings: ~200 lines.**\n\n#### Step 4 — Extract Function: FakeStore link predicates on `FakeStoreInner`\n\n(Fowler §6.1)\n\n**Fowler ref:** *Extract Function* (6.1)\n\nInside the FakeStore impls, ~15 methods repeat variations of\n`inner.links.iter()...`\n\n.\n\nExtract two methods on `FakeStoreInner`\n\n. The 15 callsites then become\nsingle-line. Methods that additionally filter by a second predicate\n(e.g. also checking `to_kind`\n\n) chain `.into_iter().filter(…)`\n\non the\nresult of the helper.\n\n**Estimated savings: ~120 lines.**\n\n#### Step 5 — Replace Inline Code with Function Call × 4: Firestore value constructors\n\n**Fowler ref:** *Replace Inline Code with Function Call* (8.5)\n\nAdd four private free functions (file-level, not methods) before the\ncodec block. Replace all 128+ `json!({\"stringValue\": …})`\n\n/\n`json!({\"timestampValue\": …})`\n\netc. inline expressions with calls to\nthese functions. Each multi-word json macro call becomes a single\nshort call.\n\n**Estimated savings: ~80 lines (mostly from multi-line json macros collapsing to one-liners).**\n\n#### Step 6 — Extract Class: `FieldsBuilder`\n\n(Fowler §7.3)\n\n**Fowler ref:** *Extract Class* (7.3)\n\nThe ~20 encoder functions all follow this shape:\n\n``` js\nlet mut fields = serde_json::Map::new();\nfields.insert(\"foo\".to_string(), str_val(&x.foo));\nfields.insert(\"bar\".to_string(), ts_val(x.bar));\njson!({\"name\": path, \"fields\": fields})\n```\n\nExtract a small builder. Rewrite each encoder function to use the builder. A ~40-line encoder shrinks to ~12 lines.\n\n**Estimated savings: ~500–600 lines across the 20 encoder functions.**\n\n#### Step 7 — Move Function: extract `src/firestore/queries.rs`\n\n**Fowler ref:** *Move Function* (8.1)\n\nConvert `src/firestore.rs`\n\nto a module directory: rename to\n`src/firestore/mod.rs`\n\n. Then create `src/firestore/queries.rs`\n\nand\nmove all 32 `LinkQuery`\n\nconstants and the\n`LinkQuery`\n\n/`EqFilter`\n\n/`EqValue`\n\n/`Ordering`\n\n/`Direction`\n\ntype\ndefinitions into it. Add `pub(super) use queries::*;`\n\nin `mod.rs`\n\n.\n\nNo behaviour changes; all callsites already reference names that were in scope via the flat file.\n\n**Reduces mod.rs by ~800 lines.**\n\n#### Step 8 — Move Function: extract `src/firestore/traits.rs`\n\n**Fowler ref:** *Move Function* (8.1)\n\nMove all 17 `pub trait`\n\ndefinitions (and their associated error types)\nto `src/firestore/traits.rs`\n\n. Re-export them from `mod.rs`\n\nwith ```\npub\nuse traits::*;\n```\n\n.\n\n**Reduces mod.rs by ~1,900 lines. Produces a ~1,900-line traits.rs\nthat needs further decomposition.**\n\n#### Step 9 — Move Function: split `traits.rs`\n\ninto a `traits/`\n\nmodule directory\n\n**Fowler ref:** *Move Function* (8.1)\n\nConvert `src/firestore/traits.rs`\n\nto a module directory by grouping the 17 traits into four domain-aligned files:\n\n| File | Traits | Approx lines |\n|---|---|---|\n`traits/planning.rs` |\n`ConcentrationStore` , `GoalStore` , `ItemStore` , `NoteStore` , `PursuitStore` , `FocusPassStore` |\n~650 |\n`traits/content.rs` |\n`CaptureStore` , `TagStore` , `UrlReferenceStore` , `DocumentStore` , `PaperStore` |\n~550 |\n`traits/people.rs` |\n`ThoughtworkerStore` , `ExternalContactStore` , `CompanyStore` |\n~300 |\n`traits/system.rs` |\n`SessionState` , `LinkStore` , `SuggestionStore` , `SuggestionVetoStore` , `OAuthTokenStore` , `MigrationLedger` , `EmbeddingStore` , `RuntimeConfigStore` , `SalesforceSyncStateStore` |\n~400 |\n\n`traits/mod.rs`\n\nbecomes a pure re-export file (~20 lines). Associated\nerror types (`FocusPassError`\n\n, `SuggestionDecisionError`\n\n, etc.) move\nwith the trait that produces them.\n\n**No trait definition changes, no callsite changes — only\nrelocation. Each resulting file is 300–650 lines.**\n\n#### Step 10 — Move Function: extract `src/firestore/codec.rs`\n\n**Fowler ref:** *Move Function* (8.1)\n\nMove all document encoder/decoder functions (`*_document`\n\n,\n`parse_*_document`\n\n, `kind_str`\n\n, `parse_kind`\n\n, `parse_capture_source`\n\n,\n`parse_outcome`\n\n, etc.) plus `FieldsBuilder`\n\nand the value constructors\nfrom Steps 5 and 6 into `src/firestore/codec.rs`\n\n. Make them\n`pub(super)`\n\n.\n\nAfter Step 6 this module will be ~400–500 lines rather than ~1,200.\n\n**Reduces mod.rs by ~500 lines (post-Step-6).**\n\n#### Step 11 — Move Function: extract `src/firestore/fake_store.rs`\n\n**Fowler ref:** *Move Function* (8.1)\n\nMove `FakeStore`\n\n, `FakeStoreInner`\n\n, and all 18 trait impl blocks for\n`FakeStore`\n\ninto `src/firestore/fake_store.rs`\n\n. Re-export `FakeStore`\n\nfrom `mod.rs`\n\nwith `pub use fake_store::FakeStore;`\n\n.\n\n`FakeStoreInner`\n\nand helper methods stay private to the module.\n\n**Reduces mod.rs by ~4,700 lines.**\n\n#### Step 12 — Move Function: split `FirestoreStore`\n\nimpls into per-trait files under `src/firestore/store/`\n\n**Fowler ref:** *Move Function* (8.1)\n\nCreate `src/firestore/store/mod.rs`\n\nwith `FirestoreStore`\n\nstruct\ndefinition, `impl FirestoreStore`\n\n(constructor + `FirestoreClient`\n\nfrom Step 1), and `MetadataAuth`\n\n.\n\nThen create one file per logical domain grouping.\n\nEach file contains only `use super::*;`\n\n(or explicit imports) and the\ntrait impl block(s). No type definitions, no helpers. Helpers used by\nmultiple impl blocks stay in `store/mod.rs`\n\n.\n\n**Reduces what would be a ~10,000-line file into ten files of 120–650\nlines each. mod.rs becomes a ~100-line re-export manifest.**\n\n#### Step 13 — Move Function: co-locate tests with their modules\n\n**Fowler ref:** *Move Function* (8.1)\n\nThe existing `#[cfg(test)]`\n\nmodules test specific domain areas and\nbelong with the modules created in Step 12 rather than in a single\n`tests.rs`\n\n.Each test module moves inside a ```\n#[cfg(test)] mod tests { …\n}\n```\n\nblock at the bottom of the target file, with `use super::*;`\n\nto\naccess the module’s internals. No test is changed, only relocated.\n\nAny shared test fixtures (`FakeStore::new`\n\n, helper builders) that are\nalready in `fake_store.rs`\n\nare accessible via the existing ```\nuse\nsuper::fake_store::FakeStore\n```\n\nimport chain.\n\n**Reduces mod.rs by ~2,000 lines; each target file gains 200–700\nlines of tests that are directly adjacent to the code they exercise.**\n\nlatest article (Jul 30):\n\nprevious article:", "url": "https://wpnews.pro/news/the-economic-benefit-of-refactoring", "canonical_source": "https://martinfowler.com/articles/exploring-gen-ai/refactoring-economic-benefit.html", "published_at": "2026-07-30 15:10:27+00:00", "updated_at": "2026-07-30 15:22:24.561138+00:00", "lang": "en", "topics": ["artificial-intelligence", "generative-ai", "ai-agents", "developer-tools"], "entities": ["Thoughtworks", "Claude Code", "Cursor", "Rust", "TypeScript", "Terraform", "FirestoreClient", "tiktoken"], "alternates": {"html": "https://wpnews.pro/news/the-economic-benefit-of-refactoring", "markdown": "https://wpnews.pro/news/the-economic-benefit-of-refactoring.md", "text": "https://wpnews.pro/news/the-economic-benefit-of-refactoring.txt", "jsonld": "https://wpnews.pro/news/the-economic-benefit-of-refactoring.jsonld"}}