DEV Community

Davi Gustavo Gonçalves da Cunha
Davi Gustavo Gonçalves da Cunha

Posted on AI-assisted

Unshackling Java Architecture: How the Builder Pattern Rescued Our Legacy System

Unshackling Java Architecture: How the Builder Pattern Rescued Our Legacy System

A practical guide to the Builder Design Pattern in Java --- from
legacy constructors to immutable, self-validating domain objects.


1. Core Fundamentals --- The Problem & The Theory

Introduction: What Are Design Patterns?

In enterprise software engineering, architectural bottlenecks and design
flaws tend to recur. As systems scale, challenges such as tight
coupling, fragile code, and monolithic classes
inevitably surface.

To prevent engineers from constantly "reinventing the wheel," the
industry relies on Design Patterns.

Cataloged in 1994 by the Gang of Four (GoF), Design Patterns are
formal, battle-tested solutions to common object-oriented software
design problems.

Key Takeaway: Design Patterns are not libraries, frameworks, or
copy-paste code snippets. Instead, they act as architectural
blueprints
that guide how classes and objects should interact to
maintain low coupling, high extensibility, and clean maintainability.

This article focuses on the Builder Pattern, a Creational Design
Pattern
.

What Is a Creational Pattern?

Creational patterns focus on how objects are created.

They help control object construction and reduce problems caused by
constructors with too many parameters, unclear argument ordering, or
partially initialized objects.


The Problem: Telescoping Constructors & Inconsistent State

In legacy Java systems, classes frequently accumulate dozens of fields
over time.

When instantiating these classes, developers often resort to two naive
approaches --- both creating serious engineering bottlenecks.

1. The Telescoping Constructor Problem

The class relies on massive constructors overloaded with parameters.

Callers end up passing multiple null references or obscure boolean
flags such as true and false, whose meanings are impossible to
decipher without checking the class declaration.

// Legacy Anti-Pattern:
// What do these parameters actually mean?
//
// Parameter order swapping can cause silent,
// critical bugs in production.

CreditApplication app = new CreditApplication(
    "12345",
    50000.00,
    24,
    true,
    false,
    null,
    null,
    true,
    0.05
);
Enter fullscreen mode Exit fullscreen mode

The main problem is not simply the number of parameters.

The real problem is that the constructor exposes implementation details
and makes the caller responsible for remembering the correct parameter
order.

2. Excessive Setters --- Mutability & Partial State

Another common solution is to instantiate an empty object and populate
its fields step-by-step through setter methods.

CreditApplication app = new CreditApplication();

app.setCustomerId("12345");
app.setRequestedAmount(new BigDecimal("50000"));
app.setTenureMonths(24);
app.setIncludesInsurance(true);
Enter fullscreen mode Exit fullscreen mode

Although this approach appears simpler, it introduces another problem.

During construction, the object may exist in a temporarily invalid or
incomplete state
.

Furthermore, excessive use of setters prevents fields from being
declared final, weakening immutability and making the object harder to
reason about in concurrent environments.


The Concept: The Assembly Line & Fluent Interface

The Builder Pattern resolves this dilemma by separating the
construction of a complex object from its representation
.

Think of the Builder as a custom assembly line.

Instead of forcing every component into a constructor at once,
construction is delegated to an intermediate Builder object.

The process becomes:

  1. Configure the object step-by-step.
  2. Use descriptive, self-documenting methods.
  3. Enforce required fields early.
  4. Configure optional parameters only when necessary.
  5. Execute validation through .build().
  6. Return a fully initialized object.

This approach enables a Fluent Interface, where builder methods
return this, allowing clean method chaining.

Example

CreditApplication application =
    new CreditApplication.Builder(
        "CUST-9981",
        new BigDecimal("150000.00")
    )
    .withTenureMonths(36)
    .withInsurance(true)
    .withGuarantor("TAX-ID-5541")
    .build();
Enter fullscreen mode Exit fullscreen mode

The resulting code is significantly easier to understand than a
constructor containing several positional parameters.


2. Development --- Case Study, Architecture & Code

Real-World Scenario: The Credit Analysis Engine

In financial-sector architectures such as Fintech applications, the
business domain is highly complex.

Imagine a microservice responsible for processing loan applications.

Each credit proposal requires mandatory information:

  • Customer ID
  • Requested amount

It may also contain optional information such as:

  • Tenure in months
  • Life insurance inclusion
  • Guarantor tax ID

In the legacy system, instantiating the CreditApplication class
required the use of a Telescoping Constructor.

Developers were forced to pass null for unused fields.

This created a serious risk: objects could reach the Risk Analysis
Engine
in an inconsistent state, resulting in silent failures or
unexpected business behavior.


3. Visual Representation --- System Diagrams

To document the solution, we mapped the pattern at two architectural
levels
.

UML Class Diagram --- The Pattern's Structure

The UML diagram illustrates how the CreditApplication domain class
encapsulates its private constructor, forcing instantiation
exclusively through the static inner Builder class.

