EctoMiddleware behaviour (ecto_middleware v2.0.0)

View Source

Behaviour for creating middleware that intercepts and transforms Ecto repository operations.

EctoMiddleware provides a composable way to add cross-cutting concerns to your Ecto operations using a middleware pipeline pattern, similar to Plug or Absinthe middleware.

Quick Example

defmodule NormalizeEmail do
  use EctoMiddleware

  @impl EctoMiddleware
  def process_before(changeset, _resolution) do
    case Ecto.Changeset.fetch_change(changeset, :email) do
      {:ok, email} ->
        {:cont, Ecto.Changeset.put_change(changeset, :email, String.downcase(email))}
      :error ->
        {:cont, changeset}
    end
  end
end

defmodule MyApp.Repo do
  use Ecto.Repo, otp_app: :my_app
  use EctoMiddleware.Repo

  @impl EctoMiddleware.Repo
  def middleware(action, resource) when is_insert(action, resource) do
    [NormalizeEmail, HashPassword, AuditLog]
  end

  def middleware(_action, _resource), do: [AuditLog]
end

Core Concepts

The API

Implement process_before/2 to transform data before the database operation:

defmodule AddTimestamp do
  use EctoMiddleware

  @impl EctoMiddleware
  def process_before(changeset, _resolution) do
    {:cont, Ecto.Changeset.put_change(changeset, :processed_at, DateTime.utc_now())}
  end
end

Implement process_after/2 to process data after the database operation:

defmodule NotifyAdmin do
  use EctoMiddleware

  @impl EctoMiddleware
  def process_after({:ok, user} = result, _resolution) do
    Task.start(fn -> send_notification(user) end)
    {:cont, result}
  end

  def process_after(result, _resolution), do: {:cont, result}
end

Implement process/2 to wrap around the entire operation:

defmodule MeasureLatency do
  use EctoMiddleware

  @impl EctoMiddleware
  def process(resource, resolution) do
    start_time = System.monotonic_time()

    # Yields control to the next middleware (or Repo operation) in the chain
    # before resuming here.
    {result, _updated_resolution} = yield(resource, resolution)

    duration = System.monotonic_time() - start_time
    Logger.info("#{resolution.action} took #{duration}ns")

    result
  end
end

Important: When using process/2, you must call yield/2 to continue the middleware chain.

Halting Execution

Return {:halt, value} to stop the middleware chain and return a value immediately:

defmodule RequireAuth do
  use EctoMiddleware

  @impl EctoMiddleware
  def process(resource, resolution) do
    if authorized?(resolution) do
      {result, _} = yield(resource, resolution)
      result
    else
      {:halt, {:error, :unauthorized}}
    end
  end

  defp authorized?(resolution) do
    get_private(resolution, :current_user) != nil
  end
end

Guards for Operation Detection

EctoMiddleware.Utils provides guards to detect operations, especially useful for :insert_or_update:

defmodule ConditionalMiddleware do
  use EctoMiddleware

  @impl EctoMiddleware
  def process_before(changeset, resolution) when is_insert(changeset, resolution) do
    {:cont, add_created_metadata(changeset)}
  end

  def process_before(changeset, resolution) when is_update(changeset, resolution) do
    {:cont, add_updated_metadata(changeset)}
  end

  def process_before(changeset, _resolution) do
    {:cont, changeset}
  end
end

Return Value Conventions

For process_before/2 and process_after/2

Returning bare values from middleware is supported, but to be explicit, return one of:

Bare values are always treated as {:cont, value}.

Migration from V1

V1 middleware continue to work but emit deprecation warnings.

Key differences:

See the Migration Guide for details.

Silencing Deprecation Warnings

During migration, you can silence warnings temporarily:

# In config/config.exs
config :ecto_middleware, :silence_deprecation_warnings, true

This should only be used during migration - deprecated APIs will be removed in v3.0.

Summary

Functions

Stubs out the necessary behaviours and imports for implementing an EctoMiddleware middleware.

Types

middleware_result()

@type middleware_result() ::
  term() | {:cont, term()} | {:halt, term()} | {:ok, term()} | {:error, term()}

Callbacks

middleware(resource, resolution)

(optional)
@callback middleware(
  resource :: EctoMiddleware.Repo.resource(),
  resolution :: EctoMiddleware.Resolution.t()
) :: EctoMiddleware.Repo.resource()

process(resource, resolution)

(optional)
@callback process(resource :: term(), resolution :: EctoMiddleware.Resolution.t()) ::
  middleware_result()

process_after(result, resolution)

(optional)
@callback process_after(result :: term(), resolution :: EctoMiddleware.Resolution.t()) ::
  middleware_result()

process_before(resource, resolution)

(optional)
@callback process_before(resource :: term(), resolution :: EctoMiddleware.Resolution.t()) ::
  middleware_result()

Functions

__using__(opts)

(macro)

Stubs out the necessary behaviours and imports for implementing an EctoMiddleware middleware.

For backwards compatibility, if used in an Ecto.Repo module, it will emit a deprecation warning and delegate to use EctoMiddleware.Repo instead. This behaviour will be removed in v3.0.