Build CoreOrdered learning track

Transaction Boundaries and Business Invariants

Learn Java Large Scale ERP - Part 008

Deep dive into ERP transaction boundaries, business invariants, database transactions, workflow transactions, idempotency, consistency, reversal, and failure containment in Java ERP systems.

15 min read2877 words
PrevNext
Lesson 0834 lesson track0718 Build Core
#java#erp#transactions#invariants+5 more

Part 008 — Transaction Boundaries and Business Invariants

1. Target Skill Part Ini

Part ini menjawab pertanyaan paling penting dalam engineering ERP:

Di mana batas transaksi harus ditempatkan agar uang, stok, approval, ledger, dokumen, dan integrasi tetap benar meskipun sistem gagal, request diulang, user bersamaan, dan proses berjalan lama?

Banyak engineer melihat transaksi sebagai anotasi:

@Transactional

Di ERP besar, itu hanya lapisan teknis. Yang lebih penting adalah business transaction boundary dan business invariant.

Contoh invariant ERP:

  • journal debit harus sama dengan credit;
  • invoice tidak boleh diposting dua kali;
  • stock movement harus punya jejak ledger;
  • closed fiscal period tidak boleh menerima posting baru;
  • purchase order tidak boleh approved oleh creator yang sama;
  • payment release tidak boleh terjadi sebelum invoice approved dan payable;
  • reversal tidak boleh menghapus transaksi original;
  • document number legal tidak boleh reuse;
  • outbox event tidak boleh hilang setelah commit;
  • retry integration tidak boleh menggandakan efek bisnis.

Dalam kerangka Kaufman, part ini adalah fase deconstruct + deliberate practice untuk correctness. Kita memecah transaksi menjadi beberapa jenis, lalu belajar menempatkan boundary dengan sadar.

2. Tiga Jenis Transaksi dalam ERP

Satu kata “transaction” sering dipakai untuk tiga hal berbeda.

JenisArtiContoh
Database transactionUnit atomicity di database.Insert invoice + invoice lines + audit event.
Business transactionUnit perubahan bisnis yang harus menjaga invariant.Approve PO, post journal, receive goods.
Workflow transactionStep dalam proses panjang yang bisa berlangsung hari/minggu.Requisition → approval → PO → receipt → invoice → payment.

Kesalahan umum adalah menganggap workflow panjang bisa dibungkus satu database transaction. Tidak bisa. Database transaction harus pendek. Workflow transaction harus direpresentasikan sebagai state dan event.

Rule utama:

A database transaction protects one atomic write boundary. A business transaction protects one business invariant boundary. A workflow transaction coordinates many business transactions through state.

3. Business Invariant: Jantung Correctness ERP

Invariant adalah kondisi yang harus selalu benar setelah transaksi selesai.

Format mental:

After command C commits, invariant I must hold for aggregate/resource/scope S.

Contoh:

After PostJournalCommand commits,
sum(debit) must equal sum(credit),
all lines must belong to same legal entity and accounting period,
and the period must be open at posting time.

3.1 Kategori Invariant ERP

KategoriContoh Invariant
Financial invariantDebit = credit, accounting period open, currency basis stored.
Inventory invariantStock ledger movement exists for every quantity change.
Lifecycle invariantDraft cannot be posted; cancelled document cannot be approved.
Authorization invariantActor must be authorized at action time.
SoD invariantMaker and checker cannot be same actor on same controlled document.
Idempotency invariantSame external command key cannot create duplicate business effect.
Sequence invariantLegal document number cannot be reused or silently skipped beyond policy.
Integration invariantCommitted business fact must have durable outbox event if integration depends on it.
Reversal invariantPosted transaction is reversed by compensating entry, not deleted.
Reconciliation invariantSubledger balance must reconcile to GL control account.

3.2 Invariant Harus Dinyatakan Eksplisit

Buruk:

invoice.setStatus(POSTED);
invoiceRepository.save(invoice);

Lebih baik:

invoice.post(new PostingContext(
        actorId,
        accountingPeriod,
        authorizationDecisionId,
        postingPolicyVersion
));

