Skip to main content
Version: Next

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 ruleExampleWhere it livesWhat checks it
Product ruleA required field, a format, a display name of at most 50The request typeYour input validator
Entity invariantAn owner always has a first nameThe record's constructorThe language: an invalid entity cannot exist
Column limitA length, a precisionThe database schemavalidate and validateOrThrow
Rule that depends on other rowsUnique, foreign keyThe databaseThe 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.

data class OwnerForm(
@field:NotBlank val firstName: String,
@field:NotBlank val lastName: String,
@field:Pattern(regexp = "[0-9 +-]+") val telephone: String,
)

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:

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." }
}
}

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:

val violations: List<IntegrityViolation> = 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:

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
}

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:

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."
}

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:

ViolationStatus
A value that breaks a rule of its own: anything found before the write, and a length, numeric range or check the database refused400
A conflict with other rows: a duplicate, a reference to a row that does not exist, a row still referenced409
A null the database refused: the field is nullable where the column is not, which schema validation reports500

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.