Antipattern #3 — Long parameter list
A source of ambiguities and mistakes.
📚 Readability
When you have a long list of arguments, you can’t tell what is what. You’ll be counting arguments and making quick guesses. This is worse when you have a long train of nulls. You must write them all, even if they don’t matter. From there on, you’ll bump into them when reading and changing those lines.
update_user_profile(
1, 'johndoe', None, 'john@example.com', 'John', 'Doe',
None, 'Male', '123 Main St',
None, True, None, None,
) # can you quickly tell what is what?
⚠️ Error proneness
Long parameter lists increase the likelihood of errors. We can inadvertently mix up arguments. If the data types are identical, it's easy to make mistakes (even with a good IDE). This pain can be reduced by using value objects — the static analyzer would help you a bit more, but it would not entirely solve the problem.
Positional arguments should be used sparingly. Long argument lists are ticking time bombs. I’ve seen a few nasty bugs arising from not respecting the proper argument order.
🏭 Hindered refactoring
How painful is refactoring preexisting code to comply with a new parameter you introduced (e.g., to add one more null)? You need to update dozens of calls (in the implementation and tests). That means lots of repetitive work and the associated risk. Even with a proper IDE, you still generate many code changes. You end up with bigger commits, which makes code reviews more cumbersome and can result in merge conflicts. Even worse, changes are unrelated.
Functions should be stable in terms of backward compatibility. Adding a parameter to a function should be smooth and not impose any code refactoring, especially for unrelated functionalities.
👷 Solving it
We could use maps to encapsulate the data, but that’s a lousy tradeoff as we ditch the IDE help and static validations, among others. There’s a better way. Named parameters (with defaults) are a cheap solution to the “long lists of arguments” problem.
fun createUser(
username: String,
email: String,
password: String,
address: String? = null,
){
// ...
}
// later...
createUser(
username = "jane_doe",
email = "jane@example.com",
password = "password123",
address = "456 Oak St",
)
Long argument lists are no longer an issue because we only pass precisely what we need as named arguments. The order becomes irrelevant. Passing many nulls is no longer required for the same reason. Mistakingly swapping parameters A and B is unlikely because we pass them by name (not positionally). If we need to support a new parameter, that’s fine; we don't have to update dozens of calls anymore; we default it to null or something else. Backward compatibility is guaranteed.
Most languages (e.g., Kotlin, Python, Elixir, PHP, Ruby, C#, Swift) support named parameters. TypeScript can look similar using inline interfaces:
function createUser(person: { name: string; bio: string }) {
// ...
}
// later...
createUser({name: 'Alice', bio: "bio 1"});
However, not all mainstream languages have them (e.g., C, Java, Rust, Go). Regardless, we’d sometimes benefit from reusing a contract for the data in other parts of the app. We need the parameter object pattern.
💡 Introducing a parameter object
The parameter object pattern is the ideal solution to the “long list of parameters” problem. A parameter object is simply a DTO encapsulating all the input parameters.
data class CreateUserRequest(
val username: String,
val email: String,
val password: String,
val address: String? = null,
val phone: String? = null,
val bio: String? = null
)
// later...
createUser(
CreateUserRequest(
username = "jane_doe",
email = "jane@example.com",
password = "password123",
address = "456 Oak St",
)
)
Why is the parameter object pattern the best solution? As benefits, we get:
- It solves the long-argument-list problem. We reap the same benefits when using named parameters and more. The idiomatic solution varies per language; some use struct instances (e.g., C, C++, Go, Elixir, Swift, Rust), some use objects (e.g., JavaScript, PHP, Ruby, Python, Kotlin), and some need builders (e.g., Java, C#). In any case, the arguments are named.
- It encapsulates the passed data. It’s about code cohesion—what changes at the same time should live together. Creating a dedicated type helps with that.
- The created DTO can be reused (e.g., if it crosses multiple layers).
- It provides meaning and documentation. Contextually, you’ll have self-documenting code. You’ll use and help to spread the ubiquitous language.