← Architecture
From modular monolith to services Design for the network

Communication between modules

Call when you need the answer. Announce when it already happened.

An order needs a price, stock, and a payment before the shop can say yes. It does not need an email to have been sent. In one process the two look the same, a function call each. On the day the mail provider is slow, only one of them makes the customer wait.

TypeScriptGoThe same shop, two wirings, two recorded builds.

01 / The prompt

“When an order is placed, send the customer a confirmation email.”

The shortest path from that sentence to working code is one import and one line in checkout: after the order is saved, send the email. It works on the first try, every test passes, and checkout has quietly taken on a new dependency. Its response time now includes the mail provider’s, and its failures now include the mail provider’s too.

In the lesson’s shop, wired that way, a provider that takes 4 seconds turns into a 500 for the customer after 2.0 s. By then the order had already been taken, and the sale was never counted.

Neither recorded agent in section 08 fell into that. Both kept the email out of the request, and one of them still let checkout take charge of the mail module.

02 / Name the shape

Two ways for modules to talk, for two different jobs.

A call asks another module for something and waits for the answer. An event announces something that already happened, and lets any module that cares react later. Calls couple the caller to the callee’s time and failures; that is the price of needing the answer. Events do not, and their price is that the reaction happens later, maybe twice, and somewhere else.

Call a module when you cannot continue without its answer. Announce an event when the thing has already happened, and let the modules that care react on their own time.

Here is every interaction in the shop, sorted that way.

Calls and events in the example shop
InteractionKindWhy
Orders asks Catalog for pricesCallThe total depends on the answer.
Orders reserves stock from InventoryCallNo stock means no order.
Orders charges PaymentsCallA declined card means no order.
Orders announces OrderPlacedEventCatalog counts the sale and Notifications emails the customer. The order stands either way.
Inventory announces StockLowEventNotifications emails purchasing. The commit already happened.

Words to put in a prompt or a review

Call
A request that waits for an answer, and fails when the callee does.
Event
A record of something that already happened, published for anyone to react to.
Temporal coupling
When one module can only work while another is up and fast.
At-least-once delivery
Every event arrives, and some arrive more than once.
Idempotent handler
A reaction that does its work once, however many times the event arrives.
Dead letter
An event whose handler kept failing, kept where a person can look at it.
Events are not freeWhat you give up

An event trades a failure you can see for work you have to track. The bestselling list is right a moment after the order, not at the moment of it. Nothing in Orders tells you who reacts to OrderPlaced, so that list has to be written down somewhere and kept true. A handler that runs twice has to notice. And an in-process bus keeps its queue in memory, so a crash loses what it had not delivered; Transactional outbox is the fix for that.

03 / Break the mail provider

Same shop, same order, two wirings. What does the customer see?

Both columns run the lesson’s shop. On the left, every reaction to the order runs inside checkout, the way a direct call would. On the right, reactions run from a queue after checkout answers. Watch four kinds of mail provider, then open Try it and pick one.

Communication between modules

Call it, or announce it?

The mail provider answers in 0.3 s

Checkout calls Notifications

Checkout announces OrderPlaced

01/ 04
Mail is fast

Mail is fast.

An order for two totes. Both wirings send the confirmation and the low-stock alert, and count the sale. Calling inside checkout costs the customer the time of two emails.

Reduced motion: choose a scene to see its completed state.

Read this scene

An order for two totes. Both wirings send the confirmation and the low-stock alert, and count the sale. Calling inside checkout costs the customer the time of two emails.

Checkout calls Notifications: .

Checkout announces OrderPlaced: .

Watch restarts the story when you come back. Step through keeps your step. Try it places the order again whenever you change the provider.

04 / Read the shape

An event contract, a module that calls and then announces, and one place that wires them.

Basic form is the bus. In the wild is Orders: three calls it cannot do without, then one announcement. At the call site is the composition root, where the subscriptions are made. Notice what Orders never imports.

The event bus contract: an event is plain data named for something that already happened, publish returns before anyone reacts, and a handler may run twice. Go declares the same interface.

TypeScriptReading
events/index.ts
/** Plain, JSON-safe, and named for something that already happened. */
export type Event = { id: string; type: string; data: Record<string, unknown> };

export type Handler = (event: Event) => Promise<void>;

