Maintenance and Efficiency
Refactoring: Changing Structure While Preserving the Behaviour That Matters
How to change the structure of code while preserving the behaviour people rely on, by naming the contract, gathering evidence, working in small steps and…
Imagine an agent proposes to "simplify" the booking permission check because the same conditions appear in two places. The code will get shorter, but will a guest still be able to cancel only their own booking? Will an administrator keep their separate permission? Will the system's response still be what clients expect? Without answers to those questions, fewer lines of code prove nothing.
Learning outcome. After this unit the reader can distinguish refactoring from a bug fix, a functional change, an architectural migration and a rewrite; name the contract and the evidence needed to change structure safely; spot a change in external behaviour that an agent has hidden inside "tidying up the code"; and choose between a local refactoring and a gradual replacement.
This unit builds on the material about verifying behaviour in Testing Fundamentals, Test Pyramid and Strategy, AI Code Review, Adversarial Review and Database Migrations, and on the discussion of the cost of future change in Technical Debt. The focus here is narrower: first state which behaviour must be preserved, then move the code in small steps. How to prepare a verified change for release is covered separately in Release Management.
What exactly we call refactoring
Martin Fowler defines refactoring as a change to the internal structure of a program that makes it easier to understand and cheaper to modify without changing its observable behaviour. When a team adds a new feature, fixes an incorrect result or changes how compatibility works, that may be necessary work, but it should not be called pure refactoring. Fowler, "Definition of Refactoring"
Observable behaviour is whatever a user, another component, an operator or an external system can notice and reasonably rely on. The contract may include:
- the values, shape and order of data;
- successful and error responses, the type of error and the condition under which it occurs;
- the application programming interface (API), and the format of requests and responses;
- identity and permission checks;
- the order of events and messages, retries and their side effects;
- time, memory or throughput costs that matter to consumers;
- operational signals, such as audit records that the on-call shift depends on.
Not every internal detail is part of the contract. The name of a local variable can change; so can the order of two calculations, provided it does not affect the result, errors, side effects or meaningful performance. But if a report, an operator or a neighbouring component consumes that "internal" effect, the team has to take it into account before treating it as invisible.
Refactoring improves the shape of the code while preserving the contract. A bug fix changes behaviour that the team considers wrong. A functional change adds or alters a capability. A migration moves an implementation, data or traffic to a different boundary or system. A rewrite builds a significant part of the system again from scratch. In a large piece of work these can be combined, but the team should name each one separately, show the evidence for each and agree the corresponding risk.
This separation lets you wear "one hat at a time": first make a small refactoring that preserves the contract, then add the new behaviour as a separate step. The metaphor is Fowler's, from his description of refactoring workflows: preserving behaviour and adding a feature have different criteria for success. When they are combined into one large, undifferentiated diff, it is harder for a reviewer to see whether the contract broke, whether the requirement changed, or whether both steps were done. Fowler, "Workflows of Refactoring"
First establish the contract and the reason for the change
"The code is ugly" or "the agent found a duplicate" is a reason to take a look, not a justification for changing the system. Start by stating a concrete goal. For example: the authorisation rule is currently duplicated and the copies have already drifted apart; a typical request requires changing four components at once; the module cannot be tested without running an external system; a needed feature cannot be added in a safe place.
Then establish what the implementation does today and what evidence you are relying on:
- Read the requirement or contract and identify who maintains it.
- Find all the callers, consumers, configuration switches, side effects and related messages.
- Review the existing checks, the change history and any incidents involving this behaviour.
- Draw up a short list of invariants: which values, errors, permissions and consequences must stay the same.
- Mark the unknowns. If the requirement, the boundary or a consumer is unknown, that is not permission for the agent to choose whatever behaviour suits it.
If the documentation is incomplete, characterisation tests can capture what the program does today. Michael Feathers describes them as a way first to understand the actual behaviour and then to detect an unexpected change after structural work. They do not establish that the captured behaviour is correct. Once it has been observed, the owner of the contract has to decide whether to keep it or change it separately. Feathers, as presented by Alberto Savoia, "Working Effectively With Characterization Tests"
This distinction matters for legacy systems. For example, an old function might return an empty string for a missing name. A test that records that result helps avoid changing it by accident. But only the owner of the product contract can say whether the empty string really has to stay, or whether it is a defect that should be fixed separately.
When the available specification is not enough, start with a small area and representative cases: a successful result, a refusal, a boundary value, an empty or missing field, and a retry if that matters. The choice depends on the contract. Automated checks are useful, but not all behaviour fits easily into a test: sometimes you need to inspect responses, check a report, measure latency or get the owner's confirmation. The mere fact that the whole existing test suite passed does not prove that it covered the invariant you need.
Make the change in small, verifiable steps
Once the invariants are defined, choose a local operation: rename a concept, extract a function, remove a duplicate, separate out a dependency, or move a responsibility across one clear boundary. Each step should be small enough that a reviewer can answer: what changed in the structure, what changed in the behaviour, and where exactly can that be seen?
For example, this duplicated condition:
if booking.owner_id == actor.id or actor.is_admin:
cancel(booking)
can first be extracted without changing the conditions:
def may_cancel(booking, actor):
return booking.owner_id == actor.id or actor.is_admin
The result of the call must stay the same for every meaningful case. Then both old locations start calling the shared function. If a new rule is added to that same function (for example, an administrator may no longer cancel a booking after it has been paid for), that is a change in behaviour. It must not slip into the refactoring unnoticed just because the condition has already been extracted.
The previous section helps you establish the actual contract before the change. For each step, choose evidence in proportion to the risk:
| What might change | Evidence needed |
|---|---|
| A function's output or error | Checks of expected values and edge cases |
| A permission or refusal | A positive and a negative scenario for each important role |
| An external schema or a neighbouring call | A contract check on both sides of the boundary |
| Data storage, retries or the order of events | A check of the integration flow, the sequence and repeated execution |
| Response time or resource use | A comparison under the same load and environment |
| A test helper or a test suite | A check that the test itself still notices an incorrect result |
| Operational behaviour | A check of logs, reports, signals and the recovery procedure |
When the team is changing the test helpers themselves, seeing a green result is not enough: an assertion can be removed from a check by accident. The Google Testing Blog describes a technique of confirming that deliberately wrong code makes the check fail as expected, and then restoring the correct code. This is an example for situations where a team is deliberately verifying that a test works, not a universal instruction to run broken code in production. Google Testing Blog, "Refactoring Tests in the Red"
The size of the diff is also part of the evidence. File renames, reformatting, a new feature and a structural move in a single change set obscure one another. If a large reformatting or a generator change is needed, make it a separate step, or show how the semantic change can be reviewed on its own. The agent should state the exact files and areas where it checked the contract, and the author should review the final diff at precisely that revision. Tools for agent-driven development, such as Codex, offer views of changed files, branch comparisons and line comments; these help direct attention to the diff, but the review interface itself does not prove that behaviour was preserved. OpenAI, "Mastering remote engineering work from your phone", 23 June 2026
Do not disguise an external change as an internal one
Moving a private function to another file usually does not change the API. Renaming a public field, changing an error code, removing a response attribute or changing a permission can. In the second case the team needs the same decisions as for any compatibility-sensitive change: who approves the contract, whether a transition period is needed, how long the old and new variants coexist, how consumers are notified, how the migration is measured, and how to recover.
A careful replacement often goes through these stages:
- Create a shared boundary or adapter that can call the old implementation.
- Connect the new implementation to that boundary without switching all the traffic.
- Test it on a controlled volume, or compare results where it is safe to do so.
- Switch calls over gradually, by consumer or by functional area.
- Keep the necessary compatibility and means of recovery until the data and dependencies confirm that the transition is complete.
- Remove the old implementation and the temporary layer once there is evidence they are no longer needed.
Fowler calls one such approach Branch by Abstraction: gradual switching by means of a shared abstraction. It lets you replace an implementation without a long-lived branch cut off from the rest of the system's changes, but it requires you to keep track of the temporary boundary and eventually remove it. Fowler, "Branch By Abstraction"
A transitional adapter is a temporary obligation with an owner and a removal criterion, not a new, free, permanent piece of architecture. Name the sign of completion in advance: the share of calls migrated, reconciled data, no calls to the old path over an agreed period. Otherwise the "temporary" layer may outlive both the old and the new implementation.
Modularity and cleanliness are trade-offs, not a universal reward
Less duplication is useful when the copies really implement one rule and have to change together. If code that looks similar expresses different policies, forcing it together creates a shared module with conditions for every case, a hidden dependency or overly broad access. Then structurally "less" code can mean more coupling.
In Software Architecture: The Hard Parts, the authors treat architectural decisions as trade-offs: coupling between components, module boundaries, data and migration conditions all have to be assessed in a specific context. Coupling can be static, through a shared build or shared code, or dynamic, through calls, messages and data at run time. Removing one form of coupling sometimes means adding another, such as a network call, synchronisation or duplicated data. So the goal is not "zero coupling" but a form of coupling that is understandable and acceptable for typical changes and for the characteristics the system needs. Ford, Richards, Sadalage and Dehghani, Software Architecture: The Hard Parts, 2021; publisher's page
This is consistent with Frederick Brooks: part of complexity is essential, arising from the problem itself and its concepts; the rest is accidental, arising from poor means of representation or needless obstacles. Brooks stresses that simplifying notation does not by itself remove the complexity of requirements, states and interactions. He also links structural complexity to the difficulty of seeing the whole and to a weakening of conceptual integrity: it becomes harder for the reader to see the system's unifying design. So refactoring should remove unnecessary complexity without erasing the differences between real domain rules. Brooks, "No Silver Bullet", University of North Carolina technical report, 1986
Brooks's "second-system effect" adds one more caution. After a first, constrained solution, an architect may try to pack every postponed idea into the next system, which then becomes excessively complex. For refactoring this means: do not turn a local function extraction into an excuse to add a universal rules engine, a new framework or the architecture of some imagined future platform. The Mythical Man-Month describes this effect in its own essay; the mere fact that something is a second generation is not evidence that it needs to be trimmed or rewritten. Brooks, The Mythical Man-Month, essay "The Second-System Effect"
Three worked cases
Case 1: two places check the right to cancel a booking
Situation. In a booking service, the web page and the support tool each have their own conditions for allowing a booking to be cancelled. During an earlier change the web page's check was updated but the support tool's check was forgotten: the first allows cancellation by the booking's owner and by an administrator, the second only by the owner. The owner of the behaviour confirms that the agreed rule is the same for both paths and includes the administrator. So the functional discrepancy must first be fixed separately; the refactoring itself is not where the decision about which rule is correct gets made.
Action. The owner of the behaviour states the invariants: an owner can cancel their own booking; an administrator can cancel someone else's; an unrelated guest is refused; a refusal does not create a cancellation record; the response and the audit log stay compatible. The team first fixes, and separately verifies, the support path that violated the agreed rule. Only once the behaviour has been aligned does the team record positive and negative examples and extract the shared permission decision into a function that both paths call. The refactoring below preserves the rule that has already been agreed; no new rule about payment is added to it.
After the separate behaviour fix, but immediately before the extraction, the same condition appears in each handler:
if booking.owner_id == actor.id or actor.is_admin:
cancel_booking(booking)
After the extraction, both paths use a single decision:
def may_cancel_booking(booking, actor):
return booking.owner_id == actor.id or actor.is_admin
if may_cancel_booking(booking, actor):
cancel_booking(booking)
The function has only one responsibility: deciding the permission. The order in which the cancellation is called and the audit record is created does not change.
Observable result. The separate fix first brings the support path into line with the agreed rule. The refactoring after it does not change behaviour: an owner can cancel a booking through either path; an administrator can cancel someone else's; an unrelated guest is refused; no cancellation is created for a refusal; the response and the audit do not change. A future change to the rule can now be made in one place. This result is confirmed by the corresponding checks of both paths and by the contract of the response and the audit record, not merely by a smaller diff.
Why this is refactoring. The extraction itself changes structure: after the separate fix, the repeated rule gets a single implementation, while the declared positive and negative scenarios stay constant. The earlier fix to the support path was a separate change in behaviour, because it removed the discrepancy that had been found. If the owner of the behaviour had confirmed that the rules were different, merging the checks would have been wrong, even if the functions looked identical from the outside.
Limits. The examples above are an invented teaching scenario, not a report about a specific product. In a real system you would also have to consider other permissions, a time window for cancellation, duplicate requests, logging and notifications. An unknown contract, or a missing check on one of the paths, is a reason to stop and gather evidence, not to assume the merge is safe.
Case 2: an agent "simplifies" the handling of a missing name
Situation. An agent changes how a profile response is built. The old code distinguishes a missing value from an empty string, but the agent treats them as equivalent:
nickname = user.nickname if user.nickname is not None else ""
The proposal replaces this with a shorter expression:
nickname = user.nickname or None
In Python, None, zero and empty sequences are false in a Boolean context, and the or operator returns one of its operands rather than just a Boolean true or false. So an empty string will be replaced by None. Official Python documentation, "Truth Value Testing" and "Boolean Operations"
Action. The reviewer compares three cases on the old and new code and reads the profile contract:
| Input value | Old code | Proposed code |
|---|---|---|
| "Maria" | "Maria" | "Maria" |
| empty string | empty string | None |
| missing value None | empty string | None |
In the serialised response None becomes the value null, not an empty string. The API consumer uses an empty string to mean "name not set yet" and handles null separately as "data incomplete". That means the response contract has changed. The team rejects the change as "pure refactoring" and asks the agent to restore the form that preserves the distinction:
nickname = user.nickname if user.nickname is not None else ""
Observable result. The current contract stays unchanged: an existing name is returned as is, an empty one stays empty, and a missing value becomes an empty string. After separate approval, the product owner may decide to normalise missing values to null, but then the specification, the checks and the consumers are changed as a separate, compatibility-managed change.
Why this is not refactoring. Shortening the line silently merged two different inputs into one and changed the external meaning of the response. Tests written only after the change, and based on the new code, could have "confirmed" exactly the agent's mistake. What you need first is the existing contract, the behaviour of the old version or an agreed decision by the owner.
Limits. This is a teaching example; it illustrates Python semantics and a possible contract, but it does not describe an existing API. In Python a class can also define its own truth value through special methods, so you cannot assume in general that every value other than None is true. Check the rules of the specific language and the specific contract.
Case 3: a one-shot rewrite of an integration versus gradual replacement
Situation. In a case described on Fowler's site, an organisation was replacing old integration middleware that carried events about the products in its shop. A one-shot cutover would have meant that every product had to switch paths at the same moment. The team had no evidence that all the hidden consumers and reports would behave the same after a full replacement.
Action. The team created an event router as a transparent structural layer: at first it simply passed messages on without changing observable behaviour. Then the team added routing by product type and by individual product, and gradually increased the share handled by the new system. This is a real example of gradual replacement from Ian Cartwright, Rob Horn and James Lewis's "Patterns of Legacy Displacement"; it is not a recipe for turning every system into a set of services. Cartwright, Horn and Lewis, "Patterns of Legacy Displacement"
Observable result and an unexpected discovery. Shortly after the new implementation was tested in production, the team discovered that important management reports did not reconcile. The middleware's database, which held the state of long-running transactions, was being copied to a data warehouse; batch jobs, stored procedures and report views used that data. The team added interception of sales messages and additional compatibility components that wrote the required data into the warehouse. The authors describe these as part of a temporary architecture and say they will be removed when that becomes possible; the article does not claim the removal has already happened. The approach was meant to reduce risk, but it cost extra work: it was a conscious trade-off, not a free simplification.
How to compare the options. A one-shot rewrite is shorter only on the plan diagram: before it is fully switched on, every consumer, report, data flow and recovery path has to be confirmed. The team compares the old and new routes on control sets: the same number of events for each product, their order, errors, redelivery, and the data the reports are built from; before switching it also checks that the old route is still available for recovery. Rolling back would require a fast reverse switch and compatible state. A gradual transition keeps two paths running for longer and needs a router, monitoring, switching criteria and a date for removing the transitional code. Before each expansion the team reconciles event counters, errors and key reports for the segment already migrated; a rollback affects the route for a specific group of products, unless the data requires a separate recovery. Each such step yields evidence at every stage and limits how much changes at once. If the system is small, the dependencies are known, and a full equivalent run and recovery have been tested, a one-shot switch can be acceptable. If the boundaries are hidden or the data cannot be rolled back instantly, a staged plan is needed.
Why this matters for refactoring. The first router created a structural boundary while preserving behaviour; the gradual transfer of flows was a migration. The article distinguishes this structural change from the migration that followed, and calls the additional compatibility components a temporary architecture that was planned to be removed when that became possible. The source does not confirm that the removal took place.
Limits. This is an account of one specific experience, published by the article's authors, not a universal report with a quantitatively controlled comparison of rewriting and migration. It shows a real kind of hidden dependency and the team's decisions; in your own project the scale, the data and the cost of the transitional layer have to be established separately.
An example from a real repository
In September 2025, the merged pull request (PR) #55732 in Rails removed repeated definitions of three helper methods in the tests for database tasks: one method had been defined six times, another three times and the third five times. The PR moved these definitions into a shared DatabaseTasksHelper module. Its description called this an improvement in readability and reuse, and recorded that it changed test helpers. Rails, PR #55732, 22 September 2025
This example shows how a bounded refactoring can be recognised from its diff: duplication in test code in one file was removed, rather than the application's executable code being rewritten. The fact that the PR was merged and passed three checks on GitHub confirms the history of this request, but it does not prove the behaviour of Rails as a whole or the quality of the tests in every case. The original PR #55713 contained both an optimisation of schema writing and the clean-up of test helpers. Its description says explicitly that the full helper clean-up was moved into a separate PR, #55732, so that it could be merged first; and that is what happened: #55732 was merged on 22 September and #55713 on 25 September 2025. After that, #55713 focused on avoiding writing the same schema repeatedly and on checks for that path. The author reported schema creation roughly 40% faster on one large application, with the caveat that the result depends on the size of the schema. That is a run-time optimisation with its own test and measurement, not evidence of pure refactoring. The history of the two PRs shows how adjacent goals can be split into separate, verifiable changes.
When not to start a refactoring now
Postponing a structural change is sometimes the responsible decision. Do not start one if:
- it is unknown who consumes the behaviour or which contract is in force;
- the problem rests only on the author's preference, with no measurable future benefit;
- there is no way to verify the required invariants, and unknown consequences could affect data, permissions or money;
- a critical release no longer has a window for verification and safe recovery;
- a "small clean-up" hides new behaviour, an API change or an architectural migration that has not been agreed;
- the proposal has grown into a full rewrite even though the stated problem is local;
- a new transitional adapter has no owner, no removal condition or no means of recovery;
- it is impossible to explain what exactly becomes cheaper after the change.
Such a pause must not turn into a silent accumulation of debt. Record the unknowns, the person responsible for checking them and the condition for returning to the question: for example, the next change to this area, the appearance of a known consumer, or an agreed release window. If the change is needed for a new request, justify it within the scope of that request rather than automatically widening it for the sake of a future hypothesis.
How to set boundaries for an agent
A refactoring task for an agent should describe the contract and the evidence, not just the desired shape of the code. Include in the request:
- the problem and the area it concerns;
- the invariants that must remain unchanged;
- positive and negative scenarios, including errors and boundary values;
- the permitted scope of files or components;
- the behavioural changes that are forbidden within the task;
- the checks and other evidence required;
- what the agent must show in its report: the exact diff, the files changed, the results of the checks and the unknowns.
Ask the agent first to find the call sites and the contract, and then to propose a bounded plan. Do not let it combine formatting, a dependency upgrade, a bug fix and a refactoring in one pass unless you have agreed each goal. Review the real diff after the agent's last change: a point-in-time answer may be stale if the code changed after it was written.
A reviewer can ask four direct questions:
- What has become structurally simpler, and for which typical future task?
- Which contract was in force before the change, and who confirmed it?
- Which evidence checks the positive, negative and boundary scenarios?
- Did even one external response, permission, event, piece of data or characteristic that a consumer relies on change?
If the answer to the fourth question is "yes", the change is not necessarily bad; just name it, agree the contract and add a migration plan. If the answer is "I don't know", that is an open risk, not evidence that behaviour was preserved.
Sources and further reading
Links, publication dates and current pages were checked on 25 September 2026. The first two worked cases are synthetic teaching scenarios; the third case and the real-repository example refer to published primary sources.
- Martin Fowler, "Definition of Refactoring", 1 September 2004 — the definition linking internal structure with preserving observable behaviour.
- Martin Fowler, "Workflows of Refactoring", 2014 — separating structural steps from adding features, the economic justification and long-running processes.
- Martin Fowler, "Branch By Abstraction", 2014 — a technique for replacing an implementation in stages through a shared boundary.
- Michael Feathers, as presented by Alberto Savoia, "Working Effectively With Characterization Tests", 9 March 2007 — observing and capturing current behaviour, and the difference between fact and correctness.
- Google Testing Blog, "TotT: Refactoring Tests in the Red", 26 April 2007 — checking that a change to test code has not removed effective assertions.
- Neal Ford, Mark Richards, Pramod Sadalage and Zhamak Dehghani, Software Architecture: The Hard Parts, 2021 — contextual analysis of modularity, coupling, migrations and architectural trade-offs; the publisher's page was checked on 10 October 2026.
- Frederick P. Brooks Jr., "No Silver Bullet: Essence and Accidents of Software Engineering", University of North Carolina technical report, September 1986 — the primary source for the distinction between essential and accidental complexity.
- Frederick P. Brooks Jr., The Mythical Man-Month, essay "The Second-System Effect", 1995 edition — a description of the risk of piling postponed ideas into an overly ambitious next system.
- Ian Cartwright, Rob Horn and James Lewis, "Patterns of Legacy Displacement", 2021, with page updates up to 2024 — real experience of staged replacement and of management reports' unexpected dependence on the old data store.
- The Rails team, PR #55732, merged 22 September 2025 — a concrete diff of a structural refactoring of test helpers; the PR's three checks passed, but that is not evidence of the behaviour of the whole application.
- The Rails team, PR #55713, merged 25 September 2025 — a separate change that reduced duplicated schema writes, with tests for a single write per identical path, path normalisation and the author's performance measurement; cited as an example of a separate optimisation goal, not as pure refactoring.
- Python Software Foundation, Python documentation: "Built-in Types — Truth Value Testing", current documentation page — the semantics of None, empty values and the Boolean
oroperator for the second teaching case. - OpenAI, "Mastering remote engineering work from your phone", 23 June 2026 — a description of diff review and comments on changes in Codex; used here only to explain the tooling workflow, not as evidence that code is correct.