I work with some really smart people. So when I hit a function in our codebase that I can’t follow, my first thought is usually “maybe I’m just not smart enough for this one.” Turns out I’m not alone. Once a function signature grows past a certain number of flags and overrides, nobody understands it. Not the smart people, not the people who wrote it. The combinations get so tangled that the thing can’t be tested, and it definitely can’t be read.

I’ve started calling these Plinko functions.

What’s a Plinko Function?

If you’ve ever seen The Price is Right, you know Plinko. A contestant drops a chip in the top of a big pegboard, it bounces left, right, left, left, right, and lands somewhere at the bottom. Nobody can tell you where. That’s the whole game. The path through the pegs is anybody’s guess.

A Plinko function works the same way. Arguments go in the top, bounce through a board of nested conditionals, and something comes out the bottom. Which path did your call actually take? Who knows. You dropped a chip with 33 parameters into the slot and now you’re watching it rattle around, hoping it lands on the branch you wanted.

How It Starts

Nobody sets out to write one of these. It starts with a perfectly good function:

function getShops(zipCode: string) {
  return shopRepository.findAll({ zipCode });
}

One job, does it reliably. But then our users with European cars complain, because sometimes we send them to shops that can’t work on their vehicles. Fine, one more param. And since we’re good developers who can sense more filters coming, we give the argument a shape:

type ShopQuery = {
  zipCode: string;
  make?: Manufacturer;
};

function getShops({ zipCode, make }: ShopQuery) {
  if (isEuropean(make)) {
    return shopRepository.findAll({ zipCode, specialties: ["european"] });
  }

  return shopRepository.findAll({ zipCode });
}

Still fine. But now the peg board exists, and every team in the org has a chip to drop:

Each of these is one small parameter. None of them, on its own, is worth a refactor. That’s exactly how it gets you. A few months of “just one more flag” each sprint and you’re staring at this:

type ShopQuery = {
  zipCode: string
  make?: Manufacturer
  enforceMake?: boolean
  coords?: Coords
  radius?: number
  minRating?: number
  prioritizeSponsored?: boolean
  throwOnEmpty?: boolean
  serviceType?: ServiceType
  includeChains?: boolean
  excludeChains?: string[]
  onlyAcceptsCash?: boolean
  maxDistance?: number
  sortBy?: "rating" | "distance" | "price"
  limit?: number
  offset?: number
  includeClosedShops?: boolean
  useCache?: boolean
  cacheKey?: string
  ignoreUserPreferences?: boolean
  expandResults?: boolean
  includeWaitTime?: boolean
  returnFullDetails?: boolean
  groupByRegion?: boolean
  filterByAvailability?: boolean
  availabilityDate?: Date
  includeServiceHistory?: boolean
  onlyVerified?: boolean
  returnCount?: boolean
  applyDiscount?: boolean
  discountCode?: string
  validateInput?: boolean
  logQuery?: boolean
  failSilently?: boolean
}

function getShops(query: ShopQuery) {
  // 80+ lines of pegs for your chip to bounce off of
  const filter: any = { zipCode: query.zipCode }

  // What happens if enforceMake and ignoreUserPreferences are both true?
  if (query.enforceMake && !query.ignoreUserPreferences && query.make) {
    filter.specialties = getSpecialtiesFromMake(query.make)
  }

  // What if maxDistance is smaller than radius?
  if (query.coords) {
    const distance = query.maxDistance !== undefined
      ? Math.min(query.radius ?? 10, query.maxDistance)
      : query.radius ?? 10
    filter.geo = { center: query.coords, radius: distance }
  } else if (query.maxDistance) {
    // This doesn't make sense, but we handle it anyway
    filter.maxDistance = query.maxDistance
  }

  // What if includeChains is false but excludeChains is empty?
  if (query.includeChains === false && !query.excludeChains?.length) {
    filter.chainFilter = "none"
  } else if (query.excludeChains?.length) {
    filter.excludeChains = query.excludeChains
  }

  // What if applyDiscount is true but there's no discountCode?
  if (query.applyDiscount && query.discountCode) {
    const discount = validateDiscount(query.discountCode)
    if (!discount && !query.failSilently) {
      throw new InvalidDiscountError()
    }
  }

  let shops = shopRepository.findAll(filter)

  if (query.minRating) shops = shops.filter(s => s.rating >= query.minRating)
  if (!query.includeClosedShops) shops = shops.filter(s => s.isOpen)
  if (query.onlyVerified) shops = shops.filter(s => s.isVerified)

  // filterByAvailability without a date: filter? skip? warn? we chose "warn, sometimes"
  if (query.filterByAvailability && query.availabilityDate) {
    shops = shops.filter(s => hasAvailability(s, query.availabilityDate))
  } else if (query.filterByAvailability) {
    query.logQuery && console.warn("filterByAvailability set but no date")
  }

  // Can't sort by distance without coords, so... rating? Sure. Rating.
  if (query.sortBy === "distance" && query.coords) {
    shops = sortByDistance(shops, query.coords)
  } else if (query.sortBy === "price") {
    shops = sortByPrice(shops)
  } else {
    shops = sortByRating(shops)
  }

  shops = shops.slice(query.offset ?? 0, (query.offset ?? 0) + (query.limit ?? 50))

  // If returnCount and returnFullDetails are both true, what shape comes back?
  if (query.returnCount && query.returnFullDetails) {
    return { count: shops.length, shops }
  } else if (query.returnCount) {
    return { count: shops.length }
  } else if (query.returnFullDetails) {
    shops = shops.map(expandShopDetails)
  }

  if (shops.length === 0 && query.throwOnEmpty && !query.failSilently) {
    throw new NotFoundError("No shops found")
  }

  return shops
}

