Lazarus

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

Limitations / Considerations

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

Compatibility

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:

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:

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:

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