Three and a half implementations of Users, Organizations, and Memberships/Ownerships

Let’s say that we have Organizations and Users. I’m trying to decide on how to structure relationships between them. Please take a look and give critique, suggest improvements, and let me know which you like best.

Criteria:

  1. We want all Organizations to always have at least one owner.
  2. Similarly, we want to prevent a User from being destroyed if they are the sole owner of an organization.
  3. However, if an organization is destroyed, we can destroy all the associated ownership records.
  4. Users don’t have to be owners of an organization, they can just be regular members and have a role, which defaults to “default”.

Additionally, we cannot have duplicate memberships/ownerships, but this is accomplished with the unique index and so is not shown.

The first point is covered by the validate on Organization, the second by the before_destroy callback on Membership, and the third by using dependent: :delete_all instead of :destroy on Organization. We have several options on the last point though.

My first thought was to make Owners just Members with the role of “owner”, here is that implementation:

# 1
class User < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :organizations, through: :memberships
  
  has_many :ownerships, -> { where(role: "owner") }, class_name: "Membership"
  has_many :owned_organizations, through: :ownerships, source: :organization
end


class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :organization

  before_destroy :prevent_last_owner_removal
  before_update :prevent_last_owner_demotion
  
  def prevent_last_owner_removal
    if organization.ownerships.count == 1
      errors.add(:base, "Cannot remove the last owner of organization.")
      throw(:abort)
    end
  end

  def prevent_last_owner_demotion
    if role_changed? && role_was == "owner" && organization.ownerships.count == 1
      errors.add(:base, "Cannot demote the last owner of organization.")
      throw(:abort)
    end
  end
end


class Organization < ApplicationRecord
  has_many :memberships, dependent: :delete_all
  has_many :members, through: :memberships, source: :user

  has_many :ownerships, -> { where(role: "owner") }, class_name: "Membership"
  has_many :owners, through: :ownerships, source: :user
  validate :must_have_at_least_one_owner
  
  def must_have_at_least_one_owner
    # ownerships.empty? does not work, I guess the scope only works on persisted records?
    if memberships.none? {_1.role == "owner"}
      errors.add(:base, "An organization must have at least one owner.")
    end
  end
end


# seeds
user1 = User.find_or_create_by!(name: "Bob Owner")
user2 = User.find_or_create_by!(name: "Alice Owner")
user3 = User.find_or_create_by!(name: "Jane Member")

org = Organization.find_or_initialize_by(name: "Acme Corp")

org.memberships.build(user: user1, role: "owner")
org.memberships.build(user: user2, role: "owner")
org.members << user3

org.save!

So org.owners is a subset of org.members, same with user.owned_organizations and user.organizations.

Then I encountered this video by DHH where I spotted this interesting file.

He explains that by giving administratorships their own join model we can better encapsulate that concept. I took some inspiration from that and made this implementation:

# 2
class User < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :organizations, through: :memberships

  has_many :ownerships, dependent: :destroy
  has_many :owned_organizations, through: :ownerships, source: :organization
end


class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :organization
end


class Ownership < ApplicationRecord
  belongs_to :user
  belongs_to :organization

  after_create :create_membership
  before_destroy :prevent_last_owner_removal
  
  def create_membership
    Membership.create_or_find_by(user:, organization:)
  end

  def prevent_last_owner_removal
    if organization.ownerships.count == 1
      errors.add(:base, "Cannot remove the last owner of organization.")
      throw(:abort)
    end
  end
end


class Organization < ApplicationRecord
  has_many :memberships, dependent: :delete_all
  has_many :members, through: :memberships, source: :user

  has_many :ownerships, dependent: :delete_all
  has_many :owners, through: :ownerships, source: :user
  validate :must_have_at_least_one_owner
  
  def must_have_at_least_one_owner
    if ownerships.empty?
      errors.add(:base, "An organization must have at least one owner.")
    end
  end
end


# can now use << on owners
org.owners << user1
org.owners << user2
org.members << user3

Now we don’t need the before_update :prevent_last_owner_demotion. I found it a little weird to create two records for Owners though. An alternative is to make members NON-owner members only, and then have a method Organization#all_members as the union of members and owners, as seen in the Basecamp code. Conversely, this also necessitates a User#all_organizations method.

# 3
class User < ApplicationRecord
  # ...
  def all_organizations
    organizations | owned_organizations
  end
end


class Ownership < ApplicationRecord
  # remove the after_create :create_membership
end


class Organization < ApplicationRecord
  # ...
  def all_members
    members | owners
  end
end

This last implementation is conceptually very similar to the first, except instead of a scope on the has_many we have a default_scope on what is a sort of type-less STI. This will automatically set the “owner” role on creation.

# a half
class User < ApplicationRecord
  # same as implementation 2
end


class Membership < ApplicationRecord
  # same as implementation 1
end

class Ownership < Membership
  default_scope { where(role: "owner") }
end

class Organization < ApplicationRecord
  # same as implementation 2
end

# can use << unlike implementation 1
org.owners << user1
org.owners << user2
org.members << user3