SmellsLANGUAGE-SPECIFICCONTESTEDDOMAIN-SPECIFIC

Long Parameter List

Eight arguments, four of them booleans. Bundling them into a parameter object makes the call site tidier and changes nothing — the finding is usually a concept that has no name.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

This function takes nine arguments. Is the fix a parameter object, or is something missing from the model?

The requirement

Add "quote for a business customer in a second country". The signature priceOrder(items, country, currency, isBusiness, vatId, discountCode, shippingTier, includeTax, roundUp) gains two more arguments.

The obvious build

Bundle the arguments into an options object. One parameter instead of nine, the call sites get names, and adding the tenth field stops being a signature change.

Why it breaks

The object has the same nine fields, in the same unclear relationships, with the same illegal combinations — you have moved the problem behind a brace and made it slightly harder to see (Introduce Parameter Object).

How it breaks as requirements change
  • The object has the same nine fields, in the same unclear relationships, with the same illegal combinations — you have moved the problem behind a brace and made it slightly harder to see (Introduce Parameter Object).
  • It usually gets worse: every field becomes optional so that each call site can omit what it does not care about, and now the function has to defend against combinations no caller intended (Optional Values and Absence).
  • Adding a field is now invisible in the type signature of the callers, which sounds like a benefit and means the compiler stops telling you which call sites need to think about it.
  • The real cost was never typing nine arguments. It was that nobody could say what the function *does*, because its inputs describe a situation with no name.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The function has forty call sites, several of them in tests that pass positional arguments.
  • The language has no named arguments at call sites, so ordering mistakes are silent (Boolean Parameters).
  • Two of the arguments are only meaningful together, and nothing in the signature says so.
Invariants
  • A VAT id is only meaningful for a business customer; a call passing one without the other is nonsense and must not be representable (Making Illegal States Unrepresentable).
  • Every call must produce the same price for the same inputs, which is currently hard to check because "the same inputs" is a nine-dimensional statement.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Something must own "who is buying and under what terms" — the combination of customer kind, country, tax status and currency, which travels together everywhere.
  • The pricing function owns applying rules to that situation, and should not own assembling it.
  • Constructing the situation is the caller's job, done once, at a boundary, where the information actually arrives (Boundary Adapters).
Boundaries
  • The seam is wherever the arguments stop travelling as a group. Parameters that always appear together across several functions are a concept in hiding — the classic catalogue calls the group a data clump, and here it is simply a type nobody has written (Value Objects).
  • Flags are a different case: a boolean parameter that selects behaviour is usually two functions sharing a body, and the boundary is between them (Boolean Parameters).
  • Do not draw the boundary around "the arguments of this function". That produces a type named after a call site, which is the parameter-object failure.

The smell, and the case for leaving it long

What makes this entry unusual is that the standard fix is the wrong one often enough to be worth arguing about. Bundling is a syntactic change; the finding, when there is one, is semantic.

  • Recurs across signatures — the same four arguments in five functions is a concept; nine arguments in one function is a Tuesday.
  • Same-typed neighbours — two strings in a row that can be swapped silently is a defect waiting, independent of the total count.
  • Booleans — each one doubles the number of behaviours the function claims to have, and most callers pass a literal (Boolean Parameters).
  • Illegal combinations — if some pairs of arguments are nonsense together, the type system should be saying so (Making Illegal States Unrepresentable).
smellLong parameter list

looks like Signatures with seven or more arguments; several arguments of the same primitive type in a row; two or more booleans; the same four arguments appearing together in several unrelated functions.

suggests Either a group of values that belongs together and has no name, or a function doing two jobs selected by a flag. The repeated clump across signatures is the strong signal; the count on its own is the weak one.

fix Look for the clump that recurs across signatures and name it in domain terms, with construction rules that make illegal combinations impossible. Split on behavioural flags instead of passing them. Reach for a plain parameter object only when there is no concept to find.

