The Architecture of Validation: Separating Boundaries From Business Logic

In the first post of this series, we explored the cost of scattered validation logic. By using real-world examples we saw how defensive plumbing, repeated null checks, silent fallbacks, and ad-hoc defaults infest business logic, turning a simple service method into a maze of conditionals.

The problem is not validation itself, but rather that it is often mixed or placed somewhere where it causes confusion and incurs penalties for refactoring efforts.

In the previous post, we saw how a simple DbHandler.java became mixed up with business rules and how a MqHandler became a configuration validator. To fix this, we need a clear architectural contract. We need to define where each type of rule lives.

In this post, we will break down the three distinct layers of validation: Boundary, Domain, and Persistence. We will see how moving rules to their correct layer prevents the “silent repair” antipatterns that make refactoring a nightmare.

The Three Layers of Validation #

Any well-structured application will have layers, usually separated by concern. Each of these layers has specific responsibilities regarding data validation.

1. Boundary Layer (Sanitization & Format) #

This is the interface where the system meets the outside world.

This layer deals with raw data and makes sure it conforms to expected formats, ranges and structures.

Examples:

The tools we use to ensure this kind of validation are @Valid, @NotBlank, @Pattern, custom validators, DTOs, and configuration properties. The purpose of using these tools is to fail fast at the entry point and reject any input data before it enters the system.

2. Domain Layer (Invariants & State) #

This is where the heart of your business system lies; it is responsible for making sure business rules are observed and to make sure data conforms to these rules before it is processed.

Examples:

The tools we use to make sure this happens are value objects, entity constructors, and of(...) factory methods, with the goal being to fail fast when objects are invalid.

3. Persistence Layer (Schema & Constraints) #

The goal of this layer is to make sure data conforms to the storage schema. It also represents our very final layer before something is persisted.

Examples:

The tools we use to make this happen are JPA, @Column(nullable = false), Database constraints, and indexes. The ultimate goal of this layer is to protect our data at rest.


The “Boundary” Layer: Where Input Lives #

One of the most common mistakes I see, especially in spring-web projects, is the pattern of validating everything in the controllers, sprinkling some @Validated, @Valid, and tons of Jakarta bean validation annotations, along with custom validators to enforce default values.

“Fail-fast” is often misinterpreted as throwing exceptions the moment a business rule is violated, potentially right inside a controller. However, the principle applies primarily to input shape, not business policy. If your controller throws an exception because an order is ineligible for shipping, you are mixing transport concerns with domain logic. This creates tight coupling where your API layer depends on specific business states, making refactoring difficult. Instead, the controller should validate that data is well-formed (DTO validation), while the service layer enforces eligibility rules. This ensures that changes to business policy don’t ripple up to break your API contract, keeping the boundary clean and the domain explicit.

The Configuration Validation Problem #

A common issue I see often is the propensity to use @Value to inject raw values into variables, which works most of the time, but if there’s a problem you won’t know about it until the variable is accessed. Configuration should be treated the same as API input and should be validated on start.

In the previous post, we discussed MqHandler:

// Problem: Late validation in MqHandler
if (mqChannel != null && !mqChannel.equals("")) {
    MqEnvironment.channel = mqChannel;
}

Another example from a message generator:

// Problem: Parsing inside a processing loop
int max = 900;
try {
    max = Integer.parseInt(maxNumberOfMessages);
} catch (NumberFormatException e) {
    // Silent fallback
}

In order to ensure validation of your configuration, you should opt for using @ConfigurationProperties with @Validated. This will ensure that any configuration issues are detected at startup.

@Validated
@Component
@ConfigurationProperties(prefix = "app")
public record AppConfig(
        @NotBlank String queueChannel,
        @Min(1) int maxBatchSize
) {}

Controller vs. Service Responsibility #

The boundary layer should focus on format validation, not business eligibility.

Controller (Boundary): Validates that the request is well-formed.

@RestController
@RequestMapping("/orders")
@Validated
public class OrderController {

    @PostMapping
    public ResponseEntity<OrderDto> createOrder(@Valid @RequestBody CreateOrderDto dto) {
        // Format validation happens here via @Valid annotations
        // Business logic happens in the service layer
        return ResponseEntity.ok(orderService.createOrder(dto));
    }
}

Service (Domain): Validates that the business rules are satisfied.

Note: I have omitted the mapper from the service below for brevity. In a strict Domain-Driven Design (DDD) implementation, the controller would pass a DTO to an Application Mapper, which would transform it into a Domain Object (Aggregate) before passing it to the Service.

Additionally, while this example shows the Service interacting directly with the DTO (a common pragmatic approach in Spring Boot), a pure DDD design would validate business rules against the Domain Object’s internal state rather than the DTO’s fields. The core principle remains the same: Format validation at the Boundary, Business Logic in the Domain.

@Service
public class OrderService {
    
    public OrderDto createOrder(CreateOrderDto dto) {
        // Business rule: Customer must be verified
        if (!customerRepository.isVerified(dto.customerId())) {
            throw new OrderException("Customer not verified");
        }
        
        // Business rule: Order total must be positive
        if (dto.items().stream().map(Item::price).reduce(BigDecimal.ZERO, BigDecimal::add).compareTo(BigDecimal.ZERO) <= 0) {
            throw new OrderException("Order must have positive total");
        }
        
        // In a strict DDD setup, this would be: Order savedOrder = orderRepository.save(domainOrder);
        Order savedOrder = orderRepository.save(dto.toEntity());
        return OrderDto.from(savedOrder);
    }
}