classDiagram
    class CreditApplication {
        -String customerId
        -BigDecimal requestedAmount
        -int tenureMonths
        -boolean includesInsurance
        -String guarantorTaxId
        -CreditApplication(Builder builder)
        +getCustomerId() String
        +getRequestedAmount() BigDecimal
        +getTenureMonths() int
        +isIncludesInsurance() boolean
        +getGuarantorTaxId() String
    }

    class Builder {
        -String customerId
        -BigDecimal requestedAmount
        -int tenureMonths
        -boolean includesInsurance
        -String guarantorTaxId
        +Builder(String customerId, BigDecimal requestedAmount)
        +withTenureMonths(int tenure) Builder
        +withInsurance(boolean includesInsurance) Builder
        +withGuarantor(String taxId) Builder
        +build() CreditApplication
    }

    Builder ..> CreditApplication : <<instantiates>>

High-Level Software Architecture Diagram

The second diagram shows exactly where the Builder operates within the
application architecture.

The Builder is triggered by the Service Layer after the REST
Controller receives the API payload.

This ensures that only validated domain objects reach the Risk
Analysis Engine.

flowchart TD
    A[Client API] -->|1. POST /credit| B[REST Controller]
    B -->|2. DTO| C[Service Layer]

    subgraph Domain [Immutable Domain Layer]
        C -->|3. Configures| D[CreditApplication.Builder]
        D -->|4. Validates & builds| E([CreditApplication Object])
    end

    E -->|5A. Evaluates| F[Risk Analysis Engine]
    E -->|5B. Persists| G[(PostgreSQL Database)]

Architecture view: The Builder belongs inside the Domain
Layer
, between application-level input and the creation of the
immutable domain object.


4. Java Implementation --- Clean and Functional Code

Below is the implementation of the pattern.

Notice how business rules and integrity validations are handled before
the domain object is created
, protecting the domain from invalid
state.

package com.fintech.credit;

import java.math.BigDecimal;

/**
 * Immutable Domain Entity using the Builder Pattern.
 */
public class CreditApplication {

    private final String customerId;
    private final BigDecimal requestedAmount;
    private final int tenureMonths;
    private final boolean includesInsurance;
    private final String guarantorTaxId;

    private CreditApplication(Builder builder) {
        this.customerId = builder.customerId;
        this.requestedAmount = builder.requestedAmount;
        this.tenureMonths = builder.tenureMonths;
        this.includesInsurance = builder.includesInsurance;
        this.guarantorTaxId = builder.guarantorTaxId;
    }

    public String getCustomerId() {
        return customerId;
    }

    public BigDecimal getRequestedAmount() {
        return requestedAmount;
    }

    public int getTenureMonths() {
        return tenureMonths;
    }

    public boolean isIncludesInsurance() {
        return includesInsurance;
    }

    public String getGuarantorTaxId() {
        return guarantorTaxId;
    }

    public static class Builder {

        private final String customerId;
        private final BigDecimal requestedAmount;

        private int tenureMonths = 12;
        private boolean includesInsurance = false;
        private String guarantorTaxId;

        public Builder(
            String customerId,
            BigDecimal requestedAmount
        ) {
            if (customerId == null || customerId.isBlank()) {
                throw new IllegalArgumentException(
                    "Customer ID is required."
                );
            }

            if (
                requestedAmount == null ||
                requestedAmount.compareTo(BigDecimal.ZERO) <= 0
            ) {
                throw new IllegalArgumentException(
                    "Requested amount must be greater than zero."
                );
            }

            this.customerId = customerId;
            this.requestedAmount = requestedAmount;
        }

        public Builder withTenureMonths(int tenureMonths) {
            if (tenureMonths <= 0) {
                throw new IllegalArgumentException(
                    "Tenure must be greater than zero."
                );
            }

            this.tenureMonths = tenureMonths;
            return this;
        }

        public Builder withInsurance(boolean includesInsurance) {
            this.includesInsurance = includesInsurance;
            return this;
        }

        public Builder withGuarantor(String guarantorTaxId) {
            if (guarantorTaxId == null || guarantorTaxId.isBlank()) {
                throw new IllegalArgumentException(
                    "Guarantor tax ID cannot be blank."
                );
            }

            this.guarantorTaxId = guarantorTaxId;
            return this;
        }

