EctoHooks behaviour (ecto_hooks v2.0.0)

View Source

Middleware 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_hooks gives 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
end

All 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 before c:Ecto.Repo.insert/2, c:Ecto.Repo.insert!/2, and c:Ecto.Repo.insert_or_update/2 (for new records)
  • before_update/1 - Called before c:Ecto.Repo.update/2, c:Ecto.Repo.update!/2, and c:Ecto.Repo.insert_or_update/2 (for existing records)
  • before_delete/1 - Called before c: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()
end

After Hooks (arity 2)

Process data after database operations:

  • after_get/2 - Called after c: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 after c:Ecto.Repo.insert/2, c:Ecto.Repo.insert!/2, and c:Ecto.Repo.insert_or_update/2 (for new records)
  • after_update/2 - Called after c:Ecto.Repo.update/2, c:Ecto.Repo.update!/2, and c:Ecto.Repo.insert_or_update/2 (for existing records)
  • after_delete/2 - Called after c: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}"}
end

Hook Execution Flow

For write operations (insert/update/delete):

  1. Call c:before_* hook on changeset/struct
  2. Execute database operation
  3. Call c:after_* hook on result
  4. Return final result

For read operations (get/all/one):

  1. Execute database query
  2. Call after_get/2 on result(s)
  3. 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
end

Manual 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?()        #=> false

Use 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}"}
end

Audit 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
end

Data Normalization

Use before_insert/1 to normalize data before saving:

@impl EctoHooks
def before_insert(changeset) do
  changeset
  |> normalize_email()
  |> trim_whitespace()
  |> set_defaults()
end

Telemetry 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
end

Considerations

  • 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 is EctoHooks)
  • [: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
end

v2.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
end

Migration Steps

  1. Replace use EctoHooks.Repo with use Ecto.Repo
  2. Add use EctoMiddleware.Repo (already included with ecto_hooks)
  3. Implement the EctoMiddleware.Repo.middleware/2 callback 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: user

Benefits 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
  ]
end

Implementation Details

EctoHooks is implemented as an EctoMiddleware middleware that:

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.

Callbacks

after_delete(schema_struct, delta)

(optional)
@callback after_delete(schema_struct :: struct(), delta :: EctoHooks.Delta.t()) ::
  struct()

after_get(schema_struct, delta)

(optional)
@callback after_get(schema_struct :: struct(), delta :: EctoHooks.Delta.t()) :: struct()

after_insert(schema_struct, delta)

(optional)
@callback after_insert(schema_struct :: struct(), delta :: EctoHooks.Delta.t()) ::
  struct()

after_update(schema_struct, delta)

(optional)
@callback after_update(schema_struct :: struct(), delta :: EctoHooks.Delta.t()) ::
  struct()

before_delete(queryable)

(optional)
@callback before_delete(queryable :: %Ecto.Queryable{}) :: %Ecto.Queryable{}

before_insert(queryable)

(optional)
@callback before_insert(queryable :: %Ecto.Queryable{}) :: %Ecto.Queryable{}

before_update(queryable)

(optional)
@callback before_update(queryable :: %Ecto.Queryable{}) :: %Ecto.Queryable{}

Functions

disable_hooks(opts \\ [global: true])

@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 - When true (default), disables hooks globally for the process. When false, 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()
end

Internal Use

EctoHooks uses disable_hooks(global: false) internally to prevent infinite loops when a hook calls another Repo operation.

enable_hooks(opts \\ [global: true])

@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 - When true (default), enables hooks globally for the process. When false, 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 hooks

Internal 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.

hooks_enabled?()

@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?()
#=> true

Notes

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).

hooks_ref_count()

@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
end

Use Cases

This is a low-level introspection function. Most users should use hooks_enabled?/0 or in_hook?/0 instead.

in_hook?()

@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
end

Implementation

Internally, this checks if the hook ref count (see hooks_ref_count/0) is greater than zero.

process(resource, resolution)

Callback implementation for EctoMiddleware.process/2.