What Is a Signal in Magnitude's event‑core and How Does It Differ from an Event?
A signal in Magnitude's event‑core is a strongly typed, ephemeral PubSub stream emitted exclusively by projections to communicate derived state changes, whereas events are durable domain facts that implement BaseEvent and persist to the EventSink for replay and hydration.
In the magnitudedev/magnitude repository, the event‑core package provides the foundational messaging infrastructure for building event-sourced systems. Understanding the distinction between signals and events is critical for designing projections that communicate efficiently without polluting the persistent event log.
Core Architectural Concepts
Magnitude's event‑core distinguishes between two fundamental notification types that serve different purposes in the event-sourced lifecycle.
What Defines an Event in event‑core
An event is an object that implements BaseEvent and contains a mandatory type string. Created by application code and published to the EventBusCore, events represent domain facts that must survive system restarts. By default, events are persisted to the EventSink unless explicitly marked with ephemeral: true, making them replay‑able during hydration. Events can be emitted from anywhere in the system—whether from UI components, external services, background workers, or even inside projection logic.
What Defines a Signal in event‑core
A signal begins as a SignalDef<T>—a lightweight descriptor containing only a name and a phantom type field defined in packages/event-core/src/signal/define.ts. When a projection is instantiated, this definition becomes a concrete Signal<T, TSourceState> via the internal fromDef function. Unlike events, signals are never persisted; they exist solely as ephemeral derived notifications for the current logical execution. Signals are emitted only by projections (via the emit helper) and are consumed through a PubSub mechanism managed by the projection bus.
Key Differences Between Signals and Events
The architectural separation between these notification types affects persistence, origin, and delivery semantics.
-
Persistence: Events default to durable storage in the
EventSinkunless flagged asephemeral. Signals are inherently transient and are never written to storage; they vanish after the current execution context completes. -
Origin: Events can be published from any layer of the application using
EventBusCore.publish(). Signals are strictly confined to projection handlers and are emitted using theemit(signal, value)helper provided inpackages/event-core/src/signal/define.ts. -
Purpose: Events record indisputable domain facts (e.g.,
UserCreated,OrderPlaced) that drive state changes across the system. Signals communicate derived, temporary information between projections or to workers (e.g.,stateChanged,counterTick) without contaminating the event log. -
Delivery Mechanism: Events flow through
EventBusCore→ProjectionBus→ (optional)EventSink→ PubSub broadcast. Signals are buffered during the signal‑flush phase ofProjectionBusand delivered synchronously only after all event handlers for the triggering event have completed. -
Type Safety: Events use a simple union type (
BaseEvent) with an optional flag. Signals leverage strong generics viaSignal<T, TSourceState>, whereTrepresents the value type inferred from the definition, andTSourceStateattaches the source projection's state type for enhanced type safety.
How Signals Work Under the Hood
Understanding the internal implementation of signals reveals why they provide efficient, loss‑tight communication between projections.
Signal Definition and Creation
In packages/event-core/src/signal/define.ts, signals originate as SignalDef<T> objects created via the create factory function:
export const tick = create<number>('counter.tick')
When a projection is defined via Projection.define, each SignalDef in the signals configuration array transforms into a Signal<T, TSourceState> instance. This concrete Signal class holds an Effect.Context.Tag that points to a PubSub specialized for that signal's type, enabling type‑safe publication and subscription.
The Signal Emission Flow
Inside a projection handler, calling emit(CounterSignals.tick, newValue) returns an Effect that publishes the value to the signal's PubSub. However, the framework does not immediately broadcast these values. Instead, ProjectionBus queues all signal emissions during event processing and executes a signal‑flush phase only after all handlers for the current event have finished, as implemented in packages/event-core/src/core/projection-bus.ts. This buffering ensures that downstream consumers receive signals only after the source projection's state has been fully synchronized.
Consuming Signals
Workers and other projections consume signals through the stream helper or higher‑level ProjectionBus APIs. Because signals travel through PubSub rather than the persistent event store, subscribers receive values instantly without I/O overhead. Workers typically subscribe using the onSignal configuration:
onSignal: {
[CounterSignals.tick]: ({ value }) => {
console.log('Received tick:', value)
}
}
Practical Example: Implementing a Counter with Signals
The following example demonstrates defining a signal, emitting it from a projection when state changes, and subscribing from a worker.
// 1. Define the signal descriptor
import { create } from '@magnitudedev/event-core/signal'
export const CounterSignals = {
tick: create<number>('counter.tick')
}
// 2. Emit the signal from a projection
import { emit } from '@magnitudedev/event-core/signal'
import { CounterSignals } from './counter-signals'
export const CounterProjection = define<{
state: number
signals: typeof CounterSignals
}>({
name: 'counter',
signals: CounterSignals,
handlers: {
on: {
increment: ({ state }) => {
const newValue = state + 1
emit(CounterSignals.tick, newValue)
return newValue
}
}
}
})
// 3. Subscribe from a worker
import { CounterSignals } from './counter-signals'
export const CounterWorker = defineWorker({
name: 'counter-worker',
handlers: {
onSignal: {
[CounterSignals.tick]: ({ value }) => {
console.log('Counter ticked to', value)
}
}
}
})
In packages/event-core/src/projection/define.ts, the projection's signals configuration binds the SignalDef to the projection context, while packages/event-core/src/worker/define.ts illustrates how workers listen via onSignal without needing to process the underlying events.
Summary
- Signals are ephemeral, events are durable: Signals exist only for the current execution, while events persist to the
EventSinkfor replay unless markedephemeral. - Different type systems: Signals use strongly typed
Signal<T, TSourceState>viaSignalDef, whereas events implement theBaseEventinterface with stringtypefields. - Emission restrictions: Only projections can emit signals using the
emithelper; events can be published from any system layer viaEventBusCore. - Architectural flow: Events traverse
EventBusCorefor persistence, while signals are buffered and flushed byProjectionBusafter event handlers complete. - Consumption model: Signals leverage
PubSubfor instant, non‑persistent delivery, making them ideal for derived state communication between projections and workers.
Frequently Asked Questions
Can signals be persisted for replay like events?
No. According to the source implementation in packages/event-core/src/core/projection-bus.ts, signals are explicitly designed as ephemeral derived notifications that exist only for the current logical execution. Unlike events, which EventBusCore writes to the EventSink unless marked ephemeral: true, signals are never persisted and cannot be replayed during system hydration.
Can any service emit a signal, or only projections?
Only projections can emit signals. The emit helper defined in packages/event-core/src/signal/define.ts is available exclusively within projection handler contexts. Events, conversely, can be published from anywhere—including UI components, services, workers, or within projection logic—using EventBusCore.publish().
How do I subscribe to a signal from a worker?
Workers subscribe to signals using the onSignal configuration object within defineWorker, mapping signal definitions to handler functions. Alternatively, you can use Signal.stream(signal) to access the underlying PubSub directly. Because signals are delivered via the PubSub mechanism implemented in packages/event-core/src/signal/define.ts, subscribers receive values instantly without waiting for persistence operations.
What happens if a projection emits multiple signals during one event handler?
All signal emissions during a single event processing cycle are buffered internally and flushed synchronously after all event handlers for that event have completed. This signal‑flush phase, orchestrated by ProjectionBus in packages/event-core/src/core/projection-bus.ts, ensures that signals are delivered atomically once the projection state has been fully updated, preventing race conditions in downstream consumers.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →