[{"data":1,"prerenderedAt":2116},["ShallowReactive",2],{"tag-events":3},[4],{"_path":5,"_dir":6,"_draft":7,"_partial":7,"_locale":8,"title":9,"description":10,"layout":11,"date":12,"subtitle":13,"image":14,"optimized_image":14,"category":15,"tags":16,"author":21,"paginate":7,"body":22,"_type":2110,"_id":2111,"_source":2112,"_file":2113,"_stem":2114,"_extension":2115},"/posts/transactional-outbox","posts",false,"","Transactional Outbox: Saving State Without Losing Events","Understand the database-and-broker dual-write problem, implement a transactional outbox, and handle delivery retries, duplicate events, ordering, and recovery.","post","2025-06-15T11:00:00.000Z","Keep database changes and the intent to publish in the same transaction","/assets/img/uploads/transactional-outbox.jpg","code",[15,17,18,19,20],"architecture","events","postgresql","microservices","jaimedearcos",{"type":23,"children":24,"toc":2096},"root",[25,41,46,57,62,76,83,88,117,122,127,232,245,260,274,280,285,290,295,300,306,311,379,400,405,702,739,989,1010,1023,1052,1064,1070,1075,1080,1209,1221,1301,1306,1326,1331,1336,1342,1347,1460,1472,1477,1483,1488,1594,1629,1806,1820,1825,1830,1836,1841,1862,1867,1881,1887,1913,1918,1923,1928,1934,1939,1944,1949,1954,1959,1965,1970,2005,2010,2016,2021,2026,2031,2037,2090],{"type":26,"tag":27,"props":28,"children":29},"element","p",{},[30,33,39],{"type":31,"value":32},"text","Imagine an order service that saves a new order in PostgreSQL and publishes ",{"type":26,"tag":15,"props":34,"children":36},{"className":35},[],[37],{"type":31,"value":38},"OrderCreated",{"type":31,"value":40},". Other services use that event to start fulfillment, update search results, and build the customer's order history. The order is safely stored, but the process crashes before publishing the event.",{"type":26,"tag":27,"props":42,"children":43},{},[44],{"type":31,"value":45},"From the order service's perspective, the write succeeded. From the rest of the system's perspective, the order never appeared. The broker can be healthy, consumers can be running, and their lag can be zero. None of that helps: there is no message for them to process.",{"type":26,"tag":27,"props":47,"children":48},{},[49,55],{"type":26,"tag":50,"props":51,"children":52},"strong",{},[53],{"type":31,"value":54},"When a system relies on events to keep its services consistent, publishing those events is part of completing the business operation.",{"type":31,"value":56}," Losing one can leave a workflow stuck, a projection stale, or a user waiting for something that will never happen automatically.",{"type":26,"tag":27,"props":58,"children":59},{},[60],{"type":31,"value":61},"This is why Transactional Outbox matters. It gives the service a durable record of what still needs to be published, committed together with the business change. Understanding the pattern also means understanding its limits: delivery can be delayed or repeated, and the rest of the pipeline still needs a recovery strategy.",{"type":26,"tag":27,"props":63,"children":64},{},[65,67,74],{"type":31,"value":66},"The order workflow below is illustrative. It builds on the operational concerns discussed in ",{"type":26,"tag":68,"props":69,"children":71},"a",{"href":70},"/hidden-cost-event-driven-architectures/",[72],{"type":31,"value":73},"The Hidden Cost of Event-Driven Architectures",{"type":31,"value":75},", concentrating on the boundary between a database commit and event publication.",{"type":26,"tag":77,"props":78,"children":80},"h2",{"id":79},"two-successful-operations-do-not-make-one-transaction",[81],{"type":31,"value":82},"Two successful operations do not make one transaction",{"type":26,"tag":27,"props":84,"children":85},{},[86],{"type":31,"value":87},"The obvious implementation contains two steps:",{"type":26,"tag":89,"props":90,"children":93},"pre",{"className":91,"code":92,"language":31,"meta":8,"style":8},"language-text shiki shiki-themes github-dark github-light","save order and commit database transaction\npublish OrderCreated to broker\n",[94],{"type":26,"tag":15,"props":95,"children":96},{"__ignoreMap":8},[97,108],{"type":26,"tag":98,"props":99,"children":102},"span",{"class":100,"line":101},"line",1,[103],{"type":26,"tag":98,"props":104,"children":105},{},[106],{"type":31,"value":107},"save order and commit database transaction\n",{"type":26,"tag":98,"props":109,"children":111},{"class":100,"line":110},2,[112],{"type":26,"tag":98,"props":113,"children":114},{},[115],{"type":31,"value":116},"publish OrderCreated to broker\n",{"type":26,"tag":27,"props":118,"children":119},{},[120],{"type":31,"value":121},"Those steps belong to different systems. A normal database transaction cannot roll back a message that an independent broker already accepted. Likewise, a broker transaction does not automatically include a PostgreSQL commit.",{"type":26,"tag":27,"props":123,"children":124},{},[125],{"type":31,"value":126},"Changing the order only changes the failure window:",{"type":26,"tag":128,"props":129,"children":130},"table",{},[131,155],{"type":26,"tag":132,"props":133,"children":134},"thead",{},[135],{"type":26,"tag":136,"props":137,"children":138},"tr",{},[139,145,150],{"type":26,"tag":140,"props":141,"children":142},"th",{},[143],{"type":31,"value":144},"Sequence",{"type":26,"tag":140,"props":146,"children":147},{},[148],{"type":31,"value":149},"Failure window",{"type":26,"tag":140,"props":151,"children":152},{},[153],{"type":31,"value":154},"Result",{"type":26,"tag":156,"props":157,"children":158},"tbody",{},[159,178,196,214],{"type":26,"tag":136,"props":160,"children":161},{},[162,168,173],{"type":26,"tag":163,"props":164,"children":165},"td",{},[166],{"type":31,"value":167},"Commit the order, then publish",{"type":26,"tag":163,"props":169,"children":170},{},[171],{"type":31,"value":172},"Process dies after the commit but before publication",{"type":26,"tag":163,"props":174,"children":175},{},[176],{"type":31,"value":177},"The order exists; its event is missing",{"type":26,"tag":136,"props":179,"children":180},{},[181,186,191],{"type":26,"tag":163,"props":182,"children":183},{},[184],{"type":31,"value":185},"Publish, then commit the order",{"type":26,"tag":163,"props":187,"children":188},{},[189],{"type":31,"value":190},"Publication succeeds, but the database transaction rolls back",{"type":26,"tag":163,"props":192,"children":193},{},[194],{"type":31,"value":195},"Consumers receive a fact that was never committed",{"type":26,"tag":136,"props":197,"children":198},{},[199,204,209],{"type":26,"tag":163,"props":200,"children":201},{},[202],{"type":31,"value":203},"Publish while the database transaction is open",{"type":26,"tag":163,"props":205,"children":206},{},[207],{"type":31,"value":208},"Broker accepts the event; a later database operation or commit fails",{"type":26,"tag":163,"props":210,"children":211},{},[212],{"type":31,"value":213},"The external side effect survives the database rollback",{"type":26,"tag":136,"props":215,"children":216},{},[217,222,227],{"type":26,"tag":163,"props":218,"children":219},{},[220],{"type":31,"value":221},"Commit, publish, and retry after a timeout",{"type":26,"tag":163,"props":223,"children":224},{},[225],{"type":31,"value":226},"Broker accepted the message, but its acknowledgement was lost",{"type":26,"tag":163,"props":228,"children":229},{},[230],{"type":31,"value":231},"Retrying can publish the same event again",{"type":26,"tag":27,"props":233,"children":234},{},[235,237,243],{"type":31,"value":236},"A ",{"type":26,"tag":15,"props":238,"children":240},{"className":239},[],[241],{"type":31,"value":242},"try/catch",{"type":31,"value":244}," cannot run after the process has disappeared. An in-memory retry queue disappears with it. Writing a retry record only after publication fails still leaves a crash window before that record is saved.",{"type":26,"tag":27,"props":246,"children":247},{},[248,250,258],{"type":31,"value":249},"Publishing in an after-commit callback avoids announcing rolled-back state, but it does not make the callback durable. A crash can still happen between the database commit and the callback completing. Spring's ",{"type":26,"tag":68,"props":251,"children":255},{"href":252,"rel":253},"https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html",[254],"nofollow",[256],{"type":31,"value":257},"transaction-bound event listeners",{"type":31,"value":259}," let you choose a transaction phase; that phase selection alone does not create a persistent delivery mechanism.",{"type":26,"tag":27,"props":261,"children":262},{},[263,265,272],{"type":31,"value":264},"Distributed transactions can coordinate resources where the infrastructure supports them, but bring their own coupling and operational constraints. The outbox approach uses one local transaction and asynchronous delivery instead. Chris Richardson's ",{"type":26,"tag":68,"props":266,"children":269},{"href":267,"rel":268},"https://microservices.io/patterns/data/transactional-outbox.html",[254],[270],{"type":31,"value":271},"Transactional Outbox pattern",{"type":31,"value":273}," describes this trade-off.",{"type":26,"tag":77,"props":275,"children":277},{"id":276},"why-a-missing-event-can-become-permanent-inconsistency",[278],{"type":31,"value":279},"Why a missing event can become permanent inconsistency",{"type":26,"tag":27,"props":281,"children":282},{},[283],{"type":31,"value":284},"A delayed event and a lost event are different problems. If an event remains durably pending, a recovering publisher can eventually deliver it. If nothing recorded the obligation to publish, waiting longer does not recreate that obligation.",{"type":26,"tag":27,"props":286,"children":287},{},[288],{"type":31,"value":289},"For the order example, the consequences could include an order absent from the customer-facing search index, fulfillment never starting, or a dependent workflow remaining pending indefinitely. Different services can give different answers about the same operation. Support teams then have to identify the missing transition and decide which actions need replaying.",{"type":26,"tag":27,"props":291,"children":292},{},[293],{"type":31,"value":294},"This is especially serious when consumers maintain their own state rather than consulting the source service on every request. Eventual consistency depends on a mechanism that actually propagates the required changes. It does not mean the system will converge simply because enough time has passed.",{"type":26,"tag":27,"props":296,"children":297},{},[298],{"type":31,"value":299},"Reconciliation jobs can detect some discrepancies, but reconstructing a historical event from today's row is not always possible. The row may have changed several times since the missing transition. Record the intended event when the transition happens.",{"type":26,"tag":77,"props":301,"children":303},{"id":302},"commit-the-business-change-and-the-publication-intent-together",[304],{"type":31,"value":305},"Commit the business change and the publication intent together",{"type":26,"tag":27,"props":307,"children":308},{},[309],{"type":31,"value":310},"An outbox is a table in the same transactional database as the business data. The application writes both in one transaction. A separate publisher, often called a relay, reads committed outbox entries and sends them to the broker.",{"type":26,"tag":89,"props":312,"children":314},{"className":91,"code":313,"language":31,"meta":8,"style":8},"Application transaction in PostgreSQL\n    insert order\n    insert outbox event\n    commit both\n            |\n            v\nRelay reads committed event -> Broker -> Consumers\n",[315],{"type":26,"tag":15,"props":316,"children":317},{"__ignoreMap":8},[318,326,334,343,352,361,370],{"type":26,"tag":98,"props":319,"children":320},{"class":100,"line":101},[321],{"type":26,"tag":98,"props":322,"children":323},{},[324],{"type":31,"value":325},"Application transaction in PostgreSQL\n",{"type":26,"tag":98,"props":327,"children":328},{"class":100,"line":110},[329],{"type":26,"tag":98,"props":330,"children":331},{},[332],{"type":31,"value":333},"    insert order\n",{"type":26,"tag":98,"props":335,"children":337},{"class":100,"line":336},3,[338],{"type":26,"tag":98,"props":339,"children":340},{},[341],{"type":31,"value":342},"    insert outbox event\n",{"type":26,"tag":98,"props":344,"children":346},{"class":100,"line":345},4,[347],{"type":26,"tag":98,"props":348,"children":349},{},[350],{"type":31,"value":351},"    commit both\n",{"type":26,"tag":98,"props":353,"children":355},{"class":100,"line":354},5,[356],{"type":26,"tag":98,"props":357,"children":358},{},[359],{"type":31,"value":360},"            |\n",{"type":26,"tag":98,"props":362,"children":364},{"class":100,"line":363},6,[365],{"type":26,"tag":98,"props":366,"children":367},{},[368],{"type":31,"value":369},"            v\n",{"type":26,"tag":98,"props":371,"children":373},{"class":100,"line":372},7,[374],{"type":26,"tag":98,"props":375,"children":376},{},[377],{"type":31,"value":378},"Relay reads committed event -> Broker -> Consumers\n",{"type":26,"tag":27,"props":380,"children":381},{},[382,384,389,391,398],{"type":31,"value":383},"The atomic boundary is ",{"type":26,"tag":50,"props":385,"children":386},{},[387],{"type":31,"value":388},"business state plus durable event intent",{"type":31,"value":390},". It does not include the broker or the consumers. PostgreSQL's ",{"type":26,"tag":68,"props":392,"children":395},{"href":393,"rel":394},"https://www.postgresql.org/docs/current/tutorial-transactions.html",[254],[396],{"type":31,"value":397},"transaction model",{"type":31,"value":399}," makes the two writes commit or roll back together, provided they actually participate in the same transaction.",{"type":26,"tag":27,"props":401,"children":402},{},[403],{"type":31,"value":404},"Here is a small schema for a polling implementation:",{"type":26,"tag":89,"props":406,"children":410},{"className":407,"code":408,"language":409,"meta":8,"style":8},"language-sql shiki shiki-themes github-dark github-light","CREATE TABLE outbox_event (\n    event_id UUID PRIMARY KEY,\n    aggregate_type TEXT NOT NULL,\n    aggregate_id TEXT NOT NULL,\n    aggregate_version BIGINT NOT NULL,\n    event_type TEXT NOT NULL,\n    schema_version INTEGER NOT NULL,\n    payload JSONB NOT NULL,\n    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    published_at TIMESTAMPTZ\n);\n\nCREATE INDEX outbox_event_pending_idx\n    ON outbox_event (created_at, event_id)\n    WHERE published_at IS NULL;\n","sql",[411],{"type":26,"tag":15,"props":412,"children":413},{"__ignoreMap":8},[414,440,458,480,500,521,541,562,580,608,622,631,641,659,673],{"type":26,"tag":98,"props":415,"children":416},{"class":100,"line":101},[417,423,428,434],{"type":26,"tag":98,"props":418,"children":420},{"style":419},"--shiki-default:#F97583;--shiki-light:#D73A49",[421],{"type":31,"value":422},"CREATE",{"type":26,"tag":98,"props":424,"children":425},{"style":419},[426],{"type":31,"value":427}," TABLE",{"type":26,"tag":98,"props":429,"children":431},{"style":430},"--shiki-default:#B392F0;--shiki-light:#6F42C1",[432],{"type":31,"value":433}," outbox_event",{"type":26,"tag":98,"props":435,"children":437},{"style":436},"--shiki-default:#E1E4E8;--shiki-light:#24292E",[438],{"type":31,"value":439}," (\n",{"type":26,"tag":98,"props":441,"children":442},{"class":100,"line":110},[443,448,453],{"type":26,"tag":98,"props":444,"children":445},{"style":436},[446],{"type":31,"value":447},"    event_id UUID ",{"type":26,"tag":98,"props":449,"children":450},{"style":419},[451],{"type":31,"value":452},"PRIMARY KEY",{"type":26,"tag":98,"props":454,"children":455},{"style":436},[456],{"type":31,"value":457},",\n",{"type":26,"tag":98,"props":459,"children":460},{"class":100,"line":336},[461,466,471,476],{"type":26,"tag":98,"props":462,"children":463},{"style":436},[464],{"type":31,"value":465},"    aggregate_type ",{"type":26,"tag":98,"props":467,"children":468},{"style":419},[469],{"type":31,"value":470},"TEXT",{"type":26,"tag":98,"props":472,"children":473},{"style":419},[474],{"type":31,"value":475}," NOT NULL",{"type":26,"tag":98,"props":477,"children":478},{"style":436},[479],{"type":31,"value":457},{"type":26,"tag":98,"props":481,"children":482},{"class":100,"line":345},[483,488,492,496],{"type":26,"tag":98,"props":484,"children":485},{"style":436},[486],{"type":31,"value":487},"    aggregate_id ",{"type":26,"tag":98,"props":489,"children":490},{"style":419},[491],{"type":31,"value":470},{"type":26,"tag":98,"props":493,"children":494},{"style":419},[495],{"type":31,"value":475},{"type":26,"tag":98,"props":497,"children":498},{"style":436},[499],{"type":31,"value":457},{"type":26,"tag":98,"props":501,"children":502},{"class":100,"line":354},[503,508,513,517],{"type":26,"tag":98,"props":504,"children":505},{"style":436},[506],{"type":31,"value":507},"    aggregate_version ",{"type":26,"tag":98,"props":509,"children":510},{"style":419},[511],{"type":31,"value":512},"BIGINT",{"type":26,"tag":98,"props":514,"children":515},{"style":419},[516],{"type":31,"value":475},{"type":26,"tag":98,"props":518,"children":519},{"style":436},[520],{"type":31,"value":457},{"type":26,"tag":98,"props":522,"children":523},{"class":100,"line":363},[524,529,533,537],{"type":26,"tag":98,"props":525,"children":526},{"style":436},[527],{"type":31,"value":528},"    event_type ",{"type":26,"tag":98,"props":530,"children":531},{"style":419},[532],{"type":31,"value":470},{"type":26,"tag":98,"props":534,"children":535},{"style":419},[536],{"type":31,"value":475},{"type":26,"tag":98,"props":538,"children":539},{"style":436},[540],{"type":31,"value":457},{"type":26,"tag":98,"props":542,"children":543},{"class":100,"line":372},[544,549,554,558],{"type":26,"tag":98,"props":545,"children":546},{"style":436},[547],{"type":31,"value":548},"    schema_version ",{"type":26,"tag":98,"props":550,"children":551},{"style":419},[552],{"type":31,"value":553},"INTEGER",{"type":26,"tag":98,"props":555,"children":556},{"style":419},[557],{"type":31,"value":475},{"type":26,"tag":98,"props":559,"children":560},{"style":436},[561],{"type":31,"value":457},{"type":26,"tag":98,"props":563,"children":565},{"class":100,"line":564},8,[566,571,576],{"type":26,"tag":98,"props":567,"children":568},{"style":436},[569],{"type":31,"value":570},"    payload JSONB ",{"type":26,"tag":98,"props":572,"children":573},{"style":419},[574],{"type":31,"value":575},"NOT NULL",{"type":26,"tag":98,"props":577,"children":578},{"style":436},[579],{"type":31,"value":457},{"type":26,"tag":98,"props":581,"children":583},{"class":100,"line":582},9,[584,589,594,598,603],{"type":26,"tag":98,"props":585,"children":586},{"style":436},[587],{"type":31,"value":588},"    created_at ",{"type":26,"tag":98,"props":590,"children":591},{"style":419},[592],{"type":31,"value":593},"TIMESTAMPTZ",{"type":26,"tag":98,"props":595,"children":596},{"style":419},[597],{"type":31,"value":475},{"type":26,"tag":98,"props":599,"children":600},{"style":419},[601],{"type":31,"value":602}," DEFAULT",{"type":26,"tag":98,"props":604,"children":605},{"style":436},[606],{"type":31,"value":607}," CURRENT_TIMESTAMP,\n",{"type":26,"tag":98,"props":609,"children":611},{"class":100,"line":610},10,[612,617],{"type":26,"tag":98,"props":613,"children":614},{"style":436},[615],{"type":31,"value":616},"    published_at ",{"type":26,"tag":98,"props":618,"children":619},{"style":419},[620],{"type":31,"value":621},"TIMESTAMPTZ\n",{"type":26,"tag":98,"props":623,"children":625},{"class":100,"line":624},11,[626],{"type":26,"tag":98,"props":627,"children":628},{"style":436},[629],{"type":31,"value":630},");\n",{"type":26,"tag":98,"props":632,"children":634},{"class":100,"line":633},12,[635],{"type":26,"tag":98,"props":636,"children":638},{"emptyLinePlaceholder":637},true,[639],{"type":31,"value":640},"\n",{"type":26,"tag":98,"props":642,"children":644},{"class":100,"line":643},13,[645,649,654],{"type":26,"tag":98,"props":646,"children":647},{"style":419},[648],{"type":31,"value":422},{"type":26,"tag":98,"props":650,"children":651},{"style":419},[652],{"type":31,"value":653}," INDEX",{"type":26,"tag":98,"props":655,"children":656},{"style":430},[657],{"type":31,"value":658}," outbox_event_pending_idx\n",{"type":26,"tag":98,"props":660,"children":662},{"class":100,"line":661},14,[663,668],{"type":26,"tag":98,"props":664,"children":665},{"style":419},[666],{"type":31,"value":667},"    ON",{"type":26,"tag":98,"props":669,"children":670},{"style":436},[671],{"type":31,"value":672}," outbox_event (created_at, event_id)\n",{"type":26,"tag":98,"props":674,"children":676},{"class":100,"line":675},15,[677,682,687,692,697],{"type":26,"tag":98,"props":678,"children":679},{"style":419},[680],{"type":31,"value":681},"    WHERE",{"type":26,"tag":98,"props":683,"children":684},{"style":436},[685],{"type":31,"value":686}," published_at ",{"type":26,"tag":98,"props":688,"children":689},{"style":419},[690],{"type":31,"value":691},"IS",{"type":26,"tag":98,"props":693,"children":694},{"style":419},[695],{"type":31,"value":696}," NULL",{"type":26,"tag":98,"props":698,"children":699},{"style":436},[700],{"type":31,"value":701},";\n",{"type":26,"tag":27,"props":703,"children":704},{},[705,707,713,715,721,723,729,731,737],{"type":31,"value":706},"Assume ",{"type":26,"tag":15,"props":708,"children":710},{"className":709},[],[711],{"type":31,"value":712},"purchase_order",{"type":31,"value":714}," has an ",{"type":26,"tag":15,"props":716,"children":718},{"className":717},[],[719],{"type":31,"value":720},"id",{"type":31,"value":722}," primary key, a ",{"type":26,"tag":15,"props":724,"children":726},{"className":725},[],[727],{"type":31,"value":728},"status",{"type":31,"value":730},", and a ",{"type":26,"tag":15,"props":732,"children":734},{"className":733},[],[735],{"type":31,"value":736},"version",{"type":31,"value":738},". Creating an order and its event becomes:",{"type":26,"tag":89,"props":740,"children":742},{"className":407,"code":741,"language":409,"meta":8,"style":8},"BEGIN;\n\nINSERT INTO purchase_order (id, status, version)\nVALUES (:order_id, 'CREATED', 1);\n\nINSERT INTO outbox_event (\n    event_id, aggregate_type, aggregate_id, aggregate_version,\n    event_type, schema_version, payload\n)\nVALUES (\n    :event_id, 'Order', :order_id, 1,\n    'OrderCreated', 1,\n    jsonb_build_object('orderId', :order_id, 'status', 'CREATED')\n);\n\nCOMMIT;\n",[743],{"type":26,"tag":15,"props":744,"children":745},{"__ignoreMap":8},[746,758,765,796,829,836,848,856,864,871,882,908,928,962,969,976],{"type":26,"tag":98,"props":747,"children":748},{"class":100,"line":101},[749,754],{"type":26,"tag":98,"props":750,"children":751},{"style":419},[752],{"type":31,"value":753},"BEGIN",{"type":26,"tag":98,"props":755,"children":756},{"style":436},[757],{"type":31,"value":701},{"type":26,"tag":98,"props":759,"children":760},{"class":100,"line":110},[761],{"type":26,"tag":98,"props":762,"children":763},{"emptyLinePlaceholder":637},[764],{"type":31,"value":640},{"type":26,"tag":98,"props":766,"children":767},{"class":100,"line":336},[768,773,778,782,787,791],{"type":26,"tag":98,"props":769,"children":770},{"style":419},[771],{"type":31,"value":772},"INSERT INTO",{"type":26,"tag":98,"props":774,"children":775},{"style":436},[776],{"type":31,"value":777}," purchase_order (id, ",{"type":26,"tag":98,"props":779,"children":780},{"style":419},[781],{"type":31,"value":728},{"type":26,"tag":98,"props":783,"children":784},{"style":436},[785],{"type":31,"value":786},", ",{"type":26,"tag":98,"props":788,"children":789},{"style":419},[790],{"type":31,"value":736},{"type":26,"tag":98,"props":792,"children":793},{"style":436},[794],{"type":31,"value":795},")\n",{"type":26,"tag":98,"props":797,"children":798},{"class":100,"line":345},[799,804,809,815,819,825],{"type":26,"tag":98,"props":800,"children":801},{"style":419},[802],{"type":31,"value":803},"VALUES",{"type":26,"tag":98,"props":805,"children":806},{"style":436},[807],{"type":31,"value":808}," (:order_id, ",{"type":26,"tag":98,"props":810,"children":812},{"style":811},"--shiki-default:#9ECBFF;--shiki-light:#032F62",[813],{"type":31,"value":814},"'CREATED'",{"type":26,"tag":98,"props":816,"children":817},{"style":436},[818],{"type":31,"value":786},{"type":26,"tag":98,"props":820,"children":822},{"style":821},"--shiki-default:#79B8FF;--shiki-light:#005CC5",[823],{"type":31,"value":824},"1",{"type":26,"tag":98,"props":826,"children":827},{"style":436},[828],{"type":31,"value":630},{"type":26,"tag":98,"props":830,"children":831},{"class":100,"line":354},[832],{"type":26,"tag":98,"props":833,"children":834},{"emptyLinePlaceholder":637},[835],{"type":31,"value":640},{"type":26,"tag":98,"props":837,"children":838},{"class":100,"line":363},[839,843],{"type":26,"tag":98,"props":840,"children":841},{"style":419},[842],{"type":31,"value":772},{"type":26,"tag":98,"props":844,"children":845},{"style":436},[846],{"type":31,"value":847}," outbox_event (\n",{"type":26,"tag":98,"props":849,"children":850},{"class":100,"line":372},[851],{"type":26,"tag":98,"props":852,"children":853},{"style":436},[854],{"type":31,"value":855},"    event_id, aggregate_type, aggregate_id, aggregate_version,\n",{"type":26,"tag":98,"props":857,"children":858},{"class":100,"line":564},[859],{"type":26,"tag":98,"props":860,"children":861},{"style":436},[862],{"type":31,"value":863},"    event_type, schema_version, payload\n",{"type":26,"tag":98,"props":865,"children":866},{"class":100,"line":582},[867],{"type":26,"tag":98,"props":868,"children":869},{"style":436},[870],{"type":31,"value":795},{"type":26,"tag":98,"props":872,"children":873},{"class":100,"line":610},[874,878],{"type":26,"tag":98,"props":875,"children":876},{"style":419},[877],{"type":31,"value":803},{"type":26,"tag":98,"props":879,"children":880},{"style":436},[881],{"type":31,"value":439},{"type":26,"tag":98,"props":883,"children":884},{"class":100,"line":624},[885,890,895,900,904],{"type":26,"tag":98,"props":886,"children":887},{"style":436},[888],{"type":31,"value":889},"    :event_id, ",{"type":26,"tag":98,"props":891,"children":892},{"style":811},[893],{"type":31,"value":894},"'Order'",{"type":26,"tag":98,"props":896,"children":897},{"style":436},[898],{"type":31,"value":899},", :order_id, ",{"type":26,"tag":98,"props":901,"children":902},{"style":821},[903],{"type":31,"value":824},{"type":26,"tag":98,"props":905,"children":906},{"style":436},[907],{"type":31,"value":457},{"type":26,"tag":98,"props":909,"children":910},{"class":100,"line":633},[911,916,920,924],{"type":26,"tag":98,"props":912,"children":913},{"style":811},[914],{"type":31,"value":915},"    'OrderCreated'",{"type":26,"tag":98,"props":917,"children":918},{"style":436},[919],{"type":31,"value":786},{"type":26,"tag":98,"props":921,"children":922},{"style":821},[923],{"type":31,"value":824},{"type":26,"tag":98,"props":925,"children":926},{"style":436},[927],{"type":31,"value":457},{"type":26,"tag":98,"props":929,"children":930},{"class":100,"line":643},[931,936,941,945,950,954,958],{"type":26,"tag":98,"props":932,"children":933},{"style":436},[934],{"type":31,"value":935},"    jsonb_build_object(",{"type":26,"tag":98,"props":937,"children":938},{"style":811},[939],{"type":31,"value":940},"'orderId'",{"type":26,"tag":98,"props":942,"children":943},{"style":436},[944],{"type":31,"value":899},{"type":26,"tag":98,"props":946,"children":947},{"style":811},[948],{"type":31,"value":949},"'status'",{"type":26,"tag":98,"props":951,"children":952},{"style":436},[953],{"type":31,"value":786},{"type":26,"tag":98,"props":955,"children":956},{"style":811},[957],{"type":31,"value":814},{"type":26,"tag":98,"props":959,"children":960},{"style":436},[961],{"type":31,"value":795},{"type":26,"tag":98,"props":963,"children":964},{"class":100,"line":661},[965],{"type":26,"tag":98,"props":966,"children":967},{"style":436},[968],{"type":31,"value":630},{"type":26,"tag":98,"props":970,"children":971},{"class":100,"line":675},[972],{"type":26,"tag":98,"props":973,"children":974},{"emptyLinePlaceholder":637},[975],{"type":31,"value":640},{"type":26,"tag":98,"props":977,"children":979},{"class":100,"line":978},16,[980,985],{"type":26,"tag":98,"props":981,"children":982},{"style":419},[983],{"type":31,"value":984},"COMMIT",{"type":26,"tag":98,"props":986,"children":987},{"style":436},[988],{"type":31,"value":701},{"type":26,"tag":27,"props":990,"children":991},{},[992,994,1000,1002,1008],{"type":31,"value":993},"The ",{"type":26,"tag":15,"props":995,"children":997},{"className":996},[],[998],{"type":31,"value":999},":order_id",{"type":31,"value":1001}," and ",{"type":26,"tag":15,"props":1003,"children":1005},{"className":1004},[],[1006],{"type":31,"value":1007},":event_id",{"type":31,"value":1009}," names represent application-bound parameters, not SQL to paste unchanged into a PostgreSQL console. Generate the event UUID once for this event and preserve it on every publication retry. A real payload must include the facts its consumers need to act without guessing at later database state.",{"type":26,"tag":27,"props":1011,"children":1012},{},[1013,1015,1021],{"type":31,"value":1014},"If the outbox insert fails, roll back the business write too. Do not swallow the failure and commit the order. In a Java service, verify that both repositories use the same database transaction and connection context; a separate transaction such as ",{"type":26,"tag":15,"props":1016,"children":1018},{"className":1017},[],[1019],{"type":31,"value":1020},"REQUIRES_NEW",{"type":31,"value":1022}," would defeat the atomic boundary.",{"type":26,"tag":27,"props":1024,"children":1025},{},[1026,1028,1034,1036,1042,1044,1050],{"type":31,"value":1027},"Keep three concepts separate: ",{"type":26,"tag":15,"props":1029,"children":1031},{"className":1030},[],[1032],{"type":31,"value":1033},"event_id",{"type":31,"value":1035}," identifies a particular event, ",{"type":26,"tag":15,"props":1037,"children":1039},{"className":1038},[],[1040],{"type":31,"value":1041},"schema_version",{"type":31,"value":1043}," identifies its payload contract, and ",{"type":26,"tag":15,"props":1045,"children":1047},{"className":1046},[],[1048],{"type":31,"value":1049},"aggregate_version",{"type":31,"value":1051}," describes the source entity's progression. None is a substitute for an API request idempotency key. If a client retries order creation after an ambiguous HTTP response, the command layer still needs to prevent an unintended second order.",{"type":26,"tag":27,"props":1053,"children":1054},{},[1055,1057,1062],{"type":31,"value":1056},"Store an immutable event payload. Loading the latest order and constructing ",{"type":26,"tag":15,"props":1058,"children":1060},{"className":1059},[],[1061],{"type":31,"value":38},{"type":31,"value":1063}," later can accidentally publish facts from a different transition.",{"type":26,"tag":77,"props":1065,"children":1067},{"id":1066},"publish-from-the-outbox-and-acknowledge-in-the-right-order",[1068],{"type":31,"value":1069},"Publish from the outbox, and acknowledge in the right order",{"type":26,"tag":27,"props":1071,"children":1072},{},[1073],{"type":31,"value":1074},"A minimal polling relay can lock one pending row, publish it, wait for a broker acknowledgement, then mark it as published. This is an educational implementation, not a complete production worker.",{"type":26,"tag":27,"props":1076,"children":1077},{},[1078],{"type":31,"value":1079},"Start a database transaction and select the row:",{"type":26,"tag":89,"props":1081,"children":1083},{"className":407,"code":1082,"language":409,"meta":8,"style":8},"BEGIN;\n\nSELECT event_id, aggregate_type, aggregate_id, aggregate_version,\n       event_type, schema_version, payload\nFROM outbox_event\nWHERE published_at IS NULL\nORDER BY created_at, event_id\nLIMIT 1\nFOR UPDATE SKIP LOCKED;\n",[1084],{"type":26,"tag":15,"props":1085,"children":1086},{"__ignoreMap":8},[1087,1098,1105,1118,1126,1139,1160,1173,1186],{"type":26,"tag":98,"props":1088,"children":1089},{"class":100,"line":101},[1090,1094],{"type":26,"tag":98,"props":1091,"children":1092},{"style":419},[1093],{"type":31,"value":753},{"type":26,"tag":98,"props":1095,"children":1096},{"style":436},[1097],{"type":31,"value":701},{"type":26,"tag":98,"props":1099,"children":1100},{"class":100,"line":110},[1101],{"type":26,"tag":98,"props":1102,"children":1103},{"emptyLinePlaceholder":637},[1104],{"type":31,"value":640},{"type":26,"tag":98,"props":1106,"children":1107},{"class":100,"line":336},[1108,1113],{"type":26,"tag":98,"props":1109,"children":1110},{"style":419},[1111],{"type":31,"value":1112},"SELECT",{"type":26,"tag":98,"props":1114,"children":1115},{"style":436},[1116],{"type":31,"value":1117}," event_id, aggregate_type, aggregate_id, aggregate_version,\n",{"type":26,"tag":98,"props":1119,"children":1120},{"class":100,"line":345},[1121],{"type":26,"tag":98,"props":1122,"children":1123},{"style":436},[1124],{"type":31,"value":1125},"       event_type, schema_version, payload\n",{"type":26,"tag":98,"props":1127,"children":1128},{"class":100,"line":354},[1129,1134],{"type":26,"tag":98,"props":1130,"children":1131},{"style":419},[1132],{"type":31,"value":1133},"FROM",{"type":26,"tag":98,"props":1135,"children":1136},{"style":436},[1137],{"type":31,"value":1138}," outbox_event\n",{"type":26,"tag":98,"props":1140,"children":1141},{"class":100,"line":363},[1142,1147,1151,1155],{"type":26,"tag":98,"props":1143,"children":1144},{"style":419},[1145],{"type":31,"value":1146},"WHERE",{"type":26,"tag":98,"props":1148,"children":1149},{"style":436},[1150],{"type":31,"value":686},{"type":26,"tag":98,"props":1152,"children":1153},{"style":419},[1154],{"type":31,"value":691},{"type":26,"tag":98,"props":1156,"children":1157},{"style":419},[1158],{"type":31,"value":1159}," NULL\n",{"type":26,"tag":98,"props":1161,"children":1162},{"class":100,"line":372},[1163,1168],{"type":26,"tag":98,"props":1164,"children":1165},{"style":419},[1166],{"type":31,"value":1167},"ORDER BY",{"type":26,"tag":98,"props":1169,"children":1170},{"style":436},[1171],{"type":31,"value":1172}," created_at, event_id\n",{"type":26,"tag":98,"props":1174,"children":1175},{"class":100,"line":564},[1176,1181],{"type":26,"tag":98,"props":1177,"children":1178},{"style":419},[1179],{"type":31,"value":1180},"LIMIT",{"type":26,"tag":98,"props":1182,"children":1183},{"style":821},[1184],{"type":31,"value":1185}," 1\n",{"type":26,"tag":98,"props":1187,"children":1188},{"class":100,"line":582},[1189,1194,1199,1204],{"type":26,"tag":98,"props":1190,"children":1191},{"style":419},[1192],{"type":31,"value":1193},"FOR",{"type":26,"tag":98,"props":1195,"children":1196},{"style":419},[1197],{"type":31,"value":1198}," UPDATE",{"type":26,"tag":98,"props":1200,"children":1201},{"style":419},[1202],{"type":31,"value":1203}," SKIP",{"type":26,"tag":98,"props":1205,"children":1206},{"style":436},[1207],{"type":31,"value":1208}," LOCKED;\n",{"type":26,"tag":27,"props":1210,"children":1211},{},[1212,1214,1219],{"type":31,"value":1213},"If there is no row, end the transaction. Otherwise, keep the lock while the application sends an envelope containing the selected fields, including the stable ",{"type":26,"tag":15,"props":1215,"children":1217},{"className":1216},[],[1218],{"type":31,"value":1033},{"type":31,"value":1220},". Only after the broker acknowledges acceptance under the required durability settings should the same transaction run:",{"type":26,"tag":89,"props":1222,"children":1224},{"className":407,"code":1223,"language":409,"meta":8,"style":8},"UPDATE outbox_event\nSET published_at = CURRENT_TIMESTAMP\nWHERE event_id = :event_id;\n\nCOMMIT;\n",[1225],{"type":26,"tag":15,"props":1226,"children":1227},{"__ignoreMap":8},[1228,1240,1262,1283,1290],{"type":26,"tag":98,"props":1229,"children":1230},{"class":100,"line":101},[1231,1236],{"type":26,"tag":98,"props":1232,"children":1233},{"style":419},[1234],{"type":31,"value":1235},"UPDATE",{"type":26,"tag":98,"props":1237,"children":1238},{"style":436},[1239],{"type":31,"value":1138},{"type":26,"tag":98,"props":1241,"children":1242},{"class":100,"line":110},[1243,1248,1252,1257],{"type":26,"tag":98,"props":1244,"children":1245},{"style":419},[1246],{"type":31,"value":1247},"SET",{"type":26,"tag":98,"props":1249,"children":1250},{"style":436},[1251],{"type":31,"value":686},{"type":26,"tag":98,"props":1253,"children":1254},{"style":419},[1255],{"type":31,"value":1256},"=",{"type":26,"tag":98,"props":1258,"children":1259},{"style":436},[1260],{"type":31,"value":1261}," CURRENT_TIMESTAMP\n",{"type":26,"tag":98,"props":1263,"children":1264},{"class":100,"line":336},[1265,1269,1274,1278],{"type":26,"tag":98,"props":1266,"children":1267},{"style":419},[1268],{"type":31,"value":1146},{"type":26,"tag":98,"props":1270,"children":1271},{"style":436},[1272],{"type":31,"value":1273}," event_id ",{"type":26,"tag":98,"props":1275,"children":1276},{"style":419},[1277],{"type":31,"value":1256},{"type":26,"tag":98,"props":1279,"children":1280},{"style":436},[1281],{"type":31,"value":1282}," :event_id;\n",{"type":26,"tag":98,"props":1284,"children":1285},{"class":100,"line":345},[1286],{"type":26,"tag":98,"props":1287,"children":1288},{"emptyLinePlaceholder":637},[1289],{"type":31,"value":640},{"type":26,"tag":98,"props":1291,"children":1292},{"class":100,"line":354},[1293,1297],{"type":26,"tag":98,"props":1294,"children":1295},{"style":419},[1296],{"type":31,"value":984},{"type":26,"tag":98,"props":1298,"children":1299},{"style":436},[1300],{"type":31,"value":701},{"type":26,"tag":27,"props":1302,"children":1303},{},[1304],{"type":31,"value":1305},"An asynchronous producer's local enqueue or returned future is not enough: wait for the relevant broker confirmation. On failure or timeout, roll back and retry later. A timeout is ambiguous, so that retry may be a duplicate.",{"type":26,"tag":27,"props":1307,"children":1308},{},[1309,1315,1317,1324],{"type":26,"tag":15,"props":1310,"children":1312},{"className":1311},[],[1313],{"type":31,"value":1314},"SKIP LOCKED",{"type":31,"value":1316}," lets concurrent workers bypass rows already locked by other workers. PostgreSQL documents its usefulness for ",{"type":26,"tag":68,"props":1318,"children":1321},{"href":1319,"rel":1320},"https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE",[254],[1322],{"type":31,"value":1323},"queue-like tables",{"type":31,"value":1325},". It is a work-claiming mechanism, not an ordering guarantee.",{"type":26,"tag":27,"props":1327,"children":1328},{},[1329],{"type":31,"value":1330},"Holding a transaction and connection open across a network operation has a cost. Keep waits bounded and avoid large locked batches. A higher-throughput design may claim rows with an expiring lease, commit the claim, and publish outside the transaction. That design needs lease recovery, ownership checks when marking success, and protection against stale workers. A lease does not remove duplicate-delivery windows.",{"type":26,"tag":27,"props":1332,"children":1333},{},[1334],{"type":31,"value":1335},"Never mark a row as published before sending. If the process dies after that mark, the relay may permanently skip an event that never reached the broker.",{"type":26,"tag":77,"props":1337,"children":1339},{"id":1338},"the-remaining-crash-window-produces-duplicates",[1340],{"type":31,"value":1341},"The remaining crash window produces duplicates",{"type":26,"tag":27,"props":1343,"children":1344},{},[1345],{"type":31,"value":1346},"The relay still talks to two systems. The important difference is that its work is durable and repeatable:",{"type":26,"tag":128,"props":1348,"children":1349},{},[1350,1371],{"type":26,"tag":132,"props":1351,"children":1352},{},[1353],{"type":26,"tag":136,"props":1354,"children":1355},{},[1356,1361,1366],{"type":26,"tag":140,"props":1357,"children":1358},{},[1359],{"type":31,"value":1360},"Failure point",{"type":26,"tag":140,"props":1362,"children":1363},{},[1364],{"type":31,"value":1365},"Durable state",{"type":26,"tag":140,"props":1367,"children":1368},{},[1369],{"type":31,"value":1370},"Recovery",{"type":26,"tag":156,"props":1372,"children":1373},{},[1374,1392,1410,1436],{"type":26,"tag":136,"props":1375,"children":1376},{},[1377,1382,1387],{"type":26,"tag":163,"props":1378,"children":1379},{},[1380],{"type":31,"value":1381},"Before the producer transaction commits",{"type":26,"tag":163,"props":1383,"children":1384},{},[1385],{"type":31,"value":1386},"Neither order nor outbox event committed",{"type":26,"tag":163,"props":1388,"children":1389},{},[1390],{"type":31,"value":1391},"Retry the command according to its idempotency policy",{"type":26,"tag":136,"props":1393,"children":1394},{},[1395,1400,1405],{"type":26,"tag":163,"props":1396,"children":1397},{},[1398],{"type":31,"value":1399},"After that commit, before publication",{"type":26,"tag":163,"props":1401,"children":1402},{},[1403],{"type":31,"value":1404},"Order and pending event exist",{"type":26,"tag":163,"props":1406,"children":1407},{},[1408],{"type":31,"value":1409},"Relay publishes after recovery",{"type":26,"tag":136,"props":1411,"children":1412},{},[1413,1426,1431],{"type":26,"tag":163,"props":1414,"children":1415},{},[1416,1418,1424],{"type":31,"value":1417},"After broker acceptance, before ",{"type":26,"tag":15,"props":1419,"children":1421},{"className":1420},[],[1422],{"type":31,"value":1423},"published_at",{"type":31,"value":1425}," commits",{"type":26,"tag":163,"props":1427,"children":1428},{},[1429],{"type":31,"value":1430},"Broker may have the event; outbox still appears pending",{"type":26,"tag":163,"props":1432,"children":1433},{},[1434],{"type":31,"value":1435},"Relay republishes the same event ID",{"type":26,"tag":136,"props":1437,"children":1438},{},[1439,1450,1455],{"type":26,"tag":163,"props":1440,"children":1441},{},[1442,1444,1449],{"type":31,"value":1443},"After ",{"type":26,"tag":15,"props":1445,"children":1447},{"className":1446},[],[1448],{"type":31,"value":1423},{"type":31,"value":1425},{"type":26,"tag":163,"props":1451,"children":1452},{},[1453],{"type":31,"value":1454},"Broker has acknowledged; relay recorded success",{"type":26,"tag":163,"props":1456,"children":1457},{},[1458],{"type":31,"value":1459},"Normal consumer delivery continues",{"type":26,"tag":27,"props":1461,"children":1462},{},[1463,1465,1470],{"type":31,"value":1464},"This is why outbox-based publication is normally designed around ",{"type":26,"tag":50,"props":1466,"children":1467},{},[1468],{"type":31,"value":1469},"at-least-once delivery",{"type":31,"value":1471},". The pattern closes the missing-intent window; it does not promise a single delivery across database, broker, and consumers.",{"type":26,"tag":27,"props":1473,"children":1474},{},[1475],{"type":31,"value":1476},"Those guarantees depend on durable storage, appropriate broker configuration, retry and recovery, and retaining pending events. A permanently broken relay or premature cleanup can still stop propagation. A published marker also says nothing about whether a consumer has completed its work.",{"type":26,"tag":77,"props":1478,"children":1480},{"id":1479},"make-the-consumers-effect-safe-to-repeat",[1481],{"type":31,"value":1482},"Make the consumer's effect safe to repeat",{"type":26,"tag":27,"props":1484,"children":1485},{},[1486],{"type":31,"value":1487},"Suppose a consumer updates an order-search projection. It can record processed event IDs in its own database, atomically with its local change:",{"type":26,"tag":89,"props":1489,"children":1491},{"className":407,"code":1490,"language":409,"meta":8,"style":8},"CREATE TABLE processed_event (\n    consumer_name TEXT NOT NULL,\n    event_id UUID NOT NULL,\n    processed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    PRIMARY KEY (consumer_name, event_id)\n);\n",[1492],{"type":26,"tag":15,"props":1493,"children":1494},{"__ignoreMap":8},[1495,1515,1535,1550,1574,1587],{"type":26,"tag":98,"props":1496,"children":1497},{"class":100,"line":101},[1498,1502,1506,1511],{"type":26,"tag":98,"props":1499,"children":1500},{"style":419},[1501],{"type":31,"value":422},{"type":26,"tag":98,"props":1503,"children":1504},{"style":419},[1505],{"type":31,"value":427},{"type":26,"tag":98,"props":1507,"children":1508},{"style":430},[1509],{"type":31,"value":1510}," processed_event",{"type":26,"tag":98,"props":1512,"children":1513},{"style":436},[1514],{"type":31,"value":439},{"type":26,"tag":98,"props":1516,"children":1517},{"class":100,"line":110},[1518,1523,1527,1531],{"type":26,"tag":98,"props":1519,"children":1520},{"style":436},[1521],{"type":31,"value":1522},"    consumer_name ",{"type":26,"tag":98,"props":1524,"children":1525},{"style":419},[1526],{"type":31,"value":470},{"type":26,"tag":98,"props":1528,"children":1529},{"style":419},[1530],{"type":31,"value":475},{"type":26,"tag":98,"props":1532,"children":1533},{"style":436},[1534],{"type":31,"value":457},{"type":26,"tag":98,"props":1536,"children":1537},{"class":100,"line":336},[1538,1542,1546],{"type":26,"tag":98,"props":1539,"children":1540},{"style":436},[1541],{"type":31,"value":447},{"type":26,"tag":98,"props":1543,"children":1544},{"style":419},[1545],{"type":31,"value":575},{"type":26,"tag":98,"props":1547,"children":1548},{"style":436},[1549],{"type":31,"value":457},{"type":26,"tag":98,"props":1551,"children":1552},{"class":100,"line":345},[1553,1558,1562,1566,1570],{"type":26,"tag":98,"props":1554,"children":1555},{"style":436},[1556],{"type":31,"value":1557},"    processed_at ",{"type":26,"tag":98,"props":1559,"children":1560},{"style":419},[1561],{"type":31,"value":593},{"type":26,"tag":98,"props":1563,"children":1564},{"style":419},[1565],{"type":31,"value":475},{"type":26,"tag":98,"props":1567,"children":1568},{"style":419},[1569],{"type":31,"value":602},{"type":26,"tag":98,"props":1571,"children":1572},{"style":436},[1573],{"type":31,"value":607},{"type":26,"tag":98,"props":1575,"children":1576},{"class":100,"line":354},[1577,1582],{"type":26,"tag":98,"props":1578,"children":1579},{"style":419},[1580],{"type":31,"value":1581},"    PRIMARY KEY",{"type":26,"tag":98,"props":1583,"children":1584},{"style":436},[1585],{"type":31,"value":1586}," (consumer_name, event_id)\n",{"type":26,"tag":98,"props":1588,"children":1589},{"class":100,"line":363},[1590],{"type":26,"tag":98,"props":1591,"children":1592},{"style":436},[1593],{"type":31,"value":630},{"type":26,"tag":27,"props":1595,"children":1596},{},[1597,1599,1604,1606,1612,1614,1620,1622,1627],{"type":31,"value":1598},"For the ",{"type":26,"tag":15,"props":1600,"children":1602},{"className":1601},[],[1603],{"type":31,"value":38},{"type":31,"value":1605}," event, assume ",{"type":26,"tag":15,"props":1607,"children":1609},{"className":1608},[],[1610],{"type":31,"value":1611},"order_projection",{"type":31,"value":1613}," contains an ",{"type":26,"tag":15,"props":1615,"children":1617},{"className":1616},[],[1618],{"type":31,"value":1619},"order_id",{"type":31,"value":1621}," primary key and a ",{"type":26,"tag":15,"props":1623,"children":1625},{"className":1624},[],[1626],{"type":31,"value":728},{"type":31,"value":1628},". This statement gates the projection insert on whether the event is new to this consumer:",{"type":26,"tag":89,"props":1630,"children":1632},{"className":407,"code":1631,"language":409,"meta":8,"style":8},"BEGIN;\n\nWITH first_delivery AS (\n    INSERT INTO processed_event (consumer_name, event_id)\n    VALUES ('order-search', :event_id)\n    ON CONFLICT (consumer_name, event_id) DO NOTHING\n    RETURNING event_id\n)\nINSERT INTO order_projection (order_id, status)\nSELECT :order_id, 'CREATED'\nFROM first_delivery;\n\nCOMMIT;\n",[1633],{"type":26,"tag":15,"props":1634,"children":1635},{"__ignoreMap":8},[1636,1647,1654,1676,1689,1712,1724,1732,1739,1759,1776,1788,1795],{"type":26,"tag":98,"props":1637,"children":1638},{"class":100,"line":101},[1639,1643],{"type":26,"tag":98,"props":1640,"children":1641},{"style":419},[1642],{"type":31,"value":753},{"type":26,"tag":98,"props":1644,"children":1645},{"style":436},[1646],{"type":31,"value":701},{"type":26,"tag":98,"props":1648,"children":1649},{"class":100,"line":110},[1650],{"type":26,"tag":98,"props":1651,"children":1652},{"emptyLinePlaceholder":637},[1653],{"type":31,"value":640},{"type":26,"tag":98,"props":1655,"children":1656},{"class":100,"line":336},[1657,1662,1667,1672],{"type":26,"tag":98,"props":1658,"children":1659},{"style":419},[1660],{"type":31,"value":1661},"WITH",{"type":26,"tag":98,"props":1663,"children":1664},{"style":436},[1665],{"type":31,"value":1666}," first_delivery ",{"type":26,"tag":98,"props":1668,"children":1669},{"style":419},[1670],{"type":31,"value":1671},"AS",{"type":26,"tag":98,"props":1673,"children":1674},{"style":436},[1675],{"type":31,"value":439},{"type":26,"tag":98,"props":1677,"children":1678},{"class":100,"line":345},[1679,1684],{"type":26,"tag":98,"props":1680,"children":1681},{"style":419},[1682],{"type":31,"value":1683},"    INSERT INTO",{"type":26,"tag":98,"props":1685,"children":1686},{"style":436},[1687],{"type":31,"value":1688}," processed_event (consumer_name, event_id)\n",{"type":26,"tag":98,"props":1690,"children":1691},{"class":100,"line":354},[1692,1697,1702,1707],{"type":26,"tag":98,"props":1693,"children":1694},{"style":419},[1695],{"type":31,"value":1696},"    VALUES",{"type":26,"tag":98,"props":1698,"children":1699},{"style":436},[1700],{"type":31,"value":1701}," (",{"type":26,"tag":98,"props":1703,"children":1704},{"style":811},[1705],{"type":31,"value":1706},"'order-search'",{"type":26,"tag":98,"props":1708,"children":1709},{"style":436},[1710],{"type":31,"value":1711},", :event_id)\n",{"type":26,"tag":98,"props":1713,"children":1714},{"class":100,"line":363},[1715,1719],{"type":26,"tag":98,"props":1716,"children":1717},{"style":419},[1718],{"type":31,"value":667},{"type":26,"tag":98,"props":1720,"children":1721},{"style":436},[1722],{"type":31,"value":1723}," CONFLICT (consumer_name, event_id) DO NOTHING\n",{"type":26,"tag":98,"props":1725,"children":1726},{"class":100,"line":372},[1727],{"type":26,"tag":98,"props":1728,"children":1729},{"style":436},[1730],{"type":31,"value":1731},"    RETURNING event_id\n",{"type":26,"tag":98,"props":1733,"children":1734},{"class":100,"line":564},[1735],{"type":26,"tag":98,"props":1736,"children":1737},{"style":436},[1738],{"type":31,"value":795},{"type":26,"tag":98,"props":1740,"children":1741},{"class":100,"line":582},[1742,1746,1751,1755],{"type":26,"tag":98,"props":1743,"children":1744},{"style":419},[1745],{"type":31,"value":772},{"type":26,"tag":98,"props":1747,"children":1748},{"style":436},[1749],{"type":31,"value":1750}," order_projection (order_id, ",{"type":26,"tag":98,"props":1752,"children":1753},{"style":419},[1754],{"type":31,"value":728},{"type":26,"tag":98,"props":1756,"children":1757},{"style":436},[1758],{"type":31,"value":795},{"type":26,"tag":98,"props":1760,"children":1761},{"class":100,"line":610},[1762,1766,1771],{"type":26,"tag":98,"props":1763,"children":1764},{"style":419},[1765],{"type":31,"value":1112},{"type":26,"tag":98,"props":1767,"children":1768},{"style":436},[1769],{"type":31,"value":1770}," :order_id, ",{"type":26,"tag":98,"props":1772,"children":1773},{"style":811},[1774],{"type":31,"value":1775},"'CREATED'\n",{"type":26,"tag":98,"props":1777,"children":1778},{"class":100,"line":624},[1779,1783],{"type":26,"tag":98,"props":1780,"children":1781},{"style":419},[1782],{"type":31,"value":1133},{"type":26,"tag":98,"props":1784,"children":1785},{"style":436},[1786],{"type":31,"value":1787}," first_delivery;\n",{"type":26,"tag":98,"props":1789,"children":1790},{"class":100,"line":633},[1791],{"type":26,"tag":98,"props":1792,"children":1793},{"emptyLinePlaceholder":637},[1794],{"type":31,"value":640},{"type":26,"tag":98,"props":1796,"children":1797},{"class":100,"line":643},[1798,1802],{"type":26,"tag":98,"props":1799,"children":1800},{"style":419},[1801],{"type":31,"value":984},{"type":26,"tag":98,"props":1803,"children":1804},{"style":436},[1805],{"type":31,"value":701},{"type":26,"tag":27,"props":1807,"children":1808},{},[1809,1811,1818],{"type":31,"value":1810},"A duplicate event inserts no marker and therefore no projection row. If the projection write fails, the marker rolls back too. Acknowledge the broker message only after the transaction commits. If the consumer then crashes before acknowledging, redelivery is harmless for this local effect. This is an application of the ",{"type":26,"tag":68,"props":1812,"children":1815},{"href":1813,"rel":1814},"https://microservices.io/patterns/communication-style/idempotent-consumer.html",[254],[1816],{"type":31,"value":1817},"Idempotent Consumer pattern",{"type":31,"value":1819},".",{"type":26,"tag":27,"props":1821,"children":1822},{},[1823],{"type":31,"value":1824},"A “have I seen this ID?” query followed by a separate transaction is not equivalent: two workers can both pass the check. The unique constraint and shared transaction are doing real work here.",{"type":26,"tag":27,"props":1826,"children":1827},{},[1828],{"type":31,"value":1829},"This protection stops at the local database boundary. Sending an email or calling another service inside the handler introduces another external effect. Use an idempotency mechanism supported by that destination, or record a new outgoing obligation in the consumer's own outbox with the corresponding recovery policy. Moving an unreliable dual write downstream does not solve it.",{"type":26,"tag":77,"props":1831,"children":1833},{"id":1832},"choose-polling-or-cdc-deliberately",[1834],{"type":31,"value":1835},"Choose polling or CDC deliberately",{"type":26,"tag":27,"props":1837,"children":1838},{},[1839],{"type":31,"value":1840},"Polling is straightforward to inspect: a worker queries pending rows and records publication progress. You own its scheduling, locking or leases, retries, backoff, and cleanup. Its latency and database load depend on the polling strategy.",{"type":26,"tag":27,"props":1842,"children":1843},{},[1844,1846,1853,1855,1860],{"type":31,"value":1845},"Change data capture is another way to implement the relay. Debezium's ",{"type":26,"tag":68,"props":1847,"children":1850},{"href":1848,"rel":1849},"https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html",[254],[1851],{"type":31,"value":1852},"Outbox Event Router",{"type":31,"value":1854}," can transform changes captured from an outbox table into broker messages. The application still writes a domain event in its transaction; CDC transports that record. Watching arbitrary business-table updates does not automatically provide a meaningful ",{"type":26,"tag":15,"props":1856,"children":1858},{"className":1857},[],[1859],{"type":31,"value":38},{"type":31,"value":1861}," contract.",{"type":26,"tag":27,"props":1863,"children":1864},{},[1865],{"type":31,"value":1866},"The schema above is a custom polling schema, not Debezium's default table layout. A CDC implementation must map its columns, routing key, event identity, and payload to the connector configuration. Route outbox inserts deliberately and account for cleanup operations. Avoid accidentally treating publication-marker updates as new domain events, or running independent polling and CDC publishers for the same records without an explicit handover plan.",{"type":26,"tag":27,"props":1868,"children":1869},{},[1870,1872,1879],{"type":31,"value":1871},"CDC removes the application's polling loop but adds connector operations: offsets, snapshots, replication slots, retained WAL, restart behavior, and recovery after prolonged downtime. The ",{"type":26,"tag":68,"props":1873,"children":1876},{"href":1874,"rel":1875},"https://debezium.io/documentation/reference/stable/connectors/postgresql.html",[254],[1877],{"type":31,"value":1878},"PostgreSQL connector documentation",{"type":31,"value":1880}," describes those responsibilities. Do not assume changing the relay transport eliminates end-to-end duplicates or consumer idempotency requirements.",{"type":26,"tag":77,"props":1882,"children":1884},{"id":1883},"ordering-needs-a-separate-design",[1885],{"type":31,"value":1886},"Ordering needs a separate design",{"type":26,"tag":27,"props":1888,"children":1889},{},[1890,1895,1897,1903,1905,1911],{"type":26,"tag":15,"props":1891,"children":1893},{"className":1892},[],[1894],{"type":31,"value":38},{"type":31,"value":1896}," followed by ",{"type":26,"tag":15,"props":1898,"children":1900},{"className":1899},[],[1901],{"type":31,"value":1902},"OrderCancelled",{"type":31,"value":1904}," may need to be applied in that order. The polling example does not guarantee it: a second worker can skip a locked earlier event and publish a later one first. Sorting by ",{"type":26,"tag":15,"props":1906,"children":1908},{"className":1907},[],[1909],{"type":31,"value":1910},"created_at",{"type":31,"value":1912}," or a generated ID does not establish commit order across concurrent transactions either.",{"type":26,"tag":27,"props":1914,"children":1915},{},[1916],{"type":31,"value":1917},"If the domain requires ordering per order, serialize the source transitions using an appropriate concurrency policy, and preserve that order through relay scheduling, broker routing, and consumer processing. For Kafka, using the aggregate ID as a partition key keeps that key together under a suitable partitioning strategy; it cannot repair events the relay already sent in the wrong order.",{"type":26,"tag":27,"props":1919,"children":1920},{},[1921],{"type":31,"value":1922},"An aggregate sequence can help consumers reject stale state or detect gaps, but define what it counts and which events a consumer is expected to see. A gap is not necessarily a lost event if the subscription intentionally filters some transitions.",{"type":26,"tag":27,"props":1924,"children":1925},{},[1926],{"type":31,"value":1927},"For a projection containing complete state snapshots, ignoring an older version may be acceptable. For a workflow that must execute every transition, skipping straight to the newest version can lose required work. Choose the policy from the business semantics, and test concurrent updates and retries against it.",{"type":26,"tag":77,"props":1929,"children":1931},{"id":1930},"operate-the-pending-work-as-part-of-the-product",[1932],{"type":31,"value":1933},"Operate the pending work as part of the product",{"type":26,"tag":27,"props":1935,"children":1936},{},[1937],{"type":31,"value":1938},"An outbox gives you a place to recover from; it does not run the recovery process for you. Monitor the oldest pending event's age, backlog size and growth, publication failures and retry counts, and the delay between committing state and consumers applying it. Broker lag alone misses unpublished outbox entries.",{"type":26,"tag":27,"props":1940,"children":1941},{},[1942],{"type":31,"value":1943},"Tie alerts to the workflow's acceptable delay. If an order can remain pending for a few seconds but not an hour, that expectation belongs in the service's operational objectives. Decide when a growing backlog should cause backpressure or make the command API stop accepting more work. The API can acknowledge a local commit while downstream work is pending, but must not imply that the entire distributed workflow has finished.",{"type":26,"tag":27,"props":1945,"children":1946},{},[1947],{"type":31,"value":1948},"Retry transient failures with bounded backoff and jitter. Repeatedly failing events need a visible quarantine or dead-letter process with ownership and a replay procedure. Moving an event aside does not mean its business obligation has been fulfilled. Where ordering matters, decide whether later events for that aggregate must wait.",{"type":26,"tag":27,"props":1950,"children":1951},{},[1952],{"type":31,"value":1953},"Retain unpublished records. Clean up published records according to an explicit recovery and replay policy, and coordinate CDC cleanup with connector recovery. The outbox is not automatically a permanent event store. Consumer deduplication records also need a retention horizon that covers possible redelivery and replay; deleting them too early allows old events to have effects again.",{"type":26,"tag":27,"props":1955,"children":1956},{},[1957],{"type":31,"value":1958},"Finally, reconcile critical business outcomes. Check, for example, whether created orders reach the expected downstream state within the allowed interval. Successful publication is only one step toward that outcome.",{"type":26,"tag":77,"props":1960,"children":1962},{"id":1961},"test-by-stopping-the-system-at-inconvenient-moments",[1963],{"type":31,"value":1964},"Test by stopping the system at inconvenient moments",{"type":26,"tag":27,"props":1966,"children":1967},{},[1968],{"type":31,"value":1969},"A happy-path test proves little about the failure windows that motivated this pattern. In an isolated environment, inject failures and check the durable result:",{"type":26,"tag":1971,"props":1972,"children":1973},"ul",{},[1974,1980,1985,1990,1995,2000],{"type":26,"tag":1975,"props":1976,"children":1977},"li",{},[1978],{"type":31,"value":1979},"Fail the outbox insert and confirm that the business row does not commit.",{"type":26,"tag":1975,"props":1981,"children":1982},{},[1983],{"type":31,"value":1984},"Terminate the producer after its commit and confirm that the relay later publishes the pending event.",{"type":26,"tag":1975,"props":1986,"children":1987},{},[1988],{"type":31,"value":1989},"Let the broker accept an event, then stop the relay before it records success. Confirm redelivery uses the same ID and the consumer effect occurs once.",{"type":26,"tag":1975,"props":1991,"children":1992},{},[1993],{"type":31,"value":1994},"Stop the consumer after its database commit but before acknowledgement. Confirm the duplicate is recognized.",{"type":26,"tag":1975,"props":1996,"children":1997},{},[1998],{"type":31,"value":1999},"Keep the broker unavailable, observe the backlog and alert, then restore it and verify recovery without deleting pending work.",{"type":26,"tag":1975,"props":2001,"children":2002},{},[2003],{"type":31,"value":2004},"Exercise two transitions for the same aggregate with concurrent workers and a delayed first publication. Verify the ordering policy rather than assuming it.",{"type":26,"tag":27,"props":2006,"children":2007},{},[2008],{"type":31,"value":2009},"Also test poison messages, replay after deduplication cleanup, and connector recovery if using CDC. These tests should establish where your guarantees hold and what an operator must do when automatic recovery stops.",{"type":26,"tag":77,"props":2011,"children":2013},{"id":2012},"events-that-maintain-consistency-need-durable-publication",[2014],{"type":31,"value":2015},"Events that maintain consistency need durable publication",{"type":26,"tag":27,"props":2017,"children":2018},{},[2019],{"type":31,"value":2020},"Transactional Outbox is valuable when a committed business change must cause an event and the database and broker do not share an atomic transaction. Systems with different foundations, such as a durable event log as their source of truth, may solve that boundary differently. The requirement is reliable propagation, not a particular table name.",{"type":26,"tag":27,"props":2022,"children":2023},{},[2024],{"type":31,"value":2025},"For a system that relies on events to keep services coherent, the essential question is practical: if this process stops immediately after committing, where is the durable evidence of what still needs to happen?",{"type":26,"tag":27,"props":2027,"children":2028},{},[2029],{"type":31,"value":2030},"An outbox gives that question a concrete answer. Reliable relays, idempotent consumers, ordering rules, and operational recovery turn that answer into a system that can converge after failures.",{"type":26,"tag":77,"props":2032,"children":2034},{"id":2033},"further-reading",[2035],{"type":31,"value":2036},"Further reading",{"type":26,"tag":1971,"props":2038,"children":2039},{},[2040,2050,2060,2070,2080],{"type":26,"tag":1975,"props":2041,"children":2042},{},[2043,2049],{"type":26,"tag":68,"props":2044,"children":2046},{"href":267,"rel":2045},[254],[2047],{"type":31,"value":2048},"Chris Richardson: Transactional Outbox",{"type":31,"value":1819},{"type":26,"tag":1975,"props":2051,"children":2052},{},[2053,2059],{"type":26,"tag":68,"props":2054,"children":2056},{"href":1813,"rel":2055},[254],[2057],{"type":31,"value":2058},"Chris Richardson: Idempotent Consumer",{"type":31,"value":1819},{"type":26,"tag":1975,"props":2061,"children":2062},{},[2063,2069],{"type":26,"tag":68,"props":2064,"children":2066},{"href":393,"rel":2065},[254],[2067],{"type":31,"value":2068},"PostgreSQL: transactions",{"type":31,"value":1819},{"type":26,"tag":1975,"props":2071,"children":2072},{},[2073,2079],{"type":26,"tag":68,"props":2074,"children":2076},{"href":1848,"rel":2075},[254],[2077],{"type":31,"value":2078},"Debezium: Outbox Event Router",{"type":31,"value":1819},{"type":26,"tag":1975,"props":2081,"children":2082},{},[2083,2089],{"type":26,"tag":68,"props":2084,"children":2086},{"href":1874,"rel":2085},[254],[2087],{"type":31,"value":2088},"Debezium: PostgreSQL connector",{"type":31,"value":1819},{"type":26,"tag":2091,"props":2092,"children":2093},"style",{},[2094],{"type":31,"value":2095},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}",{"title":8,"searchDepth":110,"depth":110,"links":2097},[2098,2099,2100,2101,2102,2103,2104,2105,2106,2107,2108,2109],{"id":79,"depth":110,"text":82},{"id":276,"depth":110,"text":279},{"id":302,"depth":110,"text":305},{"id":1066,"depth":110,"text":1069},{"id":1338,"depth":110,"text":1341},{"id":1479,"depth":110,"text":1482},{"id":1832,"depth":110,"text":1835},{"id":1883,"depth":110,"text":1886},{"id":1930,"depth":110,"text":1933},{"id":1961,"depth":110,"text":1964},{"id":2012,"depth":110,"text":2015},{"id":2033,"depth":110,"text":2036},"markdown","content:posts:transactional-outbox.md","content","posts/transactional-outbox.md","posts/transactional-outbox","md",1791120244230]