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 👇