Welcome to ShenZhenJia Knowledge Sharing Community for programmer and developer-Open, Learning and Share
menu search
person
Welcome To Ask or Share your Answers For Others

Categories

The application Im working on needs to enforce the following rules (among others):

  1. We cannot register a new user to the system if the active user quota for the tenant is exceeded.
  2. We cannot make a new project if the project quota for the tenant is exceeded.
  3. We cannot add more multimedia resources to any project that belongs to a tenant if the maximum storage quota defined in the tenant is exceeded

The main entities involved in this domain are:

  • Tenant
  • Project
  • User
  • Resource

As you can imagine, these are the relationship between entities:

  • Tenant -> Projects
  • Tenant -> Users

  • Project -> Resources

As a first glance, It seems the aggregate root that will enforce those rules is the tenant:

class Tenant
  attr_accessor :users
  attr_accessor :projects

  def register_user(name, email, ...)
     raise QuotaExceededError if active_users.count >= @users_quota

     User.new(name, email, ...).tap do |user|
       active_users << user
     end
  end

  def activate_user(user_id)
    raise QuotaExceededError if active_users.count >= @users_quota

    user = users.find {|u| u.id == user_id}
    user.activate
  end

  def make_project(name, ...)
     raise QuotaExceededError if projects.count >= @projects_quota

     Project.new(name, ...).tap do |project|
       projects << project
     end
  end
  ...

  private

  def active_users
    users.select(&:active?)
  end
end

So, in the application service, we would use this as:

class ApplicationService

  def register_user(tenant_id, *user_attrs)
    transaction do
      tenant = tenants_repository.find(tenant_id, lock: true)
      tenant.register_user(*user_attrs)
      tenants_repository.save(tenant)!
    end
  end

  ...
end

The problem with this approach is that aggregate root is quite huge because it needs to load all users, projects and resources and this is not practical. And also, in regards to concurrency, we would have a lot of penalties due to it.

An alternative would be (I'll focus on user registration):

class Tenant
  attr_accessor :total_active_users

  def register_user(name, email, ...)
     raise QuotaExceededError if total_active_users >= @users_quota

     # total_active_users += 1 maybe makes sense although this field wont be persisted
     User.new(name, email, ...)
  end
end

class ApplicationService

  def register_user(tenant_id, *user_attrs)
    transaction do
      tenant = tenants_repository.find(tenant_id, lock: true)
      user = tenant.register_user(*user_attrs)
      users_repository.save!(user)
    end
  end

  ...
end

The case above uses a factory method in Tenant that enforces the business rules and returns the User aggregate. The main advantage compared to the previous implementation is that we dont need to load all users (projects and resources) in the aggregate root, only the counts of them. Still, for any new resource, user or project we want to add/register/make, we potentially have concurrency penalties due to the lock acquired. For example, if Im registering a new user, we cannot make a new project at the same time.

Note also that we are acquiring a lock on Tenant and however we are not changing any state in it, so we dont call tenants_repository.save. This lock is used as a mutex and we cannot take advantage of optimistic concurrency unless we decide to save the tenant (detecting a change in the total_active_users count) so that we can update the tenant version and raise an error for other concurrent changes if the version has changed as usual.

Ideally, I'd like to get rid of those methods in Tenant class (because it also prevents us from splitting some pieces of the application in their own bounded contexts) and enforce the invariant rules in any other way that does not have a big impact with the concurrency in other entities (projects and resources), but I don't really know how to prevent two users to be registered simultaneously without using that Tenant as aggregate root.

I'm pretty sure that this is a common scenario that must have a better way to be implemented that my previous examples.

See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
thumb_up_alt 0 like thumb_down_alt 0 dislike
414 views
Welcome To Ask or Share your Answers For Others

1 Answer

I'm pretty sure that this is a common scenario that must have a better way to be implemented that my previous examples.

A common search term for this sort of problem: Set Validation.

If there is some invariant that must always be satisfied for an entire set, then that entire set is going to have to be part of the "same" aggregate.

Often, the invariant itself is the bit that you want to push on; does the business need this constraint strictly enforced, or is it more appropriate to loosely enforce the constraint and charge a fee when the customer exceeds its contracted limits?

With multiple sets -- each set needs to be part of an aggregate, but they don't necessarily need to be part of the same aggregate. If there is no invariant that spans multiple sets, then you can have a separate aggregate for each. Two such aggregates may be correlated, sharing the same tenant id.

It may help to review Mauro Servienti's talk All our aggregates are wrong.


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
thumb_up_alt 0 like thumb_down_alt 0 dislike
Welcome to ShenZhenJia Knowledge Sharing Community for programmer and developer-Open, Learning and Share
...