Contract changes
Changing a signature is not editing a function. It is editing everything that agreed to it — callers,
overrides, mocks, fixtures, serialised payloads, documentation — and the ones you do not find are the ones
that fail later, somewhere else, in a way that does not name you.
The rule: find every dependent before you edit, classify what breaks, and land the whole change as one
coherent unit.
1. Four kinds of dependent, and only one is obvious
| Dependent |
Missed because |
| Direct callers |
this is the one everybody greps for |
| Overrides in subclasses, traits, interface implementations |
they do not call the method, so a call-site search never shows them |
| Test doubles: mocks, stubs, fakes, spies |
they re-declare the signature to imitate it |
| Frozen copies: fixtures, recorded payloads, contract snapshots, generated clients, documentation |
nothing links them to the source at all |
Search for all four, and use whatever the ecosystem gives you that understands symbols rather than text
before falling back to a text search:
# callers
rg -n "->\s*<method>\s*\(|::\s*<method>\s*\(|\b<method>\s*\(" src/ tests/
# overrides and re-declarations — the ones the caller search cannot see
rg -n "function\s+<method>\s*\(|def\s+<method>\s*\(|<method>\s*\(.*\)\s*[:{]" src/ tests/
# who extends or implements the declaring type
rg -n "extends\s+<Type>\b|implements\s+<Type>\b|: <Type>\b" src/ tests/
# frozen copies
rg -rn "<method>|<field>" fixtures/ __snapshots__/ docs/ openapi/
A dynamic call — a name built from a string, a template, a configured handler — is invisible to every
search above. If the codebase resolves anything by convention, §4 is about you.
2. Classify before you edit
| Change |
Breaking? |
What it costs |
| Add a parameter with a default, at the end |
no |
check the overrides: they need a compatible default |
| Add a parameter without a default |
yes |
update every caller, or add it with a default first and require it in a second step |
| Remove a parameter |
yes |
every caller, and every mock |
| Reorder parameters |
yes, and silently |
callers keep compiling and pass the wrong values — prefer named arguments, or do not |
| Narrow a parameter type |
yes |
every caller; and a subclass may only widen what it accepts |
| Widen a parameter type |
on the parent only |
subclasses may widen freely; the parent's callers keep working |
| Change the return type |
yes, for the hierarchy |
a subclass may narrow, never widen — and the error is often at load time, not call time |
| Change a default value |
silently |
nothing breaks; behaviour changes for every caller that omitted it |
The last row is the dangerous one, because nothing anywhere goes red.
And one trap the table above hides: an optional trailing parameter is additive for callers and breaking
for implementers. Adding ?T $x = null to an interface method leaves every call site valid and makes
every existing implementation of that interface invalid — in a language that checks the declaration, the
class fails to load, before a line of its own code runs. If the interface is public API that other people
implement, the parameter is not the mechanism: add an optional capability interface, or carry the new fact
in an extension channel that already exists.
3. The whole change lands together
Parent, every subclass, every caller, every mock, every fixture: one commit, or one pull request whose
intermediate states nobody has to deploy. Never merge a state where parent and child disagree — in a
language that checks substitutability, that is not a failing test, it is a class that will not load, and it
takes the whole module with it.
"I will fix the rest after" is how a refactor becomes an outage.
Then let the tools confirm it: static analysis catches signature incompatibility before any test runs, and
it is the cheapest check in the sequence. Run it before the suite, not after.
4. Convention-resolved dependents fail silently
When a framework wires things by naming convention — a handler resolved from a string, a listener looked up
by table name, a property whose name must match a key, a file whose path is computed — then a rename does
not produce an error. It produces nothing: the code is never called, and no exception is thrown.
resolved = table + "EventService" → rename the class, and the hook simply stops firing
Two protections: a test that asserts the wiring resolves (not that the handler works — that it is found),
and a startup check that fails loudly when a declared name has no implementation.
5. The same rule, one level up
A signature is the smallest contract. Everything below is the same shape at a different altitude:
- An event payload other services deserialise. Adding a field is safe; removing or retyping one is not,
and the consumers are in another repository.
- A schema that gets tightened. Validation that becomes stricter breaks the fixtures and the stored
documents that were written under the looser rule — they must be migrated in the same change, or the
tightening must be staged.
- A stored contract. Recorded requests, cached serialisations and snapshots were frozen under the old
shape and will be read under the new one.
- A template that includes a partial owned by another repository. The include is a runtime
dependency on that repository's current state: rename or remove the partial on one side and the page
fails on the other, at request time, with nothing in either repository's tests to catch it. Use the
conditional form of the include, or copy the partial and accept the duplication deliberately — and when
a shared partial starts receiving a new variable, guard its presence until every consumer passes it.
- A producer moving from synchronous to queued, or the reverse: the contract that changes is the timing
and the failure mode, and callers depend on both.
6. Staging a breaking change you cannot land at once
When the dependents are outside your reach:
- Add the new shape alongside the old, with the old delegating to it.
- Make the old one warn — a deprecation that names the replacement and is visible where the caller runs.
- Migrate the dependents, tracking them against the list from §1.
- Remove the old one, once the list is empty and long enough has passed for the slowest consumer.
Steps 2 and 4 are the ones that get skipped, in opposite ways: no warning, or no removal ever.
Gotchas
- A caller search proves nothing about overrides. They are the dependents that never call you.
- Reordering parameters of the same type is the quietest breaking change in software. Everything still
compiles.
- Changing a default is a behaviour change for code nobody touched, and it will be attributed to
whatever ran next.
- Mocks re-declare the contract, so a suite can stay green against a signature that no longer exists.
- Documentation and generated clients are dependents. See
padosoft-docs-match-code.
- The error can arrive at load time, far from the change, naming two classes and no line of yours.
Checklist
Final report
Contract: <symbol / event / schema>
Change: <what> → breaking: yes | no (why)
Dependents found: callers <n> · overrides <n> · doubles <n> · fixtures/docs <n>
Convention-resolved: none | <what, and the test that asserts it>
Landed: one change | staged (<step reached>, removal due <when>)
Static analysis: <tool> clean before tests: yes | no
1---2name: padosoft-contract-changes3description: Use this skill when something other code depends on is about to change shape — a method or function signature, a parameter added, removed, reordered or retyped, a return type, an overridden method, an interface, an event payload, a schema, a response contract, a default value. Also when the user reports a signature-incompatibility error, a caller broken after a refactor, a child class that no longer matches its parent, a fixture failing after a schema was tightened, or asks how to change an API without breaking consumers. It gives the search that finds every dependent, the classification that says what is breaking, and the rule that the whole change lands together. Do not use it for designing an API from scratch, for versioning a public package, or for database migrations (padosoft-database-design covers those).4license: MIT5---67# Contract changes89Changing a signature is not editing a function. It is editing **everything that agreed to it** — callers,10overrides, mocks, fixtures, serialised payloads, documentation — and the ones you do not find are the ones11that fail later, somewhere else, in a way that does not name you.1213**The rule: find every dependent before you edit, classify what breaks, and land the whole change as one14coherent unit.**1516---1718## 1. Four kinds of dependent, and only one is obvious1920| Dependent | Missed because |21|---|---|22| **Direct callers** | this is the one everybody greps for |23| **Overrides** in subclasses, traits, interface implementations | they do not *call* the method, so a call-site search never shows them |24| **Test doubles**: mocks, stubs, fakes, spies | they re-declare the signature to imitate it |25| **Frozen copies**: fixtures, recorded payloads, contract snapshots, generated clients, documentation | nothing links them to the source at all |2627Search for all four, and use whatever the ecosystem gives you that understands *symbols* rather than text28before falling back to a text search:2930```bash31# callers32rg -n "->\s*<method>\s*\(|::\s*<method>\s*\(|\b<method>\s*\(" src/ tests/33# overrides and re-declarations — the ones the caller search cannot see34rg -n "function\s+<method>\s*\(|def\s+<method>\s*\(|<method>\s*\(.*\)\s*[:{]" src/ tests/35# who extends or implements the declaring type36rg -n "extends\s+<Type>\b|implements\s+<Type>\b|: <Type>\b" src/ tests/37# frozen copies38rg -rn "<method>|<field>" fixtures/ __snapshots__/ docs/ openapi/39```4041A dynamic call — a name built from a string, a template, a configured handler — is invisible to every42search above. If the codebase resolves anything by convention, §4 is about you.4344## 2. Classify before you edit4546| Change | Breaking? | What it costs |47|---|---|---|48| Add a parameter **with a default**, at the end | no | check the overrides: they need a compatible default |49| Add a parameter **without a default** | **yes** | update every caller, or add it with a default first and require it in a second step |50| Remove a parameter | **yes** | every caller, and every mock |51| **Reorder** parameters | **yes, and silently** | callers keep compiling and pass the wrong values — prefer named arguments, or do not |52| Narrow a parameter type | **yes** | every caller; and a subclass may only widen what it accepts |53| Widen a parameter type | on the parent only | subclasses may widen freely; the parent's callers keep working |54| Change the **return type** | **yes, for the hierarchy** | a subclass may narrow, never widen — and the error is often at load time, not call time |55| Change a default **value** | silently | nothing breaks; behaviour changes for every caller that omitted it |5657The last row is the dangerous one, because nothing anywhere goes red.5859And one trap the table above hides: **an optional trailing parameter is additive for callers and breaking60for implementers.** Adding `?T $x = null` to an interface method leaves every call site valid and makes61every existing implementation of that interface invalid — in a language that checks the declaration, the62class fails to load, before a line of its own code runs. If the interface is public API that other people63implement, the parameter is not the mechanism: add an optional capability interface, or carry the new fact64in an extension channel that already exists.6566## 3. The whole change lands together6768Parent, every subclass, every caller, every mock, every fixture: **one commit, or one pull request whose69intermediate states nobody has to deploy.** Never merge a state where parent and child disagree — in a70language that checks substitutability, that is not a failing test, it is a class that will not load, and it71takes the whole module with it.7273"I will fix the rest after" is how a refactor becomes an outage.7475Then let the tools confirm it: static analysis catches signature incompatibility before any test runs, and76it is the cheapest check in the sequence. Run it **before** the suite, not after.7778## 4. Convention-resolved dependents fail silently7980When a framework wires things by naming convention — a handler resolved from a string, a listener looked up81by table name, a property whose name must match a key, a file whose path is computed — then a rename does82not produce an error. It produces **nothing**: the code is never called, and no exception is thrown.8384```text85resolved = table + "EventService" → rename the class, and the hook simply stops firing86```8788Two protections: a test that asserts the wiring resolves (not that the handler works — that it is *found*),89and a startup check that fails loudly when a declared name has no implementation.9091## 5. The same rule, one level up9293A signature is the smallest contract. Everything below is the same shape at a different altitude:9495- **An event payload** other services deserialise. Adding a field is safe; removing or retyping one is not,96 and the consumers are in another repository.97- **A schema that gets tightened.** Validation that becomes stricter breaks the fixtures and the stored98 documents that were written under the looser rule — they must be migrated in the same change, or the99 tightening must be staged.100- **A stored contract.** Recorded requests, cached serialisations and snapshots were frozen under the old101 shape and will be read under the new one.102- **A template that includes a partial owned by another repository.** The include is a runtime103 dependency on that repository's current state: rename or remove the partial on one side and the page104 fails on the other, at request time, with nothing in either repository's tests to catch it. Use the105 conditional form of the include, or copy the partial and accept the duplication deliberately — and when106 a shared partial starts receiving a new variable, guard its presence until every consumer passes it.107- **A producer moving from synchronous to queued**, or the reverse: the contract that changes is the timing108 and the failure mode, and callers depend on both.109110## 6. Staging a breaking change you cannot land at once111112When the dependents are outside your reach:1131141. **Add the new shape alongside the old**, with the old delegating to it.1152. **Make the old one warn** — a deprecation that names the replacement and is visible where the caller runs.1163. **Migrate the dependents**, tracking them against the list from §1.1174. **Remove the old one**, once the list is empty and long enough has passed for the slowest consumer.118119Steps 2 and 4 are the ones that get skipped, in opposite ways: no warning, or no removal ever.120121---122123## Gotchas124125- **A caller search proves nothing about overrides.** They are the dependents that never call you.126- **Reordering parameters of the same type is the quietest breaking change in software.** Everything still127 compiles.128- **Changing a default is a behaviour change for code nobody touched**, and it will be attributed to129 whatever ran next.130- **Mocks re-declare the contract**, so a suite can stay green against a signature that no longer exists.131- **Documentation and generated clients are dependents.** See **`padosoft-docs-match-code`**.132- **The error can arrive at load time**, far from the change, naming two classes and no line of yours.133134## Checklist135136- [ ] Callers, overrides, test doubles **and** frozen copies all searched137- [ ] Convention-resolved dependents considered; wiring asserted by a test138- [ ] Every change classified as breaking or not, including default-value changes139- [ ] Return-type change checked against every subclass in the hierarchy140- [ ] Parent, children, callers, mocks and fixtures in one coherent change141- [ ] Static analysis run **before** the test suite142- [ ] If staged: new shape added, old one deprecated with a named replacement, removal scheduled143144## Final report145146```147Contract: <symbol / event / schema>148Change: <what> → breaking: yes | no (why)149Dependents found: callers <n> · overrides <n> · doubles <n> · fixtures/docs <n>150Convention-resolved: none | <what, and the test that asserts it>151Landed: one change | staged (<step reached>, removal due <when>)152Static analysis: <tool> clean before tests: yes | no153```