EctoHooks behaviour (ecto_hooks v2.0.0)
View SourceMiddleware for adding before_* and after_* callbacks to Ecto schemas.
EctoHooks brings back the convenience of Ecto.Model callbacks (removed in Ecto 2.0)
using the modern EctoMiddleware pipeline pattern. Perfect for centralizing virtual
field logic, audit logging, data normalization, and other cross-cutting concerns.
Built on EctoMiddleware: EctoHooks is powered by EctoMiddleware, which is included as a dependency. Installing
ecto_hooksgives you everything you need!
Quick Example
defmodule MyApp.User do
use Ecto.Schema
schema "users" do
field :first_name, :string
field :last_name, :string
field :email, :string
field :full_name, :string, virtual: true
end
# c:EctoHooks.before_insert/1 - called before c:Ecto.Repo.insert/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
end
# c:EctoHooks.after_get/2 - called after c:Ecto.Repo.get/3, c:Ecto.Repo.all/2, etc.
@impl EctoHooks
def after_get(%__MODULE__{} = user, %EctoHooks.Delta{}) do
# Populate virtual fields after fetching
%{user | full_name: "#{user.first_name} #{user.last_name}"}
end
end
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
use EctoMiddleware.Repo
@impl EctoMiddleware.Repo
def middleware(_action, _resource) do
[EctoHooks]
end
end
# Hooks run automatically!
MyApp.Repo.get!(MyApp.User, 1)
#=> %MyApp.User{first_name: "Alice", last_name: "Smith", full_name: "Alice Smith"}Setup
Add EctoHooks to your Repo's middleware pipeline. EctoHooks includes EctoMiddleware
as a dependency, so you can use it directly:
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
use EctoMiddleware.Repo # Comes with ecto_hooks!
@impl EctoMiddleware.Repo
def middleware(_action, _resource) do
[EctoHooks] # Add alongside other middleware if needed
end
endAll hooks are optional - if you don't define a hook, it simply doesn't run.
Available Hooks
Before Hooks (arity 1)
Transform data before it reaches the database:
before_insert/1- Called beforec:Ecto.Repo.insert/2,c:Ecto.Repo.insert!/2, andc:Ecto.Repo.insert_or_update/2(for new records)before_update/1- Called beforec:Ecto.Repo.update/2,c:Ecto.Repo.update!/2, andc:Ecto.Repo.insert_or_update/2(for existing records)before_delete/1- Called beforec:Ecto.Repo.delete/2,c:Ecto.Repo.delete!/2
Before hooks receive the changeset/struct and must return a changeset/struct:
# c:before_insert/1 example
@impl EctoHooks
def before_insert(changeset) do
changeset
|> normalize_email()
|> set_defaults()
|> add_timestamps()
endAfter Hooks (arity 2)
Process data after database operations:
after_get/2- Called afterc:Ecto.Repo.get/3,c:Ecto.Repo.get!/3,c:Ecto.Repo.get_by/3,c:Ecto.Repo.all/2,c:Ecto.Repo.one/2,c:Ecto.Repo.reload/2,c:Ecto.Repo.preload/3, etc.after_insert/2- Called afterc:Ecto.Repo.insert/2,c:Ecto.Repo.insert!/2, andc:Ecto.Repo.insert_or_update/2(for new records)after_update/2- Called afterc:Ecto.Repo.update/2,c:Ecto.Repo.update!/2, andc:Ecto.Repo.insert_or_update/2(for existing records)after_delete/2- Called afterc:Ecto.Repo.delete/2,c:Ecto.Repo.delete!/2
After hooks receive the struct and a EctoHooks.Delta.t/0 with metadata:
# c:after_get/2 example
@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)
# delta.source - The original queryable/changeset/struct
%{user | full_name: "#{user.first_name} #{user.last_name}"}
endHook Execution Flow
For write operations (insert/update/delete):
- Call
c:before_*hook on changeset/struct - Execute database operation
- Call
c:after_*hook on result - Return final result
For read operations (get/all/one):
- Execute database query
- Call
after_get/2on result(s) - Return transformed result(s)
Hooks are applied to all matching records. For example, c:Ecto.Repo.all/2 will
call after_get/2 on every record in the result list.
Controlling Execution
Preventing Infinite Loops
EctoHooks automatically prevents infinite loops by disabling hooks while executing a hook. If a hook calls another Repo operation, that operation won't trigger its own hooks:
@impl EctoHooks
def after_insert(user, _delta) do
# This update won't trigger c:before_update/1 or c:after_update/2 hooks
Repo.update!(User.changeset(user, %{last_login: DateTime.utc_now()}))
user
endManual Control
You can manually disable/enable hooks for the current process:
# Disable hooks
EctoHooks.disable_hooks()
Repo.insert!(user) # Won't trigger hooks
# Re-enable hooks
EctoHooks.enable_hooks()Use hooks_enabled?/0 and in_hook?/0 to check current state:
EctoHooks.hooks_enabled?() #=> true
EctoHooks.in_hook?() #=> falseUse Cases
Virtual Fields
Centralize virtual field logic instead of scattering it across your codebase with after_get/2:
@impl EctoHooks
def after_get(user, _delta) do
%{user | full_name: "#{user.first_name} #{user.last_name}"}
endAudit Logging
Use after_update/2 to track changes:
@impl EctoHooks
def after_update(user, delta) do
AuditLog.log("user_updated", user.id, delta.source)
user
endData Normalization
Use before_insert/1 to normalize data before saving:
@impl EctoHooks
def before_insert(changeset) do
changeset
|> normalize_email()
|> trim_whitespace()
|> set_defaults()
endTelemetry Events
Use after_insert/2 to emit events:
@impl EctoHooks
def after_insert(user, _delta) do
:telemetry.execute([:myapp, :user, :created], %{}, %{user_id: user.id})
user
endConsiderations
- Hooks run synchronously - expensive operations may slow down queries
- Hooks run for every operation - keep them lightweight
- Consider using background jobs for heavy processing
- Be careful with Repo calls inside hooks (see "Preventing Infinite Loops")
Telemetry Events
EctoHooks is built on EctoMiddleware, which emits telemetry events for observability. Attach handlers to monitor hook execution performance and behavior.
Available Events
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
Example: Monitoring Hook Performance
: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
)Example: Tracking Hook Failures
: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.
Migration from v1.x
EctoHooks v2.0 simplifies setup by leveraging EctoMiddleware.Repo directly.
What Changed
v1.x Setup:
defmodule MyApp.Repo do
use EctoHooks.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
endv2.0 Setup:
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]
end
endMigration Steps
- Replace
use EctoHooks.Repowithuse Ecto.Repo - Add
use EctoMiddleware.Repo(already included withecto_hooks) - Implement the
EctoMiddleware.Repo.middleware/2callback returning[EctoHooks]
What Stays the Same
All hook definitions remain unchanged! Your existing before_insert/1, before_update/1,
before_delete/1, after_get/2, after_insert/2, after_update/2, and after_delete/2
hooks in schemas will continue to work without modification:
# These work exactly the same in v2.0
@impl EctoHooks
def before_insert(changeset), do: changeset
@impl EctoHooks
def after_get(user, delta), do: userBenefits of v2.0
- Composability: Add multiple middleware alongside
EctoHooks - Built on EctoMiddleware: Leverage the full middleware ecosystem (included!)
- Flexibility: Full control over middleware ordering
- Simplicity: One less wrapper module to understand
Example: Adding Other Middleware
The new setup makes it easy to compose multiple middleware:
def middleware(_action, _resource) do
[
MyApp.RateLimiter,
MyApp.Telemetry,
EctoHooks,
MyApp.CacheInvalidation
]
endImplementation Details
EctoHooks is implemented as an EctoMiddleware middleware that:
- Implements
EctoMiddleware.process_before/2for before hooks - Implements
EctoMiddleware.process_after/2for after hooks - Uses
EctoMiddleware.Utilsguards for operation detection - Handles all Ecto return types (
{:ok, value}, lists, tuples, etc.)
Summary
Functions
Disables hooks for all future Repo operations in the current process.
Enables hooks for all future Repo operations in the current process.
Returns true if hooks are enabled for the current process, false otherwise.
Returns the current hook nesting level (ref count) for the process.
Returns true if currently executing inside a hook, false otherwise.
Callback implementation for EctoMiddleware.process/2.
Callbacks
@callback after_delete(schema_struct :: struct(), delta :: EctoHooks.Delta.t()) :: struct()
@callback after_get(schema_struct :: struct(), delta :: EctoHooks.Delta.t()) :: struct()
@callback after_insert(schema_struct :: struct(), delta :: EctoHooks.Delta.t()) :: struct()
@callback after_update(schema_struct :: struct(), delta :: EctoHooks.Delta.t()) :: struct()
@callback before_delete(queryable :: %Ecto.Queryable{}) :: %Ecto.Queryable{}
@callback before_insert(queryable :: %Ecto.Queryable{}) :: %Ecto.Queryable{}
@callback before_update(queryable :: %Ecto.Queryable{}) :: %Ecto.Queryable{}
Functions
@spec disable_hooks(Keyword.t()) :: :ok
Disables hooks for all future Repo operations in the current process.
Useful when you need to perform Repo operations without triggering hooks, such as during bulk operations or setup/teardown in tests.
Options
:global- Whentrue(default), disables hooks globally for the process. Whenfalse, only disables hooks for the next operation (used internally).
Examples
# Disable hooks for bulk operations
EctoHooks.disable_hooks()
users
|> Enum.each(&Repo.insert!/1) # None will trigger hooks
# Re-enable when done
EctoHooks.enable_hooks()
# Or use in a specific scope
try do
EctoHooks.disable_hooks()
perform_bulk_operation()
after
EctoHooks.enable_hooks()
endInternal Use
EctoHooks uses disable_hooks(global: false) internally to prevent infinite
loops when a hook calls another Repo operation.
@spec enable_hooks(Keyword.t()) :: :ok
Enables hooks for all future Repo operations in the current process.
By default, hooks are enabled. You only need to call this if you've previously disabled hooks and want to re-enable them.
Options
:global- Whentrue(default), enables hooks globally for the process. Whenfalse, only enables hooks for the next operation (used internally).
Examples
# Disable hooks temporarily
EctoHooks.disable_hooks()
Repo.insert!(user) # Won't trigger hooks
# Re-enable hooks
EctoHooks.enable_hooks()
Repo.insert!(user) # Will trigger hooksInternal Use
EctoHooks uses enable_hooks(global: false) internally to re-enable hooks
after executing a hook. This prevents infinite loops when hooks call Repo operations.
@spec hooks_enabled?() :: boolean()
Returns true if hooks are enabled for the current process, false otherwise.
Use this to check whether hooks will execute before performing operations.
Examples
EctoHooks.hooks_enabled?()
#=> true
EctoHooks.disable_hooks()
EctoHooks.hooks_enabled?()
#=> false
EctoHooks.enable_hooks()
EctoHooks.hooks_enabled?()
#=> trueNotes
This reflects the global state. Even if hooks_enabled?/0 returns true,
hooks won't execute if you're already inside a hook (see in_hook?/0).
@spec hooks_ref_count() :: non_neg_integer()
Returns the current hook nesting level (ref count) for the process.
Each time a hook executes, the ref count increments. When the hook finishes, it decrements. This allows for nested hook detection, though in practice the nesting level is typically 0 or 1.
Examples
EctoHooks.hooks_ref_count()
#=> 0
# Inside a hook
def after_insert(user, _delta) do
EctoHooks.hooks_ref_count()
#=> 1
user
endUse Cases
This is a low-level introspection function. Most users should use
hooks_enabled?/0 or in_hook?/0 instead.
@spec in_hook?() :: boolean()
Returns true if currently executing inside a hook, false otherwise.
EctoHooks automatically disables hooks when executing a hook to prevent infinite loops. This function lets you check if you're in that context.
Examples
# In your application code
EctoHooks.in_hook?()
#=> false
# Inside a hook that calls Repo
@impl EctoHooks
def after_insert(user, _delta) do
EctoHooks.in_hook?()
#=> true
# This won't trigger hooks
Repo.update!(User.changeset(user, %{last_login: DateTime.utc_now()}))
user
endImplementation
Internally, this checks if the hook ref count (see hooks_ref_count/0) is
greater than zero.
Callback implementation for EctoMiddleware.process/2.