        public CreditApplication build() {

            if (
                requestedAmount.compareTo(
                    new BigDecimal("100000")
                ) > 0 &&
                guarantorTaxId == null
            ) {
                throw new IllegalStateException(
                    "Loans over $100k require a registered guarantor."
                );
            }

            return new CreditApplication(this);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Application in the Service Layer

The Builder can then be used by the Service Layer through a fluent and
self-documenting API
.

CreditApplication secureApp =
    new CreditApplication.Builder(
        "CUST-9981",
        new BigDecimal("150000.00")
    )
    .withTenureMonths(36)
    .withInsurance(true)
    .withGuarantor("TAX-ID-5541")
    .build();
Enter fullscreen mode Exit fullscreen mode

What does this code communicate?

  • Customer: CUST-9981
  • Requested amount: $150,000.00
  • Tenure: 36 months
  • Insurance: enabled
  • Guarantor: registered
  • Object: validated and immutable after construction

The important point is that the code communicates intent, not just
values.


6. Pros and Cons --- Trade-Off Analysis

In software engineering, every architectural decision has a cost.

Adopting the Builder Pattern brought the following impacts to our
architecture.

Pros --- Advantages

1. Guaranteed Immutability

Using final variables and removing setters makes the object
immutable after construction.

Once constructed, the state of the CreditApplication cannot be
changed.

This makes the object easier to reason about and safer to use in
concurrent environments.

2. Centralized Validation

No CreditApplication instance is created before the required
validation rules are satisfied.

The guarantor rule, for example, is enforced inside .build().

if (
    requestedAmount.compareTo(
        new BigDecimal("100000")
    ) > 0 &&
    guarantorTaxId == null
) {
    throw new IllegalStateException(
        "Loans over $100k require a registered guarantor."
    );
}
Enter fullscreen mode Exit fullscreen mode

This protects the domain from invalid states.

3. Readability --- Fluent API

Method chaining makes the code's intent explicit.

Without Builder:

new CreditApplication(
    "CUST-9981",
    new BigDecimal("150000.00"),
    36,
    true,
    "TAX-ID-5541"
);
Enter fullscreen mode Exit fullscreen mode

With Builder:

new CreditApplication.Builder(
    "CUST-9981",
    new BigDecimal("150000.00")
)
.withTenureMonths(36)
.withInsurance(true)
.withGuarantor("TAX-ID-5541")
.build();
Enter fullscreen mode Exit fullscreen mode

The second version communicates the meaning of each value without
requiring the developer to inspect the constructor declaration
.

Cons --- Trade-Offs & Costs

1. Boilerplate Code

There is obvious code duplication.

Entity attributes must generally be represented in both the domain
object and the Builder.

This increases the amount of code that must be maintained.

2. Maintenance Cost

When adding a new field, developers may need to update multiple
locations:

  • The domain entity
  • The Builder
  • The private constructor
  • Potential validation rules
  • Service-layer construction code

Therefore, the Builder Pattern is not completely free from maintenance
overhead
.

3. Initial Overhead

For simple domain classes containing only two or three fields,
introducing a Builder may constitute over-engineering.

For example:

new Address("Street A", "123");
Enter fullscreen mode Exit fullscreen mode

may be perfectly reasonable.

The Builder Pattern becomes more useful as:

  • The number of optional parameters increases.
  • Validation rules become more complex.
  • Object construction becomes harder to understand.
  • Immutability becomes important.
  • Multiple construction variations exist.

7. Conclusion

Summary

Refactoring the CreditApplication class using the Builder Pattern
directly solved the readability and fragility issues of the
microservice.

The approach provides several architectural benefits:

  • Eliminates telescoping constructors.
  • Reduces the use of meaningless null parameters.
  • Prevents incomplete objects from being created.
  • Encourages immutable domain objects.
  • Centralizes construction-time validation.
  • Makes object creation self-documenting.
  • Improves maintainability of complex domain models.

The result is not simply cleaner syntax.

It is a domain model with stronger guarantees about its own state.

Personal View

Implementing the Builder Pattern in a real-world scenario showed me how
small design flaws --- such as accepting constructors full of null
values or abusing setter methods --- can accumulate significant
technical debt over time.

As a Software Engineering student focusing on modern software
ecosystems, I realize that an engineer's true differentiator is not
simply making the code run.

It is designing software that is:

  • Predictable
  • Secure
  • Maintainable
  • Extensible
  • Self-documenting

The Builder Pattern is a relatively small design decision, but when
applied to the right problem, it can significantly improve the quality
of a domain model.

Final Takeaway

The Builder Pattern is not a universal solution.

It should not be introduced merely because a class has several fields.

Its value becomes clear when object construction itself becomes a source
of complexity, ambiguity, or invalid state.

In those situations, the Builder acts as a controlled assembly line:

Required Data
      ↓
   Builder
      ↓
Optional Configuration
      ↓
  Validation
      ↓
   .build()
      ↓
Immutable Domain Object
Enter fullscreen mode Exit fullscreen mode

Key Principle:\
Make invalid objects difficult --- or impossible --- to create.

That principle is often more valuable than the pattern itself.


Discussion

Have you ever had to deal with massive constructors full of null
values and indecipherable boolean parameters
in a legacy project?

How does your team handle the construction of complex domain objects?

Share your experience or tag a colleague who needs to refactor that
legacy constructor today!

Linkedln: www.linkedin.com/in/davi-gustavo-gonçalves-da-cunha-ba2689282

GitHub: https://github.com/DaviGGC

Top comments (0)