Refactoring Java toString() Without Repetitive if Statements

Large Java objects often end up with toString() methods containing dozens of conditional checks. This guide shows clean, performant, production-ready ways to refactor such methods without clutter, while keeping full control over formatting and avoiding reflection.


Starting Point: The Problem

@Override
public String toString() {
    ToStringBuilder builder = new ToStringBuilder(this, CUSTOM_STYLE);

    if (StringUtils.isNotBlank(requestId)) {
        builder.append("REQUEST_ID", requestId);
    }
    if (StringUtils.isNotBlank(userId)) {
        builder.append("USER_ID", userId);
    }
    if (StringUtils.isNotBlank(status)) {
        builder.append("STATUS", status);
    }
    if (StringUtils.isNotBlank(total)) {
        builder.append("TOTAL", total);
    }
    // ... many more if statements

    return builder.toString();
}

Problems:

  • High noise-to-signal ratio
  • Repetitive conditionals
  • Hard to extend
  • Easy to break formatting

Step 1: Extract the Conditional Logic (Baseline Refactor)

Helper Method

private static void appendIfNotBlank(
        ToStringBuilder builder,
        String key,
        String value
) {
    if (StringUtils.isNotBlank(value)) {
        builder.append(key, value);
    }
}

Refactored toString()

@Override
public String toString() {
    ToStringBuilder builder =
            new ToStringBuilder(this, CUSTOM_STYLE);

    appendIfNotBlank(builder, "REQUEST_ID", requestId);
    appendIfNotBlank(builder, "USER_ID", userId);
    appendIfNotBlank(builder, "REQUEST_TYPE", requestType);
    appendIfNotBlank(builder, "STATUS", status);
    appendIfNotBlank(builder, "TOTAL", total);
    appendIfNotBlank(builder, "MESSAGE_CODE", messageCode);
    appendIfNotBlank(builder, "REQUEST_INITIATOR", requestInitiator);
    appendIfNotBlank(builder, "CONSENT_ID", consentId);
    appendIfNotBlank(builder, "NUMBER_OF_CONSENTS", numberOfConsents);
    appendIfNotBlank(builder, "FI_ID", externalFI);

    if (externalFiStatus != null) {
        builder.append("EXTERNAL_FI_STATUS", externalFiStatus);
    }

    appendIfNotBlank(builder, "EXTERNAL_FI_MESSAGE_CODE", externalFiMessageCode);
    appendIfNotBlank(builder, "EXTERNAL_FI_TOTAL_TIME", externalFITotal);

    return builder.toString();
}

Characteristics

  • ✔ Same runtime cost as raw if
  • ✔ No allocations
  • ✔ No streams
  • ✔ No reflection
  • ✔ Extremely readable

➡️ This is the recommended default for production systems


Step 2: Consumer-Based Pattern (Functional, Optional)

If you want to reduce visual repetition even further, use Consumer<ToStringBuilder>.

Consumer Factory

private static Consumer<ToStringBuilder> appendIfNotBlank(
        String key,
        String value
) {
    return builder -> {
        if (StringUtils.isNotBlank(value)) {
            builder.append(key, value);
        }
    };
}

Usage

@Override
public String toString() {
    ToStringBuilder builder =
            new ToStringBuilder(this, CUSTOM_STYLE);

    Stream.of(
            appendIfNotBlank("REQUEST_ID", requestId),
            appendIfNotBlank("USER_ID", userId),
            appendIfNotBlank("REQUEST_TYPE", requestType),
            appendIfNotBlank("STATUS", status),
            appendIfNotBlank("TOTAL", total),
            appendIfNotBlank("MESSAGE_CODE", messageCode)
    ).forEach(c -> c.accept(builder));

    if (externalFiStatus != null) {
        builder.append("EXTERNAL_FI_STATUS", externalFiStatus);
    }

    return builder.toString();
}

Trade-offs

AspectResult
ReadabilityHigh
PerformanceSlightly lower
AllocationsLambda + Stream
Best useConfigurable / declarative code

Step 3: Zero-Lambda, Zero-Stream Loop (Highly Performant)

If this method is used inside hot paths (logging, metrics), this pattern avoids lambdas entirely.

Field Descriptor

record Field(String key, String value) {}

Implementation

@Override
public String toString() {
    ToStringBuilder builder =
            new ToStringBuilder(this, CUSTOM_STYLE);

    Field[] fields = {
            new Field("REQUEST_ID", requestId),
            new Field("USER_ID", userId),
            new Field("REQUEST_TYPE", requestType),
            new Field("STATUS", status),
            new Field("TOTAL", total),
            new Field("MESSAGE_CODE", messageCode)
    };

    for (Field field : fields) {
        if (StringUtils.isNotBlank(field.value())) {
            builder.append(field.key(), field.value());
        }
    }

    if (externalFiStatus != null) {
        builder.append("EXTERNAL_FI_STATUS", externalFiStatus);
    }

    return builder.toString();
}

Why This Is Fast

  • No streams
  • No lambdas
  • Predictable allocations
  • Easy to reorder fields

❌ Patterns to Avoid

❌ Reflection-Based toString()

for (Field f : getClass().getDeclaredFields()) {
    ...
}

Why avoid:

  • Slow
  • Breaks encapsulation
  • Hard to format
  • Dangerous for logs

❌ Map-Driven Approach

Map<String, String> values = new HashMap<>();

Why avoid:

  • Unnecessary allocations
  • Worse performance
  • Less explicit
  • Harder debugging

Final Recommendation

Use This in 90% of Cases

appendIfNotBlank(builder, "FIELD", value);

✔ Fast
✔ Explicit
✔ Readable
✔ Testable
✔ Production-safe


Summary

ApproachRecommended
Helper method✅ Yes
Consumer + Stream⚠️ Optional
Loop over descriptors✅ Yes
Reflection❌ No
Map-based❌ No

 

This article is inspired by real-world challenges we tackle in our projects. If you're looking for expert solutions or need a team to bring your idea to life,

Let's talk!

    Please fill your details, and we will contact you back

      Please fill your details, and we will contact you back