export interface EventBus {
	/** Queues the event for every subscriber. Returns before any of them runs. */
	publish(event: Event): Promise<void>;
	/** Handlers may run more than once for the same event, so they must be idempotent. */
	subscribe(type: string, subscriber: string, handler: Handler): void;
}

export type DeadLetter = { eventId: string; subscriber: string; attempts: number };
GoAlongside
events/events.go
// Event is plain data, named for something that already happened.
type Event struct {
	ID   string         `json:"id"`
	Type string         `json:"type"`
	Data map[string]any `json:"data"`
}

// Handler may run more than once for the same event, so it must be idempotent.
// Returning an error asks for the delivery to be tried again.
type Handler func(ctx context.Context, event Event) error

type Bus interface {
	// Publish queues the event for every subscriber and returns before any runs.
	Publish(ctx context.Context, event Event) error
	Subscribe(eventType, subscriber string, handler Handler)
}

type DeadLetter struct {
	EventID    string `json:"eventId"`
	Subscriber string `json:"subscriber"`
	Attempts   int    `json:"attempts"`
}
Notifications, the only module that talks to the providerTwo idempotent handlers

Each handler uses the event id as the idempotency key, so an event delivered twice sends one email, and a failed send throws, which asks the bus to try again.

notifications/index.ts
import type { Event, EventBus } from '../events/index.ts';
import type { StockLow } from '../inventory/index.ts';
import type { OrderPlaced } from '../orders/index.ts';

// Notifications is the only module that talks to the mail provider. It reacts
// to events; nothing calls it, so a slow or failing provider cannot slow or
// fail an order.

export type Message = { to: string; subject: string; text: string; idempotencyKey: string };
export type SendResult =
	{ status: 'accepted'; id: string } | { status: 'failed'; reason: 'unavailable' | 'timed-out' };

/** The port a mail provider adapter implements. */
export interface Mailer {
	send(message: Message): Promise<SendResult>;
}

export function createNotifications({ mailer, bus }: { mailer: Mailer; bus: EventBus }) {
	// The event id is the idempotency key, so a redelivered event cannot send a second email.
	async function send(message: Message) {
		const result = await mailer.send(message);
		if (result.status === 'failed') throw new Error(`mail ${result.reason}`);
	}

	bus.subscribe('OrderPlaced', 'notifications.confirmation', async (event: Event) => {
		const order = event.data as OrderPlaced;
		await send({
			to: order.email,
			subject: `Your order ${order.orderId} is confirmed`,
			text: `Thanks for your order. Total: ${(order.total / 100).toFixed(2)}.`,
			idempotencyKey: event.id
		});
	});

	bus.subscribe('StockLow', 'notifications.stockLow', async (event: Event) => {
		const { sku, available } = event.data as StockLow;
		await send({
			to: 'purchasing@shop.example',
			subject: `Low stock: ${sku} (${available} left)`,
			text: `Reorder ${sku}.`,
			idempotencyKey: event.id
		});
	});
}
The mail provider adapterA timeout that cancels

The adapter takes its deadline from its config (timeoutMs; the story uses two seconds) and cancels the request after that. A timeout is reported as a failure, although a slow provider may still have sent the message, which is why the key matters.

notifications/mailers.ts
export function httpMailer(config: { baseUrl: string; key: string; timeoutMs: number }): Mailer {
	return {
		async send({ to, subject, text, idempotencyKey }): Promise<SendResult> {
			try {
				const response = await fetch(`${config.baseUrl}/v1/messages`, {
					method: 'POST',
					headers: {
						authorization: `Bearer ${config.key}`,
						'content-type': 'application/json',
						'idempotency-key': idempotencyKey
					},
					body: JSON.stringify({ to, subject, text }),
					// A deadline that cancels the request, so a slow provider costs a retry, not a stuck handler.
					signal: AbortSignal.timeout(config.timeoutMs)
				});
				if (response.status !== 202) return { status: 'failed', reason: 'unavailable' };
				const body = (await response.json()) as { id?: unknown };
				return typeof body.id === 'string'
					? { status: 'accepted', id: body.id }
					: { status: 'failed', reason: 'unavailable' };
			} catch (error) {
				const timedOut = error instanceof DOMException && error.name === 'TimeoutError';
				return { status: 'failed', reason: timedOut ? 'timed-out' : 'unavailable' };
			}
		}
	};
}
The in-process busQueue, retries, dead letters

