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:
- 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 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:
- 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 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:
- 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).
- You don’t touch
OrderEntityor the mapper — they never knew the rule existed in the first place.
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. - Keep the Entity Dumb: An
@Entityshould describe a table row, not decide whether an order is valid — that belongs to a separate domain object. - 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.