Your API returns a transaction hash. The customer sees “Payment sent.” An indexer finds the transfer in a block.
Can the merchant release the order?
If Fulfillment interprets “observed” as “paid,” a reorganization before finality can remove the inclusion after the warehouse has acted. The RPC integration may be correct. The queue may be healthy. The wrong business decision still happened.
This is the part of architecture I want a design review to expose: which evidence permits which decision, who owns that decision, and what survives a crash.
DDD, EventStorming, ports and adapters, and event-driven design are useful together because they answer different parts of that question. We will work through 16 heuristics using one Ethereum payment flow, with three concrete examples per heuristic.
You can use the result as a review reference: a context map, directory structure, TypeScript ports, an acceptance decision table, and a failure-injection checklist.
Jump to: DDD · Discovery · Ports and adapters · Events · Acceptance decisions · Failure tests
The payment contract we are actually designing
The example is an off-chain merchant payment service on Ethereum mainnet. Its automatic acceptance path handles one supported ERC-20 transfer for one positive invoice amount.
The policy is deliberately explicit:
- Match the chain, token contract, recipient, and exact amount in base units.
- Recognize a successful transfer using validated receipt and log evidence from an approved token integration.
- Require the referenced inclusion to belong to the finalized chain.
- Use the inclusion block’s timestamp to decide whether the payment met the invoice deadline. Observation and finality may arrive later.
- Send underpayments, overpayments, late transfers, and unsupported token behavior to review. Partial-payment aggregation and refunds need their own rules.
- Treat invoice expiry as the end of the payment window; allow later evidence of an on-time transfer to be evaluated. Order cancellation remains a separate decision.
“Supported token” includes the recognition rules, decimals metadata, fee behavior, rebasing behavior, and a policy for implementation changes. This example excludes fee-on-transfer and rebasing tokens from automatic acceptance. An ERC-20 interface alone does not establish compatibility with a payment policy.
These choices make the example reviewable. A rollup, a bridge, or another chain needs its own evidence and finality contract. The TypeScript snippets show application contracts; the persistence and chain adapters still have to implement their guarantees.
Define the states before writing the handlers
On Ethereum, transaction execution participates in block production and validation. Finality comes later. An included transaction can also have failed execution; a finalized block does not turn a failed transfer into a successful payment. See Ethereum’s transaction execution and finality flow and the receipt status specification.
| Statement | What it establishes | What it leaves open |
|---|---|---|
| Submission acknowledged | A particular node or provider acknowledged the submission. | Propagation, inclusion, execution outcome, finality. |
| Transfer observed | Our observer found evidence of a supported token transfer in a specific block. | Whether that inclusion remains canonical and becomes finalized. |
| Inclusion finalized | The referenced inclusion belongs to the finalized chain under Ethereum’s consensus rules. | Whether the transfer satisfies this invoice’s payment policy. |
| Payment accepted | Payments recorded that the required evidence and invoice rules were satisfied. | Whether Fulfillment has released the order. |
Ethereum finality has consensus assumptions. A business may choose a different risk threshold, but it should name that choice honestly. Likewise, an Ethereum mainnet policy should not quietly become a policy for every rollup or other chain. Ethereum’s finality FAQ explains the underlying distinction.
I. DDD: decide who owns the meaning
A bounded context gives a model a place where its language can stay coherent. An aggregate gives a set of rules a consistency boundary. These boundaries answer different questions; neither requires a microservice.
1. Protect the business’s differentiating decisions
The technically impressive subsystem can pull all the attention. In a protocol team, consensus design may deserve it. In a merchant payment company, the advantage may be recognizing payments correctly and resolving exceptions without exhausting the support team.
The core domain depends on the business. Put your deepest modeling effort where better decisions make the product meaningfully better. That is the strategic intent behind Eric Evans’s Core Domain pattern.
Asset matching. Accept the token identified by the configured chain and contract address. A familiar token symbol alone does not satisfy an invoice.
Late payment. A transfer’s inclusion timestamp falls after invoice expiry. Route it to review under this policy; any refund follows a separate authorization and submission workflow.
Duplicate payment. When two transfers pay one invoice, preserve both observations and decide what happens to the excess. A single
paidflag cannot explain the case.
Review question: Which business decision deserves our best modeling effort, and what customer outcome does it protect?
2. Give shared words a local definition
Two teams can use the same word correctly and still build an incorrect integration.
Consider “account.” A customer account, a ledger account, and an Ethereum account have different identities and rules. A wallet is software used to interact with accounts; an address is not proof of a person’s identity. Ethereum itself distinguishes externally owned and contract accounts. Its account documentation is a useful starting point.
Write down each context’s meaning before introducing a shared Account class.
Chain Observation: “Included” means evidence belongs to a particular block hash. The word makes no promise about an invoice.
Payments: “Accepted” means a payment passed a named policy for a particular invoice, using recorded evidence.
Accounting: “Posted” means the accounting entry was recorded. It does not mean a new Ethereum transaction was broadcast.
Review question: Which shared word changes meaning across this integration?
3. Draw aggregates around rules that must hold together
A blockchain noun is not automatically an aggregate. Neither is a table.
Start with a rule that must remain true when requests race. Then find the smallest boundary that can protect it. Reference other aggregates by identity where practical, while keeping genuine atomic requirements intact. Vaughn Vernon’s Effective Aggregate Design makes this tradeoff concrete.
One acceptance. A
Paymentaggregate allows its acceptance transition once. Version checks or equivalent concurrency control make that rule survive simultaneous workers.A stable obligation. Payment stores the invoice ID and an agreed payment-terms snapshot. A later catalog price change cannot silently change the amount being evaluated.
One allocation. A transfer cannot fund two payments. Protect that cross-payment uniqueness with an atomic allocation claim or another explicit consistency design; two independent “unused?” checks are insufficient.
For this example, the transfer-allocation claim, the payment acceptance transition, and the outgoing event record share one local database transaction. They are separately modeled responsibilities with an explicit atomic requirement. If you put them in different databases, replace that guarantee with a reservation and recovery protocol before claiming equivalent behavior.
The eligibility calculation runs before that transaction. A successful policy evaluation is not yet a committed acceptance.
Review question: What happens when two workers try to make this decision at the same time?
4. Keep a context map that explains real dependencies
A useful context map tells you whose change creates work for whom.
It also captures the relationship between the teams. Customer–Supplier describes collaboration and influence; an arrow between services is not enough to establish it. An Anticorruption Layer translates a foreign model, but cannot make a weakened finality guarantee harmless. These are strategic relationships in Evans’s DDD reference.
Ethereum node / RPC provider
|
v
Chain Observation
publishes: transfer and inclusion evidence
|
v
Payments
translates evidence into its payment model
owns: acceptance policy
|
+----> Fulfillment owns: order release
+----> Accounting owns: posting rules
`----> Notifications owns: customer messages
Arrows show contract dependencies, not deployment boundaries.
Provider replacement. Chain Observation absorbs a different RPC response shape while preserving the documented evidence contract.
Guarantee change. If a provider stops supplying required finality evidence, Payments pauses the affected acceptance path. Translation cannot manufacture missing evidence.
Consumer evolution. Fulfillment requests an invoice reference in the integration contract instead of reading Payments’ database tables.
Review question: Whose change can invalidate the guarantee this context relies on?
II. Discovery: find the decisions before the machinery
A workshop earns its time when somebody says, “Wait. That is not what happens.”
That interruption is often more valuable than the polished diagram the meeting was supposed to produce.
5. Start with things that happened
Put the events on the wall before choosing the services. Use past-tense statements that someone in the business can recognize. Orange sticky notes make the timeline easy to work with; the conversation does the modeling.
EventStorming is a collaborative exploration practice. A wall of notes written by one architect misses much of its value. Bring the people who see the exceptions, including support and operations. See the official EventStorming introduction.
PaymentRequested. The merchant has created an obligation with an asset, amount, recipient, and expiry.TransferObserved. The observer found a supported token transfer. Nobody has yet declared the invoice paid.PaymentAccepted. Payments recorded that its acceptance requirements were satisfied.
PaymentRequested
|
v
TransferObserved ----> TransferInclusionRemoved
| |
v `--> reassess provisional evidence
TransferInclusionFinalized
|
v
[evaluate invoice + asset + amount + recipient + policy]
|
v
PaymentAccepted
|
v
[Fulfillment decides whether the order can be released]
This is a discovery sketch. Late transfers, multiple transfers, and rejection paths belong on the real wall too. The removed-inclusion branch shown here is a pre-finality path.
Review question: Which event on this timeline is actually a command or an assumption?
6. Find the events that change the available decisions
Some events divide a process into a before and an after. After them, a different team takes responsibility, a different rule applies, or an option disappears.
These pivotal events help you find useful boundaries. They are candidates for discussion, not instructions to create one service per sticky note.
PaymentAccepted. Fulfillment can now consider releasing the order, subject to its own inventory and cancellation rules.PaymentExpired. The normal payment window has ended. Later observations still need their inclusion timestamps checked; an on-time transfer discovered late differs from a transfer made late.RefundApproved. A refund may proceed through authorization and submission. Approval itself does not move tokens.
Ask what becomes possible—or stops being possible—after each event. If the answer is only “we update another status field,” keep investigating.
Review question: What becomes possible, impossible, or somebody else’s responsibility after this event?
7. Connect features to behavior that changes an outcome
“Build a reconciliation dashboard” describes work. It does not explain why that work matters.
Impact Mapping connects a goal to actors, changes in their behavior, and possible deliverables. Keeping that chain visible makes it easier to challenge an attractive feature with a weak purpose. Gojko Adžić’s drawing guide explains the four levels.
Goal: reduce payment-status support tickets by 30%
per 1,000 checkouts over eight weeks
[illustrative target; measure the baseline first]
|
`-- Actor: customers
|
`-- Impact: recognize a pending payment and avoid paying again
|
+-- Option: show distinct payment stages
`-- Option: explain the next step when progress stalls
Customers avoid repeat payments. Test whether clearer pending-state guidance reduces duplicate transfers; do not equate page views with success.
Support resolves mismatches faster. Show invoice terms beside transfer evidence, then measure resolution time and unnecessary escalations.
Merchants release eligible orders sooner. Offer actionable payment updates and measure acceptance-to-release time alongside mistaken releases.
Review question: What behavior should change, and which measurement would tell us the feature did not help?
8. Give uncertainty an owner
A pink hotspot saying “What if the transfer disappears?” is unfinished work made visible. Give it a question, an owner, and the next piece of evidence needed to answer it.
For Ethereum log subscriptions, Geth can resend logs from an old branch with removed: true. Rebuilding a projection after that signal is different from compensating for an order already shipped. It is also different from an EVM execution revert. Geth’s subscription documentation describes the behavior.
Pre-finality reorganization. Invalidate the old inclusion and reassess provisional payment evidence. Do not label this
TransactionReverted.Missed observation. A subscription disconnects. Recover from a durable cursor with historical queries and canonical-chain checks; reconnecting alone does not prove completeness.
Delayed evidence. The transfer was included before invoice expiry, but the observer learned about it afterward. Our policy uses the inclusion block timestamp. The indexer preserves both that timestamp and the observation time so replay applies the same rule.
Review question: Who owns each unresolved question, and what evidence will settle it?
III. Ports and adapters: make the decision easy to reach
A folder named domain provides no protection by itself. The test is whether the business decision can run without its delivery mechanism.
9. Let the use case speak without the framework
An application operation should accept the information it needs and express the result in its own terms. HTTP requests, queue envelopes, and framework decorators belong at its edges.
This is the practical test in Alistair Cockburn’s original ports-and-adapters article: the application can be driven and tested independently of its eventual devices and infrastructure.
HTTP entry point. A route maps a request into
CreatePaymentCommandand calls the use case.Message entry point. A consumer validates an incoming observation contract, translates it, and calls
RecordTransferObservation.Test entry point. A test invokes the same application operation directly and asserts the payment decision, without starting a server.
src/
├── payments/
│ ├── domain/
│ │ ├── payment.aggregate.ts
│ │ ├── payment-terms.value-object.ts
│ │ ├── token-amount.value-object.ts
│ │ ├── transfer-ref.value-object.ts
│ │ └── payment-acceptance.policy.ts
│ ├── application/
│ │ ├── commands/
│ │ │ └── record-transfer-observation.command.ts
│ │ ├── use-cases/
│ │ │ └── accept-payment.use-case.ts
│ │ └── ports/
│ │ ├── payment.repository.port.ts
│ │ ├── finalized-inclusion.port.ts
│ │ └── payment-commit.port.ts
│ ├── adapters/
│ │ ├── inbound/
│ │ │ ├── http/create-payment.controller.ts
│ │ │ └── messaging/transfer-observation.consumer.ts
│ │ └── outbound/
│ │ ├── persistence/postgres-payment.repository.ts
│ │ ├── persistence/postgres-payment-commit.adapter.ts
│ │ └── chain-observation/finality.adapter.ts
│ └── contracts/
│ └── payment-accepted.v1.ts
├── chain-observation/
├── fulfillment/
├── accounting/
└── bootstrap/
└── composition-root.ts
Here, the composition root wires implementations to ports. Framework and database imports stay outside the domain and application. Enforce that dependency direction with tooling if the codebase needs it. The filenames make the intent visible; they do not enforce it.
Review question: Can this use case run directly without starting a server or importing the framework?
10. Name a port for the capability you need
JsonRpcClient describes a mechanism. FinalizedInclusionPort describes a question the application needs answered.
The useful abstraction preserves meaning. It must still distinguish missing evidence from negative evidence, carry the correct network identity, and identify the exact inclusion being assessed.
// finalized-inclusion.port.ts
// Abbreviated types; validate chain identity and hashes at the boundary.
type InclusionRef = Readonly<{
chainId: string; // decimal EIP-155 chain ID
transactionHash: string;
blockHash: string; // height alone does not identify an inclusion
}>;
type FinalityAssessment =
| { kind: "finalized"; inclusion: InclusionRef }
| { kind: "not-finalized"; inclusion: InclusionRef }
| { kind: "inclusion-removed"; inclusion: InclusionRef }
| { kind: "unavailable"; retryable: boolean };
export interface FinalizedInclusionPort {
assess(inclusion: InclusionRef): Promise<FinalityAssessment>;
}
The adapter must verify membership in the finalized chain for the specified inclusion, using the configured chain source. Comparing two block heights alone cannot establish that. It must also keep safe and finalized distinct; the Ethereum execution API defines separate block tags for them. This port answers finality; payment recognition still needs transfer evidence and invoice checks.
payment.repository.port.ts. Loads payment state and exposes the concurrency semantics required by the use case.finalized-inclusion.port.ts. Answers an evidence question without returning vendor-specific receipt objects.payment-commit.port.ts. Commits the payment change and its outgoing event records atomically. The allocation claim belongs in that same atomic commit.
Review question: Which guarantee does this port expose, and what does it return when that guarantee cannot be established?
Make the commit contract explicit
A generic save(payment) hides too much if the use case needs to claim a transfer, enforce a version, and enqueue an integration event together.
// payment-commit.port.ts
// Strings are normalized and validated before this boundary.
export type FinalizedTransferRef = Readonly<{
chainId: string;
blockHash: string;
transactionHash: string;
logIndex: string; // decimal integer in this application contract
}>;
export type AcceptanceWrite = Readonly<{
paymentId: string;
expectedPaymentVersion: string;
transfer: FinalizedTransferRef;
acceptancePolicyVersion: string;
}>;
export type AcceptanceCommitResult =
| {
kind: "accepted";
eventId: string;
acceptancePolicyVersion: string;
}
| {
kind: "already-accepted";
eventId: string;
acceptancePolicyVersion: string;
}
| {
kind: "conflict";
reason:
| "payment-changed"
| "different-acceptance"
| "transfer-already-allocated";
};
export interface PaymentCommitPort {
commitAcceptance(write: AcceptanceWrite): Promise<AcceptanceCommitResult>;
}
This port has obligations the signature cannot enforce:
- Resolve an existing acceptance first: return
already-acceptedfor the same payment and transfer, ordifferent-acceptancefor different evidence. For a new acceptance, validate the expected payment version and allowed transition. - Claim the verified transfer uniquely, using its normalized chain, block, transaction, and log identity. A transaction hash alone is insufficient.
- Persist the acceptance, evidence and policy references, and one logical outbox event atomically. The complete transfer evidence lives in the verified record referenced by
transfer. - On a matching retry, return the stored event ID. A matching retry refers to the same payment and transfer; it returns the original decision and policy version. It does not reevaluate an accepted payment under a newer policy.
- Return a conflict when an already accepted payment is presented with different evidence. Keep that separate from a normal retry.
If the commit response is lost, the caller cannot infer rollback. It retries or reconciles using the same payment and transfer identity. The adapter returns the durable result when one exists.
This transfer identity is for finalized inclusions in our acceptance model. Pre-finality observations can be removed and reobserved. A contradiction involving a previously accepted finalized inclusion enters incident handling; it does not silently free the transfer for reuse.
11. Keep policy out of the translation layer
An adapter may contain substantial technical work: decoding, authentication, pagination, backoff, or error mapping. “Thin” means it carries little business authority, not that it fits in ten lines.
The moment the RPC adapter decides an invoice is paid, changing providers becomes a change to payment policy.
Transport translation. An HTTP adapter checks message shape. Payments decides whether this merchant may accept this asset.
Chain translation. An RPC adapter decodes supported token logs into exact base-unit amounts. Payments decides whether those transfers satisfy the obligation.
Delivery translation. A webhook adapter authenticates and sends an already-defined event. Its retry count does not change the payment’s acceptance status.
The application owns orchestration, and a domain service can contain business rules. What matters is which responsibility the code serves.
Review question: Would replacing this adapter accidentally change the business decision?
12. Test the decision, then test the promises around it
A fast test should tell you whether the payment decision is correct. A separate integration test should tell you whether the real adapter delivers what the decision expects.
An in-memory repository cannot prove a PostgreSQL transaction is atomic. A fake RPC response cannot prove your decoder matches the node’s contract.
Decision test. The amount and recipient match, but the token contract differs. The payment remains unaccepted.
Uncertainty test. Finality evidence is unavailable. The payment waits; an infrastructure timeout never turns into acceptance.
Boundary test. Two workers race to allocate the same transfer. Using the real persistence adapter, only one allocation succeeds, and committed state has its corresponding outgoing event.
Add cases where the risk lives: large amounts beyond JavaScript’s safe integer range, removed inclusions, duplicate delivery, and response loss. Prefer a few tests that can expose a real failure over dozens that restate getters.
Review question: Which real failure can each test expose, and which contract still needs an integration test?
IV. Events: make the consequences explicit
A message bus can move a vague statement very efficiently. The ambiguity arrives at every consumer.
13. Publish a fact precise enough to be believed
A domain event records something meaningful that happened. A command requests something that may still be refused. Consumers need to know which one they received.
Also separate an event raised inside a model from an integration event durably available to other contexts. For database-backed changes, an outbox can commit state and the outgoing record together, then relay it afterward. Delivery may repeat; consumers still need duplicate-safe handling. See Chris Richardson’s transactional outbox pattern.
TransferObserved. Evidence was observed in a specific inclusion. It does not ask the warehouse to ship.PaymentAccepted. Payments committed an acceptance decision. It identifies the decision and the evidence behind it.RefundSubmissionAcknowledged. A submission endpoint acknowledged a particular refund transaction. Refund completion remains a later determination.
One local database transaction:
claim the verified transfer
update payment state
record outgoing PaymentAccepted event
commit all three
After commit:
relay event → broker → consumer
|
`-- commit local effect + deduplication record
A retry may deliver the event again.
An external side effect needs its own idempotency/reconciliation design.
A database transaction does not make an Ethereum broadcast atomic with your database. If a submission response is lost, reconcile the known transaction before deciding whether another attempt is appropriate.
Review question: At what commit point does this event become a fact we can safely publish?
14. Permit delay where the business can explain it
Eventual consistency is an agreement about delay and recovery. Ask how stale a consumer may be, what users see while it catches up, and who notices when it stops progressing.
Keep rules requiring atomic enforcement inside a suitable consistency boundary. For other consequences, independent progress can be a useful tradeoff. Vernon’s aggregate guidance explicitly asks designers to involve domain experts in acceptable delays.
Fulfillment. A payment can be accepted while order release is pending. Show both states and alert on an excessive delay.
Accounting. An idempotent consumer posts the corresponding entry after acceptance. Track posting lag and reconcile missing entries.
Dashboard. A projection can lag behind Payments. Display freshness and offer a source-of-truth check where the user’s next action depends on it.
Review question: How long can this consumer lag before a customer promise is broken?
15. Make events understandable outside their birthplace
Design for independent interpretation. That usually means more than an unexplained ID and less than a dump of the producer’s database row.
Publish the facts consumers need to interpret the documented event. Use a query when richer detail belongs behind an access-controlled boundary. Unknown future consumers are a reason for clear meaning, not unlimited payload growth.
// payment-accepted.v1.ts
// This example supports one accepted ERC-20 transfer per payment.
export type PaymentAcceptedV1 = Readonly<{
eventId: string;
eventType: "payments.payment-accepted";
schemaVersion: 1;
acceptedAt: string; // UTC timestamp of the business transition
paymentId: string;
invoiceId: string;
acceptancePolicyVersion: string;
evidence: Readonly<{
chainId: string; // decimal EIP-155 chain ID
transactionHash: string;
blockHash: string;
logIndex: string; // decimal integer in our integration format
finality: "ethereum-pos-finalized";
tokenContract: string;
recipient: string;
amountBaseUnits: string; // exact unsigned decimal integer
}>;
}>;
The event contract must define validation, identity normalization, and timestamp meaning. A string type alone does not enforce those rules. Ethereum JSON-RPC quantities use hexadecimal encoding; our adapter deliberately translates the log index into the decimal format documented here. The JSON-RPC reference specifies the external representation.
Exact value. Carry
amountBaseUnits: "1000000"with the chain and token contract. Render human units using trusted asset metadata; never infer decimals from the symbol.Exact evidence. Preserve transaction hash, block hash, and log index. One transaction can produce multiple transfer logs, and a pre-finality observation can later belong to a removed inclusion.
Stable identity. Reuse the same event ID when redelivering the same committed event. Keep schema version separate from the version of the acceptance policy.
ERC-20 defines transfer events and makes the decimals metadata method optional. Amount interpretation therefore belongs in an explicit asset contract, not a display-name guess. Read the ERC-20 specification.
Review question: Can another team interpret and replay this event without guessing identity, units, or timestamp meaning?
16. Put the reaction beside the policy that owns it
The context publishing a fact does not automatically own every consequence.
Let each consumer decide what that fact permits within its own model. Introduce a process manager when coordination must remember progress, deadlines, or several prerequisites over time. A single reaction may only need a handler.
Payments evaluates acceptance. It combines finality evidence with the expected token, amount, recipient, and invoice policy.
Fulfillment evaluates release. It combines
PaymentAcceptedwith inventory and cancellation state. Payment acceptance alone may be insufficient.Notifications chooses the message. It decides whether a payment transition warrants an email, an in-app update, or no customer interruption.
fulfillment/
├── application/
│ ├── handlers/
│ │ └── payment-accepted.handler.ts
│ ├── process-managers/
│ │ └── order-release.process-manager.ts
│ └── ports/
│ ├── order.repository.port.ts
│ └── fulfillment-commit.port.ts
└── domain/
├── order.aggregate.ts
└── order-release.policy.ts
Use a process manager only if the workflow needs durable coordination.
The chain observer should not know warehouse rules. Fulfillment should not have to interpret raw Ethereum receipts. Their independence comes from precise contracts and explicit ownership.
Review question: Which context owns the decision, and does its coordination actually need durable state?
Acceptance decisions you can implement
Keep the policy result separate from the application result:
// payment-acceptance.policy.ts — result contract
export type PaymentEligibility =
| { kind: "eligible" }
| {
kind: "held";
reason:
| "awaiting-finality"
| "evidence-unavailable"
| "inclusion-removed"
| "manual-review";
}
| {
kind: "refused";
reason: "candidate-mismatch" | "failed-execution";
};
eligible means the supplied evidence satisfies the policy. The use case can report accepted only after the acceptance transaction commits. held means no acceptance yet, with a retry trigger or review owner. refused rejects this candidate for this payment; it does not automatically close the invoice.
| Condition | Result | Next action |
|---|---|---|
| All evidence and invoice rules pass |
eligible → commit → accepted
|
Return the stored acceptance reference. |
| Matching retry after a successful commit | already-accepted |
Return the same stored result and event ID. |
| Canonical inclusion is not finalized | held: awaiting-finality |
Reassess as finalized-chain evidence advances. |
| Required evidence is missing, unavailable, or conflicting | held: evidence-unavailable |
Retry or escalate; never infer finality from elapsed time. |
| Pre-finality inclusion is removed | held: inclusion-removed |
Invalidate that observation and seek replacement evidence. |
| Wrong chain, token contract, or recipient | refused: candidate-mismatch |
Record the reason; keep the invoice’s lifecycle separate. |
| Receipt establishes failed execution | refused: failed-execution |
Do not recognize the intended transfer as payment. |
| Underpayment, overpayment, late transfer, or unsupported token behavior | held: manual-review |
Route to the named exception owner. |
| Another payment owns the allocation | Commit conflict | Produce no second acceptance or outbox event. |
| Previously accepted payment receives contradictory evidence | Incident review | Preserve history and reconcile; do not overwrite acceptance. |
On payment-changed, reload and reevaluate rather than writing through the conflict. On different-acceptance, preserve the committed decision and investigate the new candidate. Retrying a business conflict indefinitely is not a recovery policy.
Failure tests that make the design review useful
Use these as assertions for tests against the relevant real adapter. An in-memory test cannot prove database concurrency or relay crash recovery.
| Inject this failure | Assert this observable result |
|---|---|
| Two workers accept the same payment and transfer | One acceptance and one logical outbox event. Both callers converge on the stored result. |
| Two payments compete for one finalized transfer | One allocation succeeds. The loser writes neither acceptance nor event. |
| Crash after writes but before local commit | No partial claim, acceptance, or outbox record survives. |
| Commit succeeds but the caller loses the response | Reconciliation returns the existing result without a second event. |
| Crash after commit, before relay publication | Acceptance and outbox survive. The relay publishes after recovery. |
| Relay publishes, then crashes before recording success | Redelivery keeps the same event ID; the consumer does not repeat its local effect. |
| Consumer commits its effect and deduplication record, then loses the acknowledgment | Redelivery has no second local effect. External effects exercise their own idempotency or reconciliation path. |
| A block is below finalized height but belongs to another branch | The adapter does not classify that inclusion as finalized. |
Provider returns only safe, wrong-chain data, or insufficient/conflicting evidence |
No acceptance. Return a hold or reject the invalid provider response. |
| Subscription misses a transfer, then emits a duplicate during recovery | Historical reconciliation restores the observation without allocating twice. |
| Transfer is observed after expiry but its finalized inclusion timestamp is within the window | The stored deadline policy produces the same decision during replay. |
| Amount exceeds JavaScript’s safe integer range; metadata is absent; transfer value is zero | Exact units survive. No floating-point conversion or guessed decimals. Zero cannot satisfy a positive invoice. |
| Token behavior falls outside the supported integration profile | No automatic acceptance based on calldata amount, ticker, or an assumed balance delta. |
For operations, track the oldest held payment by reason, the oldest unpublished outbox event, and acceptance-to-fulfillment lag. Set thresholds from the customer promise and assign a recovery owner. A low queue depth does not establish that the right orders were released.
What I would ask in the next design review
Pick one customer-facing word: “confirmed,” “settled,” “approved,” or “complete.” Trace it through the system.
- Which context defines it?
- What evidence establishes it?
- Which invariant must hold when workers race?
- What state and outgoing record commit together?
- How does a retry recover the existing decision?
- Who handles the work still pending after that commit?
If the team cannot answer one of those questions, you have found a concrete design task. Name its owner and the test that will demonstrate the answer.
That is the standard these heuristics are meant to support: decisions that remain understandable when messages repeat, evidence arrives late, and processes fail between two lines of code.
Which word changes meaning as it crosses your system—and where has that difference caused trouble? Share the word, the two meanings, and the integration where they collided.
— Pedro Savelis · @psavelis
These heuristics build on Eric Evans and Vaughn Vernon’s DDD work, Alberto Brandolini’s EventStorming, Gojko Adžić’s Impact Mapping, and Alistair Cockburn’s ports and adapters. They are practical adaptations, not quotations. Primary references are linked beside the relevant claims.




Top comments (0)