Specifications can start to get complicated. And you might find yourself using the same specification in multiple places. To make things easier to read, you can encapsulate parts of specifications into properties of the facts themselves.
Some specifications are conditional upon the existence of a successor fact.
You will use Any
to write those conditions.
One example that we already looked at was the existence of an AlbumDelete
fact.
var albumsByUser = Given<User>.Match((user, facts) =>
from album in facts.OfType<Album>()
where album.creator == user &&
!facts.Any<AlbumDelete>(delete => delete.album == album &&
!facts.Any<AlbumRestore>(restore => restore.delete == delete))
select album);
That specification would be more readable if we encapsulated the condition into a property of the Album
fact.
To do that, we use the Condition
type.
[FactType("Album")]
public record Album(User creator, DateTime createdAt)
{
public Condition Deleted => Condition.Define(facts =>
facts.OfType<AlbumDelete>().Any(delete => delete.album == this &&
!facts.OfType<AlbumRestore>().Any(restore => restore.delete == delete))
);
}
Now the specification is much easier to read.
var albumsByUser = Given<User>.Match((user, facts) =>
from album in facts.OfType<Album>()
where album.creator == user && !album.Deleted
select album);
Every specification traces a path from one fact to another. These paths often appear in several different specifications. You can encapsulate the path into a property of the fact. This property resembles a navigation property in Entity Framework.
For example, we defined a complicated specification that lists all of the carts for a user.
var cartsInEnvironmentForUser = Given<Environment, User>.Match((environment, user, facts) =>
from customerBuyer in facts.OfType<CustomerBuyer>()
where customerBuyer.buyer == user
from customer in facts.OfType<Customer>()
where customerBuyer.customer == customer &&
customer.environment == environment
from cart in facts.OfType<Cart>()
where cart.customer == customer
select new
{
Cart = cart,
CustomerNames =
from customerName in facts.OfType<CustomerName>()
where customerName.customer == customer &&
!facts.Any<CustomerName>(next => next.prior.Contains(customerName))
select customerName.value,
Items =
from cartItem in facts.OfType<CartItem>()
where cartItem.cart == cart
select new
{
CartItem = cartItem,
ProductNames =
from productName in facts.OfType<ProductName>()
where productName.product == cartItem.product
where !facts.Any<ProductName>(next => next.prior.Contains(productName))
select productName.value,
Quantities =
from quantity in facts.OfType<CartItemQuantity>()
where quantity.item == cartItem
select quantity.value
}
});
We can improve this specification in stages.
First we will encapsulate the path from Customer
to CustomerName
.
[FactType("Customer")]
public record Customer(Environment environment, Guid guid)
{
public Relation<string> Names => Relation.Define(facts =>
from customerName in facts.OfType<CustomerName>()
where customerName.customer == this &&
!facts.Any<CustomerName>(next => next.prior.Contains(customerName))
select customerName.value
);
}
Then we will encapsulate the path from Cart
to CartItem
.
[FactType("Cart")]
public record Cart(Customer customer, Month month, DateTime createdAt)
{
public Relation<CartItem> Items => Relation.Define(facts =>
from cartItem in facts.OfType<CartItem>()
where cartItem.cart == this
select cartItem
);
}
Let's apply the same pattern to the CartItem
to get the product name and quantity.
[FactType("CartItem")]
public record CartItem(Cart cart, Product product)
{
public Relation<string> ProductNames => Relation.Define(facts =>
from productName in facts.OfType<ProductName>()
where productName.product == this.product &&
!facts.Any<ProductName>(next => next.prior.Contains(productName))
select productName.value
);
public Relation<int> Quantities => Relation.Define(facts =>
from quantity in facts.OfType<CartItemQuantity>()
where quantity.item == this
select quantity.value
);
}
When we using these properties in the projection, the specification becomes much easier to read.
var cartsInEnvironmentForUser = Given<Environment, User>.Match((environment, user, facts) =>
from customerBuyer in facts.OfType<CustomerBuyer>()
where customerBuyer.buyer == user
from customer in facts.OfType<Customer>()
where customerBuyer.customer == customer &&
customer.environment == environment
from cart in facts.OfType<Cart>()
where cart.customer == customer
select new
{
Cart = cart,
CustomerNames = customer.Names,
Items = cart.Items.Select(cartItem => new
{
CartItem = cartItem,
ProductNames = cartItem.ProductNames,
Quantities = cartItem.Quantities
})
});
You can also define a condition or relation on a fact type that you don't own. Let's use this to improve the specification even further.
The User
fact type is defined in the Jinaga library.
We can't modify it.
But we can define an extension method that encapsulates the path from User
to Customer
.
public static class UserExtensions
{
public static Relation<Customer> Customers(this User user) =>
Relation.Define(facts =>
from customerBuyer in facts.OfType<CustomerBuyer>()
where customerBuyer.buyer == user
from customer in facts.OfType<Customer>()
where customerBuyer.customer == customer
select customer
);
}
Applying this extension method to the specification improves readability even further.
var cartsInEnvironmentForUser = Given<Environment, User>.Match((environment, user, facts) =>
from customer in user.Customers()
where customer.environment == environment
from cart in facts.OfType<Cart>()
where cart.customer == customer
select new
{
Cart = cart,
CustomerNames = customer.Names,
Items = cart.Items
});
Extension methods can even be used to extend facts that you do own. This can be useful when you want a clean separation between modules. The module that defines the successor can add the relation to a predecessor defined in another module.
When C# support for extension properties becomes available, this pattern will become even more powerful.