Domain method post harus menjaga invariant:

public void post(PostingContext context) {
    requireStatus(InvoiceStatus.APPROVED);
    requireOpenPeriod(context.accountingPeriod());
    requireNotAlreadyPosted();
    requireMatchedOrPolicyAllowsException();
    requireAuthorizationEvidence(context.authorizationDecisionId());

    this.status = InvoiceStatus.POSTED;
    this.postedAt = context.now();
    this.postedBy = context.actorId();
    this.postingPolicyVersion = context.policyVersion();

    registerEvent(new VendorInvoicePosted(this.id, this.vendorId, this.totalAmount));
}

4. Transaction Boundary Placement

Boundary yang benar biasanya berada di application service/use case, bukan di controller dan bukan di repository method kecil.

Contoh:

@Transactional
public PostInvoiceResult postInvoice(PostInvoiceCommand command, ActorId actorId) {
    VendorInvoice invoice = invoiceRepository.getForUpdate(command.invoiceId());

    AuthorizationDecision authz = authorizationService.decidePostInvoice(actorId, invoice);
    if (!authz.allowed()) {
        throw new AccessDeniedBusinessException(authz.reasonCode());
    }

    AccountingPeriod period = periodRepository.getForUpdate(invoice.accountingPeriodId());
    PostingBatch batch = postingService.buildVendorInvoicePosting(invoice, period, actorId, authz);

    invoice.post(batch.postingContext());
    journalRepository.save(batch.journal());
    invoiceRepository.save(invoice);
    auditRepository.save(AuthorizationAudit.from(authz));
    outboxRepository.save(IntegrationEvent.from(new VendorInvoicePosted(invoice.id())));

    return new PostInvoiceResult(invoice.id(), batch.journal().id());
}

Boundary ini melindungi:

  • status invoice;
  • journal creation;
  • authorization evidence;
  • outbox event;
  • audit record.

Jika salah satu gagal, seluruh business transaction gagal.

5. Jangan Membuat Transaction Terlalu Besar

Transaksi database yang terlalu panjang menyebabkan:

  • lock lama;
  • deadlock;
  • connection pool exhaustion;
  • user menunggu;
  • retry mahal;
  • rollback besar;
  • batch job mengganggu transaksi online;
  • operational incident saat peak.

Jangan membungkus hal-hal ini dalam satu DB transaction:

  • panggilan API eksternal;
  • render PDF besar;
  • kirim email;
  • upload file besar;
  • tunggu approval user;
  • proses batch multi-jam;
  • generate report besar;
  • call bank gateway;
  • call tax authority;
  • call warehouse system.

Gunakan durable state + outbox + retry untuk interaksi luar.

6. Jangan Membuat Transaction Terlalu Kecil

Transaction terlalu kecil juga berbahaya.

Contoh buruk:

@Transactional
public void markInvoicePosted(UUID invoiceId) {
    invoiceRepository.markPosted(invoiceId);
}

@Transactional
public void createJournal(UUID invoiceId) {
    journalRepository.save(buildJournal(invoiceId));
}

@Transactional
public void publishEvent(UUID invoiceId) {
    eventPublisher.publish(new InvoicePosted(invoiceId));
}

Jika markInvoicePosted sukses tetapi createJournal gagal, ERP punya invoice posted tanpa journal. Itu merusak invariant.

Rule:

All writes required to make one business fact true must commit atomically.

Untuk InvoicePosted, business fact minimal mencakup:

  • invoice status berubah;
  • journal/subledger entry tercipta;
  • audit evidence disimpan;
  • outbox event tercatat;
  • posting number/sequence dialokasikan sesuai policy.

7. Command Boundary

Setiap command harus punya boundary yang jelas.

Command = intent untuk mengubah state bisnis.
Query = intent untuk membaca state bisnis.

Contoh command ERP:

  • SubmitPurchaseOrderCommand;
  • ApprovePurchaseOrderCommand;
  • ReceiveGoodsCommand;
  • PostVendorInvoiceCommand;
  • ReverseJournalCommand;
  • CloseAccountingPeriodCommand;
  • RunDepreciationCommand.