when this is fine A genuine configuration surface, where the parameters really are independent and no concept unites them: a compression call taking level, window size, strategy and dictionary; a plotting function taking a dozen appearance knobs; a test helper assembling a fixture. Wrapping these in a type invents a concept that does not exist in the domain and leaves you naming it after the function. It is also fine at a boundary where the arguments arrive genuinely separately — a CLI entry point is supposed to take everything the flags gave it (Function Design).

Two fixes that look identical in the diff

Both versions below reduce nine parameters to two. Only one of them changes what the code can express, and the difference is invisible if you review by counting arguments.

Bundling versus naming
Parameter object named after the call site
interface PriceOrderOptions {
  country?: string; currency?: string
  isBusiness?: boolean; vatId?: string
  discountCode?: string; roundUp?: boolean
}
priceOrder(items, opts)

// vatId without isBusiness still compiles.
// Every field optional, so the body defends
// against combinations no caller sends.
The concept that was missing
type TaxProfile =
  | { kind: 'consumer'; country: Country }
  | { kind: 'business'; country: Country; vatId: VatId }

priceOrder(items, profile: TaxProfile, terms: Terms)

// A business without a VAT id does not compile.
// The type is reusable: invoicing and
// reporting needed exactly this.

The first version tidies the call site and preserves every illegal combination, while removing the compiler's ability to tell callers that something new needs their attention. The second removes a whole class of state from the program and produces a type three other modules turn out to need — which is the evidence that the concept was real rather than invented (Making Illegal States Unrepresentable).

The concept, once it has a name

CONTESTEDA strong counter-argument says this responsibility list is written after the fact and is therefore not evidence: you can make any bundle look coherent by describing it charitably. The defensible test is prospective and cheap — before naming it, check whether the same group already appears in two other signatures. If it does not, keep the long list until it does.

When the clump really is a concept, writing it down in responsibility terms is what confirms it. A genuine concept has a small, coherent list of reasons to change; an invented one has a list that reads "whenever any caller needs something different".

responsibilitiesTaxProfileTaxProfile, extracted from a parameter list
Knows
  • Whether the buyer is a consumer or a business
  • Which country they are taxed in
  • The VAT id, when and only when there is one
Does
  • Refuses to be constructed in an illegal combination
  • Answers whether reverse charge applies
  • Compares equal to another profile with the same facts
Depends on
  • Country and VatId — value types with no dependencies of their own
Changes when — 1 distinct reason
  • The tax law recognises a new kind of buyer

One reason to change, and it is a sentence about the world rather than about the code. That is what distinguishes it from PriceOrderOptions, whose reason to change would have been "whenever any of forty call sites wants something else". Three other modules — invoicing, reporting and the checkout API — turned out to need the same type, which is the retrospective evidence that the clump was a concept and not a bag (Value Objects).

How to build it

Most important first.

  • Look for the group that travels together. If country, currency, isBusiness and vatId appear side by side in five signatures, they are one thing and the model is missing it (Value Objects).
  • Name the thing in domain language — TaxProfile, PurchaseContext — and let it enforce the combinations that are legal, so the illegal ones cannot be constructed.
  • Split on the flags rather than passing them: priceOrder and priceQuote beat priceOrder(..., isQuote) when the two bodies share little (Boolean Parameters).
  • Push the assembly outwards. If a function needs nine things, the caller usually knows why they belong together and the function does not.
  • Only reach for a plain parameter object when the arguments genuinely are an unrelated bag — a configuration record, a test builder — where there is no concept to find (Introduce Parameter Object).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: adding "business customer in a second country" costs a signature change at forty call sites, forty positional-argument reviews, and a class of ordering bug the compiler cannot catch when two arguments share a type.
  • Before, in the part that hurts: nobody can tell which of the forty call sites pass a legal combination, so the audit is per-site and never finishes.
  • With a parameter object: the forty call sites do not change, which is faster now and means nobody revisits them. The illegal combinations survive and are now less visible.
  • With the concept named: the change lands in TaxProfile and in pricing. Call sites that construct a profile get a compile error only if they must, and illegal combinations stop compiling — a bounded, mechanical list.
