When building multi-layer (“enterprise”) systems, it often turns out that
ValueObject
(or case classes) are created that store information about any instance of the entity being processed by the system. For example, the class
case class Person(name: String, address: Address)
This way of presenting data in the system has the following positive properties:
- strongly typed data access
- ability to bind meta information to properties using annotations,
')
and some disadvantages:
- if there are many entities, then there are also quite a few such classes, and their processing requires a lot of the same type of code (copy-paste);
- The needs of individual layers of the system for meta-information can be presented as annotations to the properties of this object, but the possibilities of annotations are limited and require the use of reflection;
- if it is required to present data not about all the properties of an object at once, then the created classes are difficult to use;
- it is also difficult to imagine a change in the value of a property (delta).
We want to implement a framework that allows you to create new "classes" (types, constructors of these types, objects of new types) incrementally, using our own "building blocks". Along the way, using the fact that we ourselves manufacture "bricks", we can achieve these useful properties:
- the ability to describe individual properties of entities (with the indication of the data type in this property and any meta-information required by the application, in a form suitable for this particular application);
- the ability to operate on the properties of instances in a strongly typed way (with type checking at compile time);
- to provide partial / incomplete information about the values of the properties of an instance of an entity, using the declared properties;
- create the type of the object containing partial information about the properties of the entity instance. And to use this type along with other types (classes, primitive types, etc.).
To construct a new composite type, it is necessary to figure out how a regular class works. In the declaration of the class
Person
you can select components
- ordered list of properties / slots (slot sequence),
- property / slot name (slot id),
- property / slot type.
When using the
Person
class and its properties, you can select operations -
- get the value of an instance property (instance.name)
- get a new instance with a changed property (since the
Person
class is immutable, for mutable classes, the analog is to change the value of an object property)
In this case, the essence of the "first class" is the class of
Person
, and its properties - the essence of the "second class". They are not objects and we are not able to operate with them abstractly.
We want to make properties independent entities of the “first class”, from which a new “class” will be constructed.
So, let's declare the
name
property:
trait SlotId[T] case class SlotIdImpl[T](slotId:String, ...) extends SlotId[T] def slot[T](slotId:String, ...) = SlotIdImpl[T](slotId, ...) val name = slot[String]("name", ...)
Such an announcement brings to the fore the property itself, regardless of the entity in which the property will be used. Meta-information can obviously be tied to a property identifier (using an external display), or specified directly in the object that represents the property. In the latter version, the handling of data is somewhat simplified, although the expansion of new types of meta-information is difficult.
Slot sequence
To get a new type, you need to collect several properties in an ordered list. To construct a type composed of others, we will use the same approach as in the HList type (from the remarkable
shapeless library, for example).
sealed trait SlotSeq { type ValueType <: HList } case object SNil extends SlotSeq { type ValueType = HNil } case class ::[H<:SlotId, T<:SlotSeq](head:H, tail : T) extends SlotSeq { type ValueType = H :: T#ValueType }
As you can see, in the process of constructing a property list, we also construct a value type (
ValueType
) that is compatible with the property list.
Property grouping
Properties can be used as is, simply by creating a complete collection of all possible properties. However, it is better to organize the properties in “clusters” - sets of properties belonging to the same class / type of objects.
object PersonType { val name = slot[String]("name", ...) val address ... ... }
This grouping can also be done with the help of traits, which allows you to declare the same properties in different “clusters”.
trait Identifiable { val id = slot[Long]("id") } object Employee extends Identifiable
In addition, the “clusters” allow you to automatically add a covering object to the meta-information of properties, which, in turn, can be quite useful when processing data based on meta-information.
Representation of instances
Actually, the data related to the entity can be presented in two main forms:
Map
or
RecordSet
.
Map
- contains property-value pairs, while
RecordSet
contains an ordered list of properties and an array of values arranged in the same order.
RecordSet
allows you to economically present data about a large number of instances, and
Map
allows you to create a "thing in itself" - an isolated object that contains all the meta information along with the values of properties. Both of these methods can be used in parallel, depending on current needs.
For the typed representation of
RecordSet
'strings, the remarkable
HList
structure can be used (from the shapeless library, for example). We only need to build a compatible
HList
type in the process of assembling an ordered slot sequence.
type ValueType = head.Type :: tail.ValueType
To create a strongly typed
Map
'and we need instead of the usual class
Entry
use our
SlotValue
class,
case class SlotValue[T](slot:SlotId[T], value:T)
which besides the property name and property value also contains the generic value type. This allows already at the stage of compilation to ensure that the property will receive the value of a compatible type. The
Map
itself will require a separate implementation. In the simplest case, you can use the
SlotValue
list, which is automatically converted to a regular Map as needed.
Conclusion
In addition to the above-described basic data structure and type structure, auxiliary functions based on basic tools are useful.
- gradual construction of a
Map
instance (strictly typed MapBuilder
); - lenses to access and modify nested properties;
Map
conversion - to RecordSet
and back
Such a framework can be used when it is necessary to process various types of data based on meta-information about properties, for example:
- work with DB:
- similar processing of events related to the properties of various entities, for example, changing the properties of objects.
Due to the convenience of presenting meta information, it is possible to describe in detail all aspects of data processing, without resorting to annotations.
Code for the described constructions .
UPD: Continuing the theme:
Strongly typed representation of incomplete data