Model Events¶
Every Model has overridable lifecycle hooks — no-ops by default, and the
right place to react to (or shape) a save or delete without cluttering the
controller that triggered it.
The hooks¶
save() (which create() and update() both go through) calls, in order:
saving() -> creating() or updating() -> [write to the database] -> created() or updated() -> saved()
delete() calls deleting() before removing/soft-deleting the row, then deleted() after:
class User(Model):
...
async def saving(self) -> None:
# Runs before both create and update.
self.email = self.email.strip().lower()
async def creating(self) -> None:
# Runs only on the first save.
...
async def deleted(self) -> None:
# Runs after delete() -- soft or hard.
...
Override only the hooks you need; the rest stay no-ops.
Hooks run before validation¶
creating()/updating() run before __rules__ is checked — this is
what makes them useful for deriving a field validation then depends on, not
just for reacting after the fact:
class Post(Model):
__rules__ = {"slug": [required()]}
title: Mapped[str] = mapped_column(String(255))
slug: Mapped[str] = mapped_column(String(255), default="")
async def creating(self) -> None:
if not self.slug:
self.slug = self.title.lower().replace(" ", "-")
await Post.create(title="Hello World") succeeds — slug is derived
before __rules__ ever sees it. If the ordering were reversed, this would
raise ValidationException on an empty slug every time.
Distinguishing create from update¶
is_new isn't exposed as a hook argument — creating()/created() firing
at all is that signal; updating()/updated() fire on every subsequent
save. If a hook genuinely needs both cases in one method, use saving()/
saved(), which fire on every save regardless.
Observers¶
A model's own hooks are one implementation per class — fine for behavior
that belongs to the model itself (deriving a slug, normalizing an email).
For a cross-cutting concern that doesn't belong on the model, or that
several independent things want to react to (search-index sync, cache
invalidation, audit logging), register an Observer instead:
from zeython import Observer
class PostSearchIndexObserver(Observer):
async def created(self, model: Post) -> None:
await search_index.add(model.id, model.title)
async def updated(self, model: Post) -> None:
await search_index.update(model.id, model.title)
async def deleted(self, model: Post) -> None:
await search_index.remove(model.id)
Post.observe(PostSearchIndexObserver)
observe() accepts a class (instantiated with no arguments) or an
already-constructed instance — typically called once, e.g. in a service
provider's boot(). An observer has the same eight hooks as a model
(saving/saved/creating/created/updating/updated/deleting/
deleted), each taking the model instance as its argument; override only
the ones you need. Several observers can watch the same model, and each
model class's observers are independent of every other model's. Observers
fire after the model's own same-named hook, in the order shown above.
What this isn't¶
An observer doesn't replace a model's own hooks — it's for reactions that don't belong on the model, not a place to move logic that does. If a hook is really about the model's own data (deriving a field, normalizing input before validation), keep it a method on the model, not an observer.
It's also not for application-defined events that aren't tied to a
specific model's lifecycle at all (OrderPlaced, a scheduled report
finishing) — see Events for those.