Implementing soft deletes from scratch

When you soft delete a record, you do not remove it from the database, but you mark it as deleted instead. Then you can decide if you want to show it to your users or not.

There are many ways how to implement soft deletes in Rails. For example, you can use the paranoia gem or you can implement it from scratch.

Using paranoia

Add it to Gemfile:

gem "paranoia"

Install it:

bundle install

Add deleted_at to your model:

rails g migration add_deleted_at_to_comments deleted_at:datetime:index
rails db:migrate

And call acts_as_paranoid in it:

class Comment < ApplicationRecord
  acts_as_paranoid
end

Done! Now we can soft delete comments.

irb(main):001:0> Comment.first.destroy
  ...
   (0.1ms)  BEGIN
  SQL (0.4ms)  UPDATE "comments" SET "deleted_at" = '2016-12-21 19:04:14.923481' WHERE "comments"."id" = $1  [["id", 1]]
   (0.4ms)  COMMIT

As you can see, calling #destroy did not delete the comment, but it just set its updated_at attribute to the current time.

When we query the comments, then we can see that only non-deleted comments are selected:

irb(main):002:0> Comment.count
   (0.6ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."deleted_at" IS NULL
=> 2

See WHERE "comments"."deleted_at" IS NULL which was added to the SQL query automatically.

We can query deleted comments too:

irb(main):003:0> Comment.only_deleted.count
   (0.3ms)  SELECT COUNT(*) FROM "comments" WHERE ("comments"."deleted_at" IS NOT NULL)
=> 1

Or all comments:

irb(main):004:0> Comment.with_deleted.count
   (0.3ms)  SELECT COUNT(*) FROM "comments"
=> 3

And last but not least, we can still delete a comment from the database by calling #really_destroy!:

irb(main):005:0> Comment.first.really_destroy!
...
   (0.1ms)  BEGIN
  SQL (0.2ms)  DELETE FROM "comments" WHERE "comments"."id" = $1  [["id", 2]]
   (1.0ms)  COMMIT

All of this (and more) was added to our Comment model by acts_as_paranoid method call.

Soft deletes from scratch

Now, let's implement the similar functionality by ourself.

We already have the deleted_at column in place. So let’s just remove the call to acts_as_paranoid and implement our own #destroy method:

class Comment < ApplicationRecord
  def destroy
    update(deleted_at: Time.current)
  end
end

Now, we can soft delete a comment by calling #destroy.

In the next step we add the default scope which will exclude all deleted comments.

class Comment < ApplicationRecord
  # ... 
  default_scope -> { where(deleted_at: nil) }
end

And create only_deleted and with_deleted scopes.

class Comment < ApplicationRecord
  # ...
  scope :only_deleted, -> { unscope(where: :deleted_at).where.not(deleted_at: nil) }
  scope :with_deleted, -> { unscope(where: :deleted_at) }
end

Now, we can soft delete comments and query non-deleted and deleted comments. This looks promising, but we still need to add the #really_destroy! method.

Actually, we do not need to add it. We can just rename the original one:

class Comment < ApplicationRecord
  # ...
  alias_method :really_destroy!, :destroy
end

When we put everything together it looks like this:

class Comment < ApplicationRecord  
  scope :only_deleted, -> { unscope(where: :deleted_at).where.not(deleted_at: nil) }
  scope :with_deleted, -> { unscope(where: :deleted_at) }

  default_scope -> { where(deleted_at: nil) }

  alias_method :really_destroy!, :destroy

  def destroy
    update(deleted_at: Time.current)
  end
end 

And that’s it! We recreated the functionality provided by the paranoia gem.

Well, kind of. We just scratched the surface. For example calling #delete on a comment will still delete it from the database. Also, before_destroy/after_destroy callbacks will not run when we call our new #destroy method.

Conclusion

We saw two different approaches how to implement soft deletes with ActiveRecord.

The former is battle tested and production ready. The latter is just an example how to do it yourself and I wouldn't use it in production.

But, I recently needed to implement soft deletes. I did not use either of mentioned solutions and created even simpler one instead:

class Comment < ApplicationRecord
  default_scope -> { where(deleted_at: nil) }

  def soft_delete
    update(deleted_at: Time.current)
  end
end

I like it because calling #soft_delete is explicit and intention revealing, and I didn't override default ActiveRecord methods. I believe that future programmers working on that code will thank me.

Also, I did not need all the fancy stuff provided by the paranoia gem, so there was no reason to introduce another dependency in our app.

Read this next: How I work on my spoken English