Practical DDD in Golang: Specification

- 8 minutes read - 1591 words

Use cases of the versatile Specification pattern include validation, creation, and querying.

There are not many code structures that bring me joy whenever I need to write them. The first time I implemented such code was with a lightweight ORM in Go, back when we didn’t have one. However, I used ORM for many years, and at some point, when you rely on ORM, using QueryBuilder becomes inevitable. Here, you may notice terms like “predicates”, and that’s where we can find the Specification pattern. It’s hard to find any pattern we use as Specification, yet we do not hear its name. I think the only thing harder is to write an application without using this pattern. The Specification has many applications. We can use it for querying, creation, or validation. We may provide a unique code that can do all this work or provide different implementations for different use cases.

For Validation

The first use case for the Specification pattern is validation. Typically, we validate data in forms, but this is at the presentation level. Sometimes, we perform validation during creation, such as for Value Objects. In the context of the domain layer, we can use Specifications to validate the states of Entities and filter them from a collection. So, validation at the domain layer has a broader meaning than for user inputs.

Base Product Specification

type Product struct {
	ID            uuid.UUID
	Material      MaterialType
	IsDeliverable bool
	Quantity      int
}

type ProductSpecification interface {
	IsValid(product Product) bool
}

A simple Product Specification

type HasAtLeast struct {
	pieces int
}

func NewHasAtLeast(pieces int) ProductSpecification {
	return HasAtLeast{
		pieces: pieces,
	}
}

func (h HasAtLeast) IsValid(product Product) bool {
	return product.Quantity >= h.pieces
}

In the example above, there is an interface called ProductSpecification. It defines only one method, IsValid, which expects instances of Product and returns a boolean value as a result if the Product passes validation rules. A simple implementation of this interface is HasAtLeast, which verifies the minimum quantity of the Product.

Function as a Product Specification

type FunctionSpecification func(product Product) bool

func (fs FunctionSpecification) IsValid(product Product) bool {
	return fs(product)
}

func IsPlastic(product Product) bool {
	return product.Material == Plastic
}

func IsDeliverable(product Product) bool {
	return product.IsDeliverable
}

More interesting validators are two functions, IsPlastic and IsDeliverable. We can wrap those functions with a specific type, FunctionSpecification. This type embeds a function with the same signature as the two mentioned. Besides that, it provides methods that respect the ProductSpecification interface. This example is a nice feature of Go, where we can define a function as a type and attach a method to it so that it can implicitly implement some interface. In this case, it exposes the method IsValid, which executes the embedded function.

Combine Product Specification

type AndSpecification struct {
	specifications []ProductSpecification
}

func NewAndSpecification(specifications ...ProductSpecification) ProductSpecification {
	return AndSpecification{
		specifications: specifications,
	}
}

func (s AndSpecification) IsValid(product Product) bool {
	for _, specification := range s.specifications {
		if !specification.IsValid(product) {
			return false
		}
	}
	return true
}

type OrSpecification struct {
	specifications []ProductSpecification
}

func NewOrSpecification(specifications ...ProductSpecification) ProductSpecification {
	return OrSpecification{
		specifications: specifications,
	}
}

func (s OrSpecification) IsValid(product Product) bool {
	for _, specification := range s.specifications {
		if specification.IsValid(product) {
			return true
		}
	}
	return false
}

type NotSpecification struct {
	specification ProductSpecification
}

func NewNotSpecification(specification ProductSpecification) ProductSpecification {
	return NotSpecification{
		specification: specification,
	}
}

func (s NotSpecification) IsValid(product Product) bool {
	return !s.specification.IsValid(product)
}

In addition, there is also one unique Specification, AndSpecification. Such a struct helps us use an object that implements the ProductSpecification interface but groups validation from all Specifications included.

In the code snippet above, we may find two additional Specifications. One is OrSpecification, and it, like AndSpecification, executes all Specifications which it holds. Just, in this case, it uses the “or” algorithm instead of “and”. The last one is NotSpecification, which negates the result of the embedded Specification. NotSpecification can also be a functional Specification, but I did not want to complicate it too much.

Test all together

func main() {
	spec := NewAndSpecification(
		NewHasAtLeast(10),
		FunctionSpecification(IsPlastic),
		FunctionSpecification(IsDeliverable),
	)

	fmt.Println(spec.IsValid(Product{}))
	// output: false

	fmt.Println(spec.IsValid(Product{
		Material:      Plastic,
		IsDeliverable: true,
		Quantity:      50,
	}))
	// output: true
}

For Querying

I have already mentioned in this article the application of the Specification pattern as part of ORM. In many cases, you will not need to implement Specifications for this use case, at least if you use any ORM. Excellent implementations of Specification, in the form of predicates, I found in the Ent library from Facebook. From that moment, I did not have a use case to write Specifications for querying. Still, when you find out that your query for Repository on the domain level can be too complex, you need more possibilities to filter desired Entities. Implementation can look like the example below.

One big example for Querying

type Product struct {
	ID            uuid.UUID
	Material      MaterialType
	IsDeliverable bool
	Quantity      int
}

type ProductSpecification interface {
	Query() string
	Value() []interface{}
}

type AndSpecification struct {
	specifications []ProductSpecification
}

func NewAndSpecification(specifications ...ProductSpecification) ProductSpecification {
	return AndSpecification{
		specifications: specifications,
	}
}