(I trimmed this for the blog. The real ones are worse.)

Why This Is a Problem

Some questions you cannot answer without reading the whole function, and maybe not even then:

You can’t test this thing exhaustively. If those 33 parameters were merely booleans, that would already be 2^33 combinations, about 8.5 billion. They’re not all booleans, so the real number is much worse. Nobody is writing that test suite. Nobody is even writing a representative slice of it.

And the cost isn’t just testing:

That last one is the killer. The function is unreadable because people kept adding flags, and people keep adding flags because the function is unreadable. Adding one more peg to the board is always easier than fixing the board.

The Fix: More Functions, Not More Parameters

Don’t build one function that handles every scenario. Build a function per scenario. Each one takes only what it needs:

// User searching for shops that can service their vehicle
async function findShopsForVehicle(vehicle: VehicleInfo, location: Location): Promise<Shop[]> {
  const specialties = getSpecialtiesFromMake(vehicle.make)
  const shops = await shopRepository.findBySpecialties(specialties, location.zipCode)
  return shops.filter(s => s.rating >= 4.0).slice(0, 50)
}

// User searching for shops near their current location
async function findShopsNearUser(coords: Coords, radius: number = 10): Promise<Shop[]> {
  const shops = await shopRepository.findByGeo(coords, radius)
  return sortByDistance(shops.filter(s => s.isOpen), coords)
}

// Admin browsing shops with admin-specific filters
async function findShopsForAdmin(filters: AdminShopFilters): Promise<Shop[]> {
  const shops = await shopRepository.findAll(filters)
  return filters.verifiedOnly ? shops.filter(s => s.isVerified) : shops
}

Each signature documents its own intent. You physically can’t pass conflicting options, because the conflicting options don’t exist. Testing is boring again, which is what you want testing to be.

None of this is a new idea. It’s the single responsibility principle, which we all nod along to and then violate one optional parameter at a time. Every flag on getShops was a new responsibility sneaking in the side door: it wasn’t just “find shops” anymore, it was find shops and cache and paginate and validate discounts and decide its own return shape. SRP violations don’t usually announce themselves with a bad class name. They show up as options?: { ... }.

“But now my controller has an if statement!” Yeah. That’s fine. The controller is the layer that knows what kind of search the user is doing. It’s already holding that context:

app.post("/shops/for-vehicle", async (req, res) => {
  res.json(await findShopsForVehicle(req.body.vehicle, req.body.location))
})

app.post("/shops/nearby", async (req, res) => {
  res.json(await findShopsNearUser(req.body.coords, req.body.radius))
})

The Plinko function was an attempt to push that decision down into the service layer by encoding it in parameters. That’s backwards. The chip already knows where it wants to land before it gets dropped. Let it just go there.

If the separate functions share guts (query building, filtering, sorting), extract those into small helpers the public functions call. Then adding a new search is a few lines, and nobody’s tempted to reach for a flag.

Catching It Early

The pattern is easiest to kill when it’s small:

  1. Treat optional parameters as a smell. One or two is fine. When a function is collecting them from multiple teams, the architecture is telling you the use cases have diverged. Listen to it before it hits 33.
  2. Make the refactor the price of the parameter. If your feature needs a new flag on a shared function, that’s the moment to split the function instead. It costs more this sprint. It costs way less than the six months of debugging that follows the flag.
  3. Keep the decisions at the layer that has the context. If the controller is picking between search types, it should pick between functions, not parameter combinations.

Plinko functions look efficient in the moment. One function, one call site, look how reusable! But it’s a local optimization that everyone pays for globally. Next time you’re about to add a parameter to a function that already has ten, don’t. Write a new function. Your coworkers can’t tell you where the chip lands either. They’ve just stopped admitting it.