Chapter 3: Functions

clean-code functions single-responsibility

Status: Notes complete
Difficulty: Medium
Time to complete: ~45 min read


Overview

Functions are the first line of organization in any program. This chapter establishes that functions should be small, focused, and readable almost like prose — each one doing exactly one thing at one level of abstraction. The rules here are some of the most impactful in the book: violating them produces the tangled, unreadable “spaghetti” that makes codebases hard to maintain. Master these and every codebase you touch becomes more approachable.


The Problem: What Bad Code Looks Like

Consider a typical e-commerce order processing function written without discipline:

// BAD — processOrder() does everything: validates, charges, ships, notifies
public String processOrder(Order order, PaymentInfo payment, String couponCode) {
    // Validate
    if (order == null || order.getItems().isEmpty()) {
        return "ERROR_EMPTY_ORDER";
    }
    if (payment == null || payment.getCardNumber() == null) {
        return "ERROR_NO_PAYMENT";
    }
    // Apply coupon
    double discount = 0;
    if (couponCode != null && !couponCode.isEmpty()) {
        Coupon c = couponRepo.findByCode(couponCode);
        if (c != null && c.isActive() && !c.isExpired()) {
            discount = c.getDiscountPercent() / 100.0;
        }
    }
    // Calculate total
    double subtotal = 0;
    for (Item item : order.getItems()) {
        subtotal += item.getPrice() * item.getQuantity();
    }
    double total = subtotal * (1 - discount);
    // Charge
    boolean charged = paymentGateway.charge(payment.getCardNumber(),
        payment.getExpiry(), payment.getCvv(), total);
    if (!charged) {
        return "ERROR_PAYMENT_FAILED";
    }
    // Update inventory
    for (Item item : order.getItems()) {
        inventory.decrement(item.getSku(), item.getQuantity());
    }
    // Save order
    order.setStatus("CONFIRMED");
    order.setTotal(total);
    orderRepo.save(order);
    // Send email
    String subject = "Order confirmed: " + order.getId();
    String body = "Dear customer, your order of $" + total + " is confirmed.";
    emailService.send(order.getCustomerEmail(), subject, body);
    // Log
    logger.info("Order " + order.getId() + " processed for $" + total);
    return "SUCCESS_" + order.getId();
}

This 50-line function is almost impossible to test, reason about, or change safely. Adding a loyalty points step or changing the payment gateway means editing this one fragile method. Every one of the rules in this chapter explains why this is painful and how to fix it.


Core Principles

1. Small!

Why: The smaller a function, the easier it is to name, test, and reason about. Martin’s rule: functions should rarely exceed 20 lines. Each line of a function should be at most 1–2 levels of indentation deep. If you have nested ifs inside for loops inside try blocks, that is a sign the function contains multiple things.

// BAD — 60-line monolith with nested logic
public void generateReport(List<Order> orders, ReportConfig config) {
    List<Order> filtered = new ArrayList<>();
    for (Order o : orders) {
        if (o.getDate().isAfter(config.getStartDate())) {
            if (o.getDate().isBefore(config.getEndDate())) {
                if (config.getRegions().contains(o.getRegion())) {
                    filtered.add(o);
                }
            }
        }
    }
    double total = 0;
    for (Order o : filtered) {
        for (Item item : o.getItems()) {
            total += item.getPrice() * item.getQuantity();
        }
    }
    // ... 40 more lines of formatting, writing to file, sending email ...
}
 
// GOOD — each step is a named function
public void generateReport(List<Order> orders, ReportConfig config) {
    List<Order> filtered = filterByDateAndRegion(orders, config);
    double total = calculateTotal(filtered);
    ReportDocument doc = formatReport(filtered, total, config);
    writeAndDeliver(doc, config);
}

Exception: Very simple utility functions (e.g., isEmpty(), toUpperCase()) may be one-liners and that’s fine. The 20-line rule is a ceiling for complex logic, not a mandate to split trivial helpers.


2. Do One Thing