Command harus punya:

  • command ID;
  • actor ID;
  • target resource;
  • expected version jika optimistic locking;
  • idempotency key jika external/retryable;
  • business timestamp;
  • source channel;
  • correlation ID;
  • payload;
  • reason/comment untuk controlled action.
public record ApprovePurchaseOrderCommand(
        UUID commandId,
        UUID purchaseOrderId,
        long expectedVersion,
        String comment,
        String idempotencyKey,
        Instant requestedAt,
        String sourceChannel,
        String correlationId
) {}

8. Idempotency sebagai Transaction Invariant

Retry adalah fakta hidup. User double-click, gateway retry, message broker redeliver, client timeout, batch restart.

Tanpa idempotency, retry menghasilkan duplicate business effect.

Contoh risiko:

ScenarioTanpa Idempotency
Payment callback retryPayment marked twice, duplicate receipt.
Invoice import retryDuplicate invoice.
Goods receipt retryStock bertambah dua kali.
Journal posting retryDouble posting.
PO approval retryDuplicate approval event atau state conflict.

8.1 Idempotency Key Store

Algorithm:

1. Receive command with idempotency key.
2. Insert idempotency record with unique key.
3. If insert fails:
   a. load existing record;
   b. compare request hash;
   c. return previous result if completed;
   d. reject if same key but different payload.
4. Execute business transaction.
5. Store response reference.
6. Mark idempotency record completed.

Pseudo-code:

@Transactional
public GoodsReceiptResult receiveGoods(ReceiveGoodsCommand command, ActorId actorId) {
    IdempotencyClaim claim = idempotencyService.claim(
            command.idempotencyKey(),
            command.requestHash(),
            "inventory.goods-receipt.receive"
    );

    if (claim.isReplay()) {
        return goodsReceiptRepository.loadResult(claim.responseRef());
    }

    PurchaseOrder po = purchaseOrderRepository.getForUpdate(command.purchaseOrderId());
    GoodsReceipt receipt = po.receive(command.lines(), actorId);
    StockLedgerBatch stockBatch = stockLedgerService.buildForReceipt(receipt);

    goodsReceiptRepository.save(receipt);
    stockLedgerRepository.saveAll(stockBatch.entries());
    outboxRepository.save(IntegrationEvent.from(new GoodsReceived(receipt.id())));

    idempotencyService.complete(claim, receipt.id().toString());

    return new GoodsReceiptResult(receipt.id());
}

9. Transaction Isolation: Jangan Asumsikan Serial

Database isolation menentukan anomali yang mungkin terjadi ketika transaksi berjalan bersamaan.

Read committed sering cukup untuk workload biasa, tetapi ERP punya banyak transaksi yang rentan terhadap race:

  • stock reservation;
  • approval limit check;
  • period close vs posting;
  • invoice number allocation;
  • budget consumption;
  • credit limit check;
  • duplicate vendor invoice check;
  • inventory valuation batch.

PostgreSQL, misalnya, mendokumentasikan beberapa isolation level seperti Read Committed, Repeatable Read, dan Serializable. Namun pilihan isolation bukan pengganti desain invariant. Spring dan Jakarta menyediakan abstraction transaction, tetapi database tetap menentukan perilaku concurrency aktual.

9.1 Common Anomalies

AnomalyContoh ERP
Lost updateDua user update PO line, perubahan salah satu hilang.
Non-repeatable readAmount invoice berubah antara validation dan posting.
Phantom readDuplicate invoice check lolos karena transaksi lain insert invoice serupa.
Write skewDua approver masing-masing melihat limit masih tersedia lalu keduanya approve.
Stale readUser approve dokumen yang state-nya sudah berubah.

9.2 Cara Menangani

MasalahTeknik
Lost updateOptimistic locking dengan version.
State transition raceSELECT FOR UPDATE atau optimistic compare-and-swap.
Duplicate external documentUnique constraint pada natural business key.
Stock oversellReservation row lock atau atomic decrement dengan constraint.
Period close raceLock accounting period saat posting/closing.
Budget overrunBudget bucket lock atau serializable transaction untuk bucket.
Sequence collisionDatabase sequence atau sequence allocator dengan lock.

