refactor: introduce domain events for feed side effects (Track E — point 5)

Light "collect + dispatch" variant: the Feed aggregate records FeedEvents
(FeedCreated, EmailIngested) on the mutations that have consequences, exposed via
pullEvents(). A new application dispatcher (feed-events.applyFeedEvents) maps
those events to their side effects — counters (awaited) plus WebSub pings and
favicon fetches handed to a BackgroundScheduler. This removes the inline,
scattered side effects from the ingest hot path (email-processor) and from
createFeedRecord; the aggregate is now the source of truth for "what happened".

Side effects with no aggregate mutation (rejected email, feed deletion bypassing
the aggregate, bulk admin ops, the cron, unsubscribes-sent) stay imperative by
design — there is no aggregate event for them to ride on.

BackgroundScheduler type moved to infrastructure/worker.ts (shared). CLAUDE.md
updated. 355 tests pass (+4 event tests); tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 13:12:42 +02:00
parent 46af982c40
commit b3d42f6c50
9 changed files with 146 additions and 27 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* Domain events the Feed aggregate records when it mutates. They describe *what
* happened* in business terms; the application layer decides which side effects
* to run (counters, WebSub pings, favicon caching) via a dispatcher. This keeps
* the aggregate ignorant of infrastructure and the orchestration code free of
* scattered, inline side effects.
*
* Only mutations that currently have side effects emit events — feed creation
* and email ingestion. Edits and removals carry no side effect, so they emit
* nothing. Side effects that don't flow through the aggregate (a rejected email,
* a feed deletion that bypasses the aggregate, bulk admin operations) stay
* outside this mechanism by design — they have no aggregate event to ride on.
*/
export type FeedEvent =
| { type: "FeedCreated" }
| { type: "EmailIngested"; iconDomain?: string };
+37
View File
@@ -173,6 +173,43 @@ describe("Feed.removeEmails", () => {
});
});
describe("Feed events", () => {
it("records FeedCreated on create and drains it once", () => {
const feed = Feed.create(FID, createInput());
expect(feed.pullEvents()).toEqual([{ type: "FeedCreated" }]);
// Draining clears: a second pull is empty.
expect(feed.pullEvents()).toEqual([]);
});
it("records EmailIngested (with icon domain) on ingest", () => {
const feed = Feed.reconstitute(
FID,
{ title: "T", language: "en", created_at: 0 },
{ emails: [] },
);
feed.ingest(entry({ key: "k" }), {
maxBytes: 1_000_000,
iconDomain: "example.com",
});
expect(feed.pullEvents()).toEqual([
{ type: "EmailIngested", iconDomain: "example.com" },
]);
});
it("emits no events for editDetails / edit / removeEmails", () => {
const feed = Feed.reconstitute(
FID,
{ title: "T", language: "en", created_at: 0, expires_at: 9_999_999_999 },
{ emails: [entry({ key: "k1" })] },
fixedClock(1000),
);
feed.editDetails({ title: "X" });
feed.edit({ description: "Y" }, { recomputeExpiry: false });
feed.removeEmails(["k1"]);
expect(feed.pullEvents()).toEqual([]);
});
});
describe("FeedRepository.load / save round-trip", () => {
it("persists a created feed and reflects later mutations", async () => {
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
+17 -1
View File
@@ -2,6 +2,7 @@ import { FeedConfig, FeedMetadata, EmailMetadata } from "../types";
import { FeedId } from "./value-objects/feed-id";
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
import { Clock, systemClock } from "./clock";
import { FeedEvent } from "./events";
import { resolveExpiresAt, isExpired, trimToByteBudget } from "./feed";
export interface CreateFeedInput {
@@ -65,6 +66,8 @@ export interface IngestOptions {
* concurrent writers (see email-processor.ts).
*/
export class Feed {
private readonly _events: FeedEvent[] = [];
private constructor(
readonly id: FeedId,
private _config: FeedConfig,
@@ -91,7 +94,9 @@ export class Feed {
updated_at: now,
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
};
return new Feed(id, config, { emails: [] }, clock);
const feed = new Feed(id, config, { emails: [] }, clock);
feed._events.push({ type: "FeedCreated" });
return feed;
}
/** Rebuild an aggregate from persisted state. */
@@ -112,6 +117,16 @@ export class Feed {
return this._metadata;
}
/**
* Drain the domain events recorded since the last pull. The application layer
* calls this after persisting and feeds them to a dispatcher that runs the
* side effects (counters, WebSub, favicon). Clearing on read keeps a long-lived
* aggregate from re-emitting.
*/
pullEvents(): FeedEvent[] {
return this._events.splice(0, this._events.length);
}
isExpired(now: number = this.clock.now()): boolean {
return isExpired(this._config, now);
}
@@ -145,6 +160,7 @@ export class Feed {
};
}
this._events.push({ type: "EmailIngested", iconDomain: opts.iconDomain });
return trimToByteBudget(this._metadata, opts.maxBytes);
}