<p align="center">
<img src="https://github.com/user-attachments/assets/7117678e-0c5a-472c-8b29-0a18cabbc2d0" alt="EctoHooks" style="max-width: 100%; height: auto;" />
</p>
# EctoHooks
[](https://hex.pm/packages/ecto_hooks)
[](https://hexdocs.pm/ecto_hooks/)
[](https://github.com/vereis/ecto_hooks/actions)
[](https://coveralls.io/github/vereis/ecto_hooks?branch=main)
> Add `before_*` and `after_*` callbacks to your Ecto schemas, similar to the old `Ecto.Model` callbacks.
## Installation
Add `:ecto_hooks` to the list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:ecto_hooks, "~> 2.0"}
]
end
```
> **Note:** EctoHooks is built on top of [EctoMiddleware](https://hex.pm/packages/ecto_middleware)
> and includes it as a dependency. Installing `ecto_hooks` gives you everything you need - no
> additional dependencies required!
## Quick Start
### 1. Setup Your Repo
Add `EctoHooks` to your Repo's middleware pipeline. Since `EctoMiddleware` is included
with `ecto_hooks`, you can use it directly:
```elixir
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
end
```
### 2. Define Hooks in Your Schemas
```elixir
defmodule 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
end
```
That's it! The hooks will be called automatically whenever you use your Repo.
## Available Hooks
### Before Hooks (arity 1)
Transform data **before** it reaches the database:
- `before_insert/1` - Called before `insert/2`, `insert!/2`, and `insert_or_update/2` (for new records)
- `before_update/1` - Called before `update/2`, `update!/2`, and `insert_or_update/2` (for existing records)
- `before_delete/1` - Called before `delete/2`, `delete!/2`
```elixir
@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
```
### After Hooks (arity 2)
Process data **after** database operations:
- `after_get/2` - Called after `get/3`, `get!/3`, `all/2`, `one/2`, `reload/2`, `preload/3`, etc.
- `after_insert/2` - Called after `insert/2`, `insert!/2`, and `insert_or_update/2` (for new records)
- `after_update/2` - Called after `update/2`, `update!/2`, and `insert_or_update/2` (for existing records)
- `after_delete/2` - Called after `delete/2`, `delete!/2`
All after hooks receive a `%EctoHooks.Delta{}` struct with metadata about the operation:
```elixir
@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}"}
end
```
## How It Works
EctoHooks is built on top of [EctoMiddleware](https://hex.pm/packages/ecto_middleware), which provides a middleware pipeline pattern for Ecto operations (similar to Plug or Absinthe middleware).
When you add `EctoHooks` to your middleware pipeline, it:
1. Intercepts Repo operations
2. Calls the appropriate `before_*` hook on your schema (if defined)
3. Executes the actual database operation
4. Calls the appropriate `after_*` hook on the result (if defined)
5. Returns the final result
All hooks are **optional** - if you don't define a hook, it simply doesn't run.
## Why Hooks?
### Centralize Virtual Field Logic
Instead of setting virtual fields in every controller/context function:
```elixir
# 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)
end
```
With hooks, it happens automatically:
```elixir
# 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
```
### Audit Logging
```elixir
@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
```
### Data Normalization
```elixir
@impl EctoHooks
def before_insert(changeset) do
changeset
|> normalize_email()
|> trim_strings()
|> set_defaults()
end
```
## Controlling Hook Execution
Sometimes you need to disable hooks (e.g., to prevent infinite loops or for bulk operations):
```elixir
# 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?() #=> false
```
**Note:** 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.
## Telemetry Events
EctoHooks is built on [EctoMiddleware](https://hex.pm/packages/ecto_middleware), which emits telemetry events for observability. You can 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 is `EctoHooks`)
- `[:ecto_middleware, :middleware, :stop]` - Individual hook completes
- `[:ecto_middleware, :middleware, :exception]` - Individual hook fails
### Example: Monitoring Hook Performance
```elixir
: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
```elixir
: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](https://hexdocs.pm/ecto_middleware#telemetry).
## Migration from v1.x
EctoHooks v2.0 simplifies the setup significantly:
**Before (v1.x):**
```elixir
defmodule MyApp.Repo do
use EctoHooks.Repo, # or use EctoHooks
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
end
```
**After (v2.0):**
```elixir
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
end
```
Hook definitions in schemas remain unchanged - all your existing hooks will continue to work.
## Links
- [hex.pm package](https://hex.pm/packages/ecto_hooks)
- [Online documentation](https://hexdocs.pm/ecto_hooks)
- [EctoMiddleware](https://hex.pm/packages/ecto_middleware) - The middleware engine powering EctoHooks
## License
MIT License. See [LICENSE](LICENSE) for details.