Yantrix
Yantrix is a TypeScript framework that provides a set of tools to create robust and self-documented functional applications by code generation. The business logic is represented by declarative, event-driven finite state machines, while the application state is an Anemic Domain Model, making it great a counterpart to any traditional state manager like Redux, while allowing devs to focus on describing contracts and workflows, rather than writing and debugging the actual code.
Lends itself perfectly to Architecture-as-Code paradigm and no-code/less-code tools for developers, like n8n.
Quick Start
Let's build a slider component. First, we need a diagram for a State Machine describing its behaviour. The diagram is written in Yantrix subsyntax of Mermaid and can be visualized directly with that awesome library. Click on <>
button to see the source code:
The details of what's going on here are explained below. For now, assume it's saved to a file named slider.mermaid
. Then, we pass that source to a code generator and choose a language we need
shell
$ yantrix codegen ./slider.mermaid --outfile slider_controller.js --language JavaScript --className Slider
Now, we import the generated file into your projects:
javascript
import Slider from './slider_controller.js';
const SLIDER_MAX = 1000;
const SLIDER_MIN = -1000;
const SliderController = new Slider();
SliderController.dispatch(
Slider.createAction('RESET', {
min: SLIDER_MIN,
max: SLIDER_MAX,
}),
); // adjusted to local needs
/*
* ...somewhere later on at UI ...
*/
// bind these to buttons and/or keys
const rightHandler = () => SliderController.dispatch(Slider.createAction('INCREASE', { by: 50 }));
const leftHandler = () => SliderController.dispatch(Slider.createAction('DECREASE', { by: 50 }));
// bind this to a click handler, using relative coordinate
const setHandler = percentage =>
SliderController.dispatch(
Slider.createAction('SET', {
value: SLIDER_MIN + percentage * (SLIDER_MAX - SLIDER_MIN),
}),
);
// use this to display value in the GUI
const getSliderValue = () => SliderController.getContext().value;
// and this - to prevent UI from changing the slider value
const isDisabled = () => Slider.hasState(SliderConroller, 'DISABLED');
Or you can use one of the available Integrations with a framework of your choice, i.e. Redux
Syntax Breakdown
Let's see what's happening in depth. The diagram describes an FSM
, — a finite state machine, a control object that is characterized by a limited set of States
, and it always resides in one and only one of them.
States
Every State
is independent on another and difference between States
is qualitative, i.e. they represent some scenarios in App that are independent on each other and never happen simultaneously:
- Slider is usable and idle (enabled)
- Slider can't be interacted with (disabled)
- Slider is being switched to a particular position
- Slider is being nudged left or right
Actions
To change the State
of the machine, an Action
must be invoked. Every Action
has a unique name, and the first layer of diagram is essentially a graph that depicts which Actions
lead to which States
.
Also note that not every Action
can be invoked from every State
by default. A [*]
node is used to define an Action
that can be invoked from every State
. The binding between States
and Action
define a many-to-many relation, called Transition Matrix
, that is another way to define the FSM
. Every item in this matrix is either null or an Action
name, while columns and rows correspond to States
in the same order.
From/To | ENABLED | DISABLED |
---|---|---|
any | RESET | |
ENABLED | DISABLE | |
DISABLED | ENABLE |
Payload
Every Action
can carry a Payload
, which is a plain data object that represents some meta-information about the change requested by it. For instance, when resetting (instantiating) a Slider, it's wise to provide its min and max values. Payload
is represented with a list of parameters in parentheses, each possible having a default value:
Technically, nudging Slider can be processed the same way as setting it directly, by an only Action
, carrying a polymoprhic Payload
, allowing to either adjust value or to set it directly:
However, different behaviours are better to be represented with different States
.
Intermediary States
While FSM
is transactional and synchronous (i.e. no further computation is done until the invoked Action
has been processed), most interactions are asynchronous by nature, and they have some in-between state, usually called "pending" in this context.
In fact, even synchronous operations could have an intermediate State
which actually does computations, and this would be an advised design pattern. As you can guess, those operations then could be easily plugged in with async functionality, behaving as a Promise.
here we define 3 Actions
and 3 corresponding intermediate States
for them:
SET (value)
– a direct assignment of the Slider value, leading to SETTINGState
and carrying new value inPayload
INCREASE (by)
– an increment of the Slider value, leading to INCREMENTState
and carrying a difference inPayload
DECREASE (by)
– a decrement of the Slider value, leading to DECREMENTState
and carrying a difference inPayload
Here we use [-]
labels on Actions
, which is a special symbol for ByPass Action
. Now, to emphasize that we need to add Notes to States
to describe their behaviour:
Our Transition Matrix now looks like this:
From/To | ENABLED | DISABLED | INCREMENT | DECREMENT | SETTING |
---|---|---|---|---|---|
any | RESET | ||||
ENABLED | DISABLE | INCREASE | DECREASE | SET | |
DISABLED | ENABLE | ||||
INCREMENT | INCREASE | ||||
DECREMENT | DECREASE | ||||
SETTING | SET |
Context
Now, we need to define Context
— values that are stored within FSM
along with the current State
and represent some of its quantitative properties. We define it with Reducers along with State
, and they are calculated whenever the FSM
is switched to that State
. The Reducers
defined at [*]
node are run on every transition, and the Context
properties found there are persistent through every Action
, unless specified otherwise.
here we have defined 3 Context
properties, that are copied from the previous values with every Action
:
- value stores current Slider position
- min stores minimal selectable value
- max stores maximal selectable value
Initial Context
When RESET Action
is invoked, a new min and max values can be set with Payload
. Since that Action
resolves to ENABLED State
, that's where the related Reducer
belongs:
Here we have also specified that ENABLED is the starting State
of the FSM
with Init flag.
Operations on data
Now we specify the arithmetics for intermediate "nudge" States
INCREMENT and DECREMENT, which will add or substract, respectively, the provided Payload
property by to/from stored value in Context
:
Note that we use built-in Transformer Functions min
,max
,add
and diff
here in such a manner, that the resulting value always stays within min and max bounds.
Finally, we write a simple assignment Reducer
for SETTING intermediate State
, that will copy value from Paylod
to Context
, and we are done:
Now, it's only about dispatching proper Payloads
to the FSM
.
What's next?
- See Sample FSM Designs and generate some code
- Learn Yantrix Syntax and try making an
FSM
of your own - Try some Integrations with your favorite frameworks to achieve fast results
- Read more on framework architecture and how to expand beyond one
FSM
intoSlice
s and built a wholeApplication
on Yantrix - If you like what you get, consider Contributing or resolving some Issues