Parallel change example: simplifying some test builders
Published by Manuel Rivero on 30/11/2025
1. Introduction.
In this post we’ll revisit the design of a test builder using the Curiously recurring template pattern (CRT), that we showed in a previous post: Segregating a test builder applying the curiously recurring template pattern, and discuss how we simplified it using composition after gaining some insights about the domain of our application.
We’ll also describe how we applied a parallel change to incrementally refactor from the design using generics to the new design using composition without breaking the tests in any moment[1].
2. The initial design.
We applied the CRT pattern to design three test builders that shared most of their setters. The design enabled chaining setter methods in any order while restricting available methods to only those relevant for building each specific claim type.
This is the code of the base test builder using generics and the CRT pattern, Claimbuilder:
| // imports elided... | |
| public abstract class ClaimBuilder<T extends ClaimBuilder<T>> { | |
| // some fields elided... | |
| protected ClaimBuilder() { | |
| // setting some defaults elided... | |
| } | |
| public T withClaimId(String number) { | |
| this.claimId = claimId(number); | |
| return self(); | |
| } | |
| public T withCompanyId(String id) { | |
| this.companyId = CompaniesFactory.companyId(id); | |
| return self(); | |
| } | |
| public T describedAs(String description) { | |
| this.description = description; | |
| return self(); | |
| } | |
| // other common setters elided | |
| protected ClaimData buildData() { | |
| return new ClaimData( | |
| claimId, | |
| companyId, | |
| description, | |
| claimDate, | |
| policyNumber, | |
| status | |
| ); | |
| } | |
| @SuppressWarnings("unchecked") | |
| protected final T self() { | |
| return (T) this; | |
| } | |
| } |
And these are the three concrete derived test builders
ClaimDataBuilder:
| // imports elided... | |
| public class ClaimDataBuilder extends ClaimBuilder<ClaimDataBuilder> { | |
| private ClaimDataBuilder() { | |
| super(); | |
| } | |
| public static ClaimDataBuilder aClaimDto() { | |
| return new ClaimDataBuilder(); | |
| } | |
| public ClaimData build() { | |
| return buildData(); | |
| } | |
| } |
ReadyToOpenClaimBuilder:
| // imports elided... | |
| public class ReadyToOpenClaimBuilder extends ClaimBuilder<ReadyToOpenClaimBuilder> { | |
| private Company company; | |
| private ReadyToOpenClaimBuilder() { | |
| super(); | |
| } | |
| public static ReadyToOpenClaimBuilder aClaimReadyToOpen() { | |
| ReadyToOpenClaimBuilder builder = new ReadyToOpenClaimBuilder(); | |
| builder.withStatus(ClaimStatus.ReadyToOpen); | |
| return builder; | |
| } | |
| public ReadyToOpenClaimBuilder withCompany(Company company) { | |
| Objects.requireNonNull(company, "Company must not be null"); | |
| this.company = company; | |
| return this; | |
| } | |
| public Claim build() { | |
| return new ReadyToOpenClaim( | |
| buildData(), | |
| company | |
| ); | |
| } | |
| } |
OpenButNotNotifiedClaimBuilder:
| // imports elided... | |
| public class OpenButNotNotifiedClaimBuilder extends ClaimBuilder<OpenButNotNotifiedClaimBuilder> { | |
| private ClaimReferenceInCompany referenceInCompany; | |
| private ClaimsOpeningListener claimsOpeningListener; | |
| private OpenButNotNotifiedClaimBuilder() { | |
| super(); | |
| } | |
| public static OpenButNotNotifiedClaimBuilder aClaimOpenButNotNotified() { | |
| OpenButNotNotifiedClaimBuilder builder = new OpenButNotNotifiedClaimBuilder(); | |
| builder.withStatus(ClaimStatus.OpenInCompanyButNotNotified); | |
| return builder; | |
| } | |
| public OpenButNotNotifiedClaimBuilder withOpeningListener(ClaimsOpeningListener claimsOpeningListener) { | |
| this.claimsOpeningListener = claimsOpeningListener; | |
| return this; | |
| } | |
| public OpenButNotNotifiedClaimBuilder withReferenceInCompany(String referenceInCompany) { | |
| this.referenceInCompany = claimReferenceInCompany(referenceInCompany); | |
| return this; | |
| } | |
| public Claim build() { | |
| return new OpenButNotNotifiedClaim( | |
| claimsOpeningListener, | |
| referenceInCompany, | |
| buildData() | |
| ); | |
| } | |
| } |
This design solved the problems we faced because it enabled chaining setter methods in any order while restricting available methods to only those relevant for building each specific claim type.
3. Domain insights set our minds free.
We had two implementations of Claim: ReadyToOpenClaim y OpenButNotNotifiedClaim, that were modelling the two different states of a claim in our process, and a Data Transfer Object (DTO), ClaimData. They shared most of their data and that’s why we were trying to build them using common code. Still, something was off 🤔.
Later, we learned that we were not modelling our domain usefully. Thinking about the behaviour of our application we realized that ReadyToOpenClaim and OpenButNotNotifiedClaim were not actually representing possible states of a Claim. Instead, they represented steps in the workflow of making a claim. We had conflated concepts.
To encode this learning, we started by renaming the Claim interface and its two implementations, ReadyToOpenClaim and OpenButNotNotifiedClaim, to ClaimCommand, OpenClaimCommand and NotifyOpenedClaimCommand, respectively. Then we renamed ClaimData to Claim since that name was not taken anymore.
Then, it was easier to see that it made no sense to use inheritance in the test data builders. The design using the CRT pattern was solving the symptoms caused by having conflated the concepts of the claim state and the steps in a claim opening workflow, but not the actual problem. This realization led us to separate those concepts which then enabled a simpler design option for the test builders: composition.
4. Applying parallel change to move from inheritance to composition.
We applied parallel change to incrementally introduce the new composition-based design through refactoring without making the tests fail at any moment.
We divided this introduction of the new design in two steps:
-
Introducing new composition based test builders and removing the inheritance, generic based ones for
OpenClaimCommandandNotifyOpenedClaimCommand. -
Introducing a new test builder for
Claimthat does not derive fromClaimBuilder.
Let’s comment each step in more detail:
4. 1. Introducing new composition based test builders and removing the inheritance, generic based ones for OpenClaimCommand and NotifyOpenedClaimCommand.
We’ll apply two parallel changes, one for each of the new composition based test builders.
4. 1. 1. Parallel change to introduce the new OpenClaimCommand test builder.
4. 1. 1. 1. Expansion.
We generated a builder for OpenClaimCommand using AI, renamed its setters to our liking, and used builder composition to improve readability[2] (check the of method in the code below). This is the resulting builder:
| // some imports... | |
| public class OpenClaimCommandBuilder { | |
| private Company company; | |
| private ClaimDataBuilder claimBuilder; | |
| private OpenClaimCommandBuilder() {} | |
| public static OpenClaimCommandBuilder anOpenCommand() { | |
| return new OpenClaimCommandBuilder(); | |
| } | |
| public OpenClaimCommandBuilder inCompany(Company company) { | |
| this.company = company; | |
| return this; | |
| } | |
| public OpenClaimCommandBuilder of(ClaimDataBuilder claimBuilder) { | |
| this.claimBuilder = claimBuilder; | |
| return this; | |
| } | |
| public ClaimCommand build() { | |
| Objects.requireNonNull(company, "Company must not be null"); | |
| Objects.requireNonNull(claimBuilder, "ClaimBuilder must not be null"); | |
| return new OpenClaimCommand( | |
| claimBuilder.withStatus(ClaimStatus.ReadyToOpen).build(), | |
| company | |
| ); | |
| } | |
| } |
4. 1. 1. 2. Migration.
We changed the places where the old ReadyToOpenClaimBuilder was used to use OpenCommandBuilder instead.
As an example, this is a fragment of a test case before being migrated:
| @Test | |
| public void obtaining_commands_when_some_claims_were_already_opened_but_not_notified_to_broker() { | |
| // arrange.... | |
| List<ClaimCommand> claimCommands = allClaimCommandsRepository.getCommands(); | |
| assertThat(claimCommands).isEqualTo(List.of( | |
| aClaimOpenButNotNotified().withOpeningListener(claimsOpeningListener) | |
| .withReferenceInCompany(refInCompany) | |
| .withClaimId(aClaimId).withCompanyId(companyId).build(), | |
| aClaimReadyToOpen().withCompany(otherCompany).withClaimId(anotherClaimId).withCompanyId(otherCompanyId).build() | |
| )); | |
| } |
and after being migrated:
| @Test | |
| public void obtaining_commands_when_some_claims_were_already_opened_but_not_notified_to_broker() { | |
| // arrange.... | |
| List<ClaimCommand> claimCommands = allClaimCommandsRepository.getCommands(); | |
| assertThat(claimCommands).isEqualTo(List.of( | |
| aClaimOpenButNotNotified().withOpeningListener(claimsOpeningListener) | |
| .withReferenceInCompany(refInCompany) | |
| .withClaimId(aClaimId).withCompanyId(companyId).build(), | |
| anOpenCommand().inCompany(otherCompany).of( | |
| aClaimDto().withClaimId(anotherClaimId).withCompanyId(otherCompanyId) | |
| ).build() | |
| )); | |
| } |
4. 1. 1. 3. Contraction.
Finally, we removed the imports of ReadyForOpeningBuilder, and deleted the class.
4. 1. 2. Parallel change to introduce the new NotifyOpenedClaimCommand test builder.
4. 1. 2. 1. Expansion.
We generated a builder for NotifyOpenedClaimCommand using AI, renamed its setters to our liking, and used builder composition to improve readability. This is the resulting builder:
| // some imports... | |
| public class NotifyOpenedClaimCommandBuilder { | |
| private ClaimsOpeningListener openingListener; | |
| private ClaimReferenceInCompany referenceInCompany; | |
| private ClaimDataBuilder claimBuilder; | |
| public static NotifyOpenedClaimCommandBuilder aNotifyOpenedCommand() { | |
| return new NotifyOpenedClaimCommandBuilder(); | |
| } | |
| public NotifyOpenedClaimCommandBuilder usingOpeningListener(ClaimsOpeningListener openingListener) { | |
| this.openingListener = openingListener; | |
| return this; | |
| } | |
| public NotifyOpenedClaimCommandBuilder withReferenceInCompany(String referenceInCompany) { | |
| this.referenceInCompany = claimReferenceInCompany(referenceInCompany); | |
| return this; | |
| } | |
| public NotifyOpenedClaimCommandBuilder of(ClaimDataBuilder claimBuilder) { | |
| this.claimBuilder = claimBuilder; | |
| return this; | |
| } | |
| public ClaimCommand build() { | |
| Objects.requireNonNull(openingListener, "Claims Opening Listener must not be null"); | |
| Objects.requireNonNull(referenceInCompany, "Reference In Company must not be null"); | |
| Objects.requireNonNull(claimBuilder, "Claim Builder must not be null"); | |
| return new NotifyOpenedClaimCommand( | |
| openingListener, | |
| referenceInCompany, | |
| claimBuilder.withStatus(ClaimStatus.OpenInCompanyButNotNotified).build() | |
| ); | |
| } | |
| } |
4. 1. 2. 2. Migration.
We changed the places where the old OpenButNotNotifiedClaimBuilder was used to useNotifyOpenedClaimCommandBuilder instead.
As an example, this is a fragment of a test case before being migrated:
| @Test | |
| public void obtaining_commands_when_some_claims_were_already_opened_but_not_notified_to_broker() { | |
| // arrange.... | |
| List<ClaimCommand> claimCommands = allClaimCommandsRepository.getCommands(); | |
| assertThat(claimCommands).isEqualTo(List.of( | |
| aClaimOpenButNotNotified().withOpeningListener(claimsOpeningListener) | |
| .withReferenceInCompany(refInCompany) | |
| .withClaimId(aClaimId).withCompanyId(companyId).build(), | |
| anOpenCommand().inCompany(otherCompany).of( | |
| aClaimDto().withClaimId(anotherClaimId).withCompanyId(otherCompanyId) | |
| ).build() | |
| )); | |
| } |
and after being migrated:
| @Test | |
| public void obtaining_commands_when_some_claims_were_already_opened_but_not_notified_to_broker() { | |
| // arrange.... | |
| List<ClaimCommand> claimCommands = allClaimCommandsRepository.getCommands(); | |
| assertThat(claimCommands).isEqualTo(List.of( | |
| aNotifyOpenedCommand().of( | |
| aClaimDto().withClaimId(aClaimId).withCompanyId(companyId) | |
| ).withReferenceInCompany(refInCompany).usingOpeningListener(claimsOpeningListener).build(), | |
| anOpenCommand().inCompany(otherCompany).of( | |
| aClaimDto().withClaimId(anotherClaimId).withCompanyId(otherCompanyId) | |
| ).build() | |
| )); | |
| } |
4. 1. 2. 3. Contraction.
Finally, we removed the imports of OpenButNotNotifiedClaimBuilder, and deleted the class.
4. 2. Introducing a new test builder forClaim that does not derive from ClaimBuilder.
4. 2. 1. Expansion.
We started by creating a new test builder for Claim called ClaimBuilderX that did not derive from ClaimBuilder but had the same setter methods:
| // some imports... | |
| public class ClaimBuilderX { | |
| // some fields... | |
| public static ClaimBuilderX aClaim() { | |
| return new ClaimBuilderX(); | |
| } | |
| private ClaimBuilderX() { | |
| // setting some default values | |
| } | |
| public ClaimBuilderX withClaimId(String number) { | |
| this.claimId = claimId(number); | |
| return this; | |
| } | |
| public ClaimBuilderX withStatus(ClaimStatus claimStatus) { | |
| this.status = claimStatus; | |
| return this; | |
| } | |
| // more setters... | |
| public Claim build() { | |
| return new Claim(claimId, companyId, description, claimDate, policyNumber, status); | |
| } | |
| } |
Then, we overloaded the of method of the OpenClaimCommandBuilder. The new version of the of method received a ClaimBuilderX. We did the same for the of method of the NotifyOpenedClaimCommandBuilder.
We also modified the build method of both classes, so that, if the field claimBuilderX was not null, we used ClaimBuilderX to create the Claim, whereas if it was, we used the old test data builder, ClaimDataBuilder. This conditional code was a scaffolding that allowed us to incrementally migrate from the old design to the new design.
This is the resulting OpenClaimCommandBuilder with some code omitted for the sake of brevity:
| // some imports... | |
| public class OpenClaimCommandBuilder { | |
| // some fields | |
| private OpenClaimCommandBuilder() {} | |
| public static OpenClaimCommandBuilder anOpenCommand() { | |
| return new OpenClaimCommandBuilder(); | |
| } | |
| public OpenClaimCommandBuilder of(ClaimDataBuilder claimBuilder) { | |
| this.claimBuilder = claimBuilder; | |
| return this; | |
| } | |
| public OpenClaimCommandBuilder of(ClaimBuilderX claimBuilderx) { | |
| this.claimBuilderx = claimBuilderx; | |
| return this; | |
| } | |
| // more setters | |
| public ClaimCommand build() { | |
| Objects.requireNonNull(company, "Company must not be null"); | |
| Claim claim; | |
| if(claimBuilderx != null) { | |
| claim = claimBuilderx.withStatus(ClaimStatus.ReadyToOpen).build(); | |
| } else { | |
| Objects.requireNonNull(claimBuilder, "ClaimBuilder must not be null"); | |
| claim = claimBuilder.withStatus(ClaimStatus.ReadyToOpen).build(); | |
| } | |
| return new OpenClaimCommand(claim, company); | |
| } | |
| } |
and this is the resulting NotifyOpenedClaimCommandBuilder with some code omitted for the sake of brevity:
| // some imports | |
| public class NotifyOpenedClaimCommandBuilder { | |
| // some fields | |
| public static NotifyOpenedClaimCommandBuilder aNotifyOpenedCommand() { | |
| return new NotifyOpenedClaimCommandBuilder(); | |
| } | |
| public NotifyOpenedClaimCommandBuilder of(ClaimDataBuilder claimBuilder) { | |
| this.claimBuilder = claimBuilder; | |
| return this; | |
| } | |
| public NotifyOpenedClaimCommandBuilder of(ClaimBuilderX claimBuilderx) { | |
| this.claimBuilderx = claimBuilderx; | |
| return this; | |
| } | |
| // more setters | |
| public ClaimCommand build() { | |
| Objects.requireNonNull(openingListener, "Claims Opening Listener must not be null"); | |
| Objects.requireNonNull(referenceInCompany, "Reference In Company must not be null"); | |
| Claim claim; | |
| if(claimBuilderx != null) { | |
| claim = claimBuilderx.withStatus(ClaimStatus.OpenInCompanyButNotNotified).build(); | |
| } else { | |
| Objects.requireNonNull(claimBuilder, "Claim Builder must not be null"); | |
| claim = claimBuilder.withStatus(ClaimStatus.OpenInCompanyButNotNotified).build(); | |
| } | |
| return new NotifyOpenedClaimCommand(openingListener, referenceInCompany, claim); | |
| } | |
| } |
The last part of the expansion was importing statically the aClaim method of ClaimDataBuilderX in all the tests in which the aClaimDto method of ClaimDataBuilder is used. I did it with a script that I had vibe coded with Junie.
4. 2. 2. Migration.
After all the scaffolding we prepared in the expansion phase, the migration consisted only in substituting the text “aClaimDto” with “aClaim” in every test case in which the aClaimDto method of ClaimDataBuilder was being used.
After executing this text substitution all the tests kept passing.
4. 2. 3. Contraction.
First, we removed the static imports of the unused aClaimDto method of ClaimDataBuilder using IntelliJ’s Optimize imports command on all the tests.
After that, the ClaimDataBuilder class was being used only in the scaffolding we added in the OpenClaimCommandBuilder and NotifyOpenedClaimCommandBuilder during the expansion phase to make the migration possible.
We removed the methods that the IDE marked as unused and ran the Optimize imports command in both tests. After that, ClaimDataBuilder became dead code and we deleted it.
Since ClaimDataBuilder was the only remaining class inheriting from the generic ClaimBuilder class using generics, after deleting it ClaimBuilder also became dead code, and we could delete it as well.
Finally, we renamed ClaimBuilderX to ClaimBuilder since that name was not taken anymore.
5. Final design.
The new design of the test builders using composition is easier to understand and maintain than the previous one using the CRT pattern because the new design does not use generics nor inheritance.
6. Summary.
After realizing that we were conflating concepts: ReadyToOpenClaim and OpenButNotNotifiedClaim were not states of a claim but steps in a workflow, we clarified the domain model renaming them to OpenClaimCommand and NotifyOpenedClaimCommand, respectively, and also renaming ClaimData to Claim.
This domain insight also removed the need for inheritance in the test builders using the CRT pattern, and opened the door to a simpler design: composition-based test builders, that better matched the domain.
We applied a parallel change to safely move from one design to the other without ever breaking the test suite. In the expansion phase we generated new builders and introduced temporary scaffolding, then, in the migration phase, we gradually migrated the test builders usage in the tests, and only once everything was migrated, we finally performed a controlled cleanup of the old classes.
The final design avoids generics and inheritance entirely, relying instead on straightforward composition. Each command builder now composes a ClaimBuilder, leading to a clearer, flatter structure. This makes the builders easier to understand, modify, and extend while better reflecting the clarified domain model.
We encourage you to learn more about parallel change so you too can join The Limited Red Society and practice safer, incremental refactoring[3].
Acknowledgements.
I’d like to thank my colleague Fran Reyes for giving me feedback about this post.
I’d also like to thank Carlos Miguel Seco for giving us the opportunity to work with him on a very interesting project.
Finally, I’d also like to thank cottonbro studio for the photo.
Notes.
[1] See the fantastic The Limited Red Society talk to know more about the origins of this technique.
[2] Read Nat Pryce’s short post about composing test data builders: Tricks with Test Data Builders: Combining Builders.
[3] Our training Code Smells & Refactoring makes a special emphasis on teaching and practising the parallel change technique so teams can refactor safely and incrementally without breaking their codebase.