10. Optimistic Locking

Optimistic locking cocok ketika konflik jarang dan retry user masuk akal.

@Entity
class PurchaseOrderEntity {
    @Id
    private UUID id;

    @Version
    private long version;

    private String status;
}

Command membawa expected version:

public record SubmitPurchaseOrderCommand(
        UUID purchaseOrderId,
        long expectedVersion,
        String comment
) {}

Jika version berubah, tolak dengan business error:

Purchase order changed since you opened it. Please reload before submitting.

Optimistic locking cocok untuk:

  • edit draft document;
  • approval task detail;
  • master data maintenance;
  • configuration editing.

Kurang cocok untuk:

  • high-contention stock bucket;
  • sequence generation;
  • period close;
  • payment release batch;
  • hot budget bucket.

11. Pessimistic Locking

Pessimistic lock cocok ketika konflik mahal dan invariant harus diproteksi langsung.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from AccountingPeriodEntity p where p.id = :id")
Optional<AccountingPeriodEntity> findForUpdate(UUID id);

Gunakan untuk:

  • accounting period close vs posting;
  • stock reservation bucket;
  • document number allocator;
  • budget commitment bucket;
  • settlement batch membership.

Jangan gunakan secara membabi-buta. Lock yang tidak perlu akan menghancurkan throughput.

12. Period Lock vs Posting Race

Scenario:

T1: User posts invoice into June period.
T2: Finance closes June period.

Jika tidak dilindungi, invoice bisa masuk setelah period dianggap closed.

Desain:

Invariant:

No posting may commit into a period after the period close transaction commits.

Implementasi bisa memakai row lock pada period atau mekanisme lebih advanced, tetapi invariant harus eksplisit.

13. Stock Reservation Race

Scenario:

Available stock = 10
Order A reserves 8
Order B reserves 8

Tanpa boundary benar, dua order bisa sama-sama berhasil.

Pilihan desain:

13.1 Lock Stock Bucket

SELECT * FROM stock_bucket
WHERE item_id = ? AND warehouse_id = ?
FOR UPDATE;

Lalu update:

available = on_hand - reserved
if available >= requested_qty:
    reserved += requested_qty
else:
    reject

13.2 Atomic Conditional Update

UPDATE stock_bucket
SET reserved_qty = reserved_qty + :qty
WHERE item_id = :itemId
  AND warehouse_id = :warehouseId
  AND on_hand_qty - reserved_qty >= :qty;

Jika affected row = 0, reject.

13.3 Reservation Ledger

Untuk auditability lebih kuat:

stock_reservation:
  reservation_id
  item_id
  warehouse_id
  source_document_id
  qty
  status

stock_ledger:
  movement_id
  item_id
  warehouse_id
  qty_delta
  movement_type
  source_document_id

Stock bucket adalah projection/cache dari ledger + reservation, bukan satu-satunya bukti.

14. Financial Posting Boundary

Financial posting adalah boundary paling sensitif.

Minimal invariant:

A posting batch must be balanced per legal entity, currency basis, accounting period, and ledger.

Contoh model:

public final class PostingBatch {
    private final List<JournalLine> lines;

    public void validate() {
        requireAtLeastTwoLines();
        requireSameLegalEntity();
        requireSameAccountingPeriod();
        requireBalanced();
        requireNoNegativeDebitCredit();
    }

    private void requireBalanced() {
        Money debit = lines.stream()
                .map(JournalLine::debit)
                .reduce(Money.zero(currency()), Money::add);

        Money credit = lines.stream()
                .map(JournalLine::credit)
                .reduce(Money.zero(currency()), Money::add);

        if (!debit.equals(credit)) {
            throw new UnbalancedJournalException(debit, credit);
        }
    }
}

Posting transaction harus menyimpan:

  • source document reference;
  • generated journal;
  • journal lines;
  • posting audit;
  • status source document;
  • outbox event;
  • idempotency result jika command retryable.