What the recommended approach costs
  • A named concept is a new vocabulary item every reader must learn, and vocabulary has a real budget (What an Abstraction Costs).
  • Constructing it costs a line at each boundary, and for a function with three callers that may not repay.
  • Splitting on flags multiplies the number of functions, and if the bodies do share most of their logic you have traded one problem for duplication (Duplicate Knowledge).

What can go wrong

Failure modes
  • The parameter object is named after the function (PriceOrderOptions) and therefore cannot be reused, so the next function gets its own, and the model is now worse than the nine arguments.
  • All fields become optional and the function grows defensive branches for combinations no caller produces, which are untested and eventually wrong.
  • The concept is found but made mutable, so callers configure it in stages and the function receives it half-built (Explicit State).
  • The mitigation fails: naming a concept before you have seen it in two or three places produces a type that fits one caller, and the second caller bends it out of shape (The Rule of Three).
Dependencies, and their direction
  • A named concept becomes a dependency of everything that prices, which is correct: they all depended on the same nine facts before, without saying so.
  • The new type must depend on nothing but values, or every consumer inherits its dependencies.
Misreads
  • "More than three parameters is a smell." Count is the weakest possible signal. A function taking six unrelated, differently-typed values is fine; one taking two confusable strings in an ambiguous order is not (Units in Names and Types).
  • "Parameter objects are the fix." They are the fix when there is no concept to find. When there is, they hide it, and hiding it is worse than the long list because the list at least was uncomfortable (Introduce Parameter Object).
  • "Use a builder." A builder makes a long list constructible in steps; it does not make illegal combinations impossible, and it usually makes them easier to produce.
  • "Just use named arguments." They fix readability, which is the smallest part of the problem. The relationships between arguments remain unexpressed (Making Illegal States Unrepresentable).
Smells this explains
  • long-parameter-list
  • primitive-obsession
  • feature-envy

Testing it, and how it ages

What to test, and at which boundary
  • Test the concept's construction rules directly: a VAT id without a business customer must fail to construct, and that test is the invariant (Enforcing Invariants).
  • Test pricing against the concept, which cuts the input space from nine loose values to a small number of legal situations.
  • If your tests need a nine-argument builder to be readable, the builder is telling you what the missing type is (Testing as Design Feedback).
How this design ages
  • Parameter lists grow one argument at a time, and each addition is smaller than the refactor that would prevent the next one. That asymmetry is why they reach nine.
  • Once a concept is named, it tends to attract the rules that were scattered across callers, which is the payoff and takes a few months to show up.
  • The concept ages badly if it was named after a transport shape rather than a domain idea — a RequestContext that is really "whatever the HTTP layer had lying around" (Naming and Domain Language).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • LANGUAGE-SPECIFICIn a language with named and defaulted arguments — Python, Kotlin, C#, Swift — the readability half of this smell largely disappears and only the modelling half remains. In one without them, positional confusion is a live defect source, so the same signature is genuinely more dangerous.
  • CONTESTEDThe strongest opposing view is that hunting for a "missing concept" is exactly the speculative modelling this domain warns about elsewhere: an options record is honest about being a bag of inputs, whereas a prematurely named PurchaseContext claims a domain meaning it may not have and is much harder to unwind once forty call sites construct one. That is a fair criticism, and the discriminator is evidence — the same group of parameters appearing in several unrelated signatures, not one long list.
  • DOMAIN-SPECIFICIn domains with genuinely wide configuration — a compiler driver, a video encoder, a query planner — long parameter lists and options records are the correct design, because the parameters really are independent knobs and no concept unifies them. The smell is aimed at business logic, where clumps usually do mean something.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — named arguments, default values and sum types are language features that change which half of this smell is even present, and their design trade-offs belong there.