Skip to content

Business rules

Step 7 set up a guard: check_in refuses to return a copy that never went out. A guard watches a transition.

A business rule watches a write. “A loan cannot be due before it was taken out” is not a transition: it is true on create, true on update, true when the data arrives through an import, a flow, or a migration script. A guard says nothing about those paths.

The gesture

modules/library/rules.py:

"""Business invariants of the library module (ADR 0093)."""
from __future__ import annotations
from typing import Any
from kewos.validation import ValidationError, register_validator
def _due_after_taken(_session: Any, _classes: Any, loan: Any) -> None:
"""A loan cannot be due before it was taken out."""
if loan.taken_on is None or loan.due_on is None:
return
if loan.due_on < loan.taken_on:
raise ValidationError(
"A loan cannot be due before it was taken out.",
field="due_on",
key="library.error.due_before_taken",
)
def register_rules() -> None:
"""Declare the invariants. Idempotent: the key is (model, name)."""
register_validator("library_loan", "due_after_taken", _due_after_taken)

And one line in __init__.py, next to the models:

def register(registry: Registry) -> None:
models.register_models(registry)
rules.register_rules() # ← here
apply_module_acls(registry, _MODULE_DIR / "acl.json")

A rule is a function of (session, classes, record). The first two parameters are for rules that need to query the database; this one does not, and ignores them.

What declaring it there buys you

Fenêtre de terminal
curl -X POST http://127.0.0.1:8080/api/v1/library_loan \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"copy_id":2,"member_id":1,"taken_on":"2026-09-06","due_on":"2026-08-30"}'
HTTP/1.1 422 Unprocessable Entity
{"detail":{"field":"due_on","reason":"A loan cannot be due before it was taken out."}}

The field name travels with the refusal, so the screen knows where to show the message, under the right control, without anyone writing frontend code.

And above all: the rule does not run in the endpoint. It runs in the ORM session, at the moment the record goes to the database. So the same refusal comes from the import, the flow engine, a migration script and the API. That is the project’s interdiction #1 applied to validation: a rule placed in an HTTP controller only protects the calls that go through that controller, and there are always others.

A rule that needs the database

The same copy cannot be out twice. That one has to look at the other loans:

def _copy_lent_once(session: Any, classes: Any, loan: Any) -> None:
"""The same copy cannot be out twice at the same time."""
if loan.state != "out" or loan.copy_id is None:
return
loan_class = classes["library_loan"]
already_out = (
session.query(loan_class)
.filter(
loan_class.copy_id == loan.copy_id,
loan_class.state == "out",
loan_class.id != (loan.id or -1),
)
.first()
)
if already_out is not None:
raise ValidationError(
"This copy is already out on another loan.",
field="copy_id",
key="library.error.copy_already_out",
)

classes gives a model’s mapped class by name, and session is the one doing the write: the rule sees what the transaction has already written, and its refusal rolls the whole thing back, not just its own row.

Translating the message

ValidationError takes a key as well as a message. The key is resolved in the request’s language, and the English message stays the fallback when the key is not translated.

The key goes in translations/, not in i18n/. Two different directories, two audiences:

DirectoryWho reads itWhat goes in
i18n/<locale>.jsonthe interfacelabels for models, fields, menus, buttons
translations/<locale>.jsonthe serverthe messages the server produces, rules included

modules/library/translations/fr.json:

{
"library.error.due_before_taken": "Un prêt ne peut pas être dû avant sa sortie.",
"library.error.copy_already_out": "Cet exemplaire est déjà sorti sur un autre prêt."
}

The same request, with Accept-Language: fr:

{"detail":{"field":"due_on","reason":"Un prêt ne peut pas être dû avant sa sortie."}}

Putting the key in i18n/ produces no error at all: the server does not find the key and serves English. It is written here because that is exactly what happened while writing this page.

The tests

A rule is a function, so it is tested as a function. modules/library/tests/test_rules.py:

def test_it_refuses_a_due_date_before_the_loan() -> None:
with pytest.raises(ValidationError) as raised:
_due_after_taken(None, None, _loan(due_on=date(2026, 8, 20)))
assert raised.value.field == "due_on"
def test_a_create_that_sends_no_state_is_still_checked() -> None:
"""A create that omits the state reaches the rule with the default already
filled in: the kernel materialises declared defaults before validating."""
with pytest.raises(ValidationError):
_copy_lent_once(_OneOtherLoan(), _CLASSES, _loan(state="out"))
$ pytest modules/library/tests/test_rules.py -q
..... [100%]
5 passed in 0.36s

The session is replaced by an object that answers first(), because what is under test is the rule, not SQLAlchemy. The proof that the kernel really calls it is the 422 above.

What a rule does not do

  • It does not replace permissions. Who may write is step 6’s business. A rule says whether the data is coherent, not whether the author is allowed.
  • It is not written in YAML or KM. A rule is module code, never an expression typed by a user: running arbitrary Python that came from data is interdiction #37, and a tenant module lives in the database. The no-code counterpart exists and it is step 12’s flow engine, which reacts afterwards instead of refusing.
  • It does not fix the past. A rule added today applies to rows already in the database as soon as you touch them. On this tutorial’s instance, two loans already broke “one copy, one loan”: the rule refused them on the first update. That is the intended behaviour, but it is better known before you meet it on a customer’s data. A module that adds a rule over existing data puts the clean-up in a migration, not in a manual fix.

Next

Amounts have a rule of their own, and it is not optional: Amounts and currencies.