Validating Data
Storm gives every rule about your data one authority and checks it there, once. Nothing is stated twice: no @Size(max = 255) beside VARCHAR(255), no exists-query beside a unique index.
This page is about the values an application writes. To check entity definitions against the database, see Schema Validation. For a worked example from a form post to the response, see the tutorial Validate input end to end.
Four Kinds of Rules
| Kind of rule | Example | Where it lives | What checks it |
|---|---|---|---|
| Product rule | A required field, a format, a display name of at most 50 | The request type | Your input validator |
| Entity invariant | An owner always has a first name | The record's constructor | The language: an invalid entity cannot exist |
| Column limit | A length, a precision | The database schema | validate and validateOrThrow |
| Rule that depends on other rows | Unique, foreign key | The database | The database, reported as IntegrityViolationException |
The examples below use this table and entity:
CREATE TABLE owner (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name VARCHAR(30) NOT NULL,
last_name VARCHAR(30) NOT NULL,
telephone VARCHAR(20) NOT NULL,
CONSTRAINT owner_telephone_uk UNIQUE (telephone)
);
Product Rules: the Request Type
What a form must contain is a decision about the product, and it can differ per endpoint. It belongs to the input validator on the request type: Bean Validation with Spring, Konform or Ktor's RequestValidation with Ktor. Storm does not take part.
- Kotlin
- Java
data class OwnerForm(
@field:NotBlank val firstName: String,
@field:NotBlank val lastName: String,
@field:Pattern(regexp = "[0-9 +-]+") val telephone: String,
)
public record OwnerForm(@NotBlank String firstName,
@NotBlank String lastName,
@Pattern(regexp = "[0-9 +-]+") String telephone) {}
A limit stricter than the column, such as a display name of at most 50 in a VARCHAR(255), is a product rule and goes here. A limit equal to the column is the column's, and restating it only gives the two a chance to disagree.
Entity Invariants: the Constructor
A rule that holds for every owner, wherever it comes from, goes in the record's constructor, so an invalid entity cannot exist:
- Kotlin
- Java
data class Owner(
@PK val id: Int = 0,
val firstName: String,
val lastName: String,
@UK val telephone: String,
) : Entity<Int> {
init {
require(firstName.isNotBlank()) { "An owner has a first name." }
}
}
public record Owner(@PK Integer id,
String firstName,
String lastName,
@UK String telephone) implements Entity<Integer> {
public Owner {
if (firstName.isBlank()) {
throw new IllegalArgumentException("An owner has a first name.");
}
}
}
The constructor also runs when Storm reads a row, so an invariant has to hold for the rows already in the table. A row that breaks one fails the read with an error naming the entity and its primary key. A rule that applies to writes alone belongs in an EntityCallback.
Column Limits: the Schema
A column already states its length and precision. validate compares an entity's values against those limits without writing it and returns every violation found, and validateOrThrow throws them as one IntegrityViolationException:
- Kotlin
- Java
val violations: List<IntegrityViolation> = owners.validate(owner)
owners.validateOrThrow(owner)
List<IntegrityViolation> violations = owners.validate(owner);
owners.validateOrThrow(owner);
The limits come from the schema Storm loads at startup, which the Spring Boot starters and the Ktor plugin do by default. Calling validate is optional, and a write without it is still safe: a database that enforces the limit refuses the same value and Storm reports it as a violation of the same shape, one at a time. Call it where a user should see every problem with a form at once. There is no need to run it on every write. See Checking an Entity Before the Write.
Rules That Depend on Other Rows: the Database
Whether a telephone number is taken depends on the other rows at the moment of the write, and only the database knows that. Storm never asks first: two requests can both find a number free and both insert it, while a unique constraint lets exactly one through. Write, and handle the answer:
- Kotlin
- Java
try {
owners.insert(owner)
} catch (e: IntegrityViolationException) {
if (e.violations(Owner_.telephone).any { it is Unique }) {
// The number is taken: tell the user, and let the transaction end.
}
throw e
}
try {
owners.insert(owner);
} catch (IntegrityViolationException e) {
if (e.violations(Owner_.telephone).stream().anyMatch(v -> v instanceof Unique)) {
// The number is taken: tell the user, and let the transaction end.
}
throw e;
}
With the Spring Boot starters this exception arrives translated, with Storm's exception as the cause. See Exception Translation.
One Shape for Every Violation
A violation has the same shape whether Storm found it before the write or the database reported it: a record of its kind, the fields it resolves to as metamodel keys, and the limit where one is known. IntegrityViolation is sealed, so one exhaustive match handles all of them:
- Kotlin
- Java
fun describe(violation: IntegrityViolation): String = when (violation) {
is Unique -> "${violation.fields().joinToString { it.fieldPath() }} is already taken."
is ForeignKey -> "It refers to a row that does not exist."
is Referenced -> "Other rows still refer to it."
is NotNull -> "A value is required."
is Check -> "It breaks rule ${violation.constraint()}."
is Length -> "At most ${violation.maxLength()} characters."
is NumericRange -> "At most ${violation.precision()} digits, ${violation.scale()} of them decimals."
}
static String describe(IntegrityViolation violation) {
return switch (violation) {
case Unique unique -> unique.fields().getFirst().fieldPath() + " is already taken.";
case ForeignKey foreignKey -> "It refers to a row that does not exist.";
case Referenced referenced -> "Other rows still refer to it.";
case NotNull notNull -> "A value is required.";
case Check check -> "It breaks rule " + check.constraint() + ".";
case Length length -> "At most " + length.maxLength() + " characters.";
case NumericRange range -> "At most " + range.precision() + " digits, " + range.scale() + " of them decimals.";
};
}
A limit is known when validate found the violation; the database does not report one. A violation the database reports can also come without fields: a deferred constraint at commit, or a template with no loaded schema to resolve a constraint against. See Integrity Violations for what each kind carries.
In a Web Application
A violation that reaches a Spring or Ktor endpoint answers the client with a status that says whose fault it is:
| Violation | Status |
|---|---|
| A value that breaks a rule of its own: anything found before the write, and a length, numeric range or check the database refused | 400 |
| A conflict with other rows: a duplicate, a reference to a row that does not exist, a row still referenced | 409 |
| A null the database refused: the field is nullable where the column is not, which schema validation reports | 500 |
No response names a table, a constraint or a statement.
- Spring Boot applies the status without application code. A handler of your own adds the field errors. See Exception Translation.
- Ktor answers with the status and the field errors from one line in
StatusPages. See Error Handling.
What Storm Does Not Do
- No validation on write. No flush-time validation and no setting that validates every write. A check runs where your code calls it, and a database rule where the database enforces it.
- No check of what the database decides. A query ahead of an insert races; the constraint does not.
- No limits in annotations. Storm reads them from the schema, so there is no second copy to keep in step.
The one check Storm makes on every write is that a non-nullable field holds a value, which it does because the field's type already says so.