Aggregate root invariant enforcement with application quotas

ぐ巨炮叔叔 提交于 2020-01-05 04:57:13

问题


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.


回答1:


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.




回答2:


An aggregate shoud be just a element that check rules. It can be from a stateless static function to a full state complex object; and does not need to match your persistence schema nor your "real life" concepts nor how you modeled your entities nor how you structure your data or your views. You model the aggregate with just the data you need to check rules in the form that suits you best.

Do not be affraid about precompute values and persist them (total_active_users in this case).

My recommendation is keep things as simple as possible and refactor (that could mean split, move and/or merge things) later; once you have all behavior modelled, is easier to rethink and analyze to refactor.

This would be my first approach without event sourcing:

TenantData { //just the data the aggregate needs from persistence
  int Id;
  int total_active_users;
  int quota;
}

UserEntity{ //the User Entity
  int id;
  string name;
  date birthDate;
  //other data and/or behaviour
}

public class RegistrarionAggregate{

    private TenantData fromTenant;//data from persistence

    public RegistrationAggregate(TenantData fromTenant){ //ctor
      this.fromTenant = fromTenant;
    }

    public UserRegistered registerUser(UserEntity user){
        if (fromTenant.total_active_users >= fromTenant.quota) throw new QuotaExceededException

        fromTeant.total_active_users++; //increase active users

        return new UserRegisteredEvent(fromTenant, user); //return system changes expressed as a event
    }
}

RegisterUserCommand{ //command structure
    int tenantId;
    UserData userData;// id, name, surname, birthDate, etc
}

class ApplicationService{
    public void registerUser(RegisterUserCommand registerUserCommand){

      var user = new UserEntity(registerUserCommand.userData); //avoid wrong entity state; ctor. fails if some data is incorrect

      RegistrationAggregate agg = aggregatesRepository.Handle(registerUserCommand); //handle is overloaded for every command we need. Use registerUserCommand.tenantId to bring total_active_users and quota from persistence, create RegistrarionAggregate fed with TenantData

      var userRegisteredEvent = agg.registerUser(user);

      persistence.Handle(userRegisteredEvent); //handle is overloaded for every event we need; open transaction, persist  userRegisteredEvent.fromTenant.total_active_users where tenantId, optimistic concurrency could fail if total_active_users has changed since we read it (rollback transaction), persist userRegisteredEvent.user in relationship with tenantId, commit transaction

    eventBus.publish(userRegisteredEvent); //notify external sources for eventual consistency

  }
}

Read this and this for a expanded explanation.



来源:https://stackoverflow.com/questions/56682746/aggregate-root-invariant-enforcement-with-application-quotas

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!