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 message generator silently fell back to a default when a configuration value failed to parse. 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 a message generator that parsed a configuration value deep inside a processing loop:

// 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 the 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
}

Keeping the Domain Object and the Entity Separate #

A common mistake is to sprinkle some business validation into your @Entity, after all, the field to check is right there and adding a small check seems sensible. But an @Entity is a persistence-layer construct, it represents a row in a database and should not decide what makes that row valid.

The fix is a small amount of extra plumbing: a plain domain object that owns the invariant, a JPA entity that owns the schema, and a mapper that connects them.

Order — the domain object. A record with a compact constructor enforcing invariants, so an invalid Order can never exist in memory:

public record Order(Long id, String customerId, BigDecimal total, OrderStatus status) {

    public Order {
        if (customerId == null || customerId.isBlank()) {
            throw new IllegalArgumentException("Customer ID is required");
        }
        if (total == null || total.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Order total must be positive");
        }
    }

    // Convenience constructor for creating a brand-new order
    public Order(String customerId, BigDecimal total) {
        this(null, customerId, total, OrderStatus.PENDING);
    }
}

OrderEntity — the JPA entity. No business rules at all, only shape and schema-level constraints:

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

    @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;

    protected OrderEntity() {
        // required by JPA
    }

    public OrderEntity(String customerId, BigDecimal total, OrderStatus status) {
        this.customerId = customerId;
        this.total = total;
        this.status = status;
    }

    public Long getId() { return id; }
    public String getCustomerId() { return customerId; }
    public BigDecimal getTotal() { return total; }
    public OrderStatus getStatus() { return status; }
}

A mapper — the only class allowed to know about both.

public final class OrderEntityMapper {

    private OrderEntityMapper() {
    }

    public static OrderEntity toEntity(Order order) {
        // order is already valid by the time it gets here — no checks needed
        return new OrderEntity(order.customerId(), order.total(), order.status());
    }

    public static Order toDomain(OrderEntity entity) {
        // trusts the DB row was written by this app and is well-formed;
        // if it throws here, that's a sign of external data corruption, not a normal path
        return new Order(entity.getId(), entity.getCustomerId(), entity.getTotal(), entity.getStatus());
    }
}

Separating code this way ensures that business logic exists in one place only, and that the @Entity need not have any knowledge of business logic.

Database Constraints as the Final Guard #

Even with a self-validating domain object, you still want a safety net at the database itself. Application code isn’t the only way rows get written — migrations, scripts, and other services can bypass it entirely.

ALTER TABLE orders
    ADD CONSTRAINT chk_orders_total_positive CHECK (total > 0);

Paired with the @Column(nullable = false) declarations already on OrderEntity, this gives you three independent layers enforcing the same rule in three different ways: the Order constructor fails fast in memory, the entity enforces shape, and the CHECK constraint catches anything that reaches the table through a path your application never saw.

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
Persistence Business rules living in the @Entity Separate domain object, mapped to the entity
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.