Simplifying Method Calls — Senior Level¶
API design at scale, semantic versioning, deprecation strategies, and how method-call refactorings ripple through systems.
Table of Contents¶
- The cost of breaking changes
- Semantic versioning and method changes
- Strangler Fig at the method level
- API design principles
- Wide-to-narrow refactoring
- Migrating exception-based to Result-based
- Renames at scale: codemods and tooling
- Cross-language API consistency
- Anti-patterns at scale
- Review questions
The cost of breaking changes¶
In a small codebase, Rename Method is one IDE keystroke. In a public library:
- Every consumer must update their code.
- Build/dependency tools must be updated.
- Major version bump required (per semver).
- Migration guide written.
- Support burden during transition.
Senior engineers calibrate refactoring ambition by blast radius:
| Scope | Cost of refactor |
|---|---|
| Within a class | Free |
| Within a module | Cheap |
| Within a service | Medium |
| Across services | High |
| Public API consumed by others | Very high |
| Library on Maven Central | Career-impacting |
Within-service: rename freely. Public API: consider 5×.
Semantic versioning and method changes¶
Semantic Versioning: MAJOR.MINOR.PATCH.
| Method change | Version impact |
|---|---|
| Rename method | MAJOR (breaking) |
| Add parameter (non-default) | MAJOR |
| Add overload (preserving old) | MINOR |
| Remove parameter | MAJOR |
| Tighten parameter type (e.g., String → URI) | MAJOR |
| Loosen return type (e.g., concrete → interface) | MINOR (sometimes) |
| Add throws of unchecked exception | depends on documentation |
| Hide method (public → private) | MAJOR |
| Add public method | MINOR |
| Bug fix | PATCH |
In Java, even adding a default method to an interface is technically MINOR but can break consumers compiling against older versions.
Spring's pattern¶
Spring Framework deprecates a method in version N, removes in N+1. Their @Deprecated(since = ..., forRemoval = true) is the canonical pattern.
Library migration¶
For widely-used libraries, plan migrations on a 12-month timeline: - v5.0: deprecate. - v5.x: deprecation notes in changelog. - v6.0: remove.
Strangler Fig at the method level¶
When you can't atomically rename or change a public method:
// Old API — kept around
@Deprecated
public Money getCharge() { return totalIncludingTax(); }
// New API
public Money totalIncludingTax() { ... }
Internal consumers migrate. External consumers see deprecation warnings. Eventually, the old is removed.
For breaking signature changes:
// v5.x:
public Result process(Order o) { ... }
// v5.0 → 6.0 transition:
public Result process(Order o, Policy p) { ... } // new
@Deprecated public Result process(Order o) { // old delegates
return process(o, defaultPolicy());
}
// v6.0:
public Result process(Order o, Policy p) { ... } // only this remains
API design principles¶
When crafting a new method (or refactoring an existing one):
1. Hard to misuse¶
vs.
2. Consistent¶
If getX() exists, also have setX() (or none). If withPolicy(...) returns a new instance, all with* methods do.
3. Minimal¶
Default to private. Only expose what consumers genuinely need.
4. Honest¶
Method name reflects effect. getX() doesn't mutate. applyDiscount() does mutate (or returns new).
5. Pit of success¶
The default invocation is the right one. Logger.info(...) is harmless. Hard cases require deliberate setup (Logger.atSensitiveLevel().with(...)).
Reference: Joshua Bloch's Effective Java, Item 2 (Builder pattern), Item 51 (API design).
Wide-to-narrow refactoring¶
When a method's parameter type is too wide:
is "polite" — accepts anything — but forces internal type checking and downcasting. Replacing with a narrower type:
Pushes the type discipline outward, making bugs visible at compile time.
Trade-off¶
- Narrow types fail-fast at the boundary; wide types push failures into the body.
- Generic / parameterized types let you have your cake and eat it:
<T>constrained where needed.
When wide is right¶
- True polymorphism where any type is acceptable (e.g.,
Object.equals(Object)). - Generic containers (
List<E>as a parameter — accept any list).
Migrating exception-based to Result-based¶
In some Java codebases, consumers want explicit error handling without exceptions. The Result<T, E> type (from Vavr, Cats, or hand-rolled):
public Result<Money, ChargeError> charge(Card c, Money amount) {
if (!c.isValid()) return Result.err(ChargeError.INVALID_CARD);
if (amount.isNegative()) return Result.err(ChargeError.INVALID_AMOUNT);
return Result.ok(processCharge(c, amount));
}
When to migrate¶
- Heavy exception use is hurting performance (deep stack capture).
- Errors are part of normal flow (validation, business rules).
- You want compile-time enforcement of error handling.
When to stay with exceptions¶
- Most consumers expect exception-based APIs.
- Errors really are exceptional.
- Migration cost outweighs benefit.
Migration approach¶
- Add the Result-returning version alongside the throwing version.
- Internally, the throwing version delegates to the Result version with
.getOrThrow(). - Migrate callers gradually.
- Eventually deprecate the throwing version.
Renames at scale: codemods and tooling¶
For renaming a method across 200 microservices, IDE refactor isn't enough.
OpenRewrite¶
type: specs.openrewrite.org/v1beta/recipe
name: com.example.RenameOrderTotalMethod
displayName: Rename Order.getCharge to Order.totalIncludingTax
recipeList:
- org.openrewrite.java.ChangeMethodName:
methodPattern: "com.example.Order getCharge()"
newMethodName: "totalIncludingTax"
Run via Maven plugin across all consumer repos.
ast-grep¶
For polyglot codebases:
jscodeshift / TypeScript transforms¶
For frontend / Node.js codebases.
IDE batch refactoring¶
IntelliJ "Migrate to..." commands handle some standard library migrations (e.g., Java's Files.readString instead of Files.readAllBytes).
Cross-language API consistency¶
When the same domain is exposed in multiple languages (Java SDK, Python SDK, Go SDK):
Naming¶
- Same conceptual operation should have the same method name across languages:
client.send_message(Python) ↔client.sendMessage(Java) ↔client.SendMessage(Go). - Idiomatic capitalization per language.
Errors¶
- Java: throws
MessageException. - Python: raises
MessageError. - Go: returns
error. - Rust: returns
Result<_, MessageError>.
Each language uses its native idiom; the information conveyed is consistent.
Result types¶
OpenAPI / Protobuf / GraphQL schemas often serve as the canonical contract. Codegen produces SDKs in each language consistently.
Anti-patterns at scale¶
1. The renamed method graveyard¶
A class has process(), processV2(), processNew(), processNewV2(). Each was the "right" version at some point. Pick one; deprecate the rest.
2. Too many overloads¶
public Result process(Order o);
public Result process(Order o, Policy p);
public Result process(Order o, Policy p, Clock c);
public Result process(Order o, Policy p, Clock c, Logger l);
You've created the overload combinatorial explosion. Use parameter object or builder.
3. Exception soup¶
Five different concerns. Either unify behind a domain exception (OrderException), use Result<Order, OrderError>, or split the method.
4. Lying names¶
Customer.deleteAccount() that... soft-deletes by setting a flag. Rename to markDeleted() or actually delete.
5. Permanent deprecation¶
@Deprecated since 2018. Either remove or undeprecate. Permanent deprecation is noise.
Review questions¶
- What's the cost calibration for refactoring depending on scope?
- What's the semver impact of common method changes?
- What's Strangler Fig at the method level?
- What are 5 principles of good API design?
- Why is "wide-to-narrow" parameter typing a refactoring direction?
- When migrate from exceptions to Result types?
- What is OpenRewrite useful for?
- Why use ast-grep for polyglot rename?
- Why do SDKs in different languages keep names consistent across capitalization?
- What's the anti-pattern of "permanent deprecation"?