Simplifying Method Calls — Middle Level¶
Trade-offs, language idioms, the boolean parameter trap, and the discipline of API design.
Table of Contents¶
- The boolean parameter trap
- Builder pattern as Long Parameter List cure
- Functional options pattern (Go)
- Named arguments
- Defaults and overloading vs. parameter objects
- Errors-as-values vs. exceptions
- Factory methods vs. constructors
- Visibility decisions
- API evolution and deprecation
- Review questions
The boolean parameter trap¶
Quick: what does that mean?
A signature like sendNotification(User u, boolean urgent, boolean silent, boolean retry) makes call sites unreadable. The call site looks like a row in a truth table.
Cure: Replace Parameter with Explicit Methods¶
Or, if combinations are too many:
Cure: Enum / Flags¶
enum NotificationOption { URGENT, SILENT, RETRY }
sendNotification(user, EnumSet.of(URGENT, RETRY));
Cure: Builder¶
When booleans are OK¶
- One boolean, named clearly:
setEnabled(true). - A
flagenum disguised as boolean:playSound(soundEnabled).
Rule¶
Two or more booleans in a parameter list = redesign required.
Builder pattern as Long Parameter List cure¶
When a constructor has 8+ parameters:
Before¶
Builder pattern¶
HttpRequest.builder()
.get()
.url("/api/users")
.headers(headers)
.timeout(Duration.ofSeconds(5))
.retries(3)
.gzip(true)
.http2(true)
.build();
Pros¶
- Each setting is named.
- Optional fields don't pollute the call.
- Validation can happen at
build().
Cons¶
- More code (the builder class).
- Lombok
@Builderannotation can auto-generate. - For records / data classes with sensible defaults, keyword arguments may suffice.
When NOT¶
- 2-3 parameters with sensible types — overkill.
- Hot path construction — builder allocates the builder; profile if it matters.
Functional options pattern (Go)¶
Go's idiomatic answer to long parameter lists:
type Server struct {
addr string
port int
tls bool
timeout time.Duration
}
type Option func(*Server)
func WithPort(p int) Option { return func(s *Server) { s.port = p } }
func WithTLS() Option { return func(s *Server) { s.tls = true } }
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, port: 8080} // defaults
for _, opt := range opts { opt(s) }
return s
}
// Caller:
srv := NewServer("0.0.0.0", WithPort(443), WithTLS())
This is Go's combination of "factory + builder + named arguments" without language-level support.
Named arguments¶
Some languages have first-class named arguments:
| Language | Syntax |
|---|---|
| Python | f(x=1, y=2) |
| Kotlin | f(x = 1, y = 2) |
| Swift | f(x: 1, y: 2) |
| Scala | f(x = 1, y = 2) |
| C# | f(x: 1, y: 2) |
In these languages, Long Parameter List is less of a problem — call sites are self-documenting.
For positional-only languages (Java, Go), Introduce Parameter Object or Builder fills the gap.
Java has been considering "named parameters" for years. Records and pattern matching get most of the benefit; explicit named args keep getting deferred.
Defaults and overloading vs. parameter objects¶
When some parameters are commonly defaulted:
Java overloading¶
public Result process(Order o) { return process(o, defaultPolicy()); }
public Result process(Order o, Policy p) { return process(o, p, defaultClock()); }
public Result process(Order o, Policy p, Clock c) { ... }
Works but: 2^N overloads for N optional parameters. Not scalable.
Default values¶
Kotlin:
Python:
Parameter object with defaults¶
record ProcessOptions(Policy policy, Clock clock) {
public ProcessOptions { policy = policy != null ? policy : defaultPolicy(); ... }
}
public Result process(Order o, ProcessOptions opts) { ... }
Works in Java; verbose. Builders are more ergonomic for many optional fields.
Errors-as-values vs. exceptions¶
Exception languages (Java, C#, Python)¶
Exceptions are common. The trade-off: - Pro: Don't pollute happy-path signatures with error types. - Pro: Stack trace gives context. - Con: Hidden control flow. - Con: Performance cost of stack capture.
Error-as-value languages (Go, Rust)¶
- Pro: Errors are visible in signatures.
- Pro: No hidden control flow.
- Pro: Often faster (no stack capture).
- Con: Verbose (Go especially).
Senior decision¶
Within Java, don't reinvent error-as-value. Use exceptions for errors, return values for normal flow. Use Result<T, E> types only when interfacing with libraries that demand them.
Within Go / Rust, embrace error-as-value. Don't fake exceptions with panics for normal control flow.
Replace Exception with Test¶
When an exception is being used like a return value:
vs.
Or:
Factory methods vs. constructors¶
Constructor¶
- Always returns a new instance of exactly its declared type.
- Cannot return null or subtypes.
- One per signature.
Factory method¶
- Has a name → can convey intent (
Money.fromCents(100)vs.new Money(100, ...)— what's 100?). - Can return a cached instance (Flyweight, Singleton).
- Can return a subtype (
Number.parse("3")returns Integer or Long). - Can fail with null or Optional.
- Can be sealed/private — restricting how new instances are made.
When to use a factory method¶
- Multiple ways to construct (
Color.fromRGB(...),Color.fromHSL(...)). - Caching (
Currency.of("USD")returns the cached singleton). - Polymorphic creation (
Shape.fromKind(kind, ...)). - Validation that may fail (
Email.parse(s) -> Optional<Email>).
Naming conventions¶
of(...)— short and idiomatic in modern Java (List.of,Map.of).from(...)— conversion (Duration.fromMillis(...)).parse(...)— string input.valueOf(...)— legacy convention.create(...)— neutral.
Visibility decisions¶
When refactoring a method, default to the smallest possible visibility:
| Modifier (Java) | When |
|---|---|
private | Default. Only this class needs it. |
| package-private (no modifier) | Same package; tests; closely-collaborating classes. |
protected | Subclasses extend behavior. |
public | External API — make it deliberate. |
Why default to private¶
- Smaller API surface = fewer callers to update.
- Future you can refactor freely.
- Tests might want package-private accessors — that's fine; mark it
@VisibleForTesting.
When public is right¶
- The method is genuinely on the type's public contract.
- The library / module exposes it deliberately.
Hide Method — the discipline¶
Run periodic sweeps: which public methods have only same-class callers? Hide them.
API evolution and deprecation¶
When refactoring a method that's already public:
Step 1: Add the new¶
public Money totalIncludingTax() { ... } // new
public Money getCharge() { // old, marked deprecated
return totalIncludingTax();
}
Step 2: Annotate¶
@Deprecated(since = "5.0", forRemoval = true)
public Money getCharge() {
return totalIncludingTax();
}
Step 3: Wait¶
Allow callers to migrate. Time depends on: - Internal: 1-2 sprints. - Public library: minor version cycle (3-12 months).
Step 4: Remove¶
In the next major version, delete the old.
Tools¶
@Deprecated(Java).@deprecatedJSDoc tag.#[deprecated(since = "...", note = "...")](Rust).from typing import deprecated(Python 3.13+).- Fail builds on usage of deprecated APIs (
-Xlint:all -Werror).
Review questions¶
- What's the boolean parameter trap?
- When should you use a builder vs. parameter object vs. method overloading?
- What's the functional options pattern in Go, and why is it idiomatic there?
- When does Replace Exception with Test apply?
- When does Replace Error Code with Exception apply?
- When should you choose factory method over constructor?
- What's the discipline behind defaulting to private?
- How long should
@Deprecatedexist before removal? - Why do named arguments make Long Parameter List less of a problem?
- When is Encapsulate Downcast a clean refactoring vs. a hack?