The Cost of Scattered Validation Logic

I think we have all seen the signs of a code base in trouble, code that has been written and updated without a clear strategy, showing the signs of defensive plumbing creeping into our business logic.

If you’ve ever opened a Java service class and found yourself reading this pattern five times in a row:

// @formatter:off

if (message.getWeight() == null || message.getWeight().compareTo(BigDecimal.ZERO) == 0) {
    // Logic A
} else {
    // Logic B
}

// @formatter:on

…you know exactly what I mean. It is the inevitable outcome of a poor data validation strategy, or the lack of one, where clarity erodes with every null check and buries the relevant logic deeper and deeper in a haystack of defensive plumbing.

In this series, we’re going to talk about data validation. But not just the @Valid annotation we use on DTOs, but how to prevent null checks from infesting your entire code base and complicate development, refactoring, and testing.

When validation logic is scattered throughout your code base, it creates five specific maintenance problems:

Real-world evidence #

I have brought a couple of examples from a real codebase, that highlight a couple of aspects relevant to this discussion.

A defensive DbHandler #

In the example below, the following statement is present in the middle of a method used to retrieve data from a database and save it into the message value object.

// @formatter:off

if (message.getWeight() == null || message.getWeight().compareTo(BigDecimal.ZERO) == 0) {
    message.setWeight(BigDecimal.ONE);
}

// @formatter:on

At this point in the logic, the value has already been assigned to the value object (Message), so effectively what we do next should be business logic, but the if statement is clearly both defensive plumbing (checking for null) and setting a lowest value if the weight is 0. Considering this code is at a boundary (database to code), there should be no business logic present here at all, which should be placed in a service class. Also, the null is questionable. Normally you would only allow null in the database for values that are entirely optional, which weight clearly is not since we assign it a default value in the code.

The main problem with this is that your DbHandler is now partially responsible for business rule validation, which means you have to include the DbHandler every time you need to lookup business rules. The second problem is that updates to your business logic become more tedious to implement.

The trim() Anti-Pattern #

Another common pattern is a helper method that hides null semantics:

// @formatter:off

// Problem: Naming implies trimming, behavior implies nullification
private String trim(String str) {
    if (str != null) {
        str = str.trim();
        if (!str.isEmpty()) {
            return str;
        }
    }
    return null; // Silent nullification
}

// @formatter:on

The thing that really frustrates me with the above example is that it treats null as a first class citizen used to justify further defensive plumbing, so when you use this method you are forced to check for null again. The second thing is that trim is a very specific operation in most programming languages and adding null semantics to it, just makes the method confusing.

A method like this requires you to constantly remember what it does, or look it up every time you use it. In short, it turns your codebase into a big guessing game. A constructive refactor would be to let it return a string regardless of whether the input is null or empty and rename the method to trimOrEmpty.

The Cost of “Late Validation” #

Validation should happen as close to the system boundary as possible. But often, we see validation happening deep inside runtime processing loops.

Consider this snippet from a message generator class:

// @formatter:off

// Problem: Validation happening inside a processing loop
int max = 900;
try {
    max = Integer.parseInt(maxNumberOfMessages);
} catch (NumberFormatException e){
    log.warn("Error occurred while converting int", e);
    // Silent fallback: defaults to 900
}

// @formatter:on

The biggest mistake of doing this when you’re about to send messages is that it is too late. We want the application to fail fast when we start it, and ideally it should fail directly on startup if the configuration is broken. Also falling back to a default value, or trying to repair a configuration problem with code is another scattering of logic, where configuration rules are now scattered in your code.

The Cognitive Load Tax #

Every if (field == null) is a mental branch for the developer.

With scattered validation, you run the risk of complicating your refactoring efforts. Every scattered validation method has to be updated every time your business rules are changed, making refactorings time-consuming and error-prone.

With defensive plumbing deep inside retrieval, it hides data corruption. You think you have valid data, but you actually have a default value injected by the data access layer.

The goal isn’t to remove all null checks. The goal is to move them to the boundary.

The Proposed Shift: Boundaries vs. Deep Logic #

To fix this, we need to separate three distinct layers:

An important question you always should ask yourself whenever you see a null check is Does this check belong here, or should it have been rejected earlier?

In the next article, we will define these architectural layers in detail and show you exactly where each type of rule belongs.

Key Takeaways #