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.
To install Yantrix, you can use NPM or Yarn:
npm install yantrix
or
yarn add yantrix
To work with the current version, you need Lerna. After cloning the repo, run
npm install
lerna bootstrap
npm build
That will produce Javascript module files along with d.ts
type definitions
Always create a new branch from main
with git checkout -b <your_contribution_name>
. Make the changes you want and then create a PR with them.
Yantrix suggests the following application model:
- Responsibility layers are built in accordance with a slightly adapted MVC approach
- an Event-Driven Architecture is used to communicate between layers of "Controller" part, with a globally available dictionary of
Events
, specific for the Application - "Controller" is composed of
Slices
, which are sets of interconnectedFSMs
(finite state machines), which communicate withEvents
and produceEffects
to update the "Model" - "View" part (including UI and external I/O) is updated asynchronously with a Render Loop
- I/O streams are non-duplex and are separated into
Sources
, which generateEvents
for "Controller", andDestinations
, which are updated when the "Model" has changed - "Model" component is a serializable (anemic) data structure (
Data Model
), which provides a single global store for the whole application, though it can and should be built with composition ofSlices
. It can be propagated to externalStorages
in an asynchronous Sync Loop - the Main Loop is taking
Events
from UI and I/O and repeatedly updates theData Model
andSlices
internal states based on their internal rules
erDiagram
DataModel ||..o{ Storage: "updates"
DataModel ||--o{ ModelPredicates: declares
DataModel ||..o{ UIComponent: "updates"
UIComponent ||..|{ EventStack: "emits Events"
EventDictionary ||--|{ EventMetaType: "is mapped to"
EventStack ||..o{ EventDictionary: "maps to"
FSM ||..|{ EventAdapter: references
FSM ||..o{ ActionPayloadType: references
FSM ||..o{ StateContextType: references
StorageAdapter ||..|{ Storage: "syncs with"
Slice ||..o{ EventDictionary: references
Slice ||--o{ ActionDictionary: declares
Slice ||--o{ FSM: "consists of"
Slice ||--o{ StateDictionary: declares
Slice ||--o{ StorageAdapter: declares
Slice ||--|{ EffectMatrix: declares
StateDictionary ||--o{ StateContextType: "is mapped to"
StateDictionary ||..|{ ContextPredicates: "declares"
ActionDictionary ||--o{ ActionPayloadType: "is mapped to"
Effect ||..|{ DataModel: updates
EffectMatrix ||--o{ Effect: declares
Application ||--|{ Slice: "consists of"
Application ||--o{ UIComponent: "is represented by"
Application ||--|{ DataModel: declares
DataModel ||..o{ Destinations: updates
Application ||--o{ Destinations: declares
EventAdapter ||..|{ EventStack: "translates Events"
Sources ||..|{ EventStack: "emits Events"
Application ||..o{ Sources: "declares"
All the App states are stored in a single anemic object structure, which is persisted between runs and deterministically describe the behavior of the App. Designing the proper Data Model
is the essential and the most important step to start laying out logic using Events
and Slices
.
Data Model
contract can be composited from Slices
, much like Redux Toolkit does
Event Dictionary
is an enumerable set of constants (Events
) that is shared throughout the App. Events
represent every significant atomic change in the App state and are the default way to propagate updates throughout the rest of the architecture. Every Event
type is associated with a particular type contract named Event Meta
, which is typically implemented as generic type TEventMetaType<TEventType>
. Event Meta
can be irrelevant for certain Event
types, in which case the null
value and type is used.
Slices
are independent parts of business logic layer, each having its own Effect Matrix
and a set of FSMs
. Slices
are a suggested way to chop the App logic into independent smaller pieces, which
- reduces the complexity of
Data Model
and provides a clear concern separation - enables for better performance and smart caching
- enables for smooth refactoring of the resulting App to microservices or micro-frontends if it gets too intertwined
The basic building block of state logic is a FSM
(more specifically - a Mealy Machine), which exposes a predefined Transition Matrix
that comprises the relations between States
and Actions
, representing the decision tree of the machine. Every Action
type can have a derived Payload
type, while every State
has a dependent Context
, and the latter two represent the current internal state of the machine.
Actions
/Payloads
and States
/Contexts
are enumerable values that can be composed of different Dictionaries, and can be reused independently on each other. It's perfectly fine to create several FSMs
that operate either on the same set of Actions
or States
, or both.
Unless FSM
includes an Event Adapater
, it would not accept or emit Events
into the Event Stack
and can only be controlled directly. However, in most cases it's desirable to connect it to the Event Stack
via a pub/sub mechanism, which contains asymmetrical Mapping Matrix
, that is responsible for:
- Casting handled
Events
intoActions
, including mapping ofEvent Meta
toPayload
- Producing
Events
fromState
changes, including mapping ofContext
toEvent Meta
The reason Event Adapter
is separated from FSM
is reusability. If two FSMs
share compatible contracts of Actions
and States
, they can use the same Event Adapter
too, if needed.
Predicates
are functions that return a Boolean value and are used to fork the flow of operations inside FSMs
. All Predicates
are high-order functions that allow compositing them. They come in three flavors:
Built-in Predicates
are used to combine otherPredicates
and implement logical operations likenot
,and
and so on.Model Predicates
have aData Model
as a dependency and are supposed to implement conditions that rely on the current state ofApplication
Context Predicates
are bound to a certainSlice
and itsState Dictionary
, and have aState
/Context
pair as a dependency. It's designed to create decision branching inside aTransition Matrix
Effects
are pure high-order functions that update Data Model
based on its current state and emitted Events
, very similar to the way FSMs
operate (and Redux's reducers). However, FSMs
cannot alter the Data Model
directly, locked inside their local scope, they can emit Events
through the Event Adapter
, which is mapped to a particular Effect
by the Effect Matrix
of the owning slice.
All the Effects
triggered by different slices are batched every iteration of Main Loop
, yielding exactly one (or none) Data Model
update regardless of how many FSM
transitions were performed.
Transformers
are projection-type functions that come with Slice
and translate the same types between each other. They can be:
Context Transformers
translateContexts
between each other. They are used insideTransition Matrix
to update the internal data of theFSM
when changingStates
. They are defined as a part ofState Dictionary
Reducer Transformers
translate fromState
+Action/Payload
toState/Context
Model Transformers
are a subtype ofEffects
which is context-free and is basically a function that mutates theData Model
. They can be composed withPredicates
to produceEffects
Generic Transformers
are built-in and user-defined pure functions that operate on any contract type and map the values. They are the basic building blocks of data manipulation.
Sources
and Destinations
are abstractions for, respectively, input and output channels of the App. They include, but not limited to:
- Internal Timers inside App
- Remote API calls with various protocols for backend Apps
- Hardware controls and UI interaction for frontend Apps
- Message brokers, like Kafka or RabbitMQ
- Network transports, like WebRTC or UDP streams
- Environmental calls, i.e. pipes, sockets, system clock, file system, OS or WEB APIs
Every particular kind of Source
or Destination
is represented by a corresponding class:
ISourceEmitter
forSource
channels, which allows to declare rules of publishingEvents
fromSource
. That could be done via subscription, long and short polling or by exposing hook methods to be used directly throughout the App, notably in frontendUI Components
and/or webserver routes.IDestinationGateway
forDestination
channels, which observes theData Model
and propagates the required changes into the target endpoint
Storage
is an adapter class to persist the Data Model
and to load its snapshot, like:
- LocalStorage for web apps
- in-memory key storages, like Redis
- Databases, like Mongo or Postgres
- Physical and cloud file systems
- Distributed storages like Blockchain or IPFS
The App can have multiple Storages
which can store different subsets of Data Model
. When the App starts, it polls all the Storages and integrates the received data into an initial Data Model
snapshot, using composition of Selectors.
Input streams (UI Components
and Sources
) and FSMs
are emitting Events
, that are put into a special LIFO structure, known as Event Stack
. It is processed continuously by the Main Loop
, which handles them one by one, always taking the last emitted Event
and passing it to all connected Slices
, and thus FSMs
sequenceDiagram
box rgba(25,0,25,0.25) [Representation Layer]<br/>~~~</br>UIs, APIs, Sockets, Timers
participant DST as Destinations
participant SRC as Sources
participant VL as UI
actor User as I/O
end
box rgba(0,25,0,0.25) [Business Logic Layer]<br/>~~~</br>Declarative Code, Language Models, GUI Editor
participant MT as Main Loop (Event Model)
participant EA as Event Adapter (Mapping Matrix)
participant FSM as FSM (Transition Matrix)
participant ED as Effect Dictionary(Reduction Matrix)
end
box rgba(50,0,0,0.25) [Data Model Layer]<br/>~~~</br>Memory, DBs, Cloud Storage,<br/> File System, Blockchain
participant MDL as Store (Anemic Data)
participant DB as Storages (Persistent)
end
User -->> VL: User input
DB -->> MDL: Sync application state on launch
Note over SRC, User: Events are emitted by Sources
VL -->> MT: TAutomataEventMetaType<eventId>
SRC -->> MT: TAutomataEventMetaType<eventId>
Note over MT: Incoming Events are pushed to Event Stack
loop Process Event Stack every 1/60s
Note over MT, ED: Pop Event from Event Stack
activate MT
MT ->>+ EA: TAutomataEventMetaType<eventId>
rect rgba(25,25,25,0.5)
Note over EA, FSM: Translates Events to Actions, as defined by Event Adapter
EA ->> FSM: TAutomataEventMetaType<eventId> => TAutomataActionPayload<actionId>
Note over FSM: Declarative Pure Function (Mealy Machine)<br/>Derives new Context based on incoming Actions and current Context
FSM ->> FSM: TAutomataStateContext<stateId>, TAutomataActionPayload<actionId> => TAutomataStateContext<stateId>
Note over EA, FSM: Translates Context to Event, as defined by Event Adapter
FSM ->> EA: TAutomataStateContext<stateId>
end
Note over EA, MT: Emits Events based on resolved local Context
EA ->>- MT: TAutomataStateContext<stateId> => TAutomataEventMetaType<eventId>
note over MT: Push Event to Event Stack
MT ->> MT: TAutomataEventMetaType<eventId>
rect rgba(25,25,25,0.5)
Note over MT, ED: Generate Effects as defined by Effect Matrix
MT ->> ED: TAutomataEventMetaType<eventId>
Note over ED, MDL: Update Model based on its current state and generated Events
ED ->> MDL: TAutomataEventMetaType<eventId>, Store => Store
MDL -->> DB: Sync to Storages
MDL ->> MT: Proceed to the newest Event in Event Stack
end
end
Note over MT: Store subscribers are updated
MT -->>+ DST: Update Destinations based on Model changes
MT -->>+ VL: Update UI based on Model changes
VL -->>- User: Render
DST -->>- User: API Calls