Refactoring Series — Part 9: Simplifying Method Calls
Over-parameterized, ambiguous, or poorly structured method calls lead to fragile code. Learn how to simplify method calls in Go using idiomatic patterns.
Imagine you are called to debug a critical payment failure on a Tuesday morning. You locate the payment entrypoint in the code, and its signature looks like this:
func ProcessPayment(ctx context.Context, uID string, amt int, isRetry bool, gateway string, discCode string, notifyUser bool, isCallback bool) (*Receipt, error)
You need to call this function from a new endpoint. What should you pass for isRetry? What if it is not a callback? What if discCode is empty? You end up writing ProcessPayment(ctx, userID, 15000, false, "stripe", "", true, false) and praying you didn’t mix up the positions of the boolean flags.
This is a classic symptom of method calls that have grown organically without disciplined design. When method interfaces become convoluted, they turn into cognitive bottlenecks and integration hazards. In this article, we’ll explore fourteen refactoring techniques to simplify method calls, focusing on how to translate these ideas into idiomatic, production-grade Go.
🎯 Takeaway
After reading this article, you will be able to:
- 🏷️ Name Methods Clearly to state their exact behavior and avoid package-name stuttering.
- 🧱 Decouple Commands and Queries by ensuring methods either modify state or return values, but not both.
- 📦 Consolidate Parameter Bloat into cohesive Parameter Objects.
- 🤝 Maintain Flexible Interfaces by passing whole objects instead of individual properties.
- 🛠️ Leverage Factory Functions and Functional Options to instantiate objects cleanly and safely in Go.
- 🛡️ Replace Fragile Patterns like error codes and panics with Go’s standard error handling and state pre-checks.
Method Call Refactoring Decision Map
Use this decision map to choose the correct method call refactoring technique depending on the design issue you encounter:
flowchart TD
A["Unclear Method Call 📞"] --> B{What is the issue?}
B --> C["Ambiguous or misleading name"]
B --> D["Too many parameters or boolean flags"]
B --> E["Method returns a value AND mutates state"]
B --> F["Passing individual fields of a struct"]
B --> G["Calculating a value just to pass it as a parameter"]
B --> H["Exposing setters on structs that should be read-only"]
B --> I["Direct struct literals bypass initialization logic"]
B --> J["Handling errors via codes or using recover() from panics"]
C --> C1["✅ Rename Method"]
D --> D1["✅ Introduce Parameter Object / Parameterize Method / Replace Parameter with Explicit Methods"]
E --> E1["✅ Separate Query from Modifier"]
F --> F1["✅ Preserve Whole Object"]
G --> G1["✅ Replace Parameter with Method Call"]
H --> H1["✅ Remove Setting Method / Hide Method"]
I --> I1["✅ Replace Constructor with Factory Function"]
J --> J1["✅ Return Error / Replace Exception with Test"]
style A fill:#ff6b6b,color:#fff
style C1 fill:#51cf66,color:#fff
style D1 fill:#51cf66,color:#fff
style E1 fill:#51cf66,color:#fff
style F1 fill:#51cf66,color:#fff
style G1 fill:#51cf66,color:#fff
style H1 fill:#51cf66,color:#fff
style I1 fill:#51cf66,color:#fff
style J1 fill:#51cf66,color:#fff
The 7 Core Refactoring Techniques
Let’s dive deep into the seven most critical refactoring techniques for simplifying method calls in Go.
Technique 1: Rename Method
What is the Problem?
A method’s name should tell the reader what the method does, not how it does it. Ambiguous names force developers to read the implementation to understand the behavior. In Go, we must also avoid “stuttering” (e.g., naming a method GetUserData when the package is already named user, resulting in user.GetUserData()).
Bad Code Example (❌)
package user
// ❌ BAD: Ambiguous method name. Does it load from the database,
// transform data, or write to standard output?
func (u *User) Process() string {
return fmt.Sprintf("%s <%s>", u.Name, u.Email)
}
Fix (✅)
package user
// ✅ GOOD: Clear, self-documenting name that avoids stuttering when called.
// Call site reads: u.FormatContactInfo()
func (u *User) FormatContactInfo() string {
return fmt.Sprintf("%s <%s>", u.Name, u.Email)
}
Technique 2: Separate Query from Modifier
What is the Problem?
A method should either return a value (a query) or change the state of an object (a modifier), but not both. This is known as Command-Query Separation (CQS). When queries modify state, they introduce hidden side effects, making concurrency difficult and unit tests fragile.
Bad Code Example (❌)
// ❌ BAD: Returns the status while mutating the login counter and last login timestamp.
func (s *UserService) GetAndTrackUserStatus(userID string) (string, error) {
user, err := s.db.Find(userID)
if err != nil {
return "", err
}
user.LoginCount++
user.LastLogin = time.Now()
if err := s.db.Update(user); err != nil {
return "", err
}
return user.Status, nil
}
Fix (✅)
Split the code into a pure query method and a clear state-mutating modifier method:
// ✅ GOOD: Query method has no side effects.
func (s *UserService) GetUserStatus(userID string) (string, error) {
user, err := s.db.Find(userID)
if err != nil {
return "", err
}
return user.Status, nil
}
// ✅ GOOD: Modifier method explicitly signals write intent.
func (s *UserService) IncrementLoginActivity(userID string) error {
user, err := s.db.Find(userID)
if err != nil {
return err
}
user.LoginCount++
user.LastLogin = time.Now()
return s.db.Update(user)
}
Technique 3: Introduce Parameter Object
What is the Problem?
When a function takes more than three or four parameters, it becomes difficult to read, write, and maintain. In Go, since there are no named parameters at call sites (unlike Python or Swift), a long list of parameters of the same type (like strings) is highly prone to subtle positioning bugs.
Bad Code Example (❌)
// ❌ BAD: Too many positional parameters of the same type. Easy to mix up street and city.
func CreateOrder(
userID string,
name string,
email string,
phone string,
street string,
city string,
zip string,
items []OrderItem,
) (*Order, error) {
// Processing...
return &Order{}, nil
}
Fix (✅)
Group related parameters into clean, cohesive structs. This also makes it easier to extend the parameters in the future without breaking existing function signatures:
// ✅ GOOD: High-cohesion structs grouped as a single Parameter Object.
type ContactInfo struct {
Name string
Email string
Phone string
}
type Address struct {
Street string
City string
ZIP string
}
type CreateOrderRequest struct {
UserID string
Contact ContactInfo
Shipping Address
Items []OrderItem
}
func CreateOrder(req CreateOrderRequest) (*Order, error) {
// Processing becomes easier and cleaner: req.Shipping.City, etc.
return &Order{}, nil
}
Technique 4: Preserve Whole Object
What is the Problem?
Instead of extracting three or four fields from an object and passing them into a function, pass the entire object. Passing individual values bloats the function signature and creates coupling between the caller and the details of how the function processes the data.
Bad Code Example (❌)
// ❌ BAD: Caller is forced to manually extract properties.
// If we need the user's Tier or Timezone tomorrow, this signature breaks.
func (s *NotificationService) SendWelcomeEmail(name string, email string) error {
return s.mailer.Send(email, "Welcome!", "Hello " + name)
}
// Call site:
err := notificationSrv.SendWelcomeEmail(user.Name, user.Email)
Fix (✅)
// ✅ GOOD: Pass the whole struct. Signature remains resilient to future changes.
func (s *NotificationService) SendWelcomeEmail(user *User) error {
return s.mailer.Send(user.Email, "Welcome!", "Hello " + user.Name)
}
// Call site:
err := notificationSrv.SendWelcomeEmail(user)
Note: If you want to keep your packages decoupled and avoid depending on a concrete User struct, you can define a smaller interface containing only the getters you need.
Technique 5: Replace Parameter with Method Call
What is the Problem?
If a function parameter is a value that can be calculated by calling a method on an object the function already has access to, the caller should not compute it. Let the function retrieve the value itself, reducing the caller’s cognitive load.
Bad Code Example (❌)
// ❌ BAD: The caller must calculate the discount rate and pass it in.
func CalculateDiscountPrice(basePrice float64, discountRate float64) float64 {
return basePrice * (1 - discountRate)
}
// Call site:
rate := discountPolicy.GetRate(user)
price := CalculateDiscountPrice(basePrice, rate)
Fix (✅)
// ✅ GOOD: Pass the policy and user; let the function query what it needs.
func CalculateDiscountPrice(basePrice float64, policy *DiscountPolicy, user *User) float64 {
rate := policy.GetRate(user)
return basePrice * (1 - rate)
}
// Call site:
price := CalculateDiscountPrice(basePrice, discountPolicy, user)
Technique 6: Remove Setting Method
What is the Problem?
Public setter methods (e.g., SetBalance) destroy encapsulation. They allow fields to be altered from anywhere, violating business invariants. Structs should be immutable where possible. If state changes are required, they should happen through explicit business-domain methods.
Bad Code Example (❌)
package bank
// ❌ BAD: Anyone can change the balance or ID directly, bypassing checks.
type Account struct {
ID string
Balance float64
}
func (a *Account) SetBalance(balance float64) {
a.Balance = balance
}
Fix (✅)
Unexport the fields (using lowercase names) and provide read-only getters. Mutating state must happen through explicit, validated operations:
package bank
import "errors"
// ✅ GOOD: Encapsulated fields are protected from arbitrary outside writes.
type Account struct {
id string
balance float64
}
func NewAccount(id string, initialBalance float64) *Account {
return &Account{id: id, balance: initialBalance}
}
// Read-only getters
func (a *Account) ID() string { return a.id }
func (a *Account) Balance() float64 { return a.balance }
// Validated mutator methods (No generic SetBalance)
func (a *Account) Deposit(amount float64) error {
if amount <= 0 {
return errors.New("deposit amount must be positive")
}
a.balance += amount
return nil
}
Technique 7: Replace Constructor with Factory Method
What is the Problem?
Go does not have built-in class constructors. Direct struct literals (&UserService{}) expose internal fields and allow clients to instantiate uninitialized or invalid objects. Furthermore, rigid positional constructor functions (NewUserService(db, logger, cache)) break all callers when dependencies change.
Bad Code Example (❌)
// ❌ BAD: Adding a new configuration parameter breaks every caller in the codebase.
type UserService struct {
DB *sql.DB
Logger *zap.Logger
Cache *redis.Client
Timeout time.Duration
}
func NewUserService(db *sql.DB, logger *zap.Logger, cache *redis.Client, timeout time.Duration) *UserService {
return &UserService{
DB: db,
Logger: logger,
Cache: cache,
Timeout: timeout,
}
}
Fix (✅)
Implement a factory function utilizing the Functional Options pattern. This is the gold standard in Go for configuring complex services cleanly and extensibly:
// ✅ GOOD: Clean factory function with flexible, non-breaking functional options.
type UserService struct {
db *sql.DB
logger *zap.Logger
cache *redis.Client
timeout time.Duration
}
type Option func(*UserService)
func WithLogger(logger *zap.Logger) Option {
return func(s *UserService) {
s.logger = logger
}
}
func WithCache(cache *redis.Client) Option {
return func(s *UserService) {
s.cache = cache
}
}
func WithTimeout(timeout time.Duration) Option {
return func(s *UserService) {
s.timeout = timeout
}
}
func NewUserService(db *sql.DB, opts ...Option) *UserService {
// Set default configurations
srv := &UserService{
db: db,
logger: zap.NewNop(), // Default to no-op logger
timeout: 30 * time.Second,
}
// Apply functional options
for _, opt := range opts {
opt(srv)
}
return srv
}
// Call site is clean and readable:
srv := NewUserService(db, WithTimeout(5*time.Second), WithCache(redisClient))
Other Refactoring Techniques for Method Calls
While the seven techniques above represent the core tools, let’s explore the remaining seven techniques from the catalog and how they translate to Go.
8. Add Parameter
When a method needs new information to do its job, add a parameter. Go Tip: If you find yourself adding parameters frequently, refactor the signature to use a config struct or functional options to protect backward compatibility.
9. Remove Parameter
If a method no longer uses a parameter, delete it immediately to reduce cognitive noise.
10. Parameterize Method
If several methods perform similar tasks but differ only by a specific value, merge them into a single method that accepts that value as a parameter.
// Instead of separate methods:
func (a *Account) ChargeTenPercent() { a.balance *= 0.90 }
func (a *Account) ChargeTwentyPercent() { a.balance *= 0.80 }
// Refactor to:
func (a *Account) ChargeFee(percentage float64) {
a.balance *= (1 - percentage)
}
11. Replace Parameter with Explicit Methods
The opposite of Parameterize Method. If a method changes its behavior dramatically based on a parameter (often a boolean flag), split it into separate, explicitly named methods.
// Instead of:
func (c *Connection) Connect(secure bool) {
if secure { /* setup TLS */ } else { /* setup TCP */ }
}
// Refactor to:
func (c *Connection) ConnectTLS() { ... }
func (c *Connection) ConnectPlaintext() { ... }
12. Hide Method
If a method is only called within the package it is defined in, make it unexported (change the first letter to lowercase). This keeps the package’s public API surface area small and maintainable.
13. Replace Error Code with Return Error (Go’s Exception Equivalent)
Historically, languages without exception handling (like C) returned integer status codes (e.g., -1 for error). While languages like Java use throw/catch exceptions, Go relies on explicit multi-value error returns.
// ❌ BAD: C-style error status code
func (db *DB) Save() int {
if db.isReadOnly { return -1 }
return 0
}
// ✅ GOOD: Idiomatic Go error return
func (db *DB) Save() error {
if db.isReadOnly {
return errors.New("cannot write to read-only database")
}
return nil
}
14. Replace Exception with Test/Pre-check
Some developers rely on catching exceptions or recovering from panics to handle expected execution paths. It is far more idiomatic and performant to perform validation checks (“tests”) before executing operations.
// ❌ BAD: Recovering from a panic on nil pointer
func PrintUserAge(u *User) {
defer func() {
if r := recover(); r != nil {
fmt.Println("User was nil!")
}
}()
fmt.Println(u.Profile.Age) // Panics if u or Profile is nil
}
// ✅ GOOD: Guard logic with a pre-check (test)
func PrintUserAge(u *User) {
if u == nil || u.Profile == nil {
fmt.Println("User or profile data unavailable")
return
}
fmt.Println(u.Profile.Age)
}
Case Study: Refactoring a Checkout Workflow
Let’s look at a complex checkout flow that violates multiple method-call design rules and see how we can refactor it into clean, idiomatic Go.
Before Refactoring (❌)
This implementation suffers from parameter bloat, Command-Query Separation violations, direct struct manipulation, and old-school error coding:
package checkout
import "time"
type Order struct {
ID string
Amount float64
UserEmail string
Status string
}
type CheckoutService struct {
Gateway string
IsReadOnly bool
PromoApplied bool
}
// ❌ BAD:
// 1. Long list of individual parameters.
// 2. Command-Query Separation violation: Mutates PromoApplied AND returns order status.
// 3. C-Style error codes instead of return error.
// 4. Exposed struct fields allowing external modifications.
func (s *CheckoutService) ProcessCheckout(
orderID string,
amount float64,
email string,
promo string,
street string,
city string,
zip string,
) (string, int) {
if s.IsReadOnly {
return "FAILED", -1
}
if promo == "SUMMER50" {
s.PromoApplied = true
amount = amount * 0.5
}
order := &Order{
ID: orderID,
Amount: amount,
UserEmail: email,
Status: "PROCESSED",
}
// Save to DB (simulated)
println("Saving order to DB...", order.ID)
return order.Status, 0 // 0 means Success
}
After Refactoring (✅)
We simplify this workflow by:
- Creating a
CheckoutRequestParameter Object. - Returning an idiomatic Go
errorinstead of integer codes. - Separating the promotion logic (modifier) from the checkout execution (CQS).
- Restructuring
CheckoutServicewith a Factory Function and unexported fields to enforce boundaries.
package checkout
import (
"errors"
"fmt"
)
type Order struct {
id string
amount float64
userEmail string
status string
}
type CheckoutService struct {
gateway string
isReadOnly bool
promoApplied bool
}
// ✅ GOOD: Factory function with default configuration protection
func NewCheckoutService(gateway string, isReadOnly bool) *CheckoutService {
return &CheckoutService{
gateway: gateway,
isReadOnly: isReadOnly,
}
}
type Address struct {
Street string
City string
ZIP string
}
// ✅ GOOD: Parameter Object groups order request data
type CheckoutRequest struct {
OrderID string
Amount float64
Email string
Promo string
Address Address
}
// ✅ GOOD: Mutator explicitly updates state, separating query from modification
func (s *CheckoutService) ApplyPromo(req *CheckoutRequest) {
if req.Promo == "SUMMER50" && !s.promoApplied {
s.promoApplied = true
req.Amount = req.Amount * 0.5
}
}
// ✅ GOOD: Clean signature, processes checkout, and returns idiomatic Go errors
func (s *CheckoutService) ProcessCheckout(req CheckoutRequest) (*Order, error) {
if s.isReadOnly {
return nil, errors.New("cannot process checkout: service is in read-only mode")
}
order := &Order{
id: req.OrderID,
amount: req.Amount,
userEmail: req.Email,
status: "PROCESSED",
}
// Save to DB (simulated)
fmt.Printf("Saving order %s (Amount: %.2f) to DB...\n", order.id, order.amount)
return order, nil
}
Side-by-Side Comparison
| Metric | Before Refactoring | After Refactoring |
|---|---|---|
| Number of Parameters | 7 parameters | 1 parameter (Struct) |
| Error Handling | C-Style Status Codes (0, -1) |
Idiomatic Go error return |
| Encapsulation | Public mutable fields (PromoApplied) |
Private fields, domain-driven mutations |
| Command-Query Separation | Mixed (mutates promo, returns status) | Separated into ApplyPromo and ProcessCheckout |
| Caller Safety | Weak; easy to swap string fields | High type-safety and structured request inputs |
When Not to Refactor Method Calls
Refactoring method calls is essential for cleanliness, but over-engineering signatures can lead to problems:
- Parameter Object Overkill: If a function takes only two parameters, wrapping them into a struct adds unnecessary boilerplate. Keep simple signatures simple.
- Functional Options Complexity: If your struct has only one mandatory dependency and no optional configurations, a plain, simple factory function like
NewService(db)is much easier to maintain than full Functional Options. - Polymorphism vs. Parameters: Do not generalize methods with generic interfaces (
interface{}orany) simply to avoid adding parameters. Explicit, typed parameters are always safer than runtime assertions.
📝 Summary / Recap
Simplifying method calls reduces the cognitive friction of using your interfaces. By applying these techniques, you ensure that your code reads like clear prose and minimizes bugs at the integration boundaries.
Here is a summary of the techniques we discussed:
| Refactoring Technique | When to Use | Key Benefit |
|---|---|---|
| Rename Method | Name is ambiguous or stutters | Clearly communicates intent without looking bloated |
| Separate Query from Modifier | Method queries state and mutates it | Eliminates side effects, simplifying testability |
| Introduce Parameter Object | Long parameter lists (3+ parameters) | Prevents parameter positioning bugs |
| Preserve Whole Object | Passing individual properties of a struct | Prevents code breakage when new fields are needed |
| Replace Parameter with Method Call | Caller calculates value function can get itself | Reduces caller cognitive workload |
| Remove Setting Method | Fields are modified directly | Enforces encapsulation and object validation rules |
| Replace Constructor with Factory | Complex instantiation or configuration | Clean object creation via Functional Options |
| Replace Error Code with Return Error | Return values are used to represent status | Idiomatic error handling in Go |
| Replace Exception with Test | Catching panics/exceptions for control flows | Prevents runtime crashes using safe pre-checks |
By prioritizing readable and well-bounded method signatures, you write software that is easier to maintain, document, and test.
| 🇮🇩 Versi Indonesia | 🇬🇧 English Version |