This separation ensures that:

The “Domain” Layer: Invariants and Value Objects #

Once data passes the boundary, it should be converted into Domain Objects. This is where we enforce invariants.

The of(...) Pattern #

The most robust way to handle domain validation is the Value Object pattern with static factory methods.

public final class PersonalNumber {

    private final String canonical12;

    private PersonalNumber(String canonical12) {
        this.canonical12 = canonical12;
    }

    public static PersonalNumber of(String rawInput) {
        String normalized = normalize(rawInput);
        validateChecksum(normalized);
        return new PersonalNumber(normalized);
    }
    
    private static void validateChecksum(String number) {
        // Swedish Personnummer checksum validation
        // (Different from Luhn algorithm used for credit cards)
        int checksum = calculateChecksum(number);
        if (checksum != 0) {
            throw new IllegalArgumentException("Invalid personal number checksum");
        }
    }
    
    private static int calculateChecksum(String number) {
        // Implementation of Swedish checksum algorithm
        // ...
        return 0;
    }
}

The advantages of this pattern are several, but first and foremost, it rejects invalid input on creation and an invalid object cannot be created. Once it has been created, the caller knows PersonalNumber is safe, there’s no need to check if it’s null. Also, all the complex validation rules (PersonalNumber checksum, date parts) live in the place where you expect it to be.

Bean Validation vs. Constructor Checks #

Although it is technically possible to use @Valid on entities as well, it comes with several drawbacks, the biggest one being that you rely on the framework to trigger validation, which can delay failure if implemented incorrectly. Prefer constructor checks since that will trigger validation immediately on creation.

The “Persistence” Layer: Where Data Lives #

This section addresses the common anti-patterns that occur in the data access and mapping layers. These are critical because they are often overlooked when building validation strategies.

The DbHandler Problem #

Recall the DbHandler.java example from our previous discussion:

// Problem: Mixing retrieval logic with business defaults
if (message.getWeight() == null || message.getWeight().compareTo(BigDecimal.ZERO) == 0) {
    message.setWeight(BigDecimal.ONE);
}

This is happening in the data access layer. The DbHandler is supposed to retrieve data. Instead, it is deciding what to do if the data does not conform to our business rule of a minimum weight of 1.

The problem with doing it this way is partly because this logic is placed in the data access layer, whose responsibility it is to fetch data, not fix it. There’s also the risk of hidden side effects when you don’t know if the weight is actually 0, or if it was null and got defaulted. Also updates to business rules, such as defaulting to a new lowest value would also require you to go through all the retrieval methods of your data access layer.

The Mapper Trap #

Mappers are a great place for normalization, but they should not contain business rules.

Consider this mapper method:

// Problem: Business logic in mapping default BigDecimal
private BigDecimal normalizeMeasurement (BigDecimal value) {
    if (value == null || value.signum() <= 0) {
        return new BigDecimal("1.000"); // Business default 
    }
    return value.setScale(3, RoundingMode.HALF_UP);
}

Here, 1.000 is a business default. If the business decides that “zero weight” should be treated as “1.0”, this logic changes. If you change this in the mapper, you might accidentally break a different part of the system that relies on the raw value.

A better solution is to allow the mapper to convert types and trim strings, while the domain/service layer gets to decide that 0 should be transformed to 1.

// Correct: Mapper only handles type conversion
private BigDecimal convertToBigDecimal(String value) {
    if (value == null) return BigDecimal.ZERO;
    return new BigDecimal(value.trim()).setScale(3, RoundingMode.HALF_UP);
}

// Domain layer handles business defaults
public BigDecimal getWeight() {
    return BigDecimal.ONE.max(weight); // Business rule
}

Database Constraints as the Final Guard #

The persistence layer should also include database-level constraints that serve as the final safety net.

Note: We use an argument constructor for validation, so the no-args constructor is omitted here. However, it is typically required for proper JPA proxying.

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50)
    private String customerId;

    @Column(nullable = false)
    private BigDecimal total;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(unique = true)
    private String orderNumber;

    // Business validation in constructor
    public Order(String customerId, BigDecimal total) {
        this.customerId = customerId;
        this.total = total;
        this.status = OrderStatus.PENDING;
        
        if (total.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Order total must be positive");
        }
    }
}

The database constraints provide the final layer of protection:

Constraint Purpose Enforcement Point
NOT NULL Prevents missing required data Database
UNIQUE Prevents duplicate records Database
CHECK Enforces value ranges Database
FOREIGN KEY Maintains referential integrity Database

The “Anti-Pattern” Zones #

Based on the codebase examples we reviewed, here are the specific zones where logic often leaks:

Layer Common Mistake Correct Approach
Boundary Business rules in controllers Format validation only
Domain Relying solely on framework validation Constructor checks with factory methods
Persistence Defaulting null values to 0 or 1 Let the domain object decide defaults
Mapper Business logic in normalize() methods Type conversion and trimming only
Config Manual validation in init() methods Use @ConfigurationProperties + @Validated

Why This Matters for Refactoring #

When you separate these layers, refactoring becomes predictable.

If you change the rule about “zero weight” in the domain:

If you mix them, you have to update the DbHandler, the Service, and maybe the DTO to reflect the same business rule.

Key Takeaways #

In the next article, we will dive deeper into the tools available to implement these layers, specifically focusing on Jakarta Bean Validation, Spring’s validation support, and how to create custom annotations for complex business rules.