Why: “FUNCTIONS SHOULD DO ONE THING. THEY SHOULD DO IT WELL. THEY SHOULD DO IT ONLY.” — Martin. The test: can you extract another meaningful function from it with a name that is not merely a restatement of the implementation? If yes, the function is doing more than one thing.

// BAD — authenticates AND initializes session (two things)
public boolean checkPasswordAndLogin(String username, String password) {
    User user = userRepo.findByUsername(username);
    if (user != null && bcrypt.verify(password, user.getPasswordHash())) {
        sessionManager.createSession(user.getId()); // SIDE EFFECT
        return true;
    }
    return false;
}
 
// GOOD — separated responsibilities
public boolean checkPassword(String username, String password) {
    User user = userRepo.findByUsername(username);
    return user != null && bcrypt.verify(password, user.getPasswordHash());
}
 
public void loginUser(String username) {
    User user = userRepo.findByUsername(username);
    sessionManager.createSession(user.getId());
}

C++ equivalent:

// BAD
bool authenticateAndCreateSession(const std::string& username,
                                  const std::string& password) {
    auto user = userRepo.findByUsername(username);
    if (user && bcrypt::verify(password, user->passwordHash)) {
        sessionMgr.createSession(user->id); // side effect hidden here
        return true;
    }
    return false;
}
 
// GOOD
bool checkPassword(const std::string& username, const std::string& password) {
    auto user = userRepo.findByUsername(username);
    return user && bcrypt::verify(password, user->passwordHash);
}
 
void loginUser(const std::string& username) {
    auto user = userRepo.findByUsername(username);
    sessionMgr.createSession(user->id);
}

Python equivalent:

# BAD
def check_password_and_login(username: str, password: str) -> bool:
    user = user_repo.find_by_username(username)
    if user and bcrypt.verify(password, user.password_hash):
        session_manager.create_session(user.id)  # hidden side effect
        return True
    return False
 
# GOOD
def check_password(username: str, password: str) -> bool:
    user = user_repo.find_by_username(username)
    return user is not None and bcrypt.verify(password, user.password_hash)
 
def login_user(username: str) -> None:
    user = user_repo.find_by_username(username)
    session_manager.create_session(user.id)

Exception: Some orchestration functions must coordinate multiple steps — that’s their one job: orchestrating. The key is that orchestration is their single responsibility, not “doing” any of the individual steps themselves.


3. One Level of Abstraction per Function

Why: Mixing levels of abstraction (e.g., page rendering + string concatenation) in one function is disorienting. Readers cannot tell which details are important and which are incidental. Martin’s step-down rule: reading code top to bottom, each function should introduce the next level of abstraction, like an outline.

// BAD — high-level rendering mixed with low-level string ops
public String renderPage(Page page) {
    StringBuilder html = new StringBuilder();
    html.append("<html><head><title>").append(page.getTitle()).append("</title></head>");
    html.append("<body>");
    // suddenly deep in low-level string work
    html.append("\n"); // why is this here?
    for (Section s : page.getSections()) {
        html.append("<h2>").append(s.getTitle()).append("</h2>");
        html.append("<p>").append(s.getContent().replace("\n", "<br/>")).append("</p>");
    }
    html.append("</body></html>");
    return html.toString();
}
 
// GOOD — each level of abstraction in its own function
public String renderPage(Page page) {
    return buildHtmlDocument(page.getTitle(), renderSections(page.getSections()));
}
 
private String renderSections(List<Section> sections) {
    return sections.stream()
        .map(this::renderSection)
        .collect(Collectors.joining());
}
 
private String renderSection(Section section) {
    return buildSectionHtml(section.getTitle(), formatContent(section.getContent()));
}

C++ equivalent:

// GOOD — step-down rule applied
std::string renderPage(const Page& page) {
    return buildHtmlDocument(page.title(), renderSections(page.sections()));
}
 
std::string renderSections(const std::vector<Section>& sections) {
    std::string result;
    for (const auto& s : sections) result += renderSection(s);
    return result;
}
 
