Thinking through a design
Understanding why a system behaves a certain way does not automatically tell us how it should change. A slow audit write can explain why a notification worker remained busy long enough for the same message to reach another worker. Several changes might reduce those duplicate sends. They differ in what they protect against and what can happen when something fails.
This is where an AI conversation can become useful for design. The assistant can help examine alternatives, follow their consequences, and expose assumptions that deserve a decision. The quality of that discussion depends on the problem and constraints available to it. A detailed implementation can still solve a narrower problem than the team intended.
The requirement is still a decision #
We traced a worker that sends an email, records an audit entry, and then acknowledges the queued message. A delay between sending and acknowledgement allows another delivery to overlap with the first. The same notification can therefore reach the email provider more than once.
“Prevent duplicate notifications” sounds like a complete requirement. It leaves an important question unresolved: what should happen when the application cannot determine whether the provider accepted the first request?
For a routine status update, the product owner might prefer a possible duplicate over a missed message. Another notification might be disruptive enough that an uncertain outcome should require investigation before another send. A technically convenient retry policy makes that choice on the team's behalf unless the tradeoff is discussed explicitly.
The identity of a duplicate also needs definition. Two queue deliveries may represent one intended notification. Two separate events may legitimately produce emails with identical content. A customer may request a resend. Comparing recipient addresses and message bodies would treat some of these cases as equivalent even though the product considers them different.
A productive conversation can begin with these ambiguities. The assistant can point out that a design needs a stable identity for the intended notification, while the team decides which events share that identity and when a new one is created.
To make that choice concrete, the engineer can ask the assistant to sketch the data the proposal needs. A candidate record might be:
interface NotificationRecord {
id: string; // One intended notification; an intentional resend gets a new id.
request: {
to: string;
subject: string;
body: string;
};
status: "pending" | "sent";
providerMessageId?: string;
}
interface NotificationDelivery {
notificationId: string;
attempt: number;
}
These are proposed application types, independent of a particular queue or provider. The record is persisted before its identifier is published for processing. How to keep that persistence and publication consistent is another design question; this sketch assumes the record already exists when a worker receives a delivery.
The two identifiers now have distinct purposes. notificationId selects the same record on every retry, while attempt describes an execution. Generating an idempotency key from the attempt would give each retry a new identity and undermine the proposed deduplication. In this model, sent means the provider accepted the request. It makes no claim about arrival in the recipient's inbox.
Showing these types to the assistant makes the discussion easier to challenge. Does a requested resend create a new record? Can a pending record's content change? What should status represent after an uncertain provider response? Each question refers to a visible part of the proposal.
Compare changes against failure cases #
Increasing the time a worker has to finish could address the observed overlap if the audit write normally completes within the new limit. Moving or decoupling that write could also shorten the interval before acknowledgement. Both changes deserve consideration because they act on the execution path the investigation identified.
Their limits become clearer when the conversation considers a different interruption. Suppose the worker sends the email successfully and then stops before acknowledging the message. A later delivery can send it again even if the audit write is usually fast.
An assistant can help separate these cases by asking what each proposal actually establishes. A longer processing window changes when redelivery can occur. It does not record whether the external send already happened. An earlier acknowledgement changes which failures can trigger another delivery, including failures where the email still needs to be sent.
This makes the comparison more precise. The discussion can distinguish an operational improvement for the observed incident from a broader change to how repeat processing is handled. Both may be useful, with different expectations attached to them.
Follow the proposal through an interruption #
A common proposal is to record sent notifications in the database and skip those already recorded. Its behaviour depends on the order of operations.
The initial sketch can use the same storage and provider contracts for two alternatives. Imports and infrastructure are omitted; these functions are deliberately incomplete candidates for discussion:
interface NotificationStore {
load(id: string): Promise<NotificationRecord>;
markSent(id: string): Promise<void>;
}
interface EmailProvider {
send(request: NotificationRecord["request"]): Promise<void>;
}
async function recordAfterSend(
id: string, store: NotificationStore, provider: EmailProvider,
) {
const notification = await store.load(id);
if (notification.status === "sent") return;
await provider.send(notification.request);
await store.markSent(id);
}
async function recordBeforeSend(
id: string, store: NotificationStore, provider: EmailProvider,
) {
const notification = await store.load(id);
if (notification.status === "sent") return;
await store.markSent(id);
await provider.send(notification.request);
}
The functions differ by two lines, but they make different tradeoffs. recordBeforeSend also exposes a naming problem: it records an outcome before that outcome is known. Renaming the state to claimed would describe the moment more honestly, while introducing the need for a separate completion state and a recovery policy.
Consider recording success after the send:
Provider accepts email
Worker stops before recording success
Message is delivered again
Database has no success record
Worker sends email again
Recording before the send changes the failure case:
Worker records notification as sent
Worker stops before calling the provider
Message is delivered again
Worker finds the record and skips sending
Email was never submitted
These sequences give the conversation something concrete to examine. A proposal that looked complete in a paragraph now has two distinct consequences to account for.
The engineer can ask:
Assume the database write and provider request are separate operations. Walk through the design when the worker stops between them, and when two workers process the same notification concurrently. Which outcomes remain possible?
Concurrency adds another issue. Two workers can both observe that no record exists and proceed to send. A database operation that lets only one worker claim the notification can coordinate those workers. The design still needs to decide what happens if the worker holding the claim stops, and whether reclaiming the work could repeat an external action.
The assistant's contribution is useful when it keeps these distinctions visible. A transaction around local database writes does not, by itself, make a separate provider request part of the same atomic operation. The unresolved boundary belongs in the design discussion.
External guarantees become design inputs #
Suppose the provider offers an idempotency mechanism: repeated requests with the same key can refer to the same accepted operation. That capability could help handle retries, but its actual contract determines how the application can use it.
The team would need to establish how long the provider retains keys, what happens when the same key accompanies different request data, and how concurrent requests are handled. Those details require the provider's applicable documentation and, where necessary, experiments. An assistant can identify the questions before the team commits to an approach.
The application has responsibilities too. A retry needs the original notification identity. It may also need the original recipient and rendered content. If the worker resolves the recipient again and that address has changed, the second request may differ from the first while carrying the same key.
For a provider whose contract supports the required behaviour, the interface and worker could evolve like this:
interface IdempotentEmailProvider {
send(
request: NotificationRecord["request"],
options: { idempotencyKey: string },
): Promise<{ messageId: string }>;
}
interface ReceiptStore {
load(id: string): Promise<NotificationRecord>;
markAccepted(id: string, providerMessageId: string): Promise<void>;
}
async function submitNotification(
delivery: NotificationDelivery,
store: ReceiptStore,
provider: IdempotentEmailProvider,
) {
const notification = await store.load(delivery.notificationId);
if (notification.status === "sent") return;
const receipt = await provider.send(notification.request, {
idempotencyKey: notification.id,
});
await store.markAccepted(notification.id, receipt.messageId);
}
Here markAccepted persists status: "sent" and the provider reference. The clearer method name exposes the existing status label's limitations. The request fields are frozen once submission can begin, and the adapter must pass the key through to the provider. Adding an option to a TypeScript interface alone establishes no external guarantee.
If the worker stops after acceptance but before markAccepted, a later invocation submits the same stored request with the same key. Safe repetition now depends on the provider's verified contract, including its retention period and treatment of concurrent submissions. A timeout can still leave the application uncertain about acceptance; retrying within those conditions is one possible recovery path. A replay outside them needs another policy.
An illustrative exchange can therefore test the revised proposal:
Engineer: The worker stops after the provider accepts the request. On retry, the customer's address has changed. What does this implementation send?
Assistant: It loads the persisted request and submits the original address and content with the original notification key. Resolving the current address again would change the design. If the product requires the new address, we need an explicit decision about a new notification and the unresolved first submission.
The response ties a product consequence to the actual data flow. It also gives the team a specific assumption to accept or reject: retrying an existing notification preserves its original request.
That connects the design discussion to a detail found during repository exploration: the existing handler looks up the recipient at processing time. A design based on a stable external request may require preserving more information when the notification is created. The change affects stored data and product behaviour as well as the provider adapter.
If the provider lacks the required capability, the uncertainty remains part of the problem. The team may need reconciliation, manual handling of ambiguous outcomes, or an explicit tolerance for some duplicates. These options carry operational costs that belong alongside their implementation costs.
Let new constraints change the recommendation #
An AI design discussion can become repetitive when every response elaborates on the first proposal. Introducing a real constraint gives the conversation a reason to reconsider it.
For example, suppose failed notifications can be replayed manually several days later. If a proposed deduplication mechanism forgets identities sooner than that, replay behaviour needs separate treatment. If the product permits an intentional resend, the system needs to distinguish that action from retrying the original request.
The focus becomes how the proposal behaves under those requirements. An answer may need to change the retention policy, introduce a new notification identity for resends, or acknowledge that the original recommendation is unsuitable.
This is also where the engineer's knowledge of the team brings value. A design that depends on regularly investigating uncertain deliveries creates work for someone. The assistant can describe a reconciliation process, while the team must decide whether it can operate that process reliably.
Discussions others can inspect #
The conversation can produce a short design note describing the chosen behaviour, alternatives considered, and assumptions still requiring confirmation. For this example, it might distinguish a processing-timeout adjustment from a separate proposal for stable notification identities and provider-supported retry handling.
Such a note should explain the failure cases the chosen approach addresses and the ones it leaves possible. It should also identify decisions about missed messages, duplicates, replay, and resends that require product or operational agreement.
That gives reviewers a basis for discussion before a patch makes the design expensive to reconsider. They can challenge an assumption, supply a missing constraint, or agree that a limited change is appropriate for the immediate problem.
The value of the AI conversation is visible in the decision's reasoning: alternatives were examined against specific failures, external guarantees were identified as dependencies, and unresolved choices remained visible. The team can then implement with a clearer account of what the change is expected to achieve.