Introduce Parameter Object
Bundle arguments that travel together and mean something together. Bundling them into a vague Options bag because there were too many is how a long parameter list becomes an untyped one.
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.
When does grouping arguments into a type make a signature clearer, and when does it just hide the count?
A search function has grown to nine parameters: query, page, pageSize, sortField, sortDirection, includeArchived, tenantId, locale and a callback. Callers pass null, null, null in the middle. Someone proposes an options object.
Nine parameters is too many. Put them all in an options object; now the signature is search(options: SearchOptions) and the call sites are readable.
It hides the count instead of reducing it. SearchOptions with nine optional fields is the same nine parameters with weaker typing — every field optional means every combination is constructible, including the meaningless ones (Optional Values and Absence).
- It hides the count instead of reducing it.
SearchOptionswith nine optional fields is the same nine parameters with weaker typing — every field optional means every combination is constructible, including the meaningless ones (Optional Values and Absence). - It groups things that do not belong together.
tenantIdis a security context,localeis a presentation concern and the callback is a control-flow decision; putting them in the same object assortDirectionsays they are the same kind of thing, and they are not (Cohesion). - It becomes an attractor. An
Optionsbag with no meaning cannot refuse a tenth field, so it acquires one every quarter and ends up as the god object of parameters (God Object). - It loses the invariants. Nine independent optional fields cannot express "if
sortFieldis set thensortDirectionmust be too", so those rules move into a runtime check inside the function (Invariant Leaks). - And it is often solving a problem the language already solved. Where named and default arguments exist, the readability complaint about positional nulls disappears without any new type (Boolean Parameters).
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.
- There are fourteen call sites, several passing positional nulls.
- Three of the parameters — tenantId, locale, and the callback — are not search criteria at all.
- The language has optional and named arguments available, which changes what the alternatives are.
- A parameter object must represent something the domain has a name for, or it is a bag (Naming).
- If the group has rules — page size has a maximum, sort field must be a real field — the type enforces them, so an invalid combination cannot be constructed (Making Illegal States Unrepresentable).
- Bundling changes no behaviour.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- A parameter object owns a concept and its rules:
Paginationowns page, size and the maximum;SortSpecowns field and direction, and cannot be half-set. - The function owns the operation and, after this, a signature that says what it needs.
- The security context owns itself and travels separately — a tenant id inside an options bag is a tenant id that can be forgotten (Tenant Isolation in Backend).
- Nothing owns "the rest of the arguments". A field with no home is a design question, not a slot.
- The test for a group is co-variation and meaning: do these values always travel together, and does the group have a name a domain expert would use (Value Objects)?
- The boundary between a parameter object and a context object: criteria describe the request, context describes who is asking. Mixing them makes it possible to lose the second one silently (Trust Boundaries).
- A parameter object with a validating constructor becomes a boundary in its own right — everything downstream can then assume the values are legal (Parse, Do Not Validate in Backend).
The bag and the concepts
Both versions below replace nine positional parameters. One of them can refuse an illegal call and the other cannot, and that is the whole difference.
1// the bag: nine parameters, weaker types2interface SearchOptions {3 query?: string; page?: number; pageSize?: number4 sortField?: string; sortDirection?: string5 includeArchived?: boolean; tenantId?: string6 locale?: string; onPage?: (p: Page) => void7}8// search({ sortDirection: 'desc' }) — compiles.9// No tenant. No sort field. Sorted by nothing, descending,10// across every tenant.11 12// the concepts: illegal calls do not compile13function search(14 ctx: RequestContext, // tenant + locale, never optional15 criteria: SearchCriteria, // query, includeArchived16 page: Pagination, // page + size, size <= 20017 sort?: SortSpec, // field AND direction, or neither18): PageThe second version has four parameters rather than one, which is the point: RequestContext is not a search criterion and must never be omittable, SortSpec makes "direction without field" unconstructible, and Pagination enforces the size cap once instead of in fourteen callers. The callback is gone entirely — it was control flow, not a parameter. Counting parameters would have scored the first version better.
When the bag is the smell
The options bag deserves naming as a smell in its own right, because it arrives as the *fix* for a recognised smell and is therefore rarely questioned.
looks like A type named Options, Config, Params or Args, every field optional, growing by a field or two each quarter, with a comment or a wiki page explaining which combinations are valid.
suggests The parameters were bundled to reduce a count rather than because they mean something together. Because the type names no concept, it has no basis on which to refuse a new field, so it accumulates — and because every field is optional, the rules relating them have to live as runtime checks inside the function, where no caller can see them (Invariant Leaks).
fix Split by concept, not by count. Pull out the groups that co-vary and name them; move the ones that were never parameters — security context, control-flow callbacks, presentation concerns — out of the argument list entirely; and make the remaining fields required inside small types so that an illegal combination cannot be constructed. If, after that, three unrelated arguments remain, leave them as three arguments (Value Objects).
What the caller reads
The practical test of this refactoring is what a call site looks like six months later, when someone is reading it to answer a question rather than to write it.
search(q, 1, 20, 'created', 'desc', false, tenant, 'en', cb)
// what is false? what is 20?
search({
query: q, page: 1, pageSize: 20,
sortField: 'created', sortDirection: 'desc',
includeArchived: false, tenantId: tenant,
locale: 'en', onPage: cb,
})
// readable, and still nine independent values
// with no rule connecting any of themsearch(
ctx,
{ query: q, includeArchived: false },
Pagination.of(1, 20),
SortSpec.by('created').descending(),
)
// Pagination.of rejects a size over 200 — once,
// for all fourteen call sites.
// SortSpec cannot exist half-specified.
// ctx cannot be omitted.The middle version fixes readability and nothing else — it is the same nine values with the same absence of rules, and the reviewer who approves it has removed the only moment at which anyone would have asked whether tenantId belongs in the same object as sortDirection. The third version moves three checks out of every caller into two constructors and makes two illegal states unrepresentable, which is a guarantee rather than a formatting improvement (Parse, Do Not Validate in Backend).
How to build it
Most important first.
- Group by meaning first.
Pagination { page, size }andSortSpec { field, direction }are concepts;SearchOptions { ...everything }is a container. - Give each group its rules.
Pagination.of(page, size)rejects a size over the maximum, so nothing downstream has to check (Enforcing Invariants). - Move the things that were never criteria out entirely. Tenant and locale belong to a request context passed explicitly; the callback belongs in the return type or in the caller (Request Context Propagation in Backend).
- Prefer required fields inside a small object over optional fields inside a large one — the point is to make illegal combinations unconstructible, not to make everything skippable.
- If the language has named and default arguments and the parameters have no shared meaning, use them and introduce no type at all. That is frequently the correct answer (When Design Does Not Pay).
- Do it in behaviour-preserving steps: introduce the type, have the old signature delegate, migrate call sites, then remove the old signature (The Refactoring Loop).
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.
- Before: adding a search criterion costs a tenth parameter and an edit to all fourteen call sites, most of which do not care (Shotgun Surgery).
- After, done well: adding a criterion costs a field on the concept it belongs to, and the call sites that do not use it are untouched. Changing the page-size limit costs one edit inside
Pagination, and every caller inherits it. - After, done badly: adding a criterion costs one optional field on the bag, which is cheap — and that cheapness is the problem, because it removes the moment at which anyone would ask whether the field belongs there. The cost arrives later, all at once, when the bag has to be untangled (Interest: Why Debt Compounds).
- What does not get cheaper: a call site that genuinely needs eight of the nine values now constructs two or three objects to do it, which is more code than passing eight arguments was.
- A parameter object is an extra type to name, import and maintain, and for a function with three related arguments the ceremony can genuinely exceed the benefit.
- It adds a layer between the caller and the values, so a reader must open the type to see what a call actually passes.
- Validating constructors move failures earlier, which is usually right and occasionally wrong — a batch importer may prefer to collect all the invalid rows rather than throw on the first (Error Modeling).
What can go wrong
- The bag.
Optionswith fifteen optional fields, four of which are mutually exclusive, and a comment explaining which combinations are valid (Comments). - A parameter object that is just the parameters, one to one, with no rules and no name of its own — pure ceremony.
- Optional-everything, so the function still has to check for absurd combinations and the type system contributes nothing (Optional Values and Absence).
- Security context inside the bag, and a call site that forgot to set it — which now fails open rather than failing to compile (Least Privilege as a Design Decision).
- The mitigation fails: a team splits into five small parameter objects and the signature is now five arguments of five types, which is not obviously better than what it replaced (Over-Decomposition).
- Callers depend on the new type, which is a shared vocabulary item — a benefit when it is a real concept and a liability when it is a bag, because a bag becomes a dependency everyone has and nobody can change (Shared Libraries).
- A validating parameter object is depended on for its guarantees as well as its shape, which is what makes it worth having and what makes it hard to loosen later (API Stability).
- An options bag creates coupling between unrelated callers: a field added for one search caller is now visible to all fourteen (Exposing Too Much).
- "So functions should take one argument." The goal is arguments that mean something together, not a count. Three unrelated arguments are clearer as three arguments (Function Design).
- "An options object is a parameter object." Only if it names a concept. A container named for its role in the call rather than for what it is has none of the properties this refactoring is for (Naming).
- "Make the fields optional so callers can pass what they like." Optionality is what lets illegal combinations exist. Required fields on small objects is the shape that carries guarantees (Optional Values and Absence).
- "This is just about readability." The valuable part is that a type with rules removes checks from every caller. Readability is the visible benefit and the smaller one (Enforcing Invariants).
- long-parameter-list
- primitive-obsession
Testing it, and how it ages
- Test the parameter object's rules directly — that
Pagination.of(1, 10_000)is rejected. That test replaces a check that used to live in every caller (Enforcing Invariants). - The function's own tests should not change during the refactoring (What Refactoring Actually Is).
- A test that an invalid combination cannot be constructed. If you cannot write that test because the type permits everything, you built a bag (Making Illegal States Unrepresentable).
- A meaningful parameter object attracts related behaviour and becomes a value object with methods —
Pagination.next(),SortSpec.reversed(). That is the healthy trajectory (Value Objects). - A bag attracts unrelated fields and becomes impossible to change, because fourteen callers depend on different subsets of it and no test describes which (God Object).
- What forces a rethink: two callers needing mutually exclusive fields on the same object. That is two concepts sharing a type, and the fix is to split it before the exclusivity gets encoded in comments.
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 arguments and defaults — Python, Kotlin, C#, Swift — the readability motivation largely evaporates, and the only remaining reason to introduce the type is to carry invariants. In one with positional-only arguments the readability case is much stronger, so the same nine-parameter function warrants different answers in different languages.
- GENERALThat grouped arguments should represent a concept rather than a container holds regardless of language, because the failure mode is about what the type can refuse rather than about syntax.
- CONTESTEDThe strongest opposing view is that a plain options record is simply better than a proliferation of small nominal types: it is easy to construct, easy to extend, requires no imports, and serialises directly — and the "bag" criticism is really a criticism of unbounded growth rather than of the shape. Teams working in structurally-typed languages with good inference often find small parameter types pure ceremony. The counter is invariants: a record cannot refuse an illegal combination, and once a rule exists that spans two fields, something has to enforce it and every caller is the wrong place.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — whether a small wrapper type costs anything at runtime is a language question, and where it does not, the argument against small nominal types is purely about ceremony.