Up to three attempts per subscriber, retries at the back of the queue, and dead letters for what never succeeds. A broker gives the same guarantees across processes; swapping one in changes this file.

events/index.ts
// The event bus. In-process today, with the guarantees a message broker gives,
// so moving to one changes this file and nothing that publishes or subscribes:
// delivery happens later, not inside publish, and a failed handler is tried again.

/** Plain, JSON-safe, and named for something that already happened. */
export type Event = { id: string; type: string; data: Record<string, unknown> };

export type Handler = (event: Event) => Promise<void>;

export interface EventBus {
	/** Queues the event for every subscriber. Returns before any of them runs. */
	publish(event: Event): Promise<void>;
	/** Handlers may run more than once for the same event, so they must be idempotent. */
	subscribe(type: string, subscriber: string, handler: Handler): void;
}

export type DeadLetter = { eventId: string; subscriber: string; attempts: number };

type Delivery = { event: Event; subscriber: string; handler: Handler; attempt: number };

export function createInProcessBus({ maxAttempts = 3 } = {}) {
	const subscriptions: { type: string; subscriber: string; handler: Handler }[] = [];
	const queue: Delivery[] = [];
	const dead: DeadLetter[] = [];

	return {
		async publish(event: Event) {
			// A copy, so a publisher that changes its object later changes nothing already sent.
			const sent: Event = JSON.parse(JSON.stringify(event));
			for (const subscription of subscriptions)
				if (subscription.type === sent.type)
					queue.push({ ...subscription, event: sent, attempt: 1 });
		},

		subscribe(type: string, subscriber: string, handler: Handler) {
			subscriptions.push({ type, subscriber, handler });
		},

		/** Delivers everything queued. A broker would do this continuously. */
		async drain() {
			while (queue.length > 0) {
				const delivery = queue.shift()!;
				try {
					await delivery.handler(structuredClone(delivery.event));
				} catch {
					if (delivery.attempt < maxAttempts)
						queue.push({ ...delivery, attempt: delivery.attempt + 1 });
					else
						dead.push({
							eventId: delivery.event.id,
							subscriber: delivery.subscriber,
							attempts: delivery.attempt
						});
				}
			}
		},

		pending: () => queue.length,
		/** Events that kept failing, kept where a person can see them. */
		deadLetters: (): DeadLetter[] => dead.map((letter) => ({ ...letter }))
	};
}

export type InProcessBus = ReturnType<typeof createInProcessBus>;
The behavior these examples promiseChecked by 7 shared scenarios, 42 steps
  • Placing an order publishes OrderPlaced; nothing reacts until the bus delivers it, and a declined or rejected order publishes nothing.
  • A delivery that fails is tried again, up to three attempts, then kept as a dead letter. The order stands either way.
  • An event delivered twice sends one email and counts one sale.
  • Stock that drops to 3 or fewer announces StockLow once, and again only after it has been back above 3.
  • An order without a valid email address is a bad request.

The expectations were written from these rules rather than copied from either implementation, and the TypeScript and Go tests both check every one.

Reading the TypeScriptawait, queues, and structuredClone

publish is async and returns once the event is queued; the handlers run in drain, which a broker would do continuously. Each handler gets a structuredClone of the event, so no subscriber can change what another sees.

Reading the Gohandlers that return errors

A Handler returns an error to ask for another attempt. Event data is a map[string]any, and each subscriber decodes only the fields it reads into its own struct, so Catalog and Notifications never import Orders’ types, and Go never sees an import cycle.

Run it yourselfNo dependencies

Save the complete files at the paths in their banners. Then run node --experimental-strip-types run.ts (Node 22.18 or later), or go run . in the Go folder. The provider fails the first two attempts, and both print:

placed order-1, emails sent so far: 0
sent to purchasing@shop.example: Low stock: tote (3 left)
sent to ada@example.com: Your order order-1 is confirmed
bestselling: tote (2 sold)
dead letters: 0

05 / Review the agent’s diff

“A mail outage can never fail an order.”

The try/catch is careful. Read what it still lets the mail provider decide.

The agent’s pull request

“Customers now get a confirmation email. It is wrapped in try/catch, so a mail outage can never fail an order. All tests pass.”

