Push ifs up and fors down

Alex Kladov named this precisely in “Push Ifs Up and Fors Down”: two structural habits most engineers apply by instinct sometimes, without ever generalizing them.

Push ifs up: conditional logic belongs as high in the call stack as it can go. Instead of a function checking a condition internally and doing nothing when it doesn’t apply, let the caller decide whether to call it. The function becomes unconditional, simpler, more composable. Its signature stops lying: it does X, full stop, instead of “maybe does X depending on internal state.”

Push fors down: loops belong as deep as they can go. Pass a collection into a function instead of calling a single-item function from an application-level loop. Passing the whole slice unlocks batching, parallelism, reordering, vectorization, all the optimizations the caller alone can’t perform.

The N+1 query is exactly a failure to push fors down. The application loop calls getAccountByID(id) N times instead of calling getAccountsByIDs(ids) once. The fix is always the same shape: push the collection down to the function that touches the database and let it batch.

// Ifs pushed up: caller decides whether to call, not the function
func maybeProcessTransaction(tx *Transaction) {
    if tx == nil {
        return
    }
    processTransaction(tx)
}
// vs.
if tx != nil {
    processTransaction(tx)
}
// processTransaction is now unconditional — simpler to test, simpler to reason about

// Fors pushed down: pass the collection, not individual items
for _, tx := range transactions {
    tx.Account = getAccountByID(ctx, tx.AccountID) // N database round trips
}
// vs.
accountsByID := getAccountsByIDs(ctx, accountIDsOf(transactions))
for _, tx := range transactions {
    tx.Account = accountsByID[tx.AccountID]
}
// 1 database round trip regardless of collection size
-- N+1: application loops, N separate queries
SELECT id FROM transactions WHERE posted = true;
-- then per account_id: SELECT * FROM accounts WHERE id = $transaction_account_id;

-- Fors pushed down to the database: one JOIN
SELECT t.id, t.amount_cents, a.iban AS account_iban
FROM transactions t
JOIN accounts a ON a.id = t.account_id
WHERE t.posted = true;

Compose both and you get core processing functions that are unconditional and batch-oriented, with control flow centralized at the top. The hot path becomes straight-line code that the compiler and runtime can optimize aggressively. That’s not aesthetics. That’s the architecture of systems built to handle real throughput.

Data, calculations, and effects

This taxonomy comes from Eric Normand’s Grokking Simplicity.

Every line of code in a system is exactly one of three things. Data is plain values: structs, records, JSON, numbers. No behavior. Log it, serialize it, compare it, pass it around freely. Calculations are pure functions: same input, same output, no interaction with anything outside. Effects are everything that touches something external: a database read, a network call, a file write, the current time, a random number.

Keep these three separate, and push effects to the edges. The payoff is bigger than it sounds.

Most complexity in a codebase lives in effects. They depend on external state, fail in ways you can’t predict, and resist testing without real infrastructure. Calculations are the opposite: deterministic, trivial to test, easy to reason about. The more logic you express as calculations, the smaller your surface area of real uncertainty gets.

The failure mode is mixing all three in one function:

// Logic-Effect Coupling: fetch, compute, and persist all entangled
func processTransfer(ctx context.Context, transferID TransferID) error {
    transfer, err := db.Transfers.FindByID(ctx, transferID) // effect
    if err != nil {
        return err
    }
    fee := 0.0
    if !transfer.Account.IsPremium {
        fee = transfer.AmountCents * 0.015 // calculation
    }
    net := transfer.AmountCents - fee              // calculation
    tax := net * taxRateFor(transfer.Account.Region) // calculation
    if err := db.Transfers.Update(ctx, transferID, TransferUpdate{Net: net, Tax: tax}); err != nil { // effect
        return err
    }
    return notifier.SendTransferReceipt(ctx, transfer.Account.Email, net) // effect
}

You can’t unit test this without a real database and a real notification service. The fee, net, and tax calculations sit buried inside it, unverifiable on their own. A bug in the tax logic needs a full integration test to catch.

Separate the calculation from the effects instead:

// Calculation: pure function, no dependencies, instantly testable
func computeTransferTotals(transfer Transfer) TransferTotals {
    fee := 0.0
    if !transfer.Account.IsPremium {
        fee = transfer.AmountCents * 0.015
    }
    net := transfer.AmountCents - fee
    tax := net * taxRates[transfer.Account.Region]
    return TransferTotals{Net: net, Tax: tax}
}

// Effects: thin orchestration that calls the pure core
func processTransfer(ctx context.Context, transferID TransferID) error {
    transfer, err := db.Transfers.FindByID(ctx, transferID) // effect
    if err != nil {
        return err
    }
    totals := computeTransferTotals(transfer) // calculation — no effects
    if err := db.Transfers.Update(ctx, transferID, totals); err != nil { // effect
        return err
    }
    return notifier.SendTransferReceipt(ctx, transfer.Account.Email, totals) // effect
}

Now computeTransferTotals takes a plain struct and returns a plain struct. No database, no network, no mocking. Throw a hundred edge cases at it and it runs in a microsecond. processTransfer stays thin enough to read at a glance. The two concerns stop contaminating each other.

This is the direct explanation for why some feedback loops are fast and others aren’t. Entangle logic with effects and every test needs infrastructure. Isolate logic as calculations and tests run instantly, in-process. The separation is the prerequisite, not a nice-to-have.

The same shape scales up. “Functional core, imperative shell” — Gary Bernhardt’s term — or “ports and adapters,” is this principle applied at the system level. The core is pure calculation. The shell handles I/O and calls the core. The core never reaches out; it receives data and returns data. Effects sit visibly at the boundary instead of scattered invisibly through the interior.

// Functional core: no I/O, fully testable
type WaiverResult struct {
    Account Account
    Err     error
}

func applyFeeWaiver(account Account, code WaiverCode) WaiverResult {
    if code.ExpiresAt.Before(time.Now()) {
        return WaiverResult{Err: ErrWaiverExpired}
    }
    if !code.EligibleTiers[account.Tier] {
        return WaiverResult{Err: ErrWaiverNotApplicable}
    }
    account.MonthlyFeeWaived = true
    return WaiverResult{Account: account}
}

// Imperative shell: I/O at the edges, thin logic
func applyFeeWaiverHandler(w http.ResponseWriter, r *http.Request) {
    account, err := db.Accounts.FindByID(r.Context(), accountIDFromRequest(r)) // effect
    if err != nil {
        writeError(w, 404, err)
        return
    }
    code, err := db.WaiverCodes.FindByCode(r.Context(), codeFromRequest(r)) // effect
    if err != nil {
        writeError(w, 404, err)
        return
    }
    result := applyFeeWaiver(account, code) // pure core
    if result.Err != nil {
        writeError(w, 400, result.Err)
        return
    }
    if err := db.Accounts.Update(r.Context(), result.Account); err != nil { // effect
        writeError(w, 500, err)
        return
    }
    writeJSON(w, 200, result.Account)
}

Spot it: functions you can’t unit test without mocking a database or an HTTP client, business logic that reads time.Now() deep inside, a function that computes a result and persists it in the same call, test suites that spin up containers just to test pure logic.

Raise it: “This function is hard to test because the business logic and the database calls are tangled together. Pull the calculation out into a pure function and we can unit test every edge case instantly, no database needed. The I/O stays in a thin wrapper.”