Add
before_*andafter_*callbacks to your Ecto schemas, similar to the oldEcto.Modelcallbacks.
Add :ecto_hooks to the list of dependencies in mix.exs:
def deps do
[
{:ecto_hooks, "~> 2.0"}
]
endNote: EctoHooks is built on top of EctoMiddleware and includes it as a dependency. Installing
ecto_hooksgives you everything you need - no additional dependencies required!
Add EctoHooks to your Repo's middleware pipeline. Since EctoMiddleware is included
with ecto_hooks, you can use it directly:
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
use EctoMiddleware.Repo # Comes with ecto_hooks!
@impl EctoMiddleware.Repo
def middleware(_action, _resource) do
[EctoHooks]
end
enddefmodule MyApp.User do
use Ecto.Schema
require Logger
schema "users" do
field :first_name, :string
field :last_name, :string
field :full_name, :string, virtual: true
end
@impl EctoHooks
def before_insert(changeset) do
Logger.info("Inserting new user")
changeset
end
@impl EctoHooks
def after_get(%__MODULE__{} = user, %EctoHooks.Delta{}) do
%{user | full_name: "#{user.first_name} #{user.last_name}"}
end
endThat's it! The hooks will be called automatically whenever you use your Repo.
Transform data before it reaches the database:
before_insert/1- Called beforeinsert/2,insert!/2, andinsert_or_update/2(for new records)before_update/1- Called beforeupdate/2,update!/2, andinsert_or_update/2(for existing records)before_delete/1- Called beforedelete/2,delete!/2
@impl EctoHooks
def before_insert(changeset) do
# Normalize email before saving
case Ecto.Changeset.fetch_change(changeset, :email) do
{:ok, email} -> Ecto.Changeset.put_change(changeset, :email, String.downcase(email))
:error -> changeset
end
endProcess data after database operations:
after_get/2- Called afterget/3,get!/3,all/2,one/2,reload/2,preload/3, etc.after_insert/2- Called afterinsert/2,insert!/2, andinsert_or_update/2(for new records)after_update/2- Called afterupdate/2,update!/2, andinsert_or_update/2(for existing records)after_delete/2- Called afterdelete/2,delete!/2
All after hooks receive a %EctoHooks.Delta{} struct with metadata about the operation:
@impl EctoHooks
def after_get(%__MODULE__{} = user, %EctoHooks.Delta{} = delta) do
# delta.repo_callback - Which repo function was called (:get, :all, etc.)
# delta.hook - Which hook is executing (:after_get, etc.)
# delta.source - The original queryable/changeset/struct
%{user | full_name: "#{user.first_name} #{user.last_name}"}
endEctoHooks is built on top of EctoMiddleware, which provides a middleware pipeline pattern for Ecto operations (similar to Plug or Absinthe middleware).
When you add EctoHooks to your middleware pipeline, it:
- Intercepts Repo operations
- Calls the appropriate
before_*hook on your schema (if defined) - Executes the actual database operation
- Calls the appropriate
after_*hook on the result (if defined) - Returns the final result
All hooks are optional - if you don't define a hook, it simply doesn't run.
Instead of setting virtual fields in every controller/context function:
# Without hooks - scattered across codebase
def get_user(id) do
user = Repo.get!(User, id)
%{user | full_name: "#{user.first_name} #{user.last_name}"}
end
def list_users do
User
|> Repo.all()
|> Enum.map(fn user ->
%{user | full_name: "#{user.first_name} #{user.last_name}"}
end)
endWith hooks, it happens automatically:
# With hooks - defined once in the schema
@impl EctoHooks
def after_get(user, _delta) do
%{user | full_name: "#{user.first_name} #{user.last_name}"}
end
# Now these just work
Repo.get!(User, id) # full_name set automatically
Repo.all(User) # full_name set for all users@impl EctoHooks
def after_insert(user, delta) do
AuditLog.log("user_created", user.id, delta.source)
user
end
@impl EctoHooks
def after_update(user, delta) do
AuditLog.log("user_updated", user.id, delta.source)
user
end@impl EctoHooks
def before_insert(changeset) do
changeset
|> normalize_email()
|> trim_strings()
|> set_defaults()
endSometimes you need to disable hooks (e.g., to prevent infinite loops or for bulk operations):
# Disable hooks for current process
EctoHooks.disable_hooks()
Repo.insert!(user) # Hooks won't run
# Re-enable hooks
EctoHooks.enable_hooks()
# Check if hooks are enabled
EctoHooks.hooks_enabled?() #=> true
# Check if currently inside a hook
EctoHooks.in_hook?() #=> falseNote: EctoHooks automatically prevents infinite loops by disabling hooks while executing a hook. This means if a hook calls another Repo operation, that operation won't trigger its own hooks.
EctoHooks is built on EctoMiddleware, which emits telemetry events for observability. You can attach handlers to monitor hook execution performance and behavior.
Pipeline Events:
[:ecto_middleware, :pipeline, :start]- Hook pipeline execution starts[:ecto_middleware, :pipeline, :stop]- Hook pipeline execution completes[:ecto_middleware, :pipeline, :exception]- Hook pipeline execution fails
Middleware Events:
[:ecto_middleware, :middleware, :start]- Individual hook starts (middleware isEctoHooks)[:ecto_middleware, :middleware, :stop]- Individual hook completes[:ecto_middleware, :middleware, :exception]- Individual hook fails
:telemetry.attach(
"log-slow-hooks",
[:ecto_middleware, :middleware, :stop],
fn _event, %{duration: duration}, %{middleware: EctoHooks}, _config ->
if duration > 5_000_000 do # 5ms
Logger.warning("Slow hook execution took #{duration}ns")
end
end,
nil
):telemetry.attach(
"track-hook-errors",
[:ecto_middleware, :middleware, :exception],
fn _event, measurements, %{middleware: EctoHooks, kind: kind, reason: reason}, _config ->
Logger.error("Hook failed: #{inspect(kind)} - #{inspect(reason)}")
end,
nil
)For complete telemetry documentation, see the EctoMiddleware Telemetry Guide.
EctoHooks v2.0 simplifies the setup significantly:
Before (v1.x):
defmodule MyApp.Repo do
use EctoHooks.Repo, # or use EctoHooks
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
endAfter (v2.0):
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
use EctoMiddleware.Repo
@impl EctoMiddleware.Repo
def middleware(_action, _resource) do
[EctoHooks] # Can add other middleware here too!
end
endHook definitions in schemas remain unchanged - all your existing hooks will continue to work.
- hex.pm package
- Online documentation
- EctoMiddleware - The middleware engine powering EctoHooks
MIT License. See LICENSE for details.
