speckoloo
v0.10.0
Published
Domain entites inspired by Speck
Downloads
34
Readme
Speckoloo
Domain entities inspired by Speck.
ToC
- Motivation
- Design rationale
- Functionalities
- Interfaces
- Installation
- Usage
- Contributing
Motivation
Domain Driven Design is a total new beast when it comes to Node.js/Javascript development. There are not many tools out there to help us.
I was presented to Speck around May'17 by @guisouza, a great piece of work made by him and his former colleagues at Sprinkler.
However, I could not agree to some parts of its design rationale, such as the use of classes, react proptypes and reliance on the instanceof
operator, so I decided to implement my own version.
To be clear, this is mostly a matter of style and preference. There is a long debate around this and no side is a clear winner, but I tend to agree more with one of them.
Design rationale
This library is based on two key concepts:
I prefer OLOO because attempts on simulating classical inheritance are flawed and unneeded in Javascript. Constructors are essentially broken and the workaround leads to lots of almost-the-same-but-not-quite code, so we cannot rely on .constructor
properties from objects.
Nominal typing works (barely) only for primitive types (remember that typeof null === 'object'
:expressionless:), let alone for complex types — see instanceof
lies — so I also rather use duck typing instead of nominal typing. speckoloo
relies on duck typing for entity construction, but exposes a nominal typing feature through the constructor
properties of the entities (see docs).
This might cause some discomfort for those used to static-typed languages — coincidentally those where DDD is more widespread — but the main point of duck typing is that it's the caller job to honor his side of the contract. So, if you are using this library, but are being unpolite, it will probably blow up on your face. Still, I'll try to provide the most descriptive error messages as possible.
Furthermore, this library will, as much as possible, avoid code duplication for the clients using it and be validation framework agnostic.
Functionalities
speckoloo
provides the following capabilities:
- State storage: holds data belonging to the entities
- Data validation: validates entity data against a provided schema.
- Data (de)serialization: converts data from and to plain old JSON objects.
- Data composition: allows entities to reference one another.
All functions related to the above concerns will be called synchronously.
Interfaces
Each object created by speckoloo
factories implements the Validatable
and the JSONSerializable
interfaces, defined bellow:
interface ErrorDetail {
[property: String]: String
}
interface ValidationError {
name: 'ValidationError'
message: String,
details: ErrorDetail
}
interface Validatable {
validate(context?: String) => this, throws: ValidationError | Error
}
interface JSONSerializable {
toJSON(context?: String) => Object, throws Error
}
toJSON
: returns a raw JSON object with the properties present in the entity.validate
: returns the object itself if its data is valid or throws aValidationError
Both toJSON
and validate
methods are context-aware and may receive an optional context
param. If such context doesn't exist, both will throw an Error
.
Installation
NPM
npm install --save speckoloo
Manually
git clone https://github.com/hbarcelos/speckoloo.git
cd speckoloo
npm install
npm run build
Usage
Schemas
Basic structure
To define a schema, simply create an object whose keys are the possible properties descriptors:
import { factoryFor } from 'speckoloo'
const mySchema = {
myProp1: {},
myProp2: {},
myProp3: {}
}
const myEntityFactory = factoryFor(mySchema)
const instance = myEntityFactory({
myProp1: 'a',
myProp2: 'b',
myProp3: 'c'
})
Default value
To define a schema with a default value for a given property, add default
to the schema definition.
Whenever a property value is missing when the entity factory is called, the default value is set:
import { factoryFor } from 'speckoloo'
const mySchema = {
myProp1: {
default: 'myValue'
}
}
const factory = factoryFor(mySchema)
const instance = factory({})
console.log(instance.toJSON())
Ouptut:
{
myProp1: 'myValue'
}
NOTICE: if a factory
property is set for a given property, it will be called with the default
value to obtain the final value of the property:
import { factoryFor } from 'speckoloo'
const mySchema = {
myProp1: {
default: '1',
factory: Number
}
}
const factory = factoryFor(mySchema)
const instance = factory({})
console.log(typeof instance.myProp1)
Output:
'number'
Ignoring default values
It is possible to make the factory ignore all default values, using the ignoreDefaults
option in its second parameter.
This is useful when creating a patch for a given entity, without having to build it entirely first. This way, when such data is merged, the current value is not overwritten.
import { factoryFor } from 'speckoloo'
const mySchema = {
myProp1: {
default: 'myValue'
}
}
const factory = factoryFor(mySchema)
const instance = factory({}, {
ignoreDefaults: true
})
console.log(instance.toJSON())
Output:
{}
Read-only properties
To define a schema with read-only properties, add readOnly
to the schema definition.
When a property is read-only, its value will remain the same as the time of instantiation. Any attempt on set a value for such property will throw a TypeError
:
const schema = {
prop1: {
readOnly: true
}
}
const factory = factoryFor(schema)
const instance = factory({
prop1: '__initial__'
})
instance.prop1 = '__new__' // <-- throws TypeError
Validator
To define a schema with validators, add a property validator
of type Validator
.
Validators are functions with the following interface:
interface PropertyValidationError {
error: String | Object
}
Validator(propertyValue: Any, propertyName: String, data: Object) => PropertyValidationError | Any
If the validator returns a ValidationError
, then it's interpreted as the validation has failed for that property. If it return anything else, then it's interpreted as it succeeded.
Default validators
speckoloo
provides 3 default validators:
allowAny
: allows any valueforbidAny
: forbids any valuedelegate(context?: string, options?:object)
: delegates the validation to a nested entity.
If no validator is provided for a property, allowAny
will be used by default:
const mySchema = {
myProp1: {}
myProp2: {
validator: defaultValidators.allowAny // the same as above
}
}
Creating custom validators
const requiredString = (value, key) => {
if (value !== String(value)) {
return {
error: `Value ${key} must be a string.`
}
}
// Returning `undefined` means the validation has passed
}
const mySchema = {
myProp1: {
validator: requiredString
},
myProp2: {
validator: requiredString
},
myProp3: {
validator: requiredString
}
}
Using validation adapters
It is possible to use popular validation libraries with speckoloo
, by wrapping the validation into a function of type Validator
.
Currently the default adapters available are:
joiAdapter
: adapts ajoi
function
You need to install joi
as a peer dependency to be able to use it:
# Install peer dependency
npm install --save joi
Then:
import { factoryFor } from 'speckoloo'
import joiAdapter from 'speckoloo/dist/adapters/joi'
const schema = {
id: {
validator: joiAdapter(Joi.string().alphanum().required())
}
...
}
export default factoryFor(schema)
Optional properties
To make a property optional, there is the skippable
property in the schema definition.
When set to true
, it means that the validator
will not be called when data is present.
This is useful because it doesn't force the definition of two validators to handle the case when an empty value is allowed:
const string = (value, key) => {
if (value !== String(value)) {
return {
error: `Value ${key} must be a string.`
}
}
// Returning `undefined` means the validation has passed
}
const mySchema = {
myProp1: {
validator: string,
skippable: true
}
//...
}
When myProp1
is not set, the validator
will not run.
This is specially useful when used in context definition (see $skip
).
Factory
To transform data on creation, there is the factory
property in the schema definition.
Factories are functions with the following signature:
Factory(value: Any) => Any
Example:
import { factoryFor } from 'speckoloo'
const mySchema = {
myProp1: {
factory: String // converts any input data to string
},
myProp2: {}
}
const MyFactory = factoryFor(mySchema)
const instance = MyFactory({
myProp1: 1,
myProp2: 2
})
console.log(instance.myProp1)
Output:
'1' // <--- this is a string
NOTICE: factory
is called only when property value is not undefined
:
const instance = MyFactory({
myProp2: 2
})
console.log(instance)
Output:
{ myProp2: 2 }
Methods
An important part of DDD rationale is that entities should be self-contained in terms of business logic that relates only to them.
For example, imagine the domain is a simple drawing application, where there are the entities Square
and Circle
.. One of the functionalities is ot calculate the area of the drawn objects. The logic for calculating the area of the object should be within itself. To achive this with speckoloo
, there is the special property $methods
in schema definition:
import { factoryFor } from 'speckoloo'
const circleSchema = {
radius: {
validatior: positiveNumber,
factory: Number
},
$methods: {
getArea() {
return Math.PI * this.radius * this.radius
}
}
}
const squareSchema = {
side: {
validatior: positiveNumber,
factory: Number
},
$methods: {
getArea() {
return this.side * this.side
}
}
}
const circleFactory = factoryFor(circleSchema)
const squareFactory = factoryFor(squareSchema)
const circle = circleFactory({ radius: 2 })
const square = squareFactory({ side: 4 })
console.log('Circle area: ', circle.getArea())
console.log('Square area: ', square.getArea())
Output:
Circle area: 12.566370614359172
Square area: 16
All functions in $methods
are attached to the entity — so this
refers to the entity itself — as non-enumerable, non-configurable, read-only properties.
Contexts
Entities might have different validation rules for different contexts. Some properties might be required for one context, but not for another.
To create a context, there's a special property called $contexts
that can be used in the schema definition. The $contexts
object has the following structure:
{
[contextName]: {
[operator]: Object | Array
}
}
Currently available operators are:
$include
: considers only the given properties for the context.$exclude
: removes the given properties from the context.$modify
: changes the validators for the specified properties.$skip
: skips the validation if data is not present.
Consider a User
entity:
const schema = {
id: requiredString,
name: requiredString,
email: requiredEmail,
password: requiredString,
}
Exclude properties
When creating/registering a User
, probably id
will not be available yet, so this property should be ignored in context 'create'
.
const schema = {
id: requiredString,
name: requiredString,
email: requiredEmail,
password: requiredString,
$contexts: {
create: {
$exclude: ['id']
}
}
}
Include only some properties
Also, when such User
is trying to login into an application, the only properties required are email
and password
. So there is also a login
context defined as follows:
const schema = {
id: requiredString,
name: requiredString,
email: requiredEmail,
password: requiredString,
$contexts: {
create: {
$exclude: ['id']
},
login: {
$include: ['email', 'password']
}
}
}
NOTICE: You SHOULD NOT use both $include
and $exclude
in the same context definition. Doing so will trigger a process warning and $include
will take precedence.
Modifying the validator of a property
During creation, password
strength must be enforced. For all other contexts, it could simply be a valid string
. To achive this, there is the $modify
operator, that allows changing the validator for a property only for the context.
Instead of an array, $modify
expects an object
containing property-validator pairs, such as follows:
const schema = {
id: requiredString,
name: requiredString,
email: requiredEmail,
password: requiredString,
$contexts: {
create: {
$exclude: ['id'],
$modify: {
password: passwordStrengthChecker
}
},
login: {
$include: ['email', 'password']
}
}
}
NOTICE: $modify
can be combined with both $includes
and $excludes
, but will only be applied for properties included or not excluded, respectively. Example:
const schema = {
prop1: requiredString,
prop2: requiredString,
$contexts: {
context1: {
$exclude: ['prop1'],
$modify: {
prop1: otherValidator // will be silently ignored
}
},
context2: {
$include: ['prop1'],
$modify: {
prop2: otherValidator // will be silently ignored
}
}
}
}
Skipping validation
Sometimes there's a need to allow skipping validation when data is not present. The most common use case is entity patching, when only a subset of the entity is provided.
Until 0.4.x
, to do that you needed to redeclare the validator for a property on a given context:
const schema = {
prop1: requiredString,
prop2: requiredString,
$contexts: {
context1: {
$modify: {
prop1: string // <--- a validator that allows a string to be empty
}
}
}
}
As of 0.5.0
, there is a new operator called $skip
, that shortens the code above to:
const schema = {
prop1: requiredString,
prop2: requiredString,
$contexts: {
context1: {
$skip: ['prop1']
}
}
}
NOTICE: the main difference between $exclude
and $skip
is that the former will completely exclude the property from schema definition, regardless it's present on data or not. The latter will only skip the validation when the value is not present (=== undefined
); if you provide an invalid value, the validation will fail.
Validation
validate
will throw a ValidationError
when data is invalid or return the object itself otherwise (use this for chaining).
Default validation
import { factoryFor } from 'speckoloo'
const requiredString = (value, key) => {
if (value !== String(value)) {
return {
error: `${key} must be a string.`
}
}
// Returning `undefined` means the validation has passed
}
const mySchema = {
myProp1: {
validator: requiredString
},
myProp2: {
validator: requiredString
},
myProp3: {
validator: requiredString
}
}
const MyEntityFactory = factoryFor(mySchema)
const instance = MyEntityFactory({
myProp1: 1,
myProp2: 2,
myProp3: 3
})
instance.validate() // throws an error
The code above will throw a ValidationError
like the following:
{
name: 'ValidationError'
message: 'Validation Error!'
details: {
myProp1: 'myProp1 must be a string.',
myProp2: 'myProp2 must be a string.',
myProp3: 'myProp3 must be a string.'
}
}
Context-aware validation
To make use of the defined contexts for validation, there is the context
param of validate()
.
import { factoryFor } from 'speckoloo'
const schema = {
id: requiredString,
name: requiredString,
email: requiredEmail,
password: requiredString,
$contexts: {
create: {
$exclude: ['id'],
$modify: {
password: passwordStrengthChecker
}
},
login: {
$include: ['email', 'password']
}
}
}
const UserFactory = factoryFor(schema)
const user = UserFactory({
email: '[email protected]',
password: 'dummyPass@1234'
})
user.validate('login') // doesn't throw!
Serialization
Default serialization
toJSON
will return a raw JSON object with the properties present in the entity.
NOTICE: toJSON
WILL NOT validate data before returning it. If an invalid value is present for a given property, it will be returned as is.
From the exact same example above:
const instance = MyEntityFactory({
myProp1: 1,
myProp2: 2,
myProp3: 3
})
instance.toJSON() // ignores validation
Output:
{
myProp1: 1,
myProp2: 2,
myProp3: 3
}
Chaining toJSON()
after validate()
is a way to be sure only valid data is sent forward:
getDataSomehow()
.then(MyEntityFactory)
.then(instance =>
instance.validate()
.toJSON())
Context-aware serialization
The defined contexts can also be used to build a raw JSON object. There is also the context
param of toJSON()
.
import { factoryFor } from 'speckoloo'
const schema = {
id: requiredString,
name: requiredString,
email: requiredEmail,
password: requiredString,
$contexts: {
create: {
$exclude: ['id'],
$modify: {
password: passwordStrengthChecker
}
},
login: {
$include: ['email', 'password']
}
}
}
const UserFactory = factoryFor(schema)
const user = UserFactory({
id: '1234'
name: 'SomeUser',
email: '[email protected]',
password: 'dummyPass@1234'
})
console.log(user.toJSON('login'))
Output:
{
name: 'SomeUser',
email: '[email protected]'
}
Type Checking
speckoloo
factories will optimistically try to coerce entities of compatible schemas through duck typing.
import { factoryFor } from 'speckoloo'
const mySchema1 = {
myProp1: {},
myProp2: {}
// missing myProp3
}
const mySchema2 = {
myProp1: {},
myProp2: {},
myProp3: {}
}
const myEntityFactory = factoryFor(mySchema1)
const anotherEntityFactory = factoryFor(mySchema2)
const anotherInstance = anotherEntityFactory({
myProp1: 'a',
myProp2: 'b',
myProp3: 'c'
})
// Will mostly work if the schemas are compatible:
const duckTypedInstance = myEntityFactory(anotherInstance)
duckTypedInstance.validate() // does not throw because myProp3 is not required
console.log(duckTypedInstance.toJSON()) // { myProp1: "a", myProp2: "b" }
For cases where duck typing is not desired, entities expose a constructor
property that point to the entity factory. The easiest way to check if a entity is an instance of a certain factory is by doing identity check (===
) in such property:
import { factoryFor } from 'speckoloo'
const mySchema = {
myProp1: {},
myProp2: {},
myProp3: {}
}
const myEntityFactory = factoryFor(mySchema)
const anotherEntityFactory = factoryFor(mySchema)
const instance = myEntityFactory({
myProp1: 'a',
myProp2: 'b',
myProp3: 'c'
})
const anotherInstance = anotherEntityFactory({
myProp1: 'a',
myProp2: 'b',
myProp3: 'c'
})
const duckTypedInstance = myEntityFactory(anotherInstance)
console.log(instance.constructor === myEntityFactory) // true
console.log(anotherInstance.constructor === myEntityFactory) // false (no duck typing here!)
console.log(duckTypedInstance.constructor === myEntityFactory) // true
Composite entities
When an entity contains a reference to other entity, to automatically convert raw data into a nested entity, use the factory
property on schema definition:
const childSchema = {
childProp1: {
validator: requiredString
},
childProp2: {
validator: requiredString
}
}
const ChildFactory = factoryFor(childSchema)
const parentSchema = {
prop1: {
validator: requiredString
}
child: {
factory: ChildFactory
}
}
const ParentFactory = factoryFor(parentSchema)
const instance = ParentFactory({
prop1: 1
child: {
childProp1: 'a',
childProp2: 'b'
}
})
In the example above, child
will be an entity created by ChildFactory
.
Composite entities serialization
When calling toJSON()
on a composite entity, it will automatically convert the nested entity by calling toJSON()
on it as well.
From the example above:
const instance = ParentFactory({
prop1: 1
child: {
childProp1: 'a',
childProp2: 'b'
}
})
console.log(instance.toJSON())
Will output:
{
prop1: 1
child: {
childProp1: 'a',
childProp2: 'b'
}
}
Composite entities validation
Since the default validator for any property is allowAny
, to make a composite entity validate the nested one requires an explicit validator that will delegate the validation process to the latest.
Since this is a rather common use case, speckoloo
provides a default validator called delegate
.
Redefining the parentSchema
above:
import { factoryFor, defaultValidators } from 'speckoloo'
// ...
const parentSchema = {
prop1: {
validator: requiredString
}
child: {
factory: ChildFactory,
validator: defaultValidators.delegate() // <--- Notice that `delegate` is a function!
}
}
//...
const instance = ParentFactory({
prop1: 1,
child: {
childProp1: 1,
childProp2: 2
}
})
instance.validate() // throws an error
Will throw:
{
name: 'ValidationError'
message: 'Validation Error!'
details: {
prop1: 'prop1 must be a string.',
child: {
childProp1: 'childProp1 must be a string.',
childProp1: 'childProp1 must be a string.'
}
}
}
It's possible to specify a context for the delegate
function, that will be forwarded to the nested entity validate()
method:
// ...
const parentSchema = {
prop1: {
validator: requiredString
}
child: {
factory: ChildFactory,
validator: defaultValidators.delegate('someContext')
}
}
// ...
instance.validate() // <--- Will call child.validate('someContext')
NOTICE: By default, delegate
will not validate the entity when data is missing.
const parentSchema = {
prop1: {
validator: requiredString
}
child: {
factory: ChildFactory,
validator: defaultValidators.delegate()
}
}
const parentFactory = factoryFor(parentSchema)
const instance = parentFactory({
prop1: 'a'
})
// ...
instance.validate() // <--- Will return the instance itself
To change this behavior, there is a required
param in options
that can be used:
const parentSchema = {
prop1: {
validator: requiredString
}
child: {
factory: ChildFactory,
validator: defaultValidators.delegate({ required: true }) // <----- changed here
}
}
const parentFactory = factoryFor(parentSchema)
const instance = parentFactory({
prop1: 'a'
})
// ...
instance.validate() // <--- Will throw
Output:
{
name: 'ValidationError',
message: 'Validation Error!',
details: {
child: '`child` is required'
}
}
It's also possible to combine both context
with options
. Use context
as first argument and options
as second:
defaultValidators.delegate('myContext', { required: true })
A common use case is validating the composite entity on context "A"
, in which the nested entity must be in context "B"
. In this case, delegate
can be combined with $modify
operator for context definition:
// ...
const parentSchema = {
prop1: {
validator: requiredString
}
child: {
factory: ChildFactory,
validator: defaultValidators.delegate()
},
$contexts: {
A: {
$modify: {
child: defaultValidators.delegate('B')
}
}
}
}
// ...
instance.validate('A') // <--- Will call child.validate('B')
Collection entities
Collection entities represent a list of individual entities. As an individual entity, they implement both Validatable
and JSONSerializable
.
It also implements the RandomAccessibleList
to allow an array-like access to its individual properties.
interface RandomAccessibleList {
at(n: Number) => Any
}
Furthermore, they implement the native ES6 Iterable
interface.
Example:
import { factoryFor, collectionFactoryFor } from 'speckoloo'
const entitySchema = {
myProp1: {
validator: requiredString
}
}
const entityFactory = factoryFor(entitySchema)
const collectionFactory = collectionFactoryFor(entityFactory)
const collection = collectionFactory([{
myProp1: 'a'
}, {
myProp1: 'b'
}])
Get an entity from a collection
Use the at
method:
console.log(collection.at(0).toJSON())
Output:
{ myProp1: 'a' }
Iterate over a collection
Collections can be iterated with a for..of
loop as regular arrays:
for (let entity of collection) {
console.log(entity.toJSON())
}
Output:
{ myProp1: 'a' }
{ myProp1: 'b' }
However, common array operations as map
, filter
or reduce
are not implemented. To use them, first convert a collection to a regular array using Array.from
:
console.log(Array.from(collection).map(entity => entity.myProp1))
Output:
['a', 'b']
Collection serialization
Calling toJSON
on a collection will generate a regular array containing plain JSON objects, created by calling toJSON
on each individual entity in the collection:
console.log(collection.toJSON())
Output:
[
{ myProp1: 'a' },
{ myProp1: 'b' }
]
When passing the optional context
parameter, its value will be used when calling toJSON
on each individual entity.
Collection validation
Calling validate
will return a reference to the collection itself if all entities are valid. Otherwise, it will throw an array of ValidationError
, containing the validation errors for the invalid entities:
import { factoryFor, collectionFactoryFor } from 'speckoloo'
const entitySchema = {
myProp1: {
validator: requiredString
}
}
const entityFactory = factoryFor(entitySchema)
const collectionFactory = collectionFactoryFor(entityFactory)
const collection = collectionFactory([{
myProp1: 1
}, {
myProp1: 'a'
}, {
myProp1: 2
}])
collection.validate() // <--- will throw!
Output:
{
'item#0': {
myProp1: 'Value myProp1 must be a string.'
},
'item#2': {
myProp1: 'Value myProp1 must be a string.'
}
}
The item#<n>
key indicates which of the entities in the collection are invalid.
Nested collections
It's possible to use collections as nested entitties for a composite entity. All it takes is put a collection factory into a factory
property from the schema.
Example:
const singleSchema = {
prop1: {},
prop2: {}
}
const singleFactory = factoryFor(singleSchema)
const collectionFactory = subject(singleFactory)
const compositeSchema = {
compositeProp1: {
validator: requiredString
},
nestedCollection: {
validator: defaultValidators.delegate(),
factory: collectionFactory
}
}
const compositeFactory = factoryFor(compositeSchema)
const instance = compositeFactory({
compositeProp1: 'x'
nestedCollection: [
{
prop1: 'a',
prop2: 'b'
},
{
prop1: 'c',
prop2: 'd'
}
]
})
instance.validate() // will call validate for each entity
Contributing
Feel free to open an issue, fork or create a PR.
speckoloo
uses StandardJS and I'm willing to keep it production dependency-free.
Before creating a PR, make sure you run:
npm run lint && npm run test
Some missing features:
- Support for getters and setters in schema definition.
- Support for general collection methods, such as
map
,filter
,reduce
, etc.