std::string renderSection(const Section& section) {
    return buildSectionHtml(section.title(), formatContent(section.content()));
}

Python equivalent:

# GOOD
def render_page(page: Page) -> str:
    return build_html_document(page.title, render_sections(page.sections))
 
def render_sections(sections: list[Section]) -> str:
    return "".join(render_section(s) for s in sections)
 
def render_section(section: Section) -> str:
    return build_section_html(section.title, format_content(section.content))

Exception: Very short glue functions that necessarily touch two levels are sometimes unavoidable (especially in adapters). Keep them short and name them to indicate their bridging role.


4. Switch Statements

Why: A switch on a type (employee type, order status, shape type) almost always means you’ll need to repeat the same switch everywhere: in calculatePay(), isFullTime(), deliverPay(). This violates the Open-Closed Principle (open for extension, closed for modification). The fix: bury the switch in an Abstract Factory that creates polymorphic objects; the type-based decision happens once at construction.

// BAD — switch repeated in every method that cares about EmployeeType
public Money calculatePay(Employee e) throws InvalidEmployeeType {
    switch (e.type) {
        case COMMISSIONED: return calculateCommissionedPay(e);
        case HOURLY:       return calculateHourlyPay(e);
        case SALARIED:     return calculateSalariedPay(e);
        default: throw new InvalidEmployeeType(e.type);
    }
}
// Now you need another switch in isFullTime(), deliverPay(), etc.
 
// GOOD — polymorphism via Abstract Factory
public interface Employee {
    Money calculatePay();
    boolean isFullTime();
    void deliverPay(Money pay);
}
 
public class EmployeeFactory {
    public static Employee make(EmployeeRecord r) throws InvalidEmployeeType {
        switch (r.type) {  // switch lives ONLY here
            case COMMISSIONED: return new CommissionedEmployee(r);
            case HOURLY:       return new HourlyEmployee(r);
            case SALARIED:     return new SalariedEmployee(r);
            default: throw new InvalidEmployeeType(r.type);
        }
    }
}

C++ equivalent:

// GOOD — factory + polymorphism
class Employee {
public:
    virtual Money calculatePay() = 0;
    virtual bool isFullTime() = 0;
    virtual ~Employee() = default;
};
 
std::unique_ptr<Employee> makeEmployee(const EmployeeRecord& r) {
    switch (r.type) {  // switch appears exactly once
        case EmployeeType::COMMISSIONED:
            return std::make_unique<CommissionedEmployee>(r);
        case EmployeeType::HOURLY:
            return std::make_unique<HourlyEmployee>(r);
        case EmployeeType::SALARIED:
            return std::make_unique<SalariedEmployee>(r);
        default:
            throw InvalidEmployeeType(r.type);
    }
}

Python equivalent:

# GOOD — factory dict replaces switch
from abc import ABC, abstractmethod
 
class Employee(ABC):
    @abstractmethod
    def calculate_pay(self) -> Money: ...
    @abstractmethod
    def is_full_time(self) -> bool: ...
 
_EMPLOYEE_CLASSES: dict[EmployeeType, type[Employee]] = {
    EmployeeType.COMMISSIONED: CommissionedEmployee,
    EmployeeType.HOURLY: HourlyEmployee,
    EmployeeType.SALARIED: SalariedEmployee,
}
 
def make_employee(record: EmployeeRecord) -> Employee:
    cls = _EMPLOYEE_CLASSES.get(record.type)
    if cls is None:
        raise InvalidEmployeeType(record.type)
    return cls(record)

Exception: A switch is acceptable when each case is genuinely different and the type will never change (e.g., parsing HTTP verbs in a router). But as soon as you duplicate the switch across the codebase, the factory pattern pays off immediately.


5. Use Descriptive Names

Why: Don’t be afraid of long names. A long, descriptive name is better than a short mysterious name. includeSetupAndTeardownPages() tells the reader exactly what it does. setUp() tells them nothing. The cost of reading a long name is far less than the cost of investigating a short one.

