Last week’s post made the case that “hard to test” is usually a statement about the design and not about the medium. This week the same argument meets a method I actually worked on: one that took nineteen parameters, and that every event registration in the product went through.
I want to be clear about what kind of post this is. I’m writing it from memory, so the code example isn’t 100% as it was in practice. Also, I have no data about integration time or defect rates before and after, so there are no percentages here. What I can describe is roughly what the code looked like, what it cost us in practice, what we did about it, and which parts I would do the same way again.
The company had about a hundred people, most of them engineers. That ratio matters later, because it meant a lot of parallel feature work and nobody could fund a cleanup. The product organized multi-day virtual, in-person, and hybrid events, and the method in question performed the final registration of a single attendee. It had been growing for about four years by the time we did anything about it.
Nineteen parameters accumulated one reasonable request at a time. We split the method into one class per concern and used the strangler fig pattern to move callers across gradually, instead of rewriting the monolith. The change we didn’t plan for was the testing. Once each concern was its own step, the tests became obvious, and we finally wrote them.
The Method
The reconstructed signature is below. The names are different and the domain is sanded down, but the principle and the count are similar:
public Registration registerAttendee(
Long eventId,
Long personId,
String contactName,
String contactEmail,
String contactPhone,
String attendanceType,
String dietaryRestrictions,
boolean vegetarian,
List<String> spokenLanguages,
boolean needsInterpreter,
boolean needsLiveCaptions,
String streamingHandle,
boolean recordingConsent,
String licensePlate,
boolean needsParkingSpace,
String accessibilityNeeds,
Long billingPartnerId,
String costCenter,
String notes) {
// several hundred lines
}Nobody designed that in one run. It arrived one reasonable request at a time, and the first version took an event and a person.
Then some attendees turned out to be senior enough to have somebody else handle event registration for them, and that assistant’s contactName, contactEmail and contactPhone had to be passed to the registration. The attendee was already in the database. The assistant running their week wasn’t, so the argument list was the only place those details could arrive1.
After that it was one reasonable request at a time for four years. Catering needed to know who eats what, so we added dietaryRestrictions, and a vegetarian flag beside it, because caterers counted plates separately from allergies. spokenLanguages and needsInterpreter if the attendee needed an interpreter. A venue with a gated car park wanted a licensePlate, with a needsParkingSpace flag next to it, and accessibilityNeeds for anybody the building had to accommodate. Then the events themselves changed, and a whole cluster landed at once for people who would attend remotely: attendanceType to tell them apart, streamingHandle to grant access, recordingConsent because sessions were recorded now, and needsLiveCaptions for accessibility.
The last parameter is notes. (Most forms have something similar, so nobody stops to ask whether it might be a smell.) Somebody needed to record something the form had no field for, so it was added. After that, anything the model couldn’t express went there instead of becoming a field, and nobody had to ask what the model was missing.
Each of those was a real requirement, and each of them was a five-minute change to a signature. A long parameter list is rarely a decision. It’s a sequence of small, individually defensible edits that nobody ever totaled up.
The problem wasn’t only the signature. It was that no registration ever needed all nineteen, and most needed a small minority of them, so most invocations looked like this:
Registration registration = registrationService.registerAttendee(
event.getId(), person.getId(),
null, null, null,
"VIRTUAL",
null, false, null, false, false,
"alice", true,
null, false, null,
null, null, null);In this example, fourteen of those nineteen arguments are null or false. Five of the fourteen aren’t “we don’t know yet” but “this cannot apply”: nobody attending from their living room needs a parking space, a plate, or a ramp. An in-person attendee produces the opposite image, with the streaming block empty instead. The reader can’t tell any of that without counting positions against the declaration in another file2.
What Nineteen Parameters Cost Us
The pain that the team mostly talked about was readability, because that’s the one we felt every day. We were always afraid to touch it. And it wasn’t even the most expensive problem.
The real pain was that the method contained every registration rule in one place. Every new requirement landed in the same few hundred lines. In a company that size, we always had parallel feature work, so two of us often worked on unrelated features that touched the same method in the same sprint. We got merge conflicts neither of us fully understood. Resolving them meant guessing which branch’s version of a nested if was current.
Then there was the blast radius. Adding the parking requirement should have been a change to parking. Instead it was a change to the method that also enrolled every attendee at every event, so the regression surface for a car park was the entire registration flow.
And the tests. To check one behavior, a test had to supply all nineteen arguments, most of them nulls that had nothing to do with the behavior under test. The setup buried the assertion, so a test for the interpreter rule looked almost exactly like a test for the billing rule. (That’s last week’s point arriving from a different direction.) Nothing about the interpreter rule was genuinely hard to test, but the only door into it was nineteen arguments wide.
A test data builder would have hidden those nineteen arguments, and it would have been worth having on its own. It would not have fixed this. Every test still had to stand up every collaborator the method touched, and the rule under test was still buried in the same few hundred lines as all the others. A builder shortens the setup. It doesn’t shorten the method, and the method was the problem.
We had roughly no tests on the most important method in the product, and every individual reason for that was justifiable.
Why the Obvious Refactorings Weren’t Enough
The first thing we reached for was breaking the body into private methods, each with a name. We did some of that and it made the method readable line by line and changed nothing else.
The reason is the trap that makes long methods feel addressed when they aren’t. A private method called from one place inherits the parent’s setup cost. Testing it means rebuilding the same nineteen-argument context. And the parent still owns the decision about which concerns apply, so adding a concern still means editing the parent to call the new one3.
Introducing a parameter object is better, and it’s one of the refactorings the catalogs list for Long Parameter List4. Collapsing nineteen positional arguments into a few named groups genuinely fixes the call sites. On its own, though, it fixes the signature and leaves the body alone. The method still branches through every concern to work out which ones this attendee needs.
We wanted more: named groups passed in, and one unit per concern doing the work.
Groups In, One Step Per Concern
Before any changes, our product owner sat down and read the method. He documented what it actually did, in domain language, concern by concern. He did that because a project was about to take us into the method anyway. That document is why the rest of this is as tidy as it is. We were rearranging behavior somebody had already documented, instead of discovering it one rule at a time with a merge conflict open.
The Parameters Became Groups
We created a plain final class with a builder:
public final class RegistrationRequest {
private final Attendee attendee;
private final Attendance attendance;
private final DietaryNeeds dietaryNeeds;
private final Interpreting interpreting;
private final RemoteAccess remoteAccess;
private final OnSiteNeeds onSiteNeeds;
private final Vehicle vehicle;
private final Billing billing;
private final String notes;
// builder, constructor and accessors
}Eight groups instead of nineteen positions, and the field names carry what the old signature could only imply. A virtual attendee arrives with remoteAccess filled in, and vehicle and onSiteNeeds empty. An in-person one is the opposite image. The reader no longer has to cross-check arguments to figure out which of the two this registration is.
The three contact fields joined Attendee instead of becoming a group of their own. There isn’t always a separate contact person, and when there isn’t, the phone number belongs to the attendee. A Contact group would have been empty half the time and misleading the other half, so the attendee carries how to reach whoever is handling their registration.
notes joined nothing. It’s in the request as a bare string, next to eight groups that all mean something, because a field that can contain anything has no concern to belong to. Grouping is good at revealing structure that was already there. It says nothing about a field that exists precisely because nobody wanted to name the structure.
What didn’t improve is the emptiness itself. We kept using null for the groups that didn’t apply, so every step that cared had to check for it, and the question “can this legitimately be missing?” still had no answer in the type5. Grouping made the call sites readable. It didn’t make the absent cases safe.
Two of the nineteen disappeared. Both were booleans that could be determined from other data.
needsParkingSpace was directly beside licensePlate and told us nothing the plate didn’t. An attendee with a plate wants a space, and one without one doesn’t. Once the two are passed together as a Vehicle, the parking step reads the group and the flag has nothing left to say. needsInterpreter went the same way, with one additional step. An attendance knows which language the event runs in, and the request contains the languages the attendee speaks, so the interpreter step can work out for itself who needs one.
That’s two flag arguments gone, though not by Fowler’s mechanics. Remove Flag Argument splits the method into an explicit function per case and lets callers pick the one they mean. Here the flags didn’t need splitting, because the answer could be determined from the available data. The caller had been doing the step’s thinking and passing in the conclusion6.
Each Concern Became a Step
We built the registration object step-by-step. Each step takes the current state of the registration and the whole request, and returns the registration with its own concern applied. That’s the entire contract:
public interface RegistrationStep {
Registration applyTo(Registration registration, RegistrationRequest request);
}
final class ReserveParkingSpace implements RegistrationStep {
private final ParkingLot parkingLot;
ReserveParkingSpace(ParkingLot parkingLot) {
this.parkingLot = parkingLot;
}
@Override
public Registration applyTo(Registration registration, RegistrationRequest request) {
Vehicle vehicle = request.vehicle();
if (vehicle == null) {
return registration;
}
return registration.withParkingSpace(parkingLot.reserveSpaceFor(vehicle));
}
}ReserveParkingSpace knows how to tell whether this attendee arrived by car, and what to do about it. It doesn’t know that interpreters exist, or that half of the attendees are watching from another city.
That null check is what a previous footnote was about. A NoVehicle would have removed it, and the same three-line branch appears in every step that handles an optional concern. Six copies of a guard isn’t a catastrophe. It’s the kind of repetition that tells us a modeling decision got deferred.
The litmus test that When the Aggregate Gets Heavy, Part 2 applied to the publish guards fits here too. The step keeps a ubiquitous-language name, and it owns a rule somebody from the events team could say out loud. An attendee who arrived by car gets a space. The other half of that litmus test asks whether the extracted unit turned into a procedure that reads somebody else’s getters. This one takes a Vehicle from its own input and works on it as a value. It never reads the Registration it’s building, and it leaves the allocation to the ParkingLot, which is the thing that knows about spaces.
The steps assemble as data, and the runner is boring on purpose:
public final class AttendeeRegistration {
private final List<RegistrationStep> steps;
public AttendeeRegistration(
InterpreterPool interpreterPool,
StreamingPlatform streamingPlatform,
VenueServices venueServices,
ParkingLot parkingLot,
BillingGateway billingGateway,
Mailer mailer) {
this.steps = Arrays.asList(
new RecordDietaryNeeds(),
new AssignInterpreter(interpreterPool),
new GrantStreamingAccess(streamingPlatform),
new ArrangeOnSiteSupport(venueServices),
new ReserveParkingSpace(parkingLot),
new ChargeToPartner(billingGateway),
new SendConfirmation(mailer));
}
public Registration register(RegistrationRequest request) {
Registration registration =
Registration.of(request.attendee(), request.attendance());
for (RegistrationStep step : steps) {
registration = step.applyTo(registration, request);
}
return registration;
}
}Once the steps existed, the next new requirement was handling e-learning registration and completion. It was a new class and one line in the constructor. Nobody touched the parking code to add it, and that’s most of the return on the change.
The Pattern Behind It
If the runner looks familiar, it should. It’s the structure When the Aggregate Gets Heavy, Part 2 already introduced: a sequence of small classes behind one interface, applied in order, each owning a single rule7. We finally got what we wanted: a simple and modular design with one class per concern.
Out of the Monolith, One Caller at a Time
We were in a special situation: nobody funded this, and nobody had to. A project was already carving microservices out of the monolith, and it had been running for a while. Registration came up because we needed to integrate with it more deeply. Once it was clear we would be inside that method anyway, we decided to extract it instead of integrating with it in its original place.
The integration was planned work with a budget. Doing it as extraction rather than as a twentieth parameter was a judgment call we made as part of that work. It was the only slice nobody approved, because from the outside it didn’t look like a separate thing to approve.
There’s a less charitable reading of that, which is a rewrite smuggled into somebody else’s funded work. What separates the two is that the integration had to open the method either way, and that the migration went in commits separate from the feature work, so a reviewer could tell one from the other. The system worked at every step, so nothing we shipped depended on the migration ever finishing.
We used the strangler fig8, which Sustainable Refactoring walked through: build the replacement alongside the original, redirect callers piece by piece, and delete the old code only once nothing points at it.
The replacement wasn’t a new corner of the monolith. The steps went into a separate registration service, which is closer to the application-level migration Fowler’s article describes than a package-level one would be. That raised the stakes, because a caller moving across was now crossing a process boundary rather than a package one. It also made the boundary honest: nothing could quietly reach into the old method’s internals from the new side, because there were no internals to reach.
What made it affordable anyway was keeping the old signature alive in the monolith as a thin proxy as an entry point:
public Registration registerAttendee(/* the same nineteen parameters */) {
return registrationClient.register(
RegistrationRequest.from(/* the same nineteen parameters */));
}Every existing caller kept compiling and kept working. New callers skipped the monolith and called the service directly. Whenever a feature took us into an old caller for other reasons, we pointed that one at the service as part of the work, in a separate commit from the feature.
The logic and the callers moved on completely different schedules. The registration logic went across in a single batch. We built the steps, pointed the proxy at them, and that was that. The callers crossed over slowly, one at a time, over months, and that was the part that never needed a deadline.
The proxy was also the last place the old nulls lived. RegistrationRequest.from(...) still took nineteen positional arguments and still had to work out which of them meant “not applicable”. Nulls didn’t leave the codebase, as the footnote above admits. What was left with the last old caller was the worst kind, the null we could only understand by counting its position in a signature.
Keeping the migration commits separate from the feature commits is the detail I’d defend hardest. It’s the discipline Refactoring Without Permission makes the case for, and it’s what kept the change reviewable while never appearing as a line item anybody had to approve.
The old method was deleted months later, by someone else, in a commit that removed more lines than it added and needed no explanation.
Then the Tests Became Obvious
There was a part I didn’t see coming, and it’s the reason I still tell this story.
Extracting private methods had already made the body readable line by line. What it never did was answer the question that actually blocked us: which rules apply to this attendee, and where does each one live? By the end of those four years, we answered that question by reading several hundred lines and hoping.
One class per concern answered it structurally. When a concern is one class with one collaborator, we can hold all of it in our heads at once, and the tests stop needing a decision about what to test:
@Test
void reserves_a_parking_space_for_an_attendee_who_arrives_by_car() {
RegistrationRequest request = aRegistration().arrivingBy(A_CAR).build();
Registration registration =
new ReserveParkingSpace(parkingLot).applyTo(anEmptyRegistration(), request);
assertThat(registration.parkingSpace()).isPresent();
}One step, one collaborator, one assertion, and no nulls standing in for concerns the test doesn’t care about. Compare that to the nineteen-argument version, where the same check needed seventeen other values that had nothing to do with parking.
We covered the steps with tests. Not because a policy told us to, and not because anyone got permission for a testing effort, but because writing them had stopped being expensive. That’s the point from last week’s post, observed from the other end: the tests hadn’t been missing because the domain was hard. They were missing because the design made them too hard to write.
Where the Split Cost Us
All this wasn’t free.
Splitting into steps hides the control flow. In the old method, the order of operations was visible in the order of the lines, and a debugger walked straight through it. Afterwards the order is in a list somewhere else, and stepping through means stepping through the runner seven times. New joiners asked “where does registration happen?” and the honest answer became “in a runner, an interface, seven steps, and the request types”. A process boundary in the middle of that didn’t help either.
There’s also a new kind of bug, where a step silently does nothing because the data it wanted was absent for a reason nobody intended. The old method would have thrown a NullPointerException and told us. A null sliding past all six guards tells us nothing at all.
We hit that once, and the fix was making the request object validate itself on the way in, rather than letting each step ignore it. That catches the absences nobody meant to create. It doesn’t delete a single guard, because the steps still have to handle the ones that are legitimate.
Then there’s the proxy itself. It only disappears once every old caller has been migrated, and we never scheduled that work.
That was the point, but it means the migration only progressed when a feature happened to take us into an old call site. Registration was central enough that features kept doing exactly that. Somewhere quieter in the system, nobody would have had a reason to go back in. The proxy would still be there today, and the codebase would have two ways to register an attendee instead of one. That’s worse than the single ugly method we started with.
So it depends, as usual, on two things rather than one: how many concerns are in play, and how often feature work will take somebody back to the call sites. Three concerns in one method isn’t a pipeline. It’s a method, and there are cheaper ways to improve one. Nineteen parameters’ worth is past the point where anybody can read the alternative.
What I’d Do Again, in This Order
Five steps, and the order matters more than any of them individually:
Understand and document what the method actually does. Not from memory, and not while moving rules. Read it, write the behavior down, and pin it with characterization tests. The document is what we design against. The tests are what tell us, months later, that a rule quietly changed its meaning on the way across.
Group the parameters. It’s the cheapest change, it’s mechanical, and the groups give us hints about what the steps are going to be. Ours came out of the document from step one, where the concerns were already named.
Keep the old signature as an entry point. Migration stops being a project the moment nothing has to move on a deadline. Marking it as deprecated also helps future callers realize they should use the new method.
Move the callers across one at a time, in their own commits. Separate from the feature that took us there.
Write the test as soon as the step exists. Not later. A test is cheapest while the concern is small and fresh in our heads.
Most teams skip step 1, but that’s what made everything else cheap.
Our product owner wrote the behavior down before any code moved, and then the two of us designed the new structure together. The method got understood once, on purpose, by someone whose job was to understand it, instead of seven times by whoever happened to be moving a rule that week.
That version of step 1 is the lucky one, and most teams don’t have it. Nobody is going to fund a week of reading, and a product owner who volunteers to do it is even rarer. What survives without either is smaller and still works: one engineer reads the method once, deliberately, and writes down what it does before moving any of it. Even better if the output isn’t documentation, but tests. The expensive part was never who did the reading. It was doing it seven times by accident instead of once on purpose.
Two things I’d change:
We never wrote the characterization tests. We leaned on the documentation alone, which is the half of step 1 we skipped. A document tells us what the method was supposed to do, but only a test tells us, a year later and in CI, that it still does it.
We left the absent concerns as nulls, when a Null Object would have deleted six guards.
The Takeaway
A parameter list gets to nineteen the same way most tech debt accumulates: one defensible edit at a time, with no single moment where anybody could reasonably have said no. The fix isn’t a rewrite. Group the parameters so the concerns become visible, give each concern its own class, and let the strangler fig move the callers on whatever schedule the feature work allows. The testability arrives on its own once each concern is small and independent enough to reach. The cheapest moment to start is the next time a requirement forces the method open anyway.
Forward This to Your Team
If there’s a method in the codebase that everybody edits and nobody tests, send the following to whoever decides what the team works on:
A registration method in a software system grew to nineteen parameters over four years. No single change that added one was wrong, but the cumulative result was that every new requirement, however small, had to be made inside one method that did everything. Unrelated features collided and the regression surface for a small feature exploded.
The team split it into one class per concern and moved callers across during normal feature work, without a dedicated refactoring sprint, and the system was operational through the whole process.
The signal to look for isn’t a method’s length, it’s whether two people doing unrelated work keep ending up in the same file.
Media attributions:
Cover image by the author (generated with Gemini)
The phone number had other uses, too. An attendee without an assistant could put their own mobile there, so the field was worth filling out either way. Because who doesn't love when exceptional cases have exceptional cases?
Value objects would stop the compiler from letting costCenter and licensePlate swap places, and they're worth reaching for on their own merits. They'd still be a deodorant here. A signature that needs nineteen well-typed arguments is no more readable than one that needs nineteen strings. Types stop us from passing them in the wrong order. They do nothing about there being too many.
We might feel the urge to widen a private method to package-private or public so a test can reach it, the method has stopped being an implementation detail. It's business behavior we want to verify, and widening access is the wrong answer. What it's asking for is to become its own unit, with its own name and its own inputs. That's the change the rest of this post makes.
Introduce Parameter Object is one of several refactorings that the catalogs map to the Long Parameter List smell, alongside Preserve Whole Object, Replace Parameter with Query, and Remove Flag Argument. The observation that grouping arguments often reveals a missing class comes from Refactoring.
The proper fix would have been the Null Object pattern: a NoVehicle that answers the same questions as a Vehicle and reserves nothing, so the step has no branch to write. We didn't do it, and the null checks in the steps below are the price. Optional isn't the alternative either, whatever a modern rewrite would reach for first. Its own API note says it's primarily intended as a method return type. Using it for fields and parameters trades one kind of noise for another.
Not every boolean dissolves this way, and we should know which ones won't. recordingConsent is a decision a human made and nothing in the request implies it. vegetarian looks derivable from dietaryRestrictions but it isn't, which is the whole reason it exists. needsLiveCaptions is a preference somebody stated, and no combination of the other eighteen parameters implies it. A flag is safe to delete when the request can recompute it, and dangerous to delete when it carries an intent the data doesn't.
The pattern is Chain of Responsibility if the steps can short-circuit, Pipes and Filters if they can't. In the example above, it's the latter, but it can be extended at any time.
Martin Fowler's Strangler Fig Application is the source of the name and the discipline: an incremental migration where the new system grows around the old one until the old one can be removed. The key property is that the system works at every step, which also makes the work interruptible. In my experience, a migration that only works at the end tends to be recognized as a rewrite after six months, by which point it's too big to cancel and too late to finish.