// orders/index.ts
			(added) import { sendOrderConfirmation } from '../notifications/index.ts';
			
			await inventory.commit(held.reservationId);
			const orderId = `order-${++placed}`;
			(added) try {
			(added)   await sendOrderConfirmation({ orderId, email, total });
			(added) } catch (error) {
			(added)   // Email must never fail an order.
			(added)   console.error('confirmation email failed', error);
			(added) }
			return { status: 'placed', orderId, total };
			
You are reviewing this change. What do you do?

06 / How it fails

A call spreads a failure to the caller. An event spreads it over time.

Failure modes of calls and events between modules
What goes wrongWhat happensWhat handles it
A slow dependency inside a callThe caller waits for it. In the story, checkout took 0.6 s with fast mail and 2.0 s with slow mail, against 0.0 s when the order was announced, because checkout answered before any email was tried.Take reactions out of the request path; give every remaining call a timeout.
A failure halfway through a call chainEarlier steps have committed and later ones never run: the order was taken, the customer saw an error, and the sale was not counted.Only what the answer depends on stays a call; the rest reacts to an event.
A failure that is caught and loggedCheckout survives and the work is gone, with a log line as its only trace.A queue that retries, and dead letters that someone watches.
An event delivered twiceA handler that is not idempotent sends a second email or counts a sale twice.Skip event ids already handled; pass the id as the provider’s idempotency key.
A timeout that was not a failureThe provider sent the message after the shop gave up. In the story’s slow chapter, the emails arrived and still ended as 2 dead letters.Idempotency keys make the retry safe; dead letters need a look before a replay.
Queued events lost in a crashAn in-process queue lives in memory. A deploy between the order and the email drops it.Write the event with the order, as a transactional outbox does.
A read that is not yet up to dateThe bestselling list changes when the event is handled, not when the order is placed.Decide which screens can lag, and say so in the contract.

In the story, with mail down for two attempts, the announced order’s emails went out (1 confirmation) and the called one’s did not (0). With mail down for good, the announced order still stood, and left 2 dead letters to look at.

07 / Is it worth it?

An event costs a bus, a queue to watch, and answers that arrive later.

The costs are real: an event bus and its retry policy, idempotency bookkeeping in every handler, a list of who reacts to what that nothing enforces, and screens that lag behind the data. For a reaction the caller truly needs, such as a stock reservation, an event only makes checkout guess.

So measure before moving work out of the request, and after:

  • Checkout time at the 95th percentile, with and without the side effects in the path.
  • Orders whose confirmation went out within five minutes, as a share of all orders.
  • Dead letters per day, and the age of the oldest. A number nobody looks at is a lost email with extra steps.
  • Idempotency hits at the provider: repeats it recognized and did not send.

The first is the baseline that says whether checkout got faster; the second says whether it got worse for the customer in a way the first cannot see. This lesson did not measure a real shop.

08 / Ask for it

One starting point, one email ticket, two prompts.

Two agents running Claude Sonnet each got a copy of the shop as the Module contracts run left it, and the same ticket: email a confirmation for every order and an alert when stock drops, without slowing or failing checkout, and without losing or repeating an email. One prompt added a Module communication block: calls only for answers, events for what already happened, an at-least-once bus, idempotent handlers, timeouts, failures kept visible, and a MESSAGES.md. A script ran both builds against a mail provider it controlled, with a fresh server for every question.

What the checker found, run 2026-09-14
QuestionPlain promptCommunication prompt
Checkout while mail takes 5 s201 in 3 ms201 in 1 ms
Checkout while mail stays down201 in 2 ms201 in 1 ms
Mail down for 5 s, then up: emails that arrived1 confirmation, 1 alert, by 7.0 s1 confirmation, 1 alert, by 5.2 s
Mail takes 5 s: emails that arrived, and attempts in 20 s1 confirmation, 1 alert; 8 attempts1 confirmation, 1 alert; 8 attempts
Mail stays down: attempts in 15 s20, none delivered26, none delivered
Mail requests without an idempotency key00
Low-stock alert once per dropYesYes
A declined order emails purchasingYes: the customer gets 402, purchasing gets an alertYes: the customer gets 402, purchasing gets an alert
Orders imports the mail codeorders/index.ts:12 → mail/index.tsNo
Tests38 of 38 pass, 10.1 s45 of 45 pass, 10.1 s
Code added or changed, not counting tests5 files, +240 −27 files, +325 −9