// BAD — abbreviated, unclear names
public boolean chkPwd(String u, String p) { ... }
public void proc(Order o) { ... }
public List<User> getU(boolean active) { ... }
 
// GOOD — names that read like prose
public boolean checkUserPassword(String username, String rawPassword) { ... }
public void processConfirmedOrder(Order confirmedOrder) { ... }
public List<User> findActiveUsers() { ... }

Use a consistent lexicon: if your codebase uses fetch for DB calls, don’t mix retrieve, get, and find — pick one and stick to it.

Exception: Very well-known conventions (loop counters i, j; math variables x, y) are fine when they carry exactly that conventional meaning. Don’t force rowIndex when i is universally understood.


6. Function Arguments

Why: Arguments increase cognitive load. Every argument requires the reader to build a mental model of what it represents and how it interacts with the others. Zero arguments is ideal. The more arguments, the harder the function is to understand, test (test cases multiply), and call correctly.

Argument CountNameVerdictExample
0NiladicBestcurrentDate()
1MonadicGoodfileExists("myFile")
2DyadicAcceptablenew Point(0, 0)
3TriadicAvoidassertEquals(expected, actual, delta)
4+PolyadicRefactormakeCircle(x, y, radius, color)
// BAD — triadic; which argument means what?
public Circle makeCircle(double x, double y, double radius) { ... }
// Called as: makeCircle(0.0, 5.0, 10.0) — confusing at the call site
 
// GOOD — argument object wraps coordinates into a concept
public Circle makeCircle(Point center, double radius) { ... }
// Called as: makeCircle(new Point(0.0, 5.0), 10.0) — reads naturally
 
// BAD — polyadic: hard to read at the call site
public void createUser(String firstName, String lastName, String email,
                        String role, boolean active) { ... }
 
// GOOD — argument object
public void createUser(UserRegistration registration) { ... }
// registration = new UserRegistration(firstName, lastName, email, role, active)

Argument lists (varargs): When a function takes a variable number of same-typed arguments (e.g., String.format(String fmt, Object... args)), the varargs count as a single argument from the arity perspective.

C++ equivalent:

// BAD
Circle makeCircle(double x, double y, double radius) { ... }
 
// GOOD
struct Point { double x; double y; };
Circle makeCircle(Point center, double radius) { ... }

Python equivalent:

# BAD
def make_circle(x: float, y: float, radius: float) -> Circle: ...
 
# GOOD — dataclass as argument object
from dataclasses import dataclass
 
@dataclass
class Point:
    x: float
    y: float
 
def make_circle(center: Point, radius: float) -> Circle: ...

Exception: Dyadic functions are acceptable when the two arguments form a natural ordered pair that is universally understood (Point(x, y), assertEquals(expected, actual)).


7. Flag Arguments

Why: Passing a boolean flag into a function is a clear sign the function does two things: one when the flag is true, another when it’s false. This violates Do One Thing and forces callers to decipher what render(true) means.

// BAD — what does 'true' mean here?
render(true);
renderPage(page, true);
 
// Inside: flag splits function into two
public String renderPage(Page page, boolean isSuite) {
    if (isSuite) {
        return renderPageWithSetupAndTeardown(page);
    } else {
        return renderPageContent(page);
    }
}
 
// GOOD — two honestly-named functions
public String renderForSuite(Page page) { ... }
public String renderForSingleTest(Page page) { ... }

C++ equivalent:

// BAD
void processPayment(const Payment& p, bool isRefund);
 
// GOOD
void chargePayment(const Payment& p);
void refundPayment(const Payment& p);

Python equivalent:

# BAD
def process_payment(payment: Payment, is_refund: bool) -> None: ...
 
# GOOD
def charge_payment(payment: Payment) -> None: ...
def refund_payment(payment: Payment) -> None: ...

Exception: When the flag is part of the domain model and not a branching mechanism — e.g., setActive(bool active) on a mutable entity setter — that’s acceptable because the flag is data, not logic branching.