15. Outbox sebagai Bagian dari Transaction Boundary

Jika sistem lain harus tahu bahwa invoice posted, event harus durable bersama commit.

Buruk:

invoiceRepository.save(invoice);
eventPublisher.publish(new InvoicePosted(invoice.id()));

Jika publish gagal setelah commit, event hilang.

Lebih baik:

@Transactional
public void postInvoice(...) {
    invoice.post(...);
    invoiceRepository.save(invoice);
    journalRepository.save(journal);
    outboxRepository.save(OutboxMessage.from(new InvoicePosted(invoice.id())));
}

Outbox worker mengirim setelah commit.

Invariant:

If business fact F commits and external consumers depend on F,
then durable integration intent for F must commit in the same DB transaction.

16. Transaction Propagation: Gunakan dengan Hati-Hati

Spring dan Jakarta mendukung berbagai mode transaction propagation. Ini berguna, tetapi sering disalahgunakan.

16.1 REQUIRED

Default yang paling sering benar:

Join existing transaction or create one if absent.

Cocok untuk application service utama.

16.2 REQUIRES_NEW

Membuat transaksi baru dan memisahkan commit dari outer transaction.

Risiko di ERP:

Outer transaction rollback, inner audit/payment/status transaction commit.

Kadang berguna untuk audit teknis, tetapi berbahaya untuk business fact yang harus atomik dengan outer transaction.

Contoh berbahaya:

@Transactional
public void postInvoice(...) {
    invoice.markPosted();
    auditService.auditPostedRequiresNew(invoice.id());
    throw new RuntimeException("journal failed");
}

Audit mengatakan invoice posted, tetapi invoice transaction rollback. Ini membingungkan kecuali audit event diberi semantik “attempt”, bukan “committed fact”.

16.3 NESTED

Nested transaction dengan savepoint bisa berguna untuk partial rollback dalam satu physical transaction, tetapi tidak semua transaction manager/resource mendukungnya sama. Spring mendokumentasikan bahwa nested propagation biasanya dipetakan ke JDBC savepoint untuk resource transaction tertentu.

Gunakan hanya jika engineer benar-benar memahami resource manager yang dipakai.

17. External Call Boundary

Jangan call external system di dalam DB transaction.

Buruk:

@Transactional
public void releasePayment(...) {
    Payment payment = paymentRepository.getForUpdate(id);
    bankClient.transfer(payment.toBankRequest());
    payment.markReleased();
    paymentRepository.save(payment);
}

Masalah:

  • DB lock terbuka saat menunggu bank;
  • bank bisa sukses tapi DB rollback;
  • bank bisa timeout tapi sebenarnya sukses;
  • retry bisa membayar dua kali.

Lebih baik:

1. Validate and create PaymentInstruction in DB transaction.
2. Commit instruction + outbox command.
3. Worker sends to bank with idempotency key.
4. Bank callback updates status idempotently.
5. Reconciliation confirms final state.

18. Reversal, Cancellation, Compensation

ERP tidak boleh menghapus fakta bisnis yang sudah berdampak.

IstilahKapan DipakaiEfek
CancellationSebelum posting/finalizationDokumen dibatalkan tanpa reversing ledger.
ReversalSetelah postingBuat entry lawan untuk menetralkan dampak.
CorrectionMemperbaiki data non-financial dengan auditTambah correction event.
CompensationProses bisnis lawan pada workflow/distributed processMisalnya cancel shipment request setelah payment failure.

Invariant reversal:

Original posted transaction remains immutable.
Reversal references original transaction.
Reversal posts equal and opposite financial effect according to policy.

Contoh:

@Transactional
public ReverseJournalResult reverseJournal(ReverseJournalCommand command, ActorId actorId) {
    JournalEntry original = journalRepository.getPostedForUpdate(command.originalJournalId());
    AccountingPeriod reversalPeriod = periodRepository.getOpenPeriod(command.reversalPeriodId());

    AuthorizationDecision authz = authorizationService.decideReverseJournal(actorId, original);
    if (!authz.allowed()) {
        throw new AccessDeniedBusinessException(authz.reasonCode());
    }

    JournalEntry reversal = original.createReversal(
            reversalPeriod,
            actorId,
            command.reason(),
            authz.decisionId()
    );

    journalRepository.save(reversal);
    journalRepository.markReversed(original.id(), reversal.id());
    outboxRepository.save(IntegrationEvent.from(new JournalReversed(original.id(), reversal.id())));

    return new ReverseJournalResult(reversal.id());
}