On everything the ticket measured, the two builds are the same. Both answer checkout in a few milliseconds whatever the provider does, both send every email with an idempotency key, and both get each email out exactly once after the provider recovers. The plain agent did not put the email in the request. It built a mail queue in its own mail module, held in memory and retried in the background with backoff.

The difference is who knows about whom. The plain build’s Orders imports that mail module, tells it about each order, and after every reservation reports each product’s stock, so the mail module can decide when stock is low. Inventory’s rule now lives in Mail, and checkout carries the numbers to it. In the communication build, Orders and Inventory publish events and import nothing that reacts; the mailer subscribes on its own.

orders/index.ts · plain prompt
+import { sendOrderConfirmation, notifyLowStock } from "../mail/index.ts";
   const reserveResult = reserveStock(stockItems);
   if (!reserveResult.ok) {
     return { status: "out-of-stock" };
   }
+  checkLowStock(skus);
 
   const paymentResult = charge(input.card, total);
   if (!paymentResult.approved) {
     releaseStock(reserveResult.reservationId);
+    checkLowStock(skus);
     return { status: "declined" };
   }
 
   const orderId = _nextId();
-  _save({ id: orderId, items: stockItems, total, card: input.card });
+  _save({ id: orderId, items: stockItems, total, card: input.card, email: input.email });
+  sendOrderConfirmation({ email: input.email, orderId, total });
orders/index.ts · communication prompt
+  // Fire-and-forget: publish() hands this off for async delivery and
+  // returns immediately, so a slow or down mail provider can never delay
+  // or fail this response — see mailer/index.ts and events/index.ts.
+  publish("order.created", { orderId, email: input.email, items: stockItems, total });
inventory/index.ts · communication prompt
+  const result = _reserve(items);
+  if (result.ok) {
+    for (const item of items) {
+      checkLowStock(item.sku);
+    }
+  }
+  return result;
+}

And both builds have the same bug. A declined card still emails purchasing that stock is low, because both check stock right after the reservation, before the payment, and the release that follows does not take the alert back. The communication build wrote this down in MESSAGES.md as a consequence of its design, which made a bug read like a decision. The lesson’s shop announces StockLow on commit, after the payment succeeds.

Neither build ever gives up on an email. With the provider down for good, the plain build made 20 attempts in 15 seconds and the communication build 26, with no end in sight. The communication build lists the failing ones at /admin/failing-events; the plain build lists them nowhere. Both keep the queue in memory, so a restart loses it.