8. Side Effects / Hidden Coupling

Why: A function named checkPassword should check a password — nothing else. If it also initializes a session, callers who only want to verify a password unknowingly create sessions. This creates temporal coupling (you must call these in a certain order) and bugs that are fiendishly hard to trace.

// BAD — checkPassword hides session initialization
public class UserAuthenticator {
    public boolean checkPassword(String username, String password) {
        User user = userRepo.findByUsername(username);
        if (user != null && bcrypt.verify(password, user.getPasswordHash())) {
            sessionManager.initialize(user); // SIDE EFFECT — hidden coupling
            return true;
        }
        return false;
    }
}
// Callers who just want to verify a stored password will accidentally open sessions.
 
// GOOD — rename to make the side effect explicit, or separate concerns
public boolean checkPasswordAndInitializeSession(String username, String password) {
    // name makes the coupling obvious; caller can decide if they want both
    ...
}
// Better: separate into checkPassword() + initializeSession()

Exception: Some side effects are unavoidable (logging, caching). Make them obvious by using a name that describes them or by documenting them. Never hide a meaningful state change in a function named as a query.


9. Command Query Separation

Why: A function should either do something (change state, write a file, send an email) or answer something (return a value, query a condition), never both. When a function both sets a value and returns whether the set succeeded, callers write confusing code like if (set("username", "unclebob")) — is set setting, or checking?

// BAD — set() both mutates and returns a boolean
public boolean set(String attribute, String value) {
    if (attributeExists(attribute)) {
        setAttribute(attribute, value);
        return true;
    }
    return false;
}
// Caller code is a riddle:
if (set("username", "unclebob")) { ... }  // Setting? Checking? Both?
 
// GOOD — separate the query from the command
public boolean attributeExists(String attribute) { ... }
public void setAttribute(String attribute, String value) { ... }
 
// Caller reads naturally:
if (attributeExists("username")) {
    setAttribute("username", "unclebob");
}

C++ equivalent:

// BAD
bool setConfig(const std::string& key, const std::string& value);
 
// GOOD
bool configExists(const std::string& key);
void setConfig(const std::string& key, const std::string& value);

Python equivalent:

# BAD
def set_config(key: str, value: str) -> bool: ...
 
# GOOD
def config_exists(key: str) -> bool: ...
def set_config(key: str, value: str) -> None: ...

Exception: Builder patterns often chain commands that return self (builder.setName("x").setAge(30).build()). This is a well-understood convention where the return value is the builder itself, not a query result.


10. Prefer Exceptions to Returning Error Codes

Why: When a function returns an error code, the caller must handle it immediately with nested conditionals. Error codes couple the caller to the error-handling logic and clutter the happy path. Exceptions allow the happy path to remain clean and error handling to be separated into its own try/catch block.

// BAD — error codes require immediate, nested handling
public int deleteRecord(int id) {
    if (!recordExists(id))   return ErrorCodes.RECORD_NOT_FOUND;
    if (!userCanDelete(id))  return ErrorCodes.PERMISSION_DENIED;
    database.delete(id);
    return ErrorCodes.OK;
}
 
// Caller:
if (deleteRecord(id) == ErrorCodes.OK) {
    if (logDeletion(id) == ErrorCodes.OK) {
        if (notifyAudit(id) == ErrorCodes.OK) {
            // real logic buried in nesting
        }
    }
}
 
// GOOD — exceptions keep the happy path clean
public void deleteRecord(int id) {
    if (!recordExists(id))   throw new RecordNotFoundException(id);
    if (!userCanDelete(id))  throw new PermissionDeniedException(id);
    database.delete(id);
}
 
// Caller:
try {
    deleteRecord(id);
    logDeletion(id);
    notifyAudit(id);
} catch (RecordNotFoundException e) {
    logger.warn("Record not found: " + e.getId());
} catch (PermissionDeniedException e) {
    logger.error("Unauthorized deletion attempt for: " + e.getId());
}

C++ equivalent:

