Engineering Blog

The power of Composite Specifications

tl;dr

I’m going to refactor the specifications I showed you in the previous post into a powerful, composable object model.

The generic Specification interface

First of all I’m going to generalise ISecurityRule by replacing SecurityContext with a generic type parameter.

interface ISpecification<in T>
{
  bool IsSatisfiedBy(T candidate);
}

You can now write a specification for any type you like. The old ISecurityRule interface is equivalent to ISpecification<SecurityContext>.

Extracting smaller specifications

One code smell from the earlier example was that our two rules contained duplicated code to check whether the user is a manager. It’d be nice if we could reuse the constituent parts of each specification.

We can make our rules more reusable by breaking them down into tests of individual cases.

class UserIsWorkspaceManager : ISpecification<SecurityContext>
{
  public bool IsSatisfiedBy(SecurityContext context)
  {
    return context.CurrentUser.IsManager;
  }
}

class UserHasReadPermission : ISpecification<SecurityContext>
{
  public bool IsSatisfiedBy(SecurityContext context)
  {
    var folder = context.Document.ParentFolder;
    return folder.TeamsWithReadPermission
            .Any(team => team.ContainsUser(context.CurrentUser));
  }
}

class UserCreatedTheDocument : ISpecification<SecurityContext>
{
  public bool IsSatisfiedBy(SecurityContext context)
  {
    return context.Document.Creator == context.CurrentUser;
  }
}

We can build up a library of ‘atomic’ specifications like these, each of which tests one fact, and reuse them in larger rules straightforwardly:

class ReadDocumentRule : ISpecification<SecurityContext>
{
  public bool IsSatisfiedBy(SecurityContext context)
  {
    var userIsManager = new UserIsWorkspaceManager();
    var userHasReadPermission = new UserHasReadPermission();

    return userIsManager.IsSatisfiedBy(context)
        || userHasReadPermission.IsSatisfiedBy(context);
  }
}

class DeleteDocumentRule : ISpecification<SecurityContext>
{
  public bool IsSatisfiedBy(SecurityContext context)
  {
    var userIsManager = new UserIsWorkspaceManager();
    var userCreatedTheDocument = new UserCreatedTheDocument();

    return userIsManager.IsSatisfiedBy(context)
        || userCreatedTheDocument.IsSatisfiedBy(context);
  }
}

Composing specifications

This last change has revealed a pattern in the higher-level specifications: each one is built from smaller specifications, combining them using Boolean logic. We can remove the duplicated code in those classes by writing some composite specifications to express Boolean combinations of specifications.

class OrSpecification<T> : ISpecification<T>
{
  private readonly ISpecification<T> left;
  private readonly ISpecification<T> right;

  public OrSpecification(
      ISpecification<T> left,
      ISpecification<T> right)
  {
    this.left = left;
    this.right = right;
  }

  public bool IsSatisfiedBy(T candidate)
  {
    return this.left.IsSatisfiedBy(candidate)
        || this.right.IsSatisfiedBy(candidate);
  }
}

class AndSpecification<T> : ISpecification<T>
{
  private readonly ISpecification<T> left;
  private readonly ISpecification<T> right;

  public AndSpecification(
      ISpecification<T> left,
      ISpecification<T> right)
  {
    this.left = left;
    this.right = right;
  }

  public bool IsSatisfiedBy(T candidate)
  {
    return this.left.IsSatisfiedBy(candidate)
        && this.right.IsSatisfiedBy(candidate);
  }
}

class NotSpecification<T> : ISpecification<T>
{
  private readonly ISpecification<T> spec;

  public NotSpecification(ISpecification<T> spec)
  {
    this.spec = spec;
  }

  public bool IsSatisfiedBy(T candidate)
  {
    return !this.spec.IsSatisfiedBy(candidate);
  }
}

Now that we’ve encapsulated the code to combine specifications in these three classes, our larger specifications couldn’t be simpler:

var readDocumentRule = new OrSpecification<SecurityContext>(
      new UserIsWorkspaceManager(),
      new UserHasReadPermission());
var deleteDocumentRule = new OrSpecification<SecurityContext>(
      new UserIsWorkspaceManager(),
      new UserCreatedTheDocument());

This design is fractal - you can build up specifications which are composed of specifications which are composed of specifications. For example, renaming a document could be considered a read followed by a delete:

var renameDocumentRule = new AndSpecification<SecurityContext>(
      readDocumentRule,
      deleteDocumentRule);

Despite its simplicity, this is a really powerful technique! Even with only a few atomic specifications, you can build up a large catalogue of rules by composing them with one another.

In the next post, I’ll show you how to turn this model of specifications into a clear, readable domain-specific language.

In this series

  1. All about security
  2. The power of Composite Specifications
  3. Specifications 3: The DSL Strikes Back
  4. Knock knock. Who’s there? AbstractSpecificationNodeVisitorImpl
comments powered by Disqus