line

Code Quality Improvement Techniques Part (opens in new tab)

The builder pattern is frequently overused in modern development, often leading to code that is less robust than it appears. While it provides a fluent API, it frequently moves the detection of missing mandatory fields from compile-time to runtime, creating a "house of sand" that can collapse unexpectedly. By prioritizing constructors and factory functions, developers can leverage the compiler to ensure data integrity and build more stable applications.

Limitations of the Standard Builder Pattern

  • In a typical builder implementation, mandatory fields are often initialized as nullable types and checked for nullity only when the .build() method is called.
  • This reliance on runtime checks like checkNotNull means that a developer might forget to set a required property, leading to an IllegalStateException during execution rather than a compiler error.
  • Unless the platform or a specific library (like an ORM) requires it, the boilerplate of a builder often hides these structural weaknesses without providing significant benefits.

Strengthening Foundations with Constructors and Defaults

  • Using a class constructor or a factory function is often the simplest and most effective way to prevent bugs related to missing data.
  • In languages like Kotlin, the need for builders is further reduced by the availability of default parameters and named arguments, allowing for concise instantiation even with many optional fields.
  • If a builder must be used, mandatory arguments should be required in the builder's own constructor (e.g., Builder(userName, emailAddress)) to ensure the object is never in an invalid state.

Managing Creation State and Pipelines

  • Developers sometimes pass a builder as an "out parameter" to other functions to populate data, which can obscure the flow of data and reduce readability.
  • A better approach is to use functions that return specific values, which are then passed into a final constructor, keeping the logic functional and transparent.
  • For complex, multi-stage creation logic, defining distinct types for each stage—such as moving from a UserAccountModel to a UserProfileViewComponent—can ensure that only valid, fully-formed data moves through the pipeline.

Appropriate Use of Terminal Operations

  • The builder-like syntax is highly effective when implementing "terminal operations," where various transformations are applied in an arbitrary order before a final execution.
  • This pattern is particularly useful in image processing or UI styling (e.g., .crop().fitIn().colorFilter()), where it serves as a more readable alternative to deeply nested decorator patterns.
  • In these specific cases, the pattern facilitates a clear sequence of operations while maintaining a "last step" (like .createBitmap()) that signals the end of the configuration phase.

Prioritize the use of constructors and factory functions to catch as many errors as possible during compilation. Reserve the builder pattern for scenarios involving complex terminal operations or when dealing with restrictive library requirements that demand a specific instantiation style.