Parameter Object
Three parameters, then a struct.
Too many parameters is a missing type. When the same group of values keeps travelling together, bundle them. The call sites compress; the contract clarifies; the linter stops complaining at four.
Opinion
I have lost count of the times I have read a function call with five positional arguments and had to count the commas backwards to work out which one is the timeout and which one is the retry count. The fix has been in the Refactoring catalogue since 1999: Introduce Parameter Object.1Refactoring, 2nd ed. (Addison-Wesley, 2018). Introduce Parameter Object — bundle a recurring group of arguments into a class. The Data Clump bliki entry names the underlying smell. Two engineers see the same fields travelling together, the third caller arrives, the bundle gets a name and a type, and the call sites stop reading like SQL with the column names removed.
The cap is editorial. Robert C. Martin says zero is ideal, three is the polyadic ceiling, and anything past that needs “very special justification”.2Clean Code (Prentice Hall, 2008), ch. 3 “Functions”. “The ideal number of arguments for a function is zero (niladic) … More than three (polyadic) requires very special justification.” The Prickles repo sets max-params: 3; ESLint and typescript-eslint both ship the rule out of the box. The discipline is to stop negotiating: at four, the function fails the lint and the caller writes the struct. A struct with three named fields reads better than four positional anything.
The pairing is what makes the rule useful. F2 Intention-Revealing Names picks the struct's name; F6 Encapsulation decides whether the new type is a value or an entity; T1 Domain-Driven Types brands the fields so the function signature carries the constraint. The lint fires; the rest of the canon catches the work.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
Too many parameters is a missing type. When the same group of fields keeps travelling together, bundle them into a named struct. Three positional parameters is the cap; the fourth is the lint failure that asks for the struct. /tenet/parameter-object/S3
AI eyes only
Rule: three parameters, then a struct.
Reject: function signatures with four or more positional parameters. Reject: adding “one more” optional argument to an existing four-arg helper.
Generate: when a function needs a fourth argument, group related arguments into a typed struct (or destructured object) named for the role.
Diagnostic: count parameters. Four or more: group into a struct. If the struct has no obvious name, the grouping is wrong; rethink the responsibilities.
Why?
- Call sites stop reading like SQL with the column names removed. A struct with three named fields is self-explanatory at the next reviewer's first read; four positional arguments are not.
- The struct's name is the missing type. F2 Intention-Revealing Names picks the name; the linter forces the conversation that produces it.
- Eliminates argument-order bugs. A function signature
sendEmail(to, from, subject, body, replyTo)ships the bug wheretoandfromare swapped. A struct with named fields cannot. - Stable signatures across versions. Add a new field to the struct and existing callers compile; add a new positional argument and every call site has to be updated.
- Coding agents stop generating five-argument helpers. The cap is the spec the agent inherits; the linter enforces what the model would otherwise default to.
- Hands the encapsulation conversation to F6 Encapsulation. The struct decides whether the fields are a value object or an entity; the cap is what forces the decision in the first place.
- Cuts review time. Reviewers stop arguing about whether the parameter list is too long; the linter decides for them. The conversation moves to whether the struct is well-named.
Origins
Martin Fowler catalogued Introduce Parameter Object in the first edition of Refactoring in 1999 and reprinted it in the second edition in 2018.1Refactoring, 2nd ed. (Addison-Wesley, 2018). Introduce Parameter Object — bundle a recurring group of arguments into a class. The Data Clump bliki entry names the underlying smell. The refactor names the smell it answers — Data Clump — directly: a group of data items that appear together across multiple signatures, parameter lists or class fields. The catalogue entry is operational: identify the clump, create a class, replace the parameters with the new type, update the callers, run the tests.
Robert C. Martin's Clean Code chapter on functions sharpened the threshold. “The ideal number of arguments for a function is zero (niladic). Next comes one (monadic), followed closely by two (dyadic). Three arguments (triadic) should be avoided where possible. More than three (polyadic) requires very special justification.”2Clean Code (Prentice Hall, 2008), ch. 3 “Functions”. “The ideal number of arguments for a function is zero (niladic) … More than three (polyadic) requires very special justification.” Three is the soft ceiling; four is the lint failure; the editorial position is that “very special justification” should live in a code comment with an audit trail, not in the next pull request.
The ESLint max-params rule (and its @typescript-eslint/max-params cousin) make the cap operational.4ESLint `max-params` rule (also typescript-eslint's `max-params`). The canonical lint surface for the parameter cap. Default 3; the Prickles repo runs it as a warn. Default 3; configurable per project; the Prickles repo runs it as a warn. Python's Pylint family carries the same rule as PLR0913; PHPMD and PMD ship ExcessiveParameterList; every mainstream linter has the lever.
The principle the rule sits underneath is older still. Parnas's 1972 paper on information hiding argues that modules earn their boundary by encapsulating a design decision.5“On the Criteria To Be Used in Decomposing Systems into Modules”, CACM 15(12), 1972. The foundational paper on information hiding. A struct earns its boundary by encapsulating a design decision; positional arguments leave the decision in the caller's head. A struct with three named fields encapsulates the “these belong together” decision; four positional arguments leave the decision in the caller's head. Same instinct, half a century earlier.
Quotes
I see groups of parameters that naturally go together. Replace them with an object that carries the data. Common parameter groups are an indication that I am missing a concept.
The ideal number of arguments for a function is zero (niladic). Next comes one (monadic), followed closely by two (dyadic). Three arguments (triadic) should be avoided where possible. More than three (polyadic) requires very special justification.
Bunches of data that hang around together really ought to be made into their own object. The fields and parameters that belong together don't know they belong together until you give them a name.
The best modules provide powerful functionality through simple interfaces. A simple interface is one with few parameters; the parameters that survive are the ones that carry information the module cannot infer.
Evidence
Twenty external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01The canonical refactor. Replace a recurring group of parameters with an object; update callers; run tests. The catalogue entry the rule operationalises.
- 02Data Clump (bliki)SupportsNames the smell. A group of data items that travel together across signatures wants a name. The bliki entry is the shortest single-page statement of the rule.
- 03Clean Code, ch. 3 “Functions”SupportsThree is the polyadic ceiling; more than three needs special justification. The most-quoted single-sentence formulation of the cap.
- 04“Modules Should Be Deep.” Narrow interfaces over wide ones; the parameter object is the function-level instance of the rule. Aligned with Fowler on substance, broader in scope.
- 05“The Wrong Abstraction”QualifiesThe asymmetry-of-cost argument at the signature level. A struct extracted too soon is the wrong abstraction; pair the cap with the rule of three so the bundle earns its name.
Sixteen sources, two stances. The supporters cluster on the rule itself: Fowler's Introduce Parameter Object and Data Clump entries, Martin's Clean Code chapter, Ousterhout. The qualifiers further down (led by Metz) carry the “wait until the bundle has earned its name” reading. There is no substantive opposition; the disagreement is about timing, not about the rule itself.
Examples
// Before: 5 positional parameters. max-params: 3 fires; the call site reads as commas in a row.function scheduleHedgehogVisit(name: string, ageBand: string, lat: number, lng: number, preferredFood: string): void { logVisit({ name, ageBand, lat, lng, preferredFood });}
// After: a HedgehogVisit struct names the bundle. The signature becomes one parameter.type BurrowLocation = { lat: number; lng: number };type HedgehogVisit = { name: string; ageBand: string; location: BurrowLocation; preferredFood: string;}function scheduleHedgehogVisit(visit: HedgehogVisit): void { logVisit(visit);}
// Before: 5 positional parameters. max-params: 3 fires; the call site reads as commas in a row.function scheduleHedgehogVisit(name: string, ageBand: string, lat: number, lng: number, preferredFood: string): void { logVisit({ name, ageBand, lat, lng, preferredFood });}
// After: a HedgehogVisit struct names the bundle. The signature becomes one parameter.type BurrowLocation = { lat: number; lng: number };type HedgehogVisit = { name: string; ageBand: string; location: BurrowLocation; preferredFood: string;}function scheduleHedgehogVisit(visit: HedgehogVisit): void { logVisit(visit);}
// Before: 5 positional parameters. max-params: 3 fires; the call site reads as commas in a row.function scheduleHedgehogVisit(name, ageBand, lat, lng, preferredFood) { logVisit({ name, ageBand, lat, lng, preferredFood });}
// After: a visit object names the bundle. The signature becomes one parameter.function scheduleHedgehogVisit(visit) { const { name, ageBand, location, preferredFood } = visit; logVisit({ name, ageBand, ...location, preferredFood });}
# Before: 5 positional parameters. PLR0913 fires; the call site reads as commas in a row.def schedule_hedgehog_visit(name: str, age_band: str, lat: float, lng: float, preferred_food: str) -> None: log_visit(name, age_band, lat, lng, preferred_food)
# After: a HedgehogVisit dataclass names the bundle. The signature becomes one parameter.@dataclassclass BurrowLocation: lat: float lng: float@dataclassclass HedgehogVisit: name: str age_band: str location: BurrowLocation preferred_food: strdef schedule_hedgehog_visit(visit: HedgehogVisit) -> None: log_visit(visit)
// Before: 5 positional parameters. The call site reads as commas in a row.static void scheduleHedgehogVisit(String name, String ageBand, double lat, double lng, String preferredFood) { logVisit(name, ageBand, lat, lng, preferredFood);}
// After: a HedgehogVisit record names the bundle. The signature becomes one parameter.record BurrowLocation(double lat, double lng) {}record HedgehogVisit( String name, String ageBand, BurrowLocation location, String preferredFood) {}static void scheduleHedgehogVisit(HedgehogVisit visit) { logVisit(visit);}
// Before: 5 positional parameters. The call site reads as commas in a row.function scheduleHedgehogVisit(string $name, string $ageBand, float $lat, float $lng, string $preferredFood): void { logVisit($name, $ageBand, $lat, $lng, $preferredFood);}
// After: a HedgehogVisit class names the bundle. The signature becomes one parameter.readonly class BurrowLocation { public function __construct(public float $lat, public float $lng) {}}readonly class HedgehogVisit { public function __construct( public string $name, public string $ageBand, public BurrowLocation $location, public string $preferredFood, ) {}}function scheduleHedgehogVisit(HedgehogVisit $visit): void { logVisit($visit);}
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| @typescript-eslint/max-params | typescript-eslint | function and method signatures with more than 3 parameters. Excludes overload declarations; counts the implementation signature. |
| max-params (core, disabled when TS rule is on) | ESLint core | the same cap without TypeScript awareness. Disable the core rule when `@typescript-eslint/max-params` is configured to avoid double-reporting. |
| object-destructuring as the parameter object | typescript-eslint | the right pattern when the struct is genuinely one-off. A destructured object literal counts as one parameter, satisfies the lint, and ships the same call-site benefit. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['**/*.{ts,tsx}'],
rules: {
'max-params': 'off',
'@typescript-eslint/max-params': ['warn', { max: 3 }],
}
});Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| @typescript-eslint/max-params | typescript-eslint | function and method signatures with more than 3 parameters. Excludes overload declarations; counts the implementation signature. |
| max-params (core, disabled when TS rule is on) | ESLint core | the same cap without TypeScript awareness. Disable the core rule when `@typescript-eslint/max-params` is configured to avoid double-reporting. |
| object-destructuring as the parameter object | typescript-eslint | the right pattern when the struct is genuinely one-off. A destructured object literal counts as one parameter, satisfies the lint, and ships the same call-site benefit. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['**/*.{ts,tsx}'],
rules: {
'max-params': 'off',
'@typescript-eslint/max-params': ['warn', { max: 3 }],
}
});Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| max-params | ESLint core | function and method signatures with more than 3 parameters. The canonical JavaScript surface for the cap. |
| destructured object parameter | ESLint core | the workaround when the struct is genuinely one-off. Destructured object literals count as one parameter; the call site stays readable; the lint passes. |
| destructured options object pattern | ESLint core | the conventional Node.js form: a final `options` object collects optional parameters, leaving the required ones positional. Compatible with the cap when the required count stays under 3. |
eslint.config.mjsconfiguration snippet
import js from '@eslint/js';
export default [
js.configs.recommended,
{
files: ['**/*.{js,mjs,cjs}'],
rules: {
'max-params': ['warn', 3],
},
},
];Apply these rules in pyproject.toml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| PLR0913 (too-many-arguments) | Ruff (pylint) | function definitions with more than the configured number of arguments. Default 5; tighten to 3 to match the editorial position. |
| PLR0917 (too-many-positional-arguments) | Ruff (pylint) | the positional-only count. Catches the “add a keyword-only argument to skirt PLR0913” pattern; tighten to 3. |
| R0913 (too-many-arguments) — Pylint reference | Pylint | the original Pylint implementation Ruff inherits. Useful when Pylint is the canonical CI surface. |
| dataclass / TypedDict / NamedTuple | Pylint | the right Python primitives for the parameter object. Frozen dataclass for value semantics; TypedDict for keyword-arg compatibility; NamedTuple when ordering matters. |
pyproject.tomlconfiguration snippet
[tool.ruff.lint]
select = [
"PLR0913",
"PLR0917",
]
[tool.ruff.lint.pylint]
max-args = 3
max-positional-args = 3Apply these rules in pmd-ruleset.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| ExcessiveParameterList | PMD | method signatures with more than the configured parameter count. Default 10; tighten to 4 to match the editorial position. |
| UseObjectForClearerAPI | PMD | methods that take many primitive parameters where a value object would be clearer. The Data Clump conversation expressed as a code-style rule. |
| ParameterNumber (Checkstyle) | Checkstyle | the same parameter cap as a Checkstyle rule. Useful when Checkstyle is the team's lint surface and PMD is not configured. |
| Java record | PMD | the right JDK primitive for the parameter object. Records are immutable, equal-by-value, and one line of code per field. |
pmd-ruleset.xmlconfiguration snippet
<?xml version="1.0"?>
<ruleset name="ParameterObject"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0">
<description>S3 Parameter Object — cap parameter count and surface Data Clumps.</description>
<rule ref="category/java/design.xml/ExcessiveParameterList">
<properties>
<property name="minimum" value="4"/>
</properties>
</rule>
<rule ref="category/java/codestyle.xml/UseObjectForClearerAPI"/>
</ruleset>Apply these rules in phpmd-ruleset.xml. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| ExcessiveParameterList | PHPMD | method signatures with more than the configured parameter count. Default 10; tighten to 4 for parity with the JavaScript and Python surfaces. |
| BooleanArgumentFlag | PHPMD | boolean parameters that signal a method does two different things. The Data Clump's cousin: split the method instead of growing the signature. |
| Generic.Functions.OpeningFunctionBraceBsdAllman | PHP_CodeSniffer | long parameter lists laid out in a way that makes the signature hard to read. Indirect signal of a missing parameter object. |
| PHP 8 readonly + constructor promotion | PHPMD | the right PHP primitive for the parameter object. Constructor-promoted readonly properties make the value object one line per field. |
phpmd-ruleset.xmlconfiguration snippet
<?xml version="1.0"?>
<ruleset name="ParameterObject"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0">
<description>S3 Parameter Object — cap parameter count.</description>
<rule ref="rulesets/codesize.xml/ExcessiveParameterList">
<properties>
<property name="minimum" value="4"/>
</properties>
</rule>
<rule ref="rulesets/cleancode.xml/BooleanArgumentFlag"/>
</ruleset>AI rules
.cursor/rules/s3-parameter-object.mdc---
description: Prickles S3 — Parameter Object
globs: "**/*.{ts,tsx,js,jsx,py,java,php}"
alwaysApply: false
---
## Prickles S3 — Parameter Object
Cap function parameters at three. The fourth parameter is the missing struct.
When the same group of fields keeps appearing together (a Data Clump), bundle them into a named type. Update callers; run tests.
Use the language's named-argument feature (object destructuring, keyword arguments) for cases where the struct is genuinely a one-off.
Refuse to add a fourth positional parameter to satisfy a new requirement. Either the new field belongs in the struct that should already exist, or the function is doing too much and needs to split.Repo layout, CI, and ESLint wiring for these paths live on /implementation — not repeated on every tenet.
Counter-argument
The strongest steelman is Sandi Metz's, restated for parameter lists.3“The Wrong Abstraction” (sandimetz.com, 2016). The asymmetry-of-cost argument applies to parameter objects too: a struct extracted from incomplete information is the wrong abstraction at the signature level. Metz's point is that a parameter object extracted from incomplete information is the wrong abstraction at the signature level. Two callers passing the same three fields together is not yet a Data Clump; it might be a coincidence of the API surface, and bundling the fields produces a struct that the third caller has to deconstruct because its requirements have moved on. The rule of thumb “three parameters, then a struct” is right; the rule of dogma “always wrap parameters” is wrong, and the linter knows the difference.
Counter-argument retort
Metz is right that a parameter object extracted too soon is the wrong abstraction at the signature level. The reply is that the rule of three applies to parameter objects too — bundle on the third caller, not the second.3“The Wrong Abstraction” (sandimetz.com, 2016). The asymmetry-of-cost argument applies to parameter objects too: a struct extracted from incomplete information is the wrong abstraction at the signature level. Two functions with three shared fields is a coincidence; three functions with the same three shared fields is a Data Clump and the struct earns its name. The cap fires at four parameters; the struct earns its existence at three callers. Two different triggers; one consistent rule.
The honest residue is the over-narrow object problem: a struct with three named fields can itself be the wrong abstraction when the third caller's field list is two of the three plus one new one. The answer is to split the struct, not to revert to positional parameters — the new struct still earns its name from the field combination it represents. The conversation is about the right struct, not whether to have one.
The genuine YAGNI residue is that some functions really do need four or five parameters and the struct is more ceremony than help. The constructor is the canonical example: a record with five required fields takes a five-argument constructor unless the language ships named arguments. The right answer there is to use the language's named-argument feature (Python keyword arguments, Kotlin named arguments, TypeScript object-destructuring) — which is the parameter object pattern with no extra type. The cap fires; the answer is the destructured object literal; the lint passes.
The pairing matters. The cap fires at four; F3 DRY says the second occurrence is the trigger; F2 Intention-Revealing Names picks the struct's name. The old “rule of three” counter is acknowledged and rejected: don't wait for three. With AI agents reading the change set before the human does, the cost of an early name is lower than the cost of duplication compounding silently.
Notes
- [1]Martin Fowler — Refactoring, 2nd ed. (Addison-Wesley, 2018). Introduce Parameter Object — bundle a recurring group of arguments into a class. The Data Clump bliki entry names the underlying smell.
- [2]Robert C. Martin — Clean Code (Prentice Hall, 2008), ch. 3 “Functions”. “The ideal number of arguments for a function is zero (niladic) … More than three (polyadic) requires very special justification.”
- [3]Sandi Metz — “The Wrong Abstraction” (sandimetz.com, 2016). The asymmetry-of-cost argument applies to parameter objects too: a struct extracted from incomplete information is the wrong abstraction at the signature level.