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:
- Is this string a valid email?
- Is this integer within the allowed range?
- Is the incoming JSON structurally correct?
- Can this configuration value be parsed?
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:
- A
Customermust have a validPersonalNumber. - A
Shipmentweight must be positive. - An
Accountcannot be both active and closed at the same time.
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:
- Is this field nullable in the database?
- Does this column have a length constraint?
- Is this field unique?
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:
- API stability: Format validation changes don’t affect business logic
- Business clarity: Domain rules are explicit and testable
- Refactorability: Business rules can change without touching the API layer
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:
- You update the Shipment Value Object.
- You don’t touch the DbHandler (it just fetches the data).
- You don’t touch the API DTO (it just transmits the data).
If you mix them, you have to update the DbHandler, the Service, and maybe the DTO to reflect the same business rule.
Key Takeaways #
- Separation of Concerns: Boundary (Format) != Domain (Invariants) != Persistence (Schema).
- Fail Fast: Configuration should fail at startup. DTOs should fail at entry. Domain objects should fail at creation.
- Mappers are for Conversion: Do not put business defaults (like “1.000”) in mappers.
- Value Objects are Safe: Use
of(...)to create domain-safe types from raw input. - Avoid Silent Repair: If data is invalid, reject it at the boundary. Don’t fix it deep inside the logic.
- Database Constraints Matter: They are the final safety net for your data integrity.
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.