Listening to events
While services allow plugins to expose logic and data between themselves, events are how plugins are notified that something happened in one of their dependencies.
Why use events instead of methods ?
Using events allows you to leverage the Dependency Inversion Principle when defining your plugins. Let's say we have the following setup :
PersonPlugin
that registers aperson
service that tracks a list of personsNewPersonToastPlugin
that shows a notification to the user when a person is registered in theperson
service via atoast
servicePersonRemovedMailPlugin
that send a mail when a person is removed from theperson
serviceSyncPersonWithServerPlugin
that keeps the list of persons in the service in sync with a server backup
Without using events we would need to have the PersonPlugin
call a method of the toast
service added by NewPersonToastPlugin
whenever SyncPersonWithServerPlugin
adds a person to the list. We would have a dependency graph looking like :
PersonPlugin
depends onNewPersonToastPlugin
SyncPersonWithServerPlugin
depends onPersonPlugin
NewPersonToastPlugin
depends on nothing (assuming thetoast
service does not use types coming from theperson
service)
SyncPersonWithServerPlugin
└─▶ depends on PersonPlugin
├─▶ depends on NewPersonToastPlugin
└─▶ depends on PersonRemovedMailPlugin
If at a latter date we need to add a mail notification via a mail
service when a person is removed from the service we would need to add another dependency to PersonPlugin
. The issue here is that we would directly reference the mail
and toast
services and their methods, leading to strong coupling between our plugins.
Events allow us to reverse the dependencies of PersonPlugin
so that it does not need to know if something happens when a person is added or removed from its internal list. By emitting an event when a person is added and one where the person is removed it's possible to implement the logic of NewPersonToastPlugin
and PersonRemovedMailPlugin
without having any strong connexion between them make it possible to remove one or the other if they are not needed
The dependency graph would then look like :
SyncPersonWithServerPlugin
└─▶ depends on PersonPlugin
NewPersonToastPlugin
└─▶ depends on PersonPlugin
PersonRemovedMailPlugin
└─▶ depends on PersonPlugin
Adding a listener
Event listeners are added in a plugin, by passing a target, the name of an event and a handler function to the onEvent
function.
If we wanted to listen for the added
event on the person
service it would look like :
import { definePlugin } from '@zoram/core';
definePlugin(() => {
onEvent(
/* target */ 'person',
/* event */ 'added',
/* handler */ event => { /* show the toast */
}
);
});
Here we used the service's id to subscribe to it, under the hood it was resolved to the service instance in the application the current plugin is deployed in.
When to add a listener
Because onEvent
is integrated with a plugin's hook it needs to be used in the context of a plugin. This means you can only call onEvent
as part of a plugin's setup function or in a plugin's hook.
Multiple ways of identifying a target
Because both application, services and the plugin's internal code can emit events we might need to target things that can't be identified by a service id. For this reason onEvent
accepts different kinds of target identifier:
- direct emitter : an emitter object, you might have created it as part of you plugin's code or could have retrieved it in the application.
import { definePlugin } from '@zoram/core';
definePlugin(() => {
const myEmitter = emitter();
onEvent(myEmitter, 'added', event => {
/* do the thing */
});
});
- emitter container : an object that has an
emitter
property holding an emitter, because having to writeapp.emitter
ormyService.emitter
over and over is tedious and adds a lot of noise in your code.
import { definePlugin, onCreated } from '@zoram/core';
definePlugin(() => {
onCreated(app => {
onEvent(app, 'pluginRegistered', event => {
/* I'm a unicorn */
});
})
});
- direct emitter getter : a function that takes the current application instance and returns an emitter object.
import { definePlugin, onCreated } from '@zoram/core';
definePlugin(() => {
onEvent(app => app.services.person.emitter, 'added', event => {
/* new person in the place */
});
});
- emitter container getter : a function that takes the current application instance and returns an emitter container.
import { definePlugin, onCreated } from '@zoram/core';
definePlugin(() => {
onEvent(app => app.services.person.get('bob'), 'name_changed', event => {
/* hello boris */
});
});
- service id : the id of a service available in the application.
import { definePlugin, onCreated } from '@zoram/core';
definePlugin(() => {
onEvent('myService', 'myEvent', event => {
/* it happened ! */
});
});
Those multiple ways of getting a reference to a target aim's to provide as many tools as possible to make your code concise and remove boilerplate. While there is no preferred way of referencing a target, it is advised that you stay consistent in its use at least within a single plugin's code.
Listening to multiple events at once
You might find yourself in a situation where you want to listen to 2 or 3 events from an emitter and do the same thing with all of them, or listen to everything and do the routing yourself.
Listening to all events
If you need to listen to all the events of an emitter you can pass the wildcard event '*'
.
import { definePlugin, onCreated } from '@zoram/core';
definePlugin(() => {
onEvent('person', '*', (type, event) => {
/* runs for all events */
});
});
Note that the handler now takes 2 parameter, the name of each event
received and the event payload.
Listening to a set of events
If you need to listen to a predefined set of events you can pass them as an array.
import { definePlugin, onCreated } from '@zoram/core';
definePlugin(() => {
onEvent('person', [ 'added', 'removed' ], event => {
/* runs for selected events */
});
});
Note that the type of the payload will be restricted to the intersection
of the types of all the events listed. If you need to handle some events differently than other prefer registering their listeners independently.
Removing listeners
Listeners are automatically removed during the plugin's teardown
phase but if you need to remove them manually before that you can invoke the cleanup function returned by onEvent
.
import { definePlugin, onCreated } from '@zoram/core';
definePlugin(() => {
const cleanup = onEvent(/* ... */);
onEvent('otherService', 'event', () => cleanup());
});
The cleanup function is idempotent and safe, there is no issue with calling it multiple times, either from multiple event listeners or from multiple instances of the same event, so you don't need to keep additional logic to prevent subsequent calls.
Dealing with errors in listeners
If a listener came to throw an error or invoke a function itself throws, the error would be caught by onEvent
to make sure it doesn't interfere with other even listeners that might share the same target and event. This means that an action leading to the dispatching of an event will always succeed, but it also means your code might end up in an invalid state.
It is advised that you deal with the error as close as possible to its source to avoid it being caught by zoram for you.
In dev mode error caught in that way will be pretty printed in the
console alongside the application's and plugin's id.