19. Sequence and Numbering Boundary

Legal document number sering punya constraint khusus:

  • unique;
  • gap policy;
  • per legal entity;
  • per document type;
  • per fiscal year/period;
  • auditable;
  • tidak boleh reuse;
  • reserved/cancelled number harus tercatat.

Jangan generate nomor legal di frontend.

Model:

Allocation harus atomic.

lock sequence row
read next_value
insert allocation
increment next_value
commit

Jika dokumen gagal setelah number allocated, policy harus jelas:

  • boleh gap dengan audit;
  • reserve sampai completion;
  • allocate only at posting;
  • temporary draft number berbeda dari legal number.

20. Batch Transaction Boundary

ERP punya batch berat:

  • depreciation;
  • MRP;
  • stock valuation;
  • invoice import;
  • payment proposal;
  • aging calculation;
  • statement generation;
  • period close.

Jangan satu batch besar dalam satu transaction.

Desain batch yang sehat:

job_run
  status: RUNNING | COMPLETED | FAILED | PARTIALLY_COMPLETED
  input_snapshot
  policy_version
  started_by

job_chunk
  chunk_id
  status
  item_range
  retry_count
  checksum

Setiap chunk punya transaksi sendiri dan idempotency sendiri.

21. Validation Boundary

Validasi ERP punya beberapa level.

LevelContohTempat
Syntactic validationRequired field, format, enumAPI/request DTO
Semantic validationVendor active, item purchasableApplication/domain
Policy validationAmount limit, approval matrixPolicy service
State validationDraft can submit, approved can postDomain state machine
Referential validationAccount exists, period openApplication/domain/repository
Cross-document validationPO, GR, invoice matchApplication/domain service
Reconciliation validationSubledger equals GLBatch/report control

Jangan mengandalkan database constraint untuk semua validasi. Database constraint penting, tetapi banyak invariant ERP butuh konteks bisnis.

22. Error Semantics

ERP harus membedakan error:

Error TypeContohRetry?
Validation errorAmount negativeNo
Authorization errorUser lacks approval authorityNo, unless access changes
State conflictDocument already approvedMaybe reload
Optimistic conflictVersion mismatchUser reload/retry
Transient technical errorDeadlock, timeoutAutomated retry possible
External uncertain resultBank timeoutReconcile before retry
Invariant violationUnbalanced journalNo; bug/data issue

Jangan retry semua error. Retry tanpa klasifikasi bisa menggandakan dampak.

23. Deadlock Handling

Deadlock akan terjadi di ERP besar. Yang penting adalah desain agar:

  • jarang;
  • terdeteksi;
  • aman diretry;
  • tidak menghasilkan duplicate effect.

Prinsip:

  • lock resource dalam urutan konsisten;
  • transaksi pendek;
  • gunakan idempotency untuk retry;
  • hindari query besar dalam transaction;
  • gunakan pagination/chunk untuk batch;
  • pisahkan read model dari write model;
  • log deadlock dengan business context.

Contoh lock ordering:

Always lock:
1. tenant/legal entity control row
2. accounting period
3. source document
4. ledger/stock/budget bucket
5. sequence row

Urutan tergantung domain, tetapi harus konsisten.

24. Transaction Evidence

Setiap controlled transaction harus menghasilkan evidence.

Minimal evidence:

transaction_id
command_id
actor_id
resource_id
before_state
after_state
business_time
system_time
authorization_decision_id
policy_version
idempotency_key
correlation_id

Untuk financial posting:

posting_batch_id
source_document_ref
journal_id
accounting_period_id
legal_entity_id
balanced_amount
currency
posting_policy_version

Evidence membuat transaksi defensible saat audit, support, dan dispute.

