React to events
Evolve services tell each other what happened through internal events: a category was created, a product was published, content changed. To run your own code when one arrives, give a module a handler and compose it into your service.
Internal events and vendor deliveries
Section titled “Internal events and vendor deliveries”A vendor tells you about a change in its own format. A commercetools subscription delivery, a Contentful webhook and a Storyblok webhook each arrive in the shape that vendor chose. The Evolve package for that vendor turns the delivery into an internal event and publishes it.
Your handler only receives internal events, never a vendor’s own payload. A
handler for product-category-created runs the same way no matter which
commerce engine published it.
Every internal event has a type, an origin (the service that published it)
and a timestamp.
Which events exist
Section titled “Which events exist”The schemas are in @evolve-framework/schemas, one file per type under
messages/events/, for example
@evolve-framework/schemas/messages/events/product-category-created.schema.json.
| Type | Carries | Published by | Handled in the framework by |
|---|---|---|---|
content-modified-event |
contentType (page or layout) and the changed paths |
ContentfulModule and StoryblokModule, from their CMS webhook |
No module |
product-category-created, product-category-updated |
id and the category as resource: id, key, name, path, description, ancestors |
commercetools | ContentfulModule, StoryblokModule and PayloadModule, which make sure a catalog page exists |
product-category-deleted |
id and resource with the id, key and path the category had |
commercetools | PayloadModule, which deletes the catalog page |
product-created, product-updated, product-published |
id and the product as resource: id, name, slug, store_keys |
commercetools | SearchIndexerModule, which updates the search index |
product-unpublished |
id and the product as resource |
commercetools | SearchIndexerModule, which removes the product from the index |
product-deleted |
id and resource with the product’s id, slug and store_keys |
commercetools | SearchIndexerModule, which removes the product from the index |
Only the commercetools package publishes catalog events today. It turns a
commercetools subscription delivery into these events with
createSubscriptionHandler.
It accepts both subscription formats, Platform and CloudEvents. Wire it to
the subscription’s destination the same way you wire a handler, as shown in
Deliver events to your handler.
SearchIndexerModule does not use the dispatch on this page. You pass
module.handler() to a queue entry point yourself. The table shows its default
routing, which you can change with its events option. See
SearchIndexerModule.
The schemas package also defines user notifications under
messages/notifications/. @evolve-framework/user-notifications queues them.
They are not dispatched to modules and this page does not cover them.
Handle an event
Section titled “Handle an event”Override getInternalEventConfig() in your module. handles lists the event
types the module answers to. handler runs for each of those events.
import { AbstractModule, type InternalEventConfig } from "@evolve-framework/core";import { z } from "zod";import { upsertFeedEntry } from "../feed.ts";
const categoryEvent = z.object({ type: z.enum(["product-category-created", "product-category-updated"]), id: z.string(), resource: z.object({ id: z.string(), name: z.record(z.string(), z.string()), }),});
export class CategoryFeedModule extends AbstractModule { override getInternalEventConfig(): InternalEventConfig { return { handles: ["product-category-created", "product-category-updated"], handler: async (event) => { const category = categoryEvent.parse(event); await upsertFeedEntry(category.resource.id, category.resource.name); }, }; }}The handler receives the event as a plain object, so validate it before you
use it. Then add the module to your service’s
CompositeModule:
const module = new CompositeModule([ // ...the modules your service already composes new CategoryFeedModule(),]);The composite collects every module’s config. Its handleInternalEvent
receives an event and runs the handler of each module that claims the event’s
type. The handlers run one after another, in composition order. That one
handler is your service’s whole subscription: you give it to the transport, as
shown in Deliver events to your handler.
Three rules for handlers
Section titled “Three rules for handlers”- Make your handler idempotent. Every transport delivers again after a failure. If two modules claim one type and the second one fails, the redelivery runs the first one again. Write by a key, such as the category id, instead of appending.
- An event that no module claims is dropped. It is logged and treated as
handled, so it does not come back. Check
handlesif your handler never runs. - Throw to have the event delivered again. A handler that throws stops the handlers after it and rejects, and the transport delivers the event again. Do not catch errors in your handler to keep the other handlers running. That marks a failed write as delivered.
Publish an event
Section titled “Publish an event”A module publishes through a
MessagePublisher. Take one
in the module’s options, so that each environment can pass its own. This
example turns a webhook from an ERP into an internal event:
import { AbstractModule, type HttpConfig } from "@evolve-framework/core";import type { MessagePublisher } from "@evolve-framework/core/messaging";
type ErpModuleOptions = { messagePublisher: MessagePublisher;};
export class ErpModule extends AbstractModule { private readonly messagePublisher: MessagePublisher;
constructor(options: ErpModuleOptions) { super(); this.messagePublisher = options.messagePublisher; }
override getHttpConfig(): HttpConfig { return { routes: (app) => { app.post("/erp/webhook", async (request, reply) => { const { sku } = request.body as { sku: string }; const event = { type: "stock-changed", origin: "erp", timestamp: new Date().toISOString(), sku, }; await this.messagePublisher.publish(event, { type: event.type, origin: event.origin, messageGroupId: sku, }); return reply.status(204).send(); }); }, }; }}publish()rejects when the provider does not accept the message. Let the error reach the vendor’s request, so that the vendor retries the delivery.- The publisher passes
typeandoriginto the provider next to the message, for example as SQS message attributes or as the EventBridgeDetailTypeandSource. - The publisher passes
messageGroupIdas the SQSMessageGroupId, the Pub/Sub ordering key or the Service Bus partition key. - You can use a type of your own, like
stock-changed. Give it thetype,originandtimestampthat every internal event has.
Which publisher to pass depends on the cloud. See Run on your cloud.
Deliver events to your handler
Section titled “Deliver events to your handler”Your handler is the same everywhere. What changes is the entry point that receives a message from the transport. Each cloud adapter package has one.
If the process that consumes events does not also run your Application, call
await module.init() once before you start consuming. Application normally
calls it for you.
Use a Redis list as the queue. Publish with
RedisPublisher and
consume with
startDevelopmentListener.
The target is redis://<host>:<port>/<queue>.
import { RedisPublisher, startDevelopmentListener } from "@evolve-framework/core/messaging";import { logger } from "@evolve-framework/core/observability/logging";
const eventsTarget = "redis://localhost:6379/internal-events";const messagePublisher = new RedisPublisher(eventsTarget);
void startDevelopmentListener(eventsTarget, module.handleInternalEvent, { continueOnError: true, dlq: `${eventsTarget}-dlq`,}).catch((error: unknown) => { logger.error({ msg: "Internal event listener stopped", err: error });});The listener takes a message off the queue before it runs your handler, so
nothing is delivered again. By default, a handler that throws stops the
listener. With continueOnError the listener logs the error and continues.
With dlq it also moves the message to that queue.
RedisPublisher logs a failed publish instead of rejecting. To turn publishing
off, pass a NoopPublisher,
which logs each message and drops it.
Use a Pub/Sub push subscription.
createPubSubHandler
starts its own Application, on its own port, with a POST route on path
and a GET /healthcheck. Point the push subscription at that route.
import { createPubSubHandler } from "@evolve-framework/cloud-adapter-gcp";import type { Message } from "@evolve-framework/core/messaging";
await module.init();await createPubSubHandler((event) => module.handleInternalEvent(event as Message)).start({ path: "/internal-events", httpHost: "0.0.0.0", httpPort: 8080,});The route decodes the base64 message.data and passes the published event to
your handler. It answers 200 when the handler succeeds and 500 when it
throws, so Pub/Sub delivers the message again. Without options, start()
listens on localhost:4000 at /events.
Use an SQS queue that triggers a Lambda function. Wrap the entry point in
lambdaHandlerFactory
for tracing.
When you publish with SqsPublisher, the message body is the event:
import { createSQSHandler, lambdaHandlerFactory } from "@evolve-framework/cloud-adapter-aws";
await module.init();export const handler = lambdaHandlerFactory("internal-events", () => createSQSHandler(module.handleInternalEvent),);When you publish with EventBridgePublisher and a rule sends the events to SQS,
the event is under detail. Pass a deserializer that unwraps it:
import { createSQSHandler, lambdaHandlerFactory } from "@evolve-framework/cloud-adapter-aws";import type { Message } from "@evolve-framework/core/messaging";
await module.init();export const handler = lambdaHandlerFactory("internal-events", () => createSQSHandler(module.handleInternalEvent, (message) => (message as { detail: Message }).detail),);createSQSHandler
handles the records of a batch in order. It rethrows the first error, so SQS
delivers the whole batch again, including the records that had succeeded.
To retry only the records that failed, use
createEventBridgeSQSHandler
for EventBridge events instead. It returns the failed records as
batchItemFailures, which SQS only uses when the event source mapping reports
batch item failures. It passes your handler the whole EventBridge event, so
call module.handleInternalEvent(event.detail as Message).
Use a Service Bus queue that triggers an Azure Function.
ServiceBusPublisher
puts the event under data, so pass a deserializer that unwraps it:
import { app } from "@azure/functions";import { createServiceBusHandler } from "@evolve-framework/cloud-adapter-azure";import type { Message } from "@evolve-framework/core/messaging";
await module.init();app.serviceBusQueue("internal-events", { connection: "ServiceBusConnection", queueName: "internal-events", handler: createServiceBusHandler( module.handleInternalEvent, (message) => (message as { data: Message }).data, ),});createServiceBusHandler
expects one message per invocation, which is the trigger’s default
cardinality. A handler that throws fails the invocation, so Service Bus
delivers the message again.
For Event Grid, use
createEventGridHandler.
It passes your handler the whole Event Grid event, so call
module.handleInternalEvent(event.data ?? {}). It catches a handler error and
returns a 500 result instead of rethrowing, so the invocation itself
succeeds. Use Service Bus where a failed event must be delivered again.
Test a handler
Section titled “Test a handler”handleInternalEvent is a plain async function, so you can call it in a unit
test without a transport. Test through a CompositeModule so that the test
also checks handles. This example uses Vitest:
import { CompositeModule } from "@evolve-framework/core";import { beforeEach, describe, expect, it, vi } from "vitest";import { upsertFeedEntry } from "../feed.ts";import { CategoryFeedModule } from "./category-feed.ts";
vi.mock("../feed.ts", () => ({ upsertFeedEntry: vi.fn() }));
const module = new CompositeModule([new CategoryFeedModule()]);
const created = { type: "product-category-created", origin: "commerce", timestamp: "2026-10-01T09:00:00.000Z", id: "category-1", resource: { id: "category-1", name: { "en-GB": "Bikes" }, path: { "en-GB": "bikes" } },};
describe("CategoryFeedModule", () => { beforeEach(() => { vi.clearAllMocks(); });
it("upserts a feed entry for a new category", async () => { await module.handleInternalEvent(created);
expect(upsertFeedEntry).toHaveBeenCalledWith("category-1", { "en-GB": "Bikes" }); });
it("ignores types it does not claim", async () => { await module.handleInternalEvent({ ...created, type: "product-created" });
expect(upsertFeedEntry).not.toHaveBeenCalled(); });});To check that your handler is idempotent, deliver the same event twice and assert on the end state.

