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:
- We want all Organizations to always have at least one owner.
- Similarly, we want to prevent a User from being destroyed if they are the sole owner of an organization.
- However, if an organization is destroyed, we can destroy all the associated ownership records.
- 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