25. Transaction Design Template

Gunakan template ini saat merancang use case ERP.

Use Case:
Command:
Actor:
Resource:
Business Fact Created:
Preconditions:
Invariants:
Rows/aggregates locked:
Writes in same DB transaction:
Events/outbox written:
Idempotency key:
Authorization evidence:
Failure modes:
Retry policy:
Reversal/cancellation path:
Audit evidence:

Contoh ringkas:

Use Case: Post Vendor Invoice
Command: PostVendorInvoiceCommand
Actor: AP Accountant / Finance Manager
Resource: VendorInvoice
Business Fact: VendorInvoicePosted
Preconditions:
  - invoice approved
  - invoice matched or exception approved
  - accounting period open
  - actor authorized
Invariants:
  - invoice posted once
  - journal balanced
  - subledger entry exists
  - outbox event durable
Rows locked:
  - vendor_invoice
  - accounting_period
  - document_sequence
Writes:
  - invoice.status = POSTED
  - journal + lines
  - subledger entry
  - audit event
  - outbox event
Retry:
  - idempotency key by invoice + posting command
Reversal:
  - reverse invoice posting via reversing journal

26. Anti-Patterns

Anti-PatternGejalaDampak
Transaction at controller onlySemua endpoint diberi annotation tanpa desain boundaryBoundary tidak sesuai invariant.
Repository transaction fragmentsSetiap repository save punya transaction sendiriBusiness fact commit parsial.
Long transaction with external callDB lock sambil call bank/APITimeout, rollback, duplicate external effect.
No idempotencyRetry membuat duplicate invoice/payment/receiptData korup.
Delete posted factPosted journal dihapus saat salahAudit rusak.
Event after commit without outboxEvent hilang saat publish gagalSistem lain tidak sinkron.
Read committed assumed safeRace tidak diujiOversell, over-budget, duplicate approval.
REQUIRES_NEW everywhereInner transaction commit saat outer rollbackEvidence/fact kontradiktif.
Batch as one transactionPeriod close atau import jutaan row dalam 1 txLock panjang, rollback mahal.
No lock orderingDeadlock acak di productionIncident sulit dianalisis.

27. Testing Strategy

27.1 Invariant Tests

Tulis test yang menegaskan invariant, bukan hanya happy path.

@Test
void postingVendorInvoiceCreatesBalancedJournalAndOutboxAtomically() {
    PostInvoiceCommand command = fixture.validPostInvoiceCommand();

    PostInvoiceResult result = service.postInvoice(command, fixture.apAccountant());

    VendorInvoice invoice = invoiceRepository.get(result.invoiceId());
    JournalEntry journal = journalRepository.get(result.journalId());
    List<OutboxMessage> outbox = outboxRepository.findByAggregateId(result.invoiceId());

    assertThat(invoice.status()).isEqualTo(InvoiceStatus.POSTED);
    assertThat(journal.isBalanced()).isTrue();
    assertThat(outbox).anyMatch(e -> e.type().equals("VendorInvoicePosted"));
}

27.2 Rollback Test

Simulasikan failure setelah sebagian write.

@Test
void invoicePostingRollsBackJournalWhenOutboxInsertFails() {
    outboxRepository.failNextInsert();

    assertThatThrownBy(() -> service.postInvoice(command, actor))
            .isInstanceOf(OutboxWriteException.class);

    assertThat(invoiceRepository.get(command.invoiceId()).status())
            .isEqualTo(InvoiceStatus.APPROVED);
    assertThat(journalRepository.findBySource(command.invoiceId()))
            .isEmpty();
}

27.3 Concurrency Test

Simulasikan dua transaksi bersamaan:

  • dua user approve dokumen sama;
  • post invoice saat period close;
  • dua order reserve stock sama;
  • duplicate invoice import;
  • budget consumption parallel.

Gunakan barrier/latch pada integration test untuk memaksa interleaving.

27.4 Idempotency Test

