使用应用程序配额聚合根不变实施

Aggregate root invariant enforcement with application quotas

我正在处理的应用程序需要强制执行以下规则(以及其他规则):

  1. 如果超出租户的活跃用户配额,我们将无法向系统注册新用户。
  2. 如果超出租户的项目配额,我们将无法创建新项目。
  3. 如果超过租户中定义的最大存储配额,我们将无法向属于租户的任何项目添加更多多媒体资源

该领域涉及的主要实体有:

如你所想,这些是实体之间的关系:

乍一看,执行这些规则的聚合根似乎是租户:

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

因此,在应用程序服务中,我们将其用作:

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

这种方法的问题是聚合根非常庞大,因为它需要加载所有用户、项目和资源,这是不切实际的。而且,在并发方面,我们会因此受到很多惩罚。

另一种方法是(我将专注于用户注册):

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

上述案例在 Tenant 中使用工厂方法执行业务规则和 returns User 聚合。与之前的实现相比,主要优势在于我们不需要加载聚合根中的所有用户(项目和资源),只需加载他们的数量。尽管如此,对于我们想要 add/register/make 的任何新资源、用户或项目,由于获取了锁,我们可能会受到并发惩罚。例如,如果我注册一个新用户,我们不能同时创建一个新项目。

另请注意,我们正在获取 Tenant 上的锁,但是我们不会更改其中的任何状态,因此我们不会调用 tenants_repository.save 。这个锁用作互斥锁,我们不能利用乐观并发,除非我们决定保存租户(检测 total_active_users 计数的变化)以便我们可以更新租户版本,如果版本像往常一样更改,则会为其他并发更改引发错误。

理想情况下,我想摆脱 Tenant class 中的那些方法(因为它也阻止我们将应用程序的某些部分拆分为它们自己的有界上下文)并以任何其他方式执行不变规则,这些方式不会对其他实体(项目和资源)的并发性产生重大影响,但我真的不知道如何防止两个用户在不使用的情况下同时注册租户作为聚合根。

我很确定这是一个常见的场景,必须有比我之前的示例更好的实现方式。

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

此类问题的常见搜索词:Set Validation

如果整个集合必须始终满足某些不变量,那么整个集合将必须成为 "same" 聚合的一部分。

通常,不变量本身就是您要推进的位;企业是否需要严格执行此约束,还是松散地执行约束并在客户超出其合同限制时收取费用更合适?

有多个集合——每个集合都需要是 an 集合的一部分,但它们不一定需要是 same[=] 的一部分25=]聚合。如果没有跨越多个集合的不变量,那么您可以为每个集合单独聚合。两个这样的聚合可能是相关的,共享相同的租户 ID。

复习 Mauro Servienti 的演讲可能会有所帮助 All our aggregates are wrong

聚合应该只是检查规则的元素。它可以从一个无状态的静态函数到一个全状态的复杂对象;并且不需要匹配您的持久性架构、"real life" 概念、实体建模方式以及数据或视图的结构方式。您只需使用最适合您的形式检查规则所需的数据对聚合进行建模。

不要害怕预计算值并保留它们(total_active_users 在这种情况下)。

我的建议是让事情尽可能简单,然后重构(这可能意味着拆分、移动 and/or 合并事情);一旦您对所有行为进行建模,就更容易重新思考和分析以进行重构。

这将是我第一个没有事件源的方法:

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

  }
}

阅读 this and this 以获得详细说明。