// GOOD — exceptions over error codes
void deleteRecord(int id) {
    if (!recordExists(id))  throw RecordNotFoundException(id);
    if (!userCanDelete(id)) throw PermissionDeniedException(id);
    database.deleteById(id);
}

Python equivalent:

# GOOD
def delete_record(record_id: int) -> None:
    if not record_exists(record_id):
        raise RecordNotFoundError(record_id)
    if not user_can_delete(record_id):
        raise PermissionDeniedError(record_id)
    database.delete(record_id)

Exception: Public APIs that cross process or network boundaries (e.g., REST endpoints, C APIs) often must return error codes because the caller may be in a different language/runtime. Within a single codebase, always prefer exceptions.


11. Extract Try/Catch Bodies

Why: try/catch blocks mix normal processing logic with error recovery logic in the same function. The result is a function with two jobs: the happy path and the error path. Extracting each into its own named function keeps the function structure clean.

// BAD — try/catch body mixed with business logic
public void deleteRecordWithCleanup(int id) {
    try {
        database.delete(id);
        cache.invalidate(id);
        auditLog.record("DELETED", id);
    } catch (DatabaseException e) {
        logger.error("DB failure on delete", e);
        alertOps(e);
    }
}
 
// GOOD — bodies extracted into named functions
public void deleteRecordWithCleanup(int id) {
    try {
        performDeletion(id);
    } catch (DatabaseException e) {
        handleDeletionFailure(e, id);
    }
}
 
private void performDeletion(int id) {
    database.delete(id);
    cache.invalidate(id);
    auditLog.record("DELETED", id);
}
 
private void handleDeletionFailure(DatabaseException e, int id) {
    logger.error("DB failure on delete for id=" + id, e);
    alertOps(e);
}

Exception: When the try block is a single line and the catch is trivial (e.g., swallowing a checked exception with a log), extraction adds no value. Apply when the bodies are non-trivial.


12. DRY — Don’t Repeat Yourself

Why: Duplication is the root of all evil in software. Every duplicated block of logic is a liability: when the algorithm changes, you must find and update every copy. Miss one and you have introduced a subtle bug. DRY applies at every level: lines, functions, classes, and systems.

// BAD — validation logic duplicated in two places
public void createProduct(String name, double price) {
    if (name == null || name.isBlank()) throw new IllegalArgumentException("Name required");
    if (price <= 0) throw new IllegalArgumentException("Price must be positive");
    // ... create logic ...
}
 
public void updateProduct(int id, String name, double price) {
    if (name == null || name.isBlank()) throw new IllegalArgumentException("Name required");
    if (price <= 0) throw new IllegalArgumentException("Price must be positive");
    // ... update logic ...
}
 
// GOOD — validation extracted once
private void validateProductInput(String name, double price) {
    if (name == null || name.isBlank()) throw new IllegalArgumentException("Name required");
    if (price <= 0) throw new IllegalArgumentException("Price must be positive");
}
 
public void createProduct(String name, double price) {
    validateProductInput(name, price);
    // ... create logic ...
}
 
public void updateProduct(int id, String name, double price) {
    validateProductInput(name, price);
    // ... update logic ...
}

Exception: Some duplication is structural coincidence — two things that look identical today but represent different concepts may diverge later. Premature DRY (abstracting things that are merely similar but conceptually distinct) creates the wrong couplings. The test: would changing one copy always require changing the other? If yes, extract. If maybe, wait.


13. Structured Programming (Dijkstra’s Rules)

Why: Dijkstra’s structured programming requires every function and block to have one entry and one exit: one return per function, no break or continue in loops, never goto. This makes control flow easy to trace. However, with small functions these rules matter less — a return in the middle of a 5-line function is perfectly readable. Apply strictly only to longer functions.

// BAD — multiple returns in a long function obscure control flow
public String processTransaction(Transaction tx) {
    if (tx == null) return "NULL_TX";
    if (!tx.isValid()) return "INVALID";
    if (tx.getAmount() <= 0) return "BAD_AMOUNT";
    // ... 30 more lines of logic with more returns ...
    return "SUCCESS";
}
 
