Skip to content

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.

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.

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.

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.

  • 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 handles if 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.

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 type and origin to the provider next to the message, for example as SQS message attributes or as the EventBridge DetailType and Source.
  • The publisher passes messageGroupId as the SQS MessageGroupId, the Pub/Sub ordering key or the Service Bus partition key.
  • You can use a type of your own, like stock-changed. Give it the type, origin and timestamp that every internal event has.

Which publisher to pass depends on the cloud. See Run on your cloud.

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.

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.