design-patterns

3 posts

line

Code Quality Improvement Techniques Part 21 (opens in new tab)

Designing objects that require a specific initialization sequence often leads to fragile code and runtime exceptions. When a class demands that a method like `prepare()` be called before its primary functionality becomes available, it places the burden of safety on the consumer rather than the structure of the code itself. To improve reliability, developers should aim to create "unbreakable" interfaces where an instance is either ready for use upon creation or restricted by the type system from being used incorrectly. ### Problems with "Broken" Constructors * Classes that allow instantiation in an "unprepared" state rely on documentation or developer memory to avoid `IllegalStateException` errors. * When an object is passed across different layers of an application, it becomes difficult to track whether the required setup logic has been executed. * Relying on runtime checks to verify internal state increases the surface area for bugs that only appear during specific execution paths. ### Immediate Initialization and Factory Patterns * The most direct solution is to move initialization logic into the `init` block, allowing properties to be defined as read-only (`val`). * Because constructors have limitations—such as the inability to use `suspend` functions or handle complex side effects—a private constructor combined with a static factory method (e.g., `companion object` in Kotlin) is often preferred. * Using a factory method like `createInstance()` ensures that all necessary preparation logic is completed before a user ever receives the object instance. ### Lazy and Internal Preparation * If the initialization process is computationally expensive and might not be needed for every instance, "lazy" initialization can defer the cost until the first time a functional method is called. * In Kotlin, the `by lazy` delegate can be used to encapsulate preparation logic, ensuring it only runs once and remains thread-safe. * Alternatively, the class can handle preparation internally within its main methods, checking the initialization state automatically so the user does not have to manage it manually. ### Type-Safe State Transitions * For complex lifecycles, the type system can be used to enforce order by splitting the object into two distinct classes: one for the "unprepared" state and one for the "prepared" state. * The initial class contains only the `prepare()` method, which returns a new instance of the "Prepared" class upon completion. * This approach makes it a compile-time impossibility to call methods like `play()` on an object that hasn't been prepared, effectively eliminating a whole category of runtime errors. ### Recommendations When designing classes with internal states, prioritize structural safety by making it impossible to represent an invalid state. Use factory functions for complex setup logic and consider splitting classes into separate types if they have distinct "ready" and "not ready" phases to leverage the compiler for error prevention.

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.

line

Code Quality Improvement Techniques Part (opens in new tab)

The Null Object Pattern is a design technique that replaces null values with objects representing "empty" or "invalid" states to simplify code and provide functional fallbacks. While it effectively streamlines logic for collections and general data flows, using it when error conditions must be explicitly distinguished can lead to hidden bugs and reduced type safety. Developers should generally prefer statically verified types, such as Optionals or language-native nullable types, unless the error case can be seamlessly integrated into the happy-path logic. ### Benefits of the Null Object Pattern * **Code Simplification:** By returning an empty list or a "null object" instead of a literal `null`, callers can avoid repetitive null-check boilerplate. * **Functional Continuity:** It allows for uninterrupted processing in functional chains, such as using `.asSequence().map().forEach()`, because the "empty" object still satisfies the required interface. * **Fallback Provisioning:** The pattern is useful for converting errors into safe fallback values, such as displaying an "Unknown User" profile image rather than crashing or requiring complex conditional UI logic. ### Risks of Silent Failures and Logic Errors * **Bypassing Compiler Safety:** Unlike nullable types in Kotlin or Swift, which force developers to handle the `null` case, a custom null object (e.g., `UserModel.INVALID`) allows code to compile even if the developer forgets to check the object's validity. * **Identity vs. Equivalence:** Implementing the pattern requires caution regarding how the object is compared. If a null object is checked via reference identity (`==`) but the class lacks a proper `equals` implementation, new instances with the same "empty" values may not be recognized as invalid. * **Debugging Difficulty:** When a null object is used inappropriately, the program may continue to run with dummy data. This makes bugs harder to detect compared to a runtime error or a compile-time type mismatch. ### Best Practices for Type Safeness * **Prefer Static Verification:** When boundary conditions or errors must be handled differently than the "happy path," use `Optional`, `Maybe`, or native nullable types to ensure the compiler enforces error handling. * **Criteria for Use:** Reserve the Null Object Pattern for cases where the error logic is identical to the normal logic, or when multiple "empty" candidates exist that cannot be easily resolved through static typing. * **Runtime Errors as a Tool:** In dynamic or non-nullable contexts, a runtime error is often preferable to silent execution with an invalid object, as it provides a clear signal that an unexpected state has been reached. ### Recommendation To maintain high code quality, utilize the Null Object Pattern primarily for collections and UI fallbacks. For core business logic where the presence of data is critical, rely on type-safe mechanisms that force explicit handling of missing values, thereby preventing invalid states from propagating silently through the system.