How the runs were made and checkedTwo builds, recorded as written
  • The starting point is the Module contracts lesson’s recorded contracts build, byte for byte, with its checksums checked before the runs. Both agents were launched at the same time; neither knew about the other, the lesson, or the checker.
  • All builds are kept byte for byte with checksums and diffs. The checker runs its own mail provider, which can accept a message and answer late, fail for a window, or stay down, and records every request and its idempotency key.
  • The communication agent’s first commands ran in its default working directory, which was this repository: it listed the root folder before finding its own. It read and wrote nothing here. The plain agent wrote its fake mail provider and logs to the session’s scratch folder, outside its own, and deleted them.
  • The checker was first run on the plain build alone. Its second run counted the files that publish events with a pattern that missed a bare publish( call; the third run fixed it. All three are kept.
  • One run of each prompt is a sample, not a measurement of the model.

09 / Hold it there

Nothing in the code says “this must stay an event”. A check has to.

The next ticket that needs a reaction to an order will be tempted to add one more import to checkout. Three layers keep the wiring you chose.

  1. A rule on who may import whom

    Publishers do not import the modules that react to them, and only Notifications talks to the mail provider. The rules find nothing in the lesson’s shop, and its tests add a direct import from checkout and a module that reaches for the mail adapter, and each is caught. Enforcement layer runs rules like these on every agent change.

    rules.ts
    import type { Rule } from '../../enforcement-layer/examples/rules.ts';
    
    // Who may know about whom, written as rules for the engine Enforcement layer
    // runs. Paths are relative to the shop's root folder.
    
    export const communicationRules: Rule[] = [
    	{
    		name: 'publishers-do-not-import-reactors',
    		severity: 'error',
    		comment:
    			'Orders announces OrderPlaced and Inventory announces StockLow. Neither may import the modules that react.',
    		from: { path: '^(orders|inventory)/' },
    		to: { path: '^notifications/' }
    	},
    	{
    		name: 'only-notifications-talks-to-mail',
    		severity: 'error',
    		comment: 'The mail provider sits behind Notifications. No other module imports its adapters.',
    		from: { pathNot: '^(notifications/|app\\.ts$|run\\.ts$)' },
    		to: { path: '^notifications/mailers\\.ts$' }
    	}
    ];
  2. Tests that break the dependency

    Place an order with the provider slow, then down, and assert that checkout still answers quickly and the email still goes out once the provider recovers. That is exactly what this lesson’s checker does to the recorded builds.

  3. Dead letters someone sees

    Alert on the number of dead letters and the age of the oldest. A retry policy without that is a slower way to lose email.

Your click handlers already choose: call or announceAwaiting analytics before navigating is a call. The same click can announce instead.

Where it already is in your components

A checkout button that awaits an analytics call before moving to the confirmation page has made analytics part of checkout: when the analytics service is slow, the customer waits on a spinner for something they will never see. Placing the order is a call the page needs; tracking it is not.

When you have to own it

Once several features react to the same moment, the cart clearing, the header badge resetting, analytics recording, give the browser its own announcement: checkout announces “order placed”, and each feature subscribes on its own. Checkout imports none of them, and a listener that throws breaks nothing else.

A place-order button that waits for the order, and does not wait for analytics.

ReactAlready in your code
PlaceOrderButton.tsx
// Placing the order is a call: the page needs the answer before it can move on.
// Analytics is not: nothing on the next page depends on it, so the click does not
// wait for it, and a failing analytics service cannot keep a customer on this page.
type Analytics = { track(name: string, data: Record<string, unknown>): Promise<void> };

export default function PlaceOrderButton({
	cartId,
	analytics,
	onPlaced
}: {
	cartId: string;
	analytics: Analytics;
	onPlaced: (orderId: string) => void;
}) {
	async function placeOrder() {
		const response = await fetch('/api/orders', {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({ cartId })
		});
		if (!response.ok) return;
		const { orderId } = (await response.json()) as { orderId: string };
		// Not: await analytics.track(...) before moving on.
		void analytics.track('order_placed', { orderId }).catch(() => {});
		onPlaced(orderId);
	}

	return (
		<button type="button" onClick={placeOrder}>
			Place order
		</button>
	);
}

10 / Make the call

Keep what the answer depends on as calls. Announce the rest.

Keep a call when the caller cannot answer without it, and give it a timeout and a named failure. Turn it into an event when the thing has already happened and the reaction can come later: emails, counters, search indexes, audit logs, anything another team will want to hook into. Do not turn everything into events. A stock reservation sent as an event leaves checkout promising an order it cannot confirm.

An in-process bus is a good place to start in a modular monolith, because it teaches every module the guarantees a broker will give later. When a module becomes a service, its subscriptions move to a broker, and publishers change in one way: each event is written in the same transaction as the change it announces, so a crash cannot lose it.

Take it with you

Explain it without saying “event”: “Checkout only waits for the things that decide whether the order happens. Everything else is told afterwards, and keeps trying until it has done its part, once.” Then find the last thing your own checkout awaits, and ask whether the customer’s answer depends on it.

Paste into your next prompt, and fill in the blanks

For each interaction between modules, decide whether it is a call or an event, and write the decision down in MESSAGES.md.
Use a call only when the caller needs the answer to continue (<examples>). Use an event to announce something that already happened (<examples>).
Events are plain JSON-safe records with a unique id, a type, and data. Publish through one event bus whose interface a broker could implement later: delivery is asynchronous and at least once.
Every handler is idempotent: use the event id to skip work already done, and pass it as the idempotency key to any provider that takes one.
A module that publishes an event never imports the modules that react to it; add a check that fails the build if it does.
Every call to anything outside the process has a timeout. A failed handler is retried with backoff, and an event that keeps failing is kept as a dead letter a person can see.
Add tests that place an order with the provider slow and down, and assert that checkout still answers quickly and the work still happens.
Connections to follow nextRelated lessons

Take the shop into your editor. Add a loyalty-points module that reacts to OrderPlaced, and decide what it should do when the same order arrives twice.

Back to architecture →