@coriolis/coriolis
v1.0.0-alpha5
Published
Event sourced effect management
Downloads
10
Maintainers
Readme
Coriolis
English documentation coming soon, any help welcome :wink:
Qu'est-ce que c'est ?
Coriolis est une librairie Javascript permettant de mettre en place un store d'events alimentant des effets s'appuyant sur des projections (une projection est un état déduit de différents events)
Cette librairie vous aidera à créer vos applications selon les concepts d'Event Sourcing et de Domain Driven Design.
Cette approche aide à obtenir une application au comportement prédictible, modulaire, évolutif et debuggable. Elle permet entre autre de distinguer proprement différentes typologies de logiques:
- organisation des données
- comportement
- interface utilisateur ...
Influences
La conception de Coriolis a été inspirée par Redux, en cherchant à donner le rôle de single source of truth non pas au state mais au flux d'events, et ainsi rejoindre le concept d'Event Sourcing.
Installation
:information_source: Le cycle de vie respectera (dés la periode alpha finie) la logique de version semver
Pour installer Coriolis:
npm install --save @coriolis/coriolis
Le module est fourni sous deux formes: CommonJS ou ES modules.
ESModule:
// {!examples/count-esmodule/entry.js}
import { createStore } from '@coriolis/coriolis'
const currentCount = ({ useState, useEvent }) => (
useState(0),
useEvent(),
(state, event) => {
switch (event.type) {
case 'incremented':
return state + 1
case 'decremented':
return state - 1
default:
return state
}
}
)
createStore(({ withProjection, dispatch }) => {
withProjection(currentCount).subscribe((count) => console.log(count))
// 0
dispatch({ type: 'incremented' })
// 1
dispatch({ type: 'incremented' })
// 2
dispatch({ type: 'decremented' })
// 1
})
CommonJS:
// {!examples/count-comonjs/entry.js}
const { createStore } = require('@coriolis/coriolis')
const currentCount = ({ useState, useEvent }) => (
useState(0),
useEvent(),
(state, event) => {
switch (event.type) {
case 'incremented':
return state + 1
case 'decremented':
return state - 1
default:
return state
}
}
)
createStore(({ withProjection, dispatch }) => {
withProjection(currentCount).subscribe((count) => console.log(count))
// 0
dispatch({ type: 'incremented' })
// 1
dispatch({ type: 'incremented' })
// 2
dispatch({ type: 'decremented' })
// 1
})
Utilisation
Définition d'un event
Un event doit représenter de manière factuelle une variation ayant eu lieu dans l'application. Ces informations factuelles sont donc immuables (ce fait a eu lieu, cela ne peut pas changer). L'accumulation de ces faits immuables sera la source de toute vérité dans notre application et garantira une lisibilité et une grande capacité d'évolution.
:joke_icon: Un event pourrait aussi être désigné comme un fait. Event sourcing pourrait être traduit en français par programation par le fait.
Étant donné qu'un event représente une variation ayant eu lieu, il est préférable de toujours nommer les events sous forme d'un verbe au passé.
Un event est un simple objet respectant les critères suivant:
- un type
- une valeur utile, ou "payload" (optionel)
- des méta-données (optionel)
- un indicatif d'erreur booléen (si true, le payload devrait être l'erreur correspondant)
Voici donc des events valide:
const minimum = { type: 'sent a minimal event' }
const simple = {
type: 'sent a simple event',
payload: 'simple'
}
const simpleError = {
type: 'sent a simple event',
payload: new Error('Could not be that simple'),
error: true
}
const withMeta = {
type: 'sent a simple event',
payload: 'answer me if you got it'
meta: {
// note that using meta for this data could be a wrong idea, let's keep this place only for meta-data
sender: 'Nico'
}
}
Très simple. Mais il vous sera rapidement necessaire de créer des fonctions de création d'event, de pouvoir référencer les types de ces events, de parametrer la définition du payload ou des meta de ces events.
Vous aurez donc besoin de createEventBuilder
:
// {!examples/readme-samples/events.js}
import { createEventBuilder } from '@coriolis/coriolis'
export const createMinimumEvent = createEventBuilder('sent a minimal event')
export const createSimpleEvent = createEventBuilder(
'sent a simple event',
({ message }) => message,
({ sender }) => sender && { sender },
)
export const incremented = createEventBuilder('user incremented count')
export const decremented = createEventBuilder('user decremented count')
createMinimumEvent()
// {
// type: 'sent a minimal event'
// }
createSimpleEvent({ message: 'simple' })
// {
// type: 'sent a simple event',
// payload: 'simple'
// }
createSimpleEvent({ message: new Error('Could not be that simple') })
// {
// type: 'sent a simple event',
// payload: <Error: 'Could not be that simple'>,
// error: true
// }
createSimpleEvent({
message: 'answer me if you got it',
sender: 'Nico',
})
// {
// type: 'sent a simple event',
// payload: 'answer me if you got it',
// meta: {
// sender: 'Nico'
// }
// }
createMinimumEvent.toString()
// 'sent a minimal event'
createSimpleEvent.toString()
// 'sent a simple event'
Les builder d'event créés par createEventBuilder
exposent le type d'event associé via la méthode toString()
. Il est donc facile d'utiliser ces types d'events dans une projection par exemple.
createEventBuilder
est très fortement inspiré de redux-actions
Définition d'une commande
Nous avons vu la création d'objets events. Il faut maintenant s'interesser aux règles métier aboutissant à la création de ces events.
L'exemple classique est lors d'une action utilisateur: Avant d'aboutir à un event, il est surement necessaire de faire quelques validations. Ces validations définissent les contraintes métier que vous souhaitez appliquer via votre application, c'est donc une part essentiel de votre code source.
Il est important d'écrire ces règles de manière clair et isolé. C'est le rôle des commandes.
Une comande est une simple fonction destinée à créer (ou non) des events. Cette fonction peut être asynchrone (peut retourner une promesse ou un Observable) et abouti à un ou plusieurs event (ou commande).
// {!examples/readme-samples/commands.js}
import { incremented } from './events'
import { currentCount } from './projections'
const arrayOf = (length, builder) => Array.from({ length }).map(builder)
export const double = ({ getProjectionValue }) => {
const count = getProjectionValue(currentCount)
return arrayOf(count, incremented)
}
Une commande sera exécutée en respectant l'ordre d'émission dans le flux d'events. Donc les events émis en synchrone par une commande se positionneront à la place de cette commande dans le flux d'events.
API des commandes
Lors de son exécution, une commande recevra en paramètre les fonctions suivantes:
- getProjectionValue(projection): cette fonction retourne la valeur courante de la projection donnée
- addEffect(effect): cette fonction permet d'activer un effet par le biais d'une commande (voir definition des effets plus bas)
Définition d'une projection
Une projection permet de recueillir des données provenant des events afin de disposer de toutes les données nécessaire pour ensuite définir les comportements de votre application dans les effets.
On défini dans un premier temps les sources de données nécessaires à la projection:
- useState: utiliser la dernière valeur obtenue par cette projection (on peut spécifier une valeur initiale)
- useEvent: utiliser le dernier événement émit (on peut filtrer quels types d'événements on souhaite traiter)
- useProjection/lazyProjection: utiliser la valeur obtenue d'une projection (voir détails plus loin)
- useValue: Utiliser une valeur static (cela est surtout utile pour étendre l'API, voir projections parametrées)
- setName: Attribue un nom à la projection, dans un but de debug et de lisibilité
Ensuite on défini l'algorythme de rangement des données en utilisant ces sources de données.
Ce code est incorrecte mais présente le format de définition d'une projection:
const projection = ({ setName, useState, useEvent, useProjection }) => (
setName('A custom name for this projection'), // Naming a projection is optional, usefull sometimes for debug
useState({}), // Using a state is not mandatory, but it is usually necessary
useEvent(), // You'll need to get events in at least one projection
useProjection(anyProjection), // other projections are aggregating great states, let's use those
// Here comes the function that defines the projection
// This function will receive all we defined above
(state, event, anyProjectionCurrentValue) => {
// Build a great data structure with data I got
// and return it as the current value of that projection
}
)
Petits exemples de projections de différents types:
// {!examples/readme-samples/projections.js}
import { incremented, decremented } from './events'
export const currentCount = ({ useState, useEvent }) => (
// Initial value for state should be defined here
useState(0),
// Here we filter events we will get
useEvent(incremented, decremented),
(count, { type }) => (type === incremented.toString() ? count + 1 : count - 1)
)
export const eventsNumber = ({ useState, useEvent }) => (
// Let's start counting from 0
useState(0),
// needs each event just to trigger the projection
useEvent(),
(state) => state + 1
)
export const lastEventType = ({ useEvent }) => (
// For this projection, no need for a state, just events
useEvent(), (event) => event.type
)
export const moreComplexProjection = ({ useProjection }) => (
useProjection(currentCount),
useProjection(eventsNumber),
useProjection(lastEventType),
(currentCountValue, eventsNumber, lastType) => ({
currentCountValue,
eventsNumber,
lastType,
})
)
Il faudrait ici expliquer le choix du format de définition des fonctions de projection. Ça viendra bientôt.
Définition d'un effet
Un effet est défini par une fonction recevant en paramètre les outils suivant:
- addSource
- addLogger
- addEventEnhancer
- addPastEventEnhancer
- addAllEventsEnhancer
- pastEvent$
- event$
- dispatch
- withProjection
- addEffect
Cette fonction sera en charge de définir le comportement de l'application en fonction des events et des projections qu'elle utilisera.
// {!examples/readme-samples/effects.js}
import { currentCount } from './projections'
import { incremented, decremented } from './events'
import { double } from './commands'
export const myDisplayEffect = ({ withProjection }) => {
withProjection(currentCount).subscribe(
(count) => console.log('Current count', count),
// Immediately logs "Current count 0", than other count values on each change
)
}
export const myUserEffect = ({ dispatch }) => {
dispatch(incremented())
// Current count 1
dispatch(incremented())
// Current count 2
dispatch(double)
// Current count 3
// Current count 4
dispatch(decremented())
// Current count 3
}
Motivations
Une motivation majeur avec Coriolis est d'aider à construire un code d'application lisible, en cherchant à se focaliser sur l'expression des logiques du domaine métier. Cela se manifeste à plusieurs niveaux:
La définition d'une projection est réduite à sa plus simple expression: de quoi elle a besoin et la logique de rangement des données.
La définition d'un effet peut faire appel à d'autres effets, favorisant ainsi une construction modulaire.
La définition d'un effet a accès directement à toute projection et tout event (passé ou nouveau), et peut invoquer des events passés, déclarer de nouveaux events, appliquer des stratégies de stockage d'events et ajouter d'autres effets