Adding a Reactor
1. Before You Start
Read Signal & Sweep Framework Architecture first. This page assumes you know what a signal, reactor, sweep and aggregate key are, and it will not re-explain them.
Then check Reactor Catalogue — your concern may already be described there, with a signal type and debounce window chosen.
1.1. Do you need a reactor at all?
| Situation | Use |
|---|---|
Work must happen soon after a domain change, and the change goes through application code |
A reactor with a signal handler |
Work must happen on a timetable, or must catch changes that bypass application code |
A reactor with a sweep |
Both — needs to be quick, but must never be missed |
A reactor with both. This is the common case. |
Work must complete before the caller’s request returns |
Not a reactor. Do it synchronously. |
Work is a multi-step, long-running business process with escalation and human steps |
Not a reactor on its own — a reactor that starts a Fluxnova process |
If your work must be transactionally atomic with the domain change, a reactor is the wrong tool. Signals are committed with the caller but processed later, in a different transaction.
2. Step 1 — Decide the Shape
Answer four questions before writing code.
What is the unit of work? This becomes the aggregate key. If you recalculate a whole series, the unit is the series and the key is series:{id} — not result:{id}, which would give you one recalculation per result.
How stale can the result be? This becomes the debounce window. Expensive work triggered by bursts wants a long window. A customer-facing message wants zero — squashing two payment confirmations means one customer never hears from you.
Can it run twice safely? It must. Sweeps re-run on schedule, signals are retried, and an operator can trigger a sweep by hand. If running twice causes damage, add a guard in your own code — the framework will not do it for you.
What happens if it never runs? If the answer is "someone notices eventually", a signal handler alone is fine. If the answer is "silent data corruption", you need a sweep as well.
3. Step 2 — Implement the Interface
Create the class in za.co.idealogic.event.admin.trigger.reactor:
@Component
public class ExampleReactor implements Reactor {
private static final Logger log = LoggerFactory.getLogger(ExampleReactor.class);
private static final String NAME = "example";
private final SignalSweepProperties properties;
private final ExampleDomainService domainService;
public ExampleReactor(SignalSweepProperties properties,
ExampleDomainService domainService) {
this.properties = properties;
this.domainService = domainService;
}
@Override
public String getName() {
return NAME; // matches ems.signal-sweep.reactors.example
}
@Override
public Set<String> getHandledSignalTypes() {
return Set.of("REGISTRATION_COMPLETED");
}
@Override
public void handleSignal(DomainSignal signal) {
Long orderId = signal.getEntityId();
log.debug("Reactor {} handling signal {} for order {}",
NAME, signal.getId(), orderId);
domainService.doTheWork(orderId);
}
@Override
public void sweep() {
// find outstanding work by inspecting domain state, then process it
}
@Override
public String getSweepCron() {
return properties.getReactor(NAME).getSweepCron();
}
}
Being a Spring bean is all the registration there is. There is no central list to edit.
For a sweep-only reactor return Set.of() from getHandledSignalTypes(). For a signal-only reactor return null from getSweepCron() and leave sweep() empty.
3.1. Rules for handleSignal
-
Read current entity state, not the payload, wherever correctness depends on it. Squashing overwrites the payload with the most recent change, so it is a hint, not a history.
-
Throw on failure. That is how you tell the framework to retry. Do not catch and swallow — a silently failing reactor is invisible.
-
Throw only for your own concern. Other reactors on the same signal are tracked separately and are unaffected by your failure.
-
Keep it short. You are on the Signal Master’s poll cycle. Long work belongs in a sweep, or should be broken into per-entity transactions.
3.2. Rules for sweep
-
Idempotent, always.
-
Bound the work. Page or batch rather than loading every candidate row.
-
Isolate failures per item. Process each entity in its own transaction so one bad row does not abort the batch. This is the same pattern the existing per-event batch assignment uses.
-
Log counters, not just completion: candidates found, processed, skipped, failed. These become the reactor’s operational signal.
4. Step 3 — Configure
Add the reactor’s subtree to application.yml:
ems:
signal-sweep:
reactors:
example:
enabled: true
sweep-cron: "0 */10 * * * *"
organisations: [] # tenants this reactor may act for; empty means NONE
# reactor-specific settings belong here too
dry-run: false
max-age-days: 30
organisations is empty by default and empty means none, so a new reactor is inert until an environment names its tenants. That is deliberate — see Organisation Scoping. Per-environment values belong in the deployment manifest, never in a profile file: application-prod.yml reaches stage and both production lines alike.
If your reactor touches nothing belonging to a tenant — it maintains framework tables only — override isOrganisationScoped() to return false and say why inline. Leaving the default of true on such a reactor would keep it permanently unable to run.
If you introduce a new signal type, give it a debounce window:
ems:
signal-sweep:
debounce:
MY_NEW_SIGNAL: 30s
Profile defaults:
-
application-dev.yml— off. A developer’s machine must not sweep a shared database or send email. -
application-prod.yml— on, except anything destructive, which starts disabled with a dry-run flag.
Anything that deletes data, sends a message, or calls an external system starts disabled and is enabled deliberately once verified.
5. Step 4 — Emit the Signal
Find the point in the domain service where the change is committed and emit there:
order.setStatus(OrderStatus.PAID);
orderRepository.save(order);
signalPublisher.emit(
"PAYMENT_COMPLETED", // signal type
"Order", // entity type
order.getId(), // entity id
"order:" + order.getId(), // aggregate key
order.getOrganisationId(), // organisation context, nullable
"{\"amount\":" + amount + "}" // optional JSON payload, nullable
);
Three things to get right:
-
Inside the caller’s transaction, after the change. If the transaction rolls back the signal must vanish with it.
emitis declaredPropagation.MANDATORY, so calling it without a transaction throws rather than quietly creating an orphan signal. If you hit that exception, fix the call site — do not widen the propagation. -
Not from an
AFTER_COMMITlistener or a new transaction. That reintroduces exactly the orphan-signal problem the MANDATORY propagation exists to prevent. -
Aggregate key at the reactor’s granularity, matching the convention in the catalogue.
Emitting is cheap and safe from any pod, and it is a single atomic statement — there is no race to handle and no exception to catch. Only processing is restricted to the leader.
Note that for a signal type configured with a zero debounce window, the aggregate key you pass is deliberately not stored — see the architecture page. Pass it anyway so the call site reads uniformly; the publisher decides based on configuration.
6. Step 5 — Test
Reactors are testable without the rest of the framework running.
Signal handling — construct a DomainSignal and call handleSignal directly. Assert the domain effect. Then call it twice and assert the effect is the same, proving idempotency.
Sweep — seed domain rows in each relevant state, run sweep(), and assert only the intended rows were touched. Include boundary cases explicitly: a row just inside the window, one just outside, and one in a state the sweep must never touch.
Emission — call the domain service and assert a row appears in signal_domain with the expected type, aggregate key and process_after. Then assert that a rolled-back transaction leaves none.
Squashing — emit twice in quick succession with the same key and assert there is one row with process_after pushed forward, not two rows.
Retry — make the reactor throw, run the processor, and assert signal_reactor_status shows FAILED with a next_retry_at. Assert that a second reactor on the same signal is unaffected.
Two cautions specific to this codebase:
-
Scheduling is disabled in test profiles. Drive the processor and sweeps directly from the test rather than waiting for a tick.
-
admin-service has no CI build or test gate on pull requests. Local
mvn verifyis the only check that runs. Run it before you push, and do not read a green pull request as evidence that anything was tested.
7. Step 6 — Verify in a Running System
With the service running, use the admin API (ROLE_ADMIN):
# Is my reactor registered, and is it enabled?
curl -s .../api/admin/signals/reactors
# Turn it on for this session without touching config
curl -X PUT .../api/admin/signals/reactors/example/enabled \
-H 'Content-Type: application/json' -d '{"enabled": true}'
# Run the sweep now instead of waiting for the cron
curl -X POST .../api/admin/signals/reactors/example/sweep
# What happened to a specific signal, per reactor?
curl -s .../api/admin/signals/{id}
See Management API Access for authentication against deployed environments.
8. Debugging
| Symptom | Where to look |
|---|---|
Signal never appears |
The emit is outside the transaction, or the transaction rolled back. Check the domain change actually committed. |
Signal appears but stays PENDING |
|
Signal goes straight to COMPLETED, nothing happened |
No reactor is registered for that signal type. Check the spelling in |
Reactor never fires but the signal completes |
The reactor is disabled. Check effective state in |
Signal stuck in PROCESSING |
A reactor hung, or the processor died mid-flight. |
Sweep never runs |
|
Work happens twice |
Your reactor is not idempotent. The framework will retry, re-sweep and failover — it does not promise exactly-once. |
9. Common Pitfalls
Treating the payload as authoritative. It is overwritten on squash. Read the entity.
Keying too finely. participant:{id} when the work is per-series gives you no coalescing at all.
Debouncing a communication. A non-zero window on a signal type that maps one-to-one to a customer message will eventually swallow one.
Assuming exactly-once. Retries, sweeps and failover all mean at-least-once. Design for it.
Catching your own exceptions. Swallowing a failure marks the reactor COMPLETED and the work is never retried.
Enabling a destructive reactor by default. Start disabled with a dry-run, prove the candidate set on real data, then enable.
Writing a sweep query that ignores the organisation set. The framework filters signal routing for you, and refuses to start a scoped sweep whose set is empty — but it cannot inspect your query. A sweep that selects across all tenants is a defect the framework cannot catch, so cover it with a test.
Overriding isOrganisationScoped() to false to make a reactor run. It is a statement that the reactor touches no tenant data, not a way past an empty list. If the reactor reads a shared table, the answer is to name its organisations.
Editing the Reactor interface. Two workstreams code against it. Changing it is a joint decision, not a unilateral edit.
10. Related Documentation
-
Development Workflow — branching and release process
-
GreenMail Email Server — for reactors that send email