Elixir CI

A soft-delete library for Ecto/Elixir with guard rails.

Lazarus is a strict soft-delete safety layer for Ecto, not just a convenience wrapper. It is for applications where soft-delete correctness matters more than unrestricted query flexibility.

Motivation

Soft deletion is one of those patterns that looks simple until a team has to live with it at scale. Across multiple teams and projects, I kept seeing the same class of bugs surface around deletion logic: records being hard-deleted accidentally, soft-deleted records leaking back into queries, and edge cases slipping through in more complex queries through subqueries, joins, and unions. This package came out of that frustration: it aims to make soft-delete behavior predictable and reduce the number of ways developers can get it wrong.

Features

  • Direct Repo.delete* and Repo.delete_all* are deprecated and overridden to raise, in favour of forcing explicit soft and hard delete paths (e.g. Repo.soft_delete(...), Repo.hard_delete(...))
  • Reads (Repo.get/2, Repo.all/1, etc.) skip soft-deleted rows by default. To include soft-deleted rows, pass with_deleted: true
  • Updates (Repo.update/2, Repo.update_all/3, etc.) skip soft-deleted rows by default. To update soft-deleted rows intentionally, pass with_deleted: true
  • Soft-deleted rows stay hidden through complex Ecto query shapes like joins, subqueries, CTEs, and unions
  • Soft deletes can cascade associations where relationships define on_delete: :delete_all behaviour
  • Automatic soft-delete for delete-triggering Ecto association replacement flows such as on_replace: :delete and on_replace: :delete_if_exists
  • Schema and migration helpers

Limitations / Considerations

  • Lazarus may issue additional database queries to enforce safety, especially around updates, single-row soft deletes, and cascading deletes. This is an intentional correctness tradeoff that can affect performance-sensitive paths
  • Raw SQL and schema-less Ecto sources are restricted because Lazarus cannot safely inspect or filter what it cannot understand
  • Third-party libraries that expect ordinary Repo behavior may need source bypasses or a separate unwrapped Repo
  • Wrapping a Repo changes default behavior: reads are filtered, updates skip soft-deleted rows, and direct Repo.delete* and Repo.delete_all* calls are disabled
  • Soft-delete fields, associations, and cascade behavior need to be represented in Ecto schemas. Database foreign keys alone are not enough for Lazarus to infer soft-delete cascade rules
  • Soft-deleted rows still exist in the database, so unique constraints, indexes, foreign keys, reporting queries, and direct SQL need to account for them

See Overcoming Limitations for the full list of limitations and escape hatches.

Compatibility

  • Adapter: tested only with Postgres
  • Elixir: 1.15 - 1.20
  • Ecto: 3.13 - 3.14

Installation

def deps do
  [
    {:lazarus, "~> 1.0"}
  ]
end

Quick Start

1. Wire your Repo

defmodule MyApp.Repo do
  use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Postgres
  use Lazarus
end

That does three things:

  • Blocks direct Repo.delete* and Repo.delete_all* calls unless the schema or table is bypassed
  • Adds explicit Repo.soft_delete* and Repo.hard_delete* functions
  • Hides soft-deleted rows from normal reads and skips them in updates by default

See Repo Module Setup guide for more info

2. Add soft-delete fields to your schema

defmodule MyApp.Post do
  use Ecto.Schema
  use Lazarus.Schema

  schema "posts" do
    soft_deletes()
  end
end

This adds deleted_at and deletion_reason fields by default.

See Schema Setup guide for more info

3. Add matching columns in a migration

defmodule MyApp.Repo.Migrations.AddSoftDeletesToPosts do
  use Ecto.Migration
  use Lazarus.Migrations

  def change do
    alter table(:posts) do
      soft_deletes()
    end
  end
end

This adds deleted_at and deletion_reason fields by default.

See Migration Setup guide for more info

4. Read and update active rows normally

Repo.get(Post, id)
Repo.all(Post)

post
|> Ecto.Changeset.change(title: "Updated")
|> Repo.update()

from(post in Post, where: post.author_id == ^author_id)
|> Repo.update_all(set: [status: "archived"])

Soft-deleted rows are hidden from reads and skipped by updates.

See Fetch, Update, and Delete APIs and Query Support guides for more info

5. Use the explicit delete APIs

Repo.soft_delete(post, reason: "deleted by user")
Repo.soft_delete_all(Post, reason: "cleanup job")
Repo.hard_delete(post)
Repo.hard_delete_all(Post)

See Fetch, Update, and Delete APIs and Query Support guides for more info

6. Include deleted rows only when you mean to

Repo.get(Post, id, with_deleted: true)
Repo.all(Post, with_deleted: true)

post
|> Ecto.Changeset.change(title: "Restore note")
|> Repo.update(with_deleted: true)

See Fetch, Update, and Delete APIs and Query Support guides for more info

Cascade soft-deletes

When you soft-delete record(s), you can cascade soft-delete relationships.

Cascading is opt-in. Pass cascade: true to follow association metadata recursively:

  • on_delete: :delete_all soft-deletes the child branch
  • on_delete: :nilify_all is a noop for soft deletes because the parent row still exists
  • on_delete: :nothing is a noop

If a child branch is soft-deleted, the related schema needs a deleted_at field (see schema setup). Branches listed in @hard_delete_on_cascade are physically deleted instead, and their descendants follow hard-delete cascade rules.

Example

Suppose we have a Post which has many Comment's

If we want Repo.soft_delete(post, cascade: true) to cascade soft-delete all comments that belong to that post, we'd need to set them up like so:

# comment.ex
schema "comments" do
  soft_deletes()
end

# post.ex
schema "posts" do
  has_many :comments, Comment, on_delete: :delete_all
end

Then enable cascading at the call site:

Repo.soft_delete(post, cascade: true)

See more info: Cascade Soft-Deletes

Association replacement

Ecto can call Repo.delete/2 internally during association management, for example through cast_assoc/3 or put_assoc/4 when an association uses a delete-triggering on_replace strategy such as :delete or :delete_if_exists.

Lazarus intercepts that flow:

  • If the child schema has a deleted_at field and the parent schema does not opt that association into @hard_delete_on_replace, it is soft-deleted
  • Otherwise, it is hard-deleted

That means delete-triggering on_replace flows stay data-preserving whenever the child schema is soft-delete-aware, but keep the default hard-delete behaviour when they are not.

See more info: Assoc Replace