In my previous post, I talked about software complexity.
One principle I consistently follow is:
👉 “The code I touch should be easier to understand and maintain than before—unless I’m writing it from scratch.”
I always start with the smallest unit in OOP—which, coming from a Ruby background, is the function.
To me, a function is the most fundamental building block of a class. Even if a class is 1,000+ lines long, the codebase is still workable if its functions are clear and well-structured. At the very least, it becomes easier to refactor in the future.
In this post, I want to share three principles I use to write clean, maintainable functions:
1️⃣ Keep It Short (Lines of Code)
In Ruby, it’s idiomatic to keep functions short.
When I was a tech lead, I often set a soft limit of 10–12 lines per function. But depending on the team’s experience level, that might be too strict and slow the team down.
A practical compromise I’ve found:
20 lines is a reasonable maximum.
It keeps functions simple, readable, and maintainable—without introducing unnecessary friction for the team.
📌 Example: Before & After
Before: A long, hard-to-scan function mixing multiple behaviors
def process_payment(user, order_id)
order = Order.find(order_id)
raise "Order not found" unless order
payment_gateway = PaymentGateway.new(user.payment_token)
payment_result = payment_gateway.charge(order.amount_cents)
if payment_result.success?
order.update!(status: :paid, paid_at: Time.current)
AnalyticsLogger.log_payment_success(user.id, order.id)
NotificationService.send_payment_confirmation_email(user, order)
if user.referral_code.present?
reward_service = ReferralRewardService.new(user.referral_code)
reward_service.process_reward_for(user)
end
SendThankYouGiftJob.perform_later(user.id) if order.amount_cents > 100_000
else
order.update!(status: :payment_failed)
ErrorTracker.notify("Payment failed", user_id: user.id, order_id: order.id)
end
end
After: Refactored with clarity, separation, and single-purpose focus
def process_payment(user, order_id)
order = Order.find(order_id)
raise "Order not found" unless order
if charge_order(user, order)
handle_successful_payment(user, order)
else
handle_failed_payment(user, order)
end
end
def charge_order(user, order)
result = PaymentGateway.new(user.payment_token).charge(order.amount_cents)
return false unless result.success?
order.update!(status: :paid, paid_at: Time.current)
true
end
def handle_successful_payment(user, order)
log_payment_success(user, order)
notify_user_of_payment(user, order)
reward_referral_if_applicable(user)
send_thank_you_gift_if_high_value(user, order)
end
def handle_failed_payment(user, order)
order.update!(status: :payment_failed)
ErrorTracker.notify("Payment failed", user_id: user.id, order_id: order.id)
end
def log_payment_success(user, order)
AnalyticsLogger.log_payment_success(user_id: user.id, order_id: order.id)
end
def notify_user_of_payment(user, order)
NotificationService.send_payment_confirmation_email(user, order)
end
def reward_referral_if_applicable(user)
return unless user.referral_code.present?
reward_service = ReferralRewardService.new(user.referral_code)
reward_service.process_reward_for(user)
end
def send_thank_you_gift_if_high_value(user, order)
return unless order.amount_cents > 100_000
SendThankYouGiftJob.perform_later(user.id)
end
2️⃣ One Reason to Change
A function should have only one reason to change. This aligns with the Single Responsibility Principle.
A quick test:
Ask yourself, “What does this function do?”
If the answer includes “and” (e.g., “It charges the user and sends a confirmation email”), it’s doing too much.
In the original example, the process_payment function had several reasons to change:
Payment logic
Logging behavior
Notification flow
Referral rewards
Async background jobs
Now, each function focuses on just one behavior, making changes easier and less risky.
3️⃣ Consistent Level of Abstraction
This one’s subtle but powerful.
If a function mixes different levels of abstraction, it becomes mentally exhausting to understand.
In the original version, the function:
Accessed the database (low-level)
Called an external payment API (infrastructure)
Applied business rules (mid-level)
Sent notifications (I/O)
Triggered background jobs (infra)
That’s a lot of context-switching in one place.
In the refactored version:
The main function (process_payment) stays at the orchestration level
Each helper function stays at a consistent “altitude”, making the whole flow easier to follow and evolve.
I’d love to hear your thoughts.
What principles do you follow to keep your functions clean and maintainable?
Let’s discuss 👇