Transactional outbox

Never wrap an external system call inside your own transaction.

Résumé bullet

Redesigned transaction boundaries between manufacturing operations and financial systems using a staging-table and batch-worker pattern, cutting processing time by 60% and reducing reprocessing from 20 monthly cases to near zero.

Deep-dive
  • Problem: external call failure rolled back local writes too
  • Floor moved forward, but local system recorded nothing
  • Fix 1: split transaction; local write + staging row commit
  • Fix 2: pull external call out; batch worker every minute
  • Carry timestamp on staging row to keep correct business day
  • Result: specific-issue tickets ~0, ~90% category drop
Code
// 1. Local write + a staging row in ONE transaction (Spring)
@Transactional
public void onStageComplete(Product p) {
    saveProductionState(p);
    stagingRepo.save(new Outbox(p.id(), "pending",
                                Instant.now()));   // stamp the post-date NOW
}   // operator returns immediately — no external call in this tx

// 2. A separate worker drains the staging rows every minute
@Scheduled(fixedRate = 60_000)
public void drain() {
    for (Outbox row : stagingRepo.findByStatus("pending")) {
        boolean ok = erpClient.notify(row);        // external ERP call, outside the tx
        row.setStatus(ok ? "success" : "failed");
    }
}
Say it (English)
  • ·Two writes were in one transaction: ours and the external system's.
  • ·When their system failed, ours rolled back too. Floor moved, we recorded nothing.
  • ·Fix one: split it. Save our state plus a staging row, mark the external call separately.
  • ·Fix two: pull the external call out entirely. A worker drains the staging table every minute.
  • ·Specific-issue tickets dropped to near zero, about ninety percent off the category.
Why it matters — Opendoor / as an engineer
  • Opendoor: offer-accept writes the offer + an outbox row in one tx; a worker charges payment. A payment timeout must not roll back the accepted offer.
  • Proves: transaction-boundary + eventual-consistency thinking — core for money flows.
  • Use it whenever a write triggers an external call.
Trade-offs (what you give up)
  • ERP now lags the floor by up to a minute; reads can show stale state.
  • Poll-every-minute worker burns DB queries even when staging is empty.
  • Failed rows need manual or next-cycle retry; no automatic backoff.
  • Post-date stamped at save time drifts if the row sits unprocessed past midnight.
Push it further
  • Replace minute poll with CDC (Debezium) tailing the staging table for low latency.
  • Make the ERP call idempotent via a dedup key so retries can't double-post.
  • Add exponential backoff plus a dead-letter table instead of indefinite manual retry.
  • Partition the staging table by line/region so multiple workers drain in parallel.

Understand it step by step (한국어로 이해 → 영어로 말하기)

우리 시스템에 저장하는 일과, 남의 시스템(외부)에 알려주는 일을 한 묶음(트랜잭션)으로 같이 묶었다. 남의 시스템이 실패하니까 우리 저장까지 같이 취소됐다. 물건은 이미 라인을 떠났는데 우리 기록엔 아무것도 안 남았다.
  1. 1

    공장 라인에서 제품이 한 단계 끝나면, 우리 시스템에 '여기까지 됐다'고 저장한다. 동시에 외부 시스템(ERP, 회사 회계 쪽)에도 그 사실을 알려줘야 한다. 작업자는 이 한 번의 클릭으로 둘 다 끝나길 바란다.

    // MES save + ERP notify: the two writes
    SaveProductionState();        // (1) our system
    CallExternalSystem(order);    // (2) external ERP
    🗣 영어로 말해

    When a product moves on the line, we save it and also tell the external ERP system.

    checking microphone…

  2. 2

    원래는 이 두 가지를 한 묶음으로 묶었다. '둘 다 성공하거나, 둘 다 안 되거나' 하게 만든 것이다. 한쪽만 되는 어중간한 상태를 막으려고 그렇게 한 건데, 이게 문제의 씨앗이었다.

    ⚖️ Trade-off: 한 묶음으로 묶으면 깔끔해 보인다. 둘 다 되거나 둘 다 안 되니까 데이터가 어긋날 일이 없어 보인다.

    ✅ Fix: 문제는 그 묶음 안에 '내가 통제 못 하는 남의 시스템'이 들어있다는 것. 남이 자빠지면 내 것까지 같이 취소된다.

    🔧 도구:@TransactionalSpring Data JPA

    // BUG: external call wrapped in OUR transaction (all-or-nothing)
    @Transactional
    public void onStageComplete(Order order) {
        saveProductionState();
        erpClient.notify(order);   // ERP call INSIDE the tx
    }
    🗣 영어로 말해

    We wrapped both writes in one transaction, so it was all-or-nothing.

    checking microphone…

  3. 3

    어느 날 외부 시스템(ERP)이 응답이 늦거나 에러를 냈다. 그러자 묶음 규칙대로 우리 저장까지 통째로 취소(롤백)됐다. 제품은 진짜로 라인을 떠났는데, 우리 시스템엔 '아직 생산 중'으로 남았다.

    ⚖️ Trade-off: 현실(물건은 갔다)과 기록(아직 생산 중)이 따로 논다. 미국 작업자들이 '끝난 제품이 왜 생산 중이냐'고 신고하면서 들통났다.

    ✅ Fix: 내 일이, 남의 시스템 때문에 같이 취소되면 안 된다. 그게 핵심이다.

    @Transactional
    public void onStageComplete(Order order) {
        saveProductionState();
        erpClient.notify(order);   // throws (timeout/error)
    }   // exception -> the WHOLE tx rolls back, our save included
    🗣 영어로 말해

    When their system failed, ours rolled back too. The floor moved but we recorded nothing.

    checking microphone…

  4. 4

    첫 번째 고침: 묶음을 쪼갰다. 우리 저장과 함께 '할 일 쪽지' 한 장을 같은 묶음으로 적어둔다. 쪽지엔 '아직 외부에 안 알림(대기)'이라고 표시. 외부 호출은 이 묶음에서 빼버린다. 그래서 외부가 실패해도 우리 저장은 무조건 남는다.

    ⚖️ Trade-off: 외부 시스템에 즉시 안 알린다. 둘 사이에 시간 차가 생긴다(잠깐 어긋남).

    ✅ Fix: 쪽지(스테이징 행)에 '대기'로 남겨두니, 나중에 따로 처리하면 된다. 우리 기록은 절대 안 사라진다.

    🔧 도구:staging tablestatus: pending

    // Fix 1: local write + staging row in ONE tx; no external call inside
    @Transactional
    public void onStageComplete(Order order) {
        saveProductionState();
        stagingRepo.save(new Outbox(order.id(), "pending", Instant.now()));
    }   // our state is always durable
    🗣 영어로 말해

    Fix one: split it. Save our state plus a to-do row, and handle the external call separately.

    checking microphone…

  5. 5

    두 번째 고침: 외부 호출을 아예 따로 빼서, 1분마다 도는 일꾼(배치)이 대기 쪽지들을 모아서 ERP에 알려준다. 성공하면 쪽지에 '완료', 실패하면 '실패'라고 적는다. 작업자는 외부가 느려도 안 기다리고 바로 다음 일 한다.

    ⚖️ Trade-off: 1분마다 도니까, ERP는 라인보다 최대 1분 늦는다. 또 쪽지가 없을 때도 일꾼이 헛돈다(쓸데없는 DB조회).

    ✅ Fix: 공장 기록에서 1분 늦는 건 문제 안 된다. 대신 작업자 화면이 안 멈추고, 외부가 죽어도 나중에 재시도하면 된다.

    🔧 도구:background worker (1분 주기)poll pending rows

    // Fix 2: separate worker pulls the external call out, runs every minute
    foreach (var row in GetPendingRows()) {
        var ok = CallExternalSystem(row);   // ERP, off the save path
        row.Status = ok ? "success" : "failed";
    }
    🗣 영어로 말해

    Fix two: pull the external call out. A worker drains the to-do rows every minute.

    checking microphone…

  6. 6

    한 가지 함정: ERP엔 '며칠자 비용'인지(영업일) 날짜가 중요하다. 일꾼이 늦게 돌면 자정을 넘겨서 날짜가 하루 밀릴 수 있다. 그래서 날짜를 '일꾼이 처리한 시각'이 아니라 '작업자가 저장한 시각'으로 쪽지에 미리 찍어뒀다.

    ⚖️ Trade-off: 처리를 늦게 하면, 그 시각으로 날짜를 찍으면 영업일이 틀어진다(비용이 엉뚱한 날에 잡힘).

    ✅ Fix: 쪽지를 만들 때(저장 시점) 날짜를 미리 도장 찍어둔다. 나중에 언제 처리되든 영업일은 정확히 유지된다.

    🔧 도구:postDate stamped at save time

    // Stamp the business day at SAVE time, not at processing time
    InsertStagingRow(status: "pending",
                     postDate: Now()); // worker may run after midnight; date stays correct
    🗣 영어로 말해

    I stamped the date at save time, not at processing time, so the business day stayed correct.

    checking microphone…

  7. 7

    내 마음대로 외부 시스템 방식을 바꾼 게 아니다. 먼저 ERP 팀 매니저를 찾아가 설명하고, 내 매니저와 관계자들을 참조로 넣었다. 30분 대화로 허락을 받고 진행했다.

    ⚖️ Trade-off: 방식을 바꾸면 ERP 팀의 동의가 필요하다. 혼자 결정하면 나중에 갈등이 난다.

    ✅ Fix: 먼저 찾아가 설명하고 합의했다. 짧은 대화 한 번으로 깔끔하게 풀렸다.

    🗣 영어로 말해

    I didn't change their system alone. I talked to the ERP team manager first and got the green light.

    checking microphone…

  8. 8

    결과: '끝난 제품이 생산 중으로 보인다'는 그 특정 신고는 거의 0건이 됐고, 그 종류(SOP 오류) 전체로도 약 90% 줄었다. 교훈은 하나 — 내가 통제 못 하는 외부 호출을 절대 내 트랜잭션 안에 넣지 마라.

    ✅ Fix: Jira 티켓 추이로 확인. 특정 문제는 거의 0, 같은 종류 전체는 약 90% 감소.

    🗣 영어로 말해

    Specific tickets dropped to near zero, about ninety percent off the whole category.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.