func (s AndSpecification) Query() string {
	var queries []string
	for _, specification := range s.specifications {
		queries = append(queries, specification.Query())
	}

	query := strings.Join(queries, " AND ")

	return fmt.Sprintf("(%s)", query)
}

func (s AndSpecification) Value() []interface{} {
	var values []interface{}
	for _, specification := range s.specifications {
		values = append(values, specification.Value()...)
	}
	return values
}

type OrSpecification struct {
	specifications []ProductSpecification
}

func NewOrSpecification(specifications ...ProductSpecification) ProductSpecification {
	return OrSpecification{
		specifications: specifications,
	}
}

func (s OrSpecification) Query() string {
	var queries []string
	for _, specification := range s.specifications {
		queries = append(queries, specification.Query())
	}

	query := strings.Join(queries, " OR ")

	return fmt.Sprintf("(%s)", query)
}

func (s OrSpecification) Value() []interface{} {
	var values []interface{}
	for _, specification := range s.specifications {
		values = append(values, specification.Value()...)
	}
	return values
}

type HasAtLeast struct {
	pieces int
}

func NewHasAtLeast(pieces int) ProductSpecification {
	return HasAtLeast{
		pieces: pieces,
	}
}

func (h HasAtLeast) Query() string {
	return "quantity >= ?"
}

func (h HasAtLeast) Value() []interface{} {
	return []interface{}{h.pieces}
}

func IsPlastic() string {
	return "material = 'plastic'"
}

func IsDeliverable() string {
	return "deliverable = 1"
}

type FunctionSpecification func() string

func (fs FunctionSpecification) Query() string {
	return fs()
}

func (fs FunctionSpecification) Value() []interface{} {
	return nil
}

func main() {

	spec := NewOrSpecification(
		NewAndSpecification(
			NewHasAtLeast(10),
			FunctionSpecification(IsPlastic),
			FunctionSpecification(IsDeliverable),
		),
		NewAndSpecification(
			NewHasAtLeast(100),
			FunctionSpecification(IsPlastic),
		),
	)

	fmt.Println(spec.Query())
	// output: ((quantity >= ? AND material = 'plastic' AND deliverable = 1) OR (quantity >= ? AND material = 'plastic'))

	fmt.Println(spec.Value())
	// output: [10 100]
}

In the new implementation, the ProductSpecification interface provides two methods, Query and Values. We use them to get a query string for a particular Specification and the possible values it holds. Once again, we can see additional Specifications, AndSpecification and OrSpecification. In this case, they join all underlying queries, depending on the operator they present, and merge all values. It is questionable to have such Specifications on the domain layer. As you may see from the output, Specifications provide SQL-like syntax, which delves too much into technical details. In this case, the solution would probably be to define interfaces for different Specifications on the domain layer and have actual implementations on the infrastructure layer. Or to restructure the code so that Specifications hold information about field name, operation, and value. Then, have some mapper on the infrastructure layer that can map such Specifications to an SQL query.

For Creation

One simple use case for Specifications is to create a complex object that can vary a lot. In such cases, we can combine it with the Factory pattern or use it inside a Domain Service.

One big example for Creation

type Product struct {
	ID            uuid.UUID
	Material      MaterialType
	IsDeliverable bool
	Quantity      int
}

type ProductSpecification interface {
	Create(product Product) Product
}

type AndSpecification struct {
	specifications []ProductSpecification
}

func NewAndSpecification(specifications ...ProductSpecification) ProductSpecification {
	return AndSpecification{
		specifications: specifications,
	}
}

func (s AndSpecification) Create(product Product) Product {
	for _, specification := range s.specifications {
		product = specification.Create(product)
	}
	return product
}

type HasAtLeast struct {
	pieces int
}

func NewHasAtLeast(pieces int) ProductSpecification {
	return HasAtLeast{
		pieces: pieces,
	}
}

func (h HasAtLeast) Create(product Product) Product {
	product.Quantity = h.pieces
	return product
}

func IsPlastic(product Product) Product {
	product.Material = Plastic
	return product
}

func IsDeliverable(product Product) Product {
	product.IsDeliverable = true
	return product
}

type FunctionSpecification func(product Product) Product

func (fs FunctionSpecification) Create(product Product) Product {
	return fs(product)
}

func main() {
	spec := NewAndSpecification(
		NewHasAtLeast(10),
		FunctionSpecification(IsPlastic),
		FunctionSpecification(IsDeliverable),
	)

	fmt.Printf("%+v", spec.Create(Product{
		ID: uuid.New(),
	}))
	// output: {ID:86c5db29-8e04-4caf-82e4-91d6906cff12 Material:plastic IsDeliverable:true Quantity:10}
}

In the example above, we can find a third implementation of Specification. In this scenario, ProductSpecification supports one method, Create, which expects a Product, adapts it, and returns it back. Once again, there is AndSpecification to apply changes defined by multiple Specifications, but there is no OrSpecification. I could not find an actual use case for the OR algorithm during the creation of an object. Even if it is not present, we can introduce NotSpecification, which could work with specific data types like booleans. Still, in this small example, I could not find a good fit for it.

Conclusion

Specification is a pattern that we use everywhere, in many different cases. Today, it isn’t easy to provide validation on the domain layer without the usage of Specifications. Specifications can also be used in querying objects from the underlying storage, and today, they are part of ORM. The third usage is for creating complex instances, where we can combine it with the Factory pattern.

Useful Resources

comments powered by Disqus