@Test
void repeatedGoodsReceiptCommandReturnsSameResultWithoutDuplicatingStock() {
    ReceiveGoodsCommand command = fixture.receiveGoodsCommand("idem-123");

    GoodsReceiptResult first = service.receiveGoods(command, actor);
    GoodsReceiptResult second = service.receiveGoods(command, actor);

    assertThat(second.receiptId()).isEqualTo(first.receiptId());
    assertThat(stockLedgerRepository.findBySource(first.receiptId()))
            .hasSize(command.lines().size());
}

28. Observability untuk Transaction Boundary

Monitor bukan hanya CPU dan latency. Untuk ERP transaction, monitor:

  • posting success/failure count;
  • idempotency replay rate;
  • deadlock count per use case;
  • optimistic lock conflict rate;
  • transaction duration percentile;
  • outbox lag;
  • rollback count by reason;
  • period close duration;
  • stock reservation rejection rate;
  • duplicate constraint violation;
  • reconciliation mismatch count.

Log harus membawa business context:

{
  "event": "erp.transaction.failed",
  "useCase": "PostVendorInvoice",
  "invoiceId": "inv-123",
  "legalEntity": "PT-A",
  "period": "2026-06",
  "actorId": "usr-rina",
  "reasonCode": "PERIOD_CLOSED",
  "correlationId": "req-789"
}

29. 20-Hour Practice Drill

Drill 1 — Identify Invariants

Ambil 5 use case:

  • approve PO;
  • receive goods;
  • post vendor invoice;
  • release payment;
  • reverse journal.

Untuk masing-masing, tulis minimal 10 invariant.

Drill 2 — Boundary Template

Isi transaction design template untuk setiap use case. Pastikan jelas rows mana yang dilock dan writes mana yang atomik.

Drill 3 — Idempotency

Desain idempotency key untuk:

  • invoice import;
  • bank payment callback;
  • goods receipt API;
  • external order creation.

Tentukan request hash dan replay response.

Drill 4 — Concurrency Simulation

Buat integration test dengan dua thread untuk:

  • stock reservation race;
  • period close vs posting;
  • duplicate invoice number allocation.

Drill 5 — Failure Injection

Inject failure setelah:

  • invoice status update;
  • journal insert;
  • outbox insert;
  • audit insert;
  • external callback processing.

Pastikan invariant tetap benar.

30. Ringkasan

ERP correctness bergantung pada kemampuan menempatkan transaction boundary berdasarkan invariant bisnis.

Pegangan utama:

  • database transaction harus pendek;
  • workflow panjang harus dimodelkan sebagai state, bukan satu transaction besar;
  • semua write yang membuat satu business fact benar harus commit atomically;
  • authorization evidence, audit, dan outbox sering bagian dari boundary;
  • external call tidak boleh berada di dalam DB transaction;
  • retry harus idempotent;
  • posted fact tidak dihapus, tetapi dibalik dengan reversal;
  • concurrency anomaly harus dipikirkan secara eksplisit;
  • lock harus dipakai selektif dan konsisten;
  • transaction evidence harus cukup untuk audit dan support.

Mental model yang harus dibawa:

@Transactional is not the design. It is only the mechanism used after the boundary and invariant are understood.

Part berikutnya akan masuk ke General Ledger Accounting Engine. Di sana kita akan menerapkan prinsip transaksi dan invariant ini ke domain paling sensitif dalam ERP: double-entry accounting, journal, fiscal period, posting engine, dan close process.

31. Source Notes

Materi ini disusun dengan mengacu pada prinsip umum dan dokumentasi resmi berikut:

  • Jakarta Transactions specification dan tutorial untuk konsep transaction demarcation dan transaction attribute.
  • Spring Framework transaction documentation untuk propagation, declarative transaction management, dan batas penggunaan nested/savepoint.
  • PostgreSQL official documentation untuk isolation level dan perilaku concurrency database relasional.
  • Pola Transactional Outbox yang banyak digunakan untuk menjaga atomicity antara perubahan database dan intent publikasi event.
  • Praktik umum ERP/accounting system: period lock, posting batch, reversal, stock ledger, document numbering, idempotency, dan reconciliation.
Lesson Recap

You just completed lesson 08 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.