// GOOD — small functions make multiple returns harmless (and clearer)
public String processTransaction(Transaction tx) {
    validateTransaction(tx);         // throws on invalid
    applyBusinessRules(tx);
    persistTransaction(tx);
    return buildSuccessResponse(tx);
}
// Now each helper can have its own clean single return

Exception: In small functions (under 10 lines), guard clauses (early returns) actively improve readability by eliminating nesting. Strict one-return rules only matter in large, complex functions — and the answer to those is to break them up, not add a single-return constraint.


Comparison / Summary Table

Function Argument Counts

Argument CountNameVerdictExample
0NiladicBestcurrentDate()
1MonadicGoodfileExists("myFile")
2DyadicAcceptablenew Point(0, 0)
3TriadicAvoidassertEquals(expected, actual, delta)
4+PolyadicRefactor → argument objectmakeCircle(x, y, radius, color)

Error Reporting Strategies

StrategyProsConsWhen to use
Return error codeSimple, no exception overheadCaller must check immediately; nesting; can be ignoredC APIs, cross-process boundaries
Return null/OptionalExplicit absenceCallers must check for null; doesn’t explain whySimple “not found” queries
Throw exceptionClean happy path; typed error info; can’t be silently ignoredOverhead; flow disrupted; must be declared (checked)Business logic errors within a codebase
Result/Either typeFunctional; type-safe; composableVerbose in Java; library requiredFP-style codebases

When to Apply / Common Exceptions

  • Small! — apply everywhere except trivial one-liners. The 20-line rule is guidance, not a hard limit.
  • Do One Thing — apply always, but remember orchestration is one thing.
  • Abstraction levels — critical in modules other developers read; relax slightly for throwaway scripts.
  • Switch statements — use the factory pattern when the switch will be duplicated. A one-off switch in a single location is fine.
  • Flag arguments — split them almost always. Exception: setters where the flag is the data, not a logic branch.
  • Exceptions over error codes — within a single codebase, always. At system/API boundaries, follow the protocol of the boundary.
  • DRY — be careful of premature extraction. Wait until a pattern appears 3+ times before abstracting (Rule of Three).

Checklist

  • Is every function 20 lines or fewer? If not, can it be split?
  • Does each function do exactly one thing? Can I extract another function with a non-redundant name?
  • Does every function operate at a single level of abstraction?
  • Are all switch statements buried in a factory and never duplicated?
  • Are all function names fully descriptive of what they do (not what they return)?
  • Do any functions take 3+ arguments? Can they be wrapped in an argument object?
  • Are there any boolean flag parameters? Can the function be split into two?
  • Do any functions both mutate state AND return a meaningful value (violating CQS)?
  • Are exceptions used instead of error codes for business logic failures?
  • Is every try/catch body extracted into its own named function?

Key Takeaways

  1. Small! — functions should do one thing, and small enough means you cannot extract another function from it with a meaningful name.
  2. Do One Thing — the most important rule: “FUNCTIONS SHOULD DO ONE THING. THEY SHOULD DO IT WELL. THEY SHOULD DO IT ONLY.”
  3. One level of abstraction per function; read code top-to-bottom via the step-down rule, each function descending one level.
  4. Switch statements violate SRP and OCP; bury them in a factory that is called exactly once.
  5. Descriptive names — a long, expressive name is always better than a short, cryptic one. Be consistent in your naming lexicon.
  6. Argument count — niladic > monadic > dyadic > triadic; use argument objects to collapse 3+ arguments into a single concept.
  7. Flag arguments are a code smell — they announce that the function does two things. Split into two named functions.
  8. Command Query Separation — a function either does something or answers something, never both.
  9. Exceptions over error codes — exceptions allow the happy path to be clean and error handling to be decoupled.
  10. DRY — every duplication is a future bug waiting to happen; extract the shared logic into a single, named function.


Last Updated: 2026-04-14