Intent Driven Development
on example of my love hate relationship with Builder Design Pattern
Have you ever felt irritation from looking at someone else’s code after realizing that you have no idea what idea was behind the developer who originated that code piece? Or do most comments you get at your pull requests relate to styling omitting the domain completely? Maybe after those pull requests when code is merged it occurs that it doesn’t meet the business requirements or the other developers using your code encounter bugs related to use cases for which that part isn't really designed for?
Often codebase like that is tagged as ‘legacy’ or ‘difficult to read’. Beside quality of developer lives it also may cause development cost to increase significantly as it requires more time to develop features and those features may require expensive fixes after deployment.
Of course as a developer the first mentioned drawback is more relatable to me even though the other one may buy you some time for that mythical refactoring with your manager. Unfortunately showing clearly your intent is not as easy as it may sound. There are tons of books related to clean code and development practices which usually are great but when applied it turns out that they don’t resolve the issue or as I learned by heart they can make the matter worse if applied Incorrectly.
But what does incorrectly mean in that context? In my opinion the main drawback of those resources is that examples shown in them apply to the domain from that example. What makes it more difficult is that adding the whole context could make the book unreadable so some simplifications are mandatory. Understanding what the author meant requires experience from the reader which means the entry level books may result as a trap if the developers reading the won’t have someone experienced nearby to help them translate given book teachings to their specific project.
And I am not saying that you shouldn't listen to the almighty senior developer in your team. You can and in my opinion should challenge their way of thinking and habits even if you are just starting your development journey as it may result in solutions which none of you wouldn’t think of.
What worked for me is to try to focus on intent while designing my part of the code. That means if I see any design pattern unless I will find out what using that pattern suggests and what is most important before even choosing the design pattern I need to identify reasons for change and effect which I would like to get.
To show you what I mean I would like to focus on the Builder pattern this time. Let’s say that we need to model the Report entity. Which represents sets of data points which are reported to the controlling party. To simplify the example let's also assume that we have access to all of necessary information we only need to model the object properly. SO that report could look a little bit like this:
public class Report {
public Report() {
}
public String publicId;
public String resourceId;
public String internalId;
public String description;
public String reportName;
public Instant creationDate;
public List<DataPoint> dataPoints;
}
So since we have a piece of code before we go to the construction part, let's take a break and think a little about what we have here.
So we have a public class which suggests that this class can be used externally by different modules.
One public empty constructor which suggests that fields are set outside of the code since all of them are public.
Two sets of IDs which suggest that external parties may have different requirements for it then our system.
Name and description fields which look like variables which are either human-readable or part of more complex uniqueness verification.
Creation date, which is either a simple timestamp used for debugging or filed that can be used other parties to validate freshness of the report
Finally we have a List of data points which probably can be used for some kind of chart or other validation.
Do we need all of those fields? It depends. they may be part of a contract agreement and are needed by the report recipient. They may be a set of data which can uniquely define a given time frame and system state. Or this can be just a start for a whole blown feature.
As you can see it’s difficult to make the entire context from just one class but even this small piece can introduce a lot of questions. So clearly defining the intent around this class can make the lives of other developers easier. That’s why I generally do not advise starting from defining the fields as removal or change in the future may be difficult or even impossible if this format is propagated far enough. This approach may be done e.g. by starting the implementation from contracts or test cases but before we go deeper into this rabbit hole of questions let’s assume that this is part of ready feature we only want to add construction of entity.
Easy enough, let’s introduce some helper library Lombok, which can add some useful decorators. We can even add some tests since we don’t want to lower our coverage rate and we’ll have:
@Builder
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Report {
private String publicId;
private String resourceId;
private String internalId;
private String description;
private String reportName;
private Instant creationDate;
private List<DataPoint> dataPoints;
}
@Test
void shouldCreateReportEntityWithNecessaryFields() {
//given
final var publicId = "publicId";
final var resourceID = "resourceID";
final var internalId = "internalId";
final var description = "description";
final var reportName = "reportName";
final var creationDate = Instant.now();
final var dataPoints = List.<DataPoint>of();
//when
final var report = Report.builder()
.publicId(publicId)
.resourceId(resourceID)
.internalId(internalId)
.description(description)
.reportName(reportName)
.creationDate(creationDate)
.dataPoints(dataPoints)
.build();
//then
Assertions.assertAll(
() -> Assertions.assertEquals(publicId, report.getPublicId()),
() -> Assertions.assertEquals(resourceID, report.getResourceId()),
() -> Assertions.assertEquals(internalId, report.getInternalId()),
() -> Assertions.assertEquals(description, report.getDescription()),
() -> Assertions.assertEquals(reportName, report.getReportName()),
() -> Assertions.assertEquals(creationDate, report.getCreationDate()),
() -> Assertions.assertIterableEquals(dataPoints, report.getDataPoints())
);
}
Is it any better?
As always, it depends.
We’ve changed fields and constructor visibility to private so we can no longer change them after construction which suggests that we wan’t to have control over those changes. Maybe we plan to add some rules for creation or there are some invalid combinations.
We have public values that can still be accessed. And since it’s on class level it suggests that every field has a meaning to someone who will get the entity instance
And finally we have a builder which suggests that any field here is optional as we can construct the class without actually setting anything.
Ok but what if the last point is not true? And if that's the case why we’ve removed the option to change value is it really so that we can construct a class with only one field and it will be a perfectly valid entity?
We can resolve that if it’s an issue in a few different ways e.g. we can add validation e.g. in the constructor or we can introduce factory methods and make the builder available only there.
But do we need builders if we use factory methods or constructor anyways?
Again depends on the complexity of the factory but let’s say that after review of business requirements it turns out that we need all of the fields except for reportName and description as they are helpers for humans which will work with the reports.
What’s more interesting, there would be no business logic inside those methods as we want to ensure that the report does not change the values which are received from the source of truth.
But as usually the case there is one exception which is internalId. It turns out that this iID is used by database and we should set random UUID on report creation but we shouldn’t make it available outside of entity(That could be another conversation let’s live it like this)
Having those assumptions we could add the test to reflect that, maybe even some validation but our Report code is kind of ready right?
We could argue that it is the case but let wait a minute when the other developer working on that case would find out about those requirements? If we are lucky they could look into the code or the documentation if we have any. But let’s be honest, do you do that for every class which you use in your code, if so it is a bit time consuming isn’t it? So if we skip those steps we would find out about potential missing value during execution of tests which on its own also takes some time to get feedback. And relying only on the test pipeline has some drawbacks on its own. Given some circumstances, the test case may be removed or the whole pipeline can be disabled. It is an extreme case and we would have more troubles than entity construction refactoring but this example is meant to show that how we write the code is also a part of the quality of our entire software.
So, how could we rewrite our case to make it better?
Long story short it could look sth like this:
public class Report {
@Getter
private final String publicId;
@Getter
private final String resourceId;
@Getter(AccessLevel.PRIVATE)
private final String internalId;
@Getter
private final String description;
@Getter
private final String reportName;
@Getter
private final Instant creationDate;
@Getter
private final List<DataPoint> dataPoints;
public Report(String publicId, String resourceId, String description,
String reportName, Instant creationDate, List<DataPoint> dataPoints) {
this.publicId = publicId;
this.resourceId = resourceId;
this.internalId = UUID.randomUUID().toString();
this.description = description;
this.reportName = reportName;
this.creationDate = creationDate;
this.dataPoints = dataPoints;
}
public Report(String publicId, String resourceId, Instant creationDate,
List<DataPoint> dataPoints) {
this(publicId, resourceId, null, null, creationDate, dataPoints);
}
public Report(String publicId, String resourceId, String description, Instant creationDate,
List<DataPoint> dataPoints) {
this(publicId, resourceId, description, null, creationDate, dataPoints);
}
public Report(String publicId, String resourceId, Instant creationDate,
List<DataPoint> dataPoints, String reportName) {
this(publicId, resourceId, null, reportName, creationDate, dataPoints);
}
}
Now we’ve made sure that methods won’t be changed as the fields are final.
Constructor make sure that proper fields are set and only possible solutions are there to use for developers. We could use factory methods but since logic is simple and it does not look like it could be a subject for frequent change, the constructor should do a good enough job.
Fields which are optional are explicitly set as null in the constructor so other developers can at least assume that it was intentional and we didn’t leave them out by mistake.
InternalId is set in the constructor so we are sure that logic is valid, we could write the test for that one but since it strictly relates to the database we could use integration test for that.
Of course this is not the ultimate Report class and some improvement could be added to it but the goal was to show that even commonly approved patterns may be invalid in some contexts. Also I didn’t intend to critique builder pattern. In classes where all fields are indeed optional this pattern would work perfectly. Also using ready builders from Lombok is not the only option. With a mix of required and optional fields in DTO classes we could write our own builder with few parameters in one building block.
There are countless options but if this approach is new to you then I hope that you may find some uses for it as certainly it made my life as developer easier