Two Write Models, Bypassed Entity State, Hidden Flush, Duplicate Validation, and Inconsistent Filters
Anti-Patterns in Mixing MyBatis and JPA
Anti-pattern utama ketika MyBatis dan JPA/Hibernate dipakai bersama tanpa ownership, transaction ordering, cache discipline, dan invariant consistency yang jelas.
Part 022 — Anti-Patterns in Mixing MyBatis and JPA
Part sebelumnya membahas pola aman.
Part ini membahas kebalikannya: pola yang terlihat praktis di awal tetapi biasanya menciptakan bug persistence yang sulit dilacak.
Anti-pattern mixing MyBatis dan JPA biasanya tidak langsung gagal saat development.
Mereka gagal ketika:
- traffic concurrent meningkat,
- transaction lebih panjang,
- cache diaktifkan,
- endpoint command bertambah kompleks,
- migration mengubah schema,
- audit/compliance membutuhkan traceability,
- retry/idempotency mulai dipakai,
- rollback partial terjadi,
- production incident menuntut root cause yang jelas.
Prinsip review:
The danger is not that two frameworks exist.
The danger is that two frameworks silently own the same data semantics.
1. Anti-Pattern: One Table, Two Write Models
Ini anti-pattern paling umum.
Contoh:
JPA entity QuoteEntity maps to quote table.
MyBatis QuoteMapper also updates quote table.
Both paths can change quote.status, quote.total_amount, updated_at, updated_by.
Masalahnya bukan hanya duplicate SQL.
Masalahnya adalah duplicate model of truth.
Pertanyaan yang sulit dijawab:
- siapa yang menjaga invariant status transition?
- siapa yang update
version? - siapa yang update audit fields?
- siapa yang publish outbox event?
- siapa yang menerapkan tenant/soft delete filter?
- siapa yang tahu entity sedang managed di persistence context?
- siapa yang bertanggung jawab jika data inconsistent?
Smell
JPA has QuoteEntity.approve()
MyBatis has updateQuoteStatusToApproved()
Itu bukan “dua cara praktis”.
Itu dua write model.
2. Anti-Pattern: Entity State Bypassed by Mapper
JPA/Hibernate mengelola object state di persistence context.
MyBatis mengirim SQL langsung ke database.
Jika mapper mengubah row yang sudah di-load oleh EntityManager, JPA tidak otomatis tahu.
Contoh:
QuoteEntity quote = entityManager.find(QuoteEntity.class, quoteId);
quoteMapper.updateStatus(quoteId, "APPROVED");
if (quote.isDraft()) {
quote.cancel();
}
Dalam memory, quote bisa masih DRAFT.
Database sudah APPROVED.
Bug muncul karena dua state berbeda hidup bersamaan.
Failure Modes
- stale read dari managed entity,
- overwrite update saat flush,
- derived field dihitung dari state lama,
- audit event salah,
- business rule dievaluasi terhadap object stale,
- response API mengembalikan state yang tidak sama dengan database.
3. Anti-Pattern: Mapper Updates Behind EntityManager Then JPA Flushes Later
Ini versi lebih berbahaya.
QuoteEntity quote = entityManager.find(QuoteEntity.class, id);
quote.setCustomerReference("A");
quoteMapper.updateStatus(id, "APPROVED");
quote.setStatus("CANCELLED");
// commit triggers flush
Urutan SQL bisa berakhir seperti:
UPDATE quote SET status = 'APPROVED' WHERE id = ?;
UPDATE quote SET customer_reference = 'A', status = 'CANCELLED' WHERE id = ?;
Developer mungkin mengira status final APPROVED.
JPA flush bisa menjadikannya CANCELLED.
Root Cause
MyBatis changed database state.
JPA still held an older managed object.
Flush wrote managed object state later.
Review Rule
Jika MyBatis write terjadi setelah JPA load entity yang sama, PR harus menjelaskan:
- apakah entity akan di-refresh,
- apakah persistence context akan di-clear,
- apakah JPA mutation dihentikan setelah mapper write,
- apakah version check mencegah overwrite,
- apakah test membuktikan final DB state.
4. Anti-Pattern: Hidden Flush Before Mapper Query
Hibernate bisa flush sebelum query tertentu agar query JPA konsisten dengan persistence context.
Namun MyBatis query tidak otomatis memahami persistence context.
Yang sering terjadi:
quote.approve();
// Developer expects MyBatis query to see approved state.
QuoteSummary summary = quoteMapper.findSummary(id);
Jika JPA belum flush, MyBatis membaca database lama.
Developer lalu menambahkan query JPA sebelum mapper:
entityManager.createQuery("select q from QuoteEntity q where q.id = :id")
.setParameter("id", id)
.getSingleResult();
QuoteSummary summary = quoteMapper.findSummary(id);
Ini buruk karena flush behavior menjadi efek samping tidak eksplisit.
Yang benar adalah:
entityManager.flush();
QuoteSummary summary = quoteMapper.findSummary(id);
Atau desain ulang response agar tidak perlu mapper read di command transaction.
5. Anti-Pattern: Duplicate Transaction Boundaries
Contoh buruk:
Application service transaction starts.
JPA joins transaction.
MyBatis opens its own SqlSession/connection with autocommit or independent transaction.
Akibat:
- JPA rollback tetapi MyBatis sudah commit,
- MyBatis rollback tetapi JPA commit,
- lock duration berbeda,
- isolation berbeda,
- connection pool usage membengkak,
- test lokal lolos tetapi production failure saat exception path.
Smell
- manual
SqlSession.openSession()di dalam service transactional, - manual commit/rollback di mapper helper,
- DataSource berbeda tanpa alasan jelas,
- transaction annotation tidak mencakup mapper execution,
- autocommit behavior tidak diketahui.
Required Review
Pastikan:
JPA and MyBatis participate in the same physical transaction when used in one command flow.
Jika tidak, harus ada alasan eksplisit dan failure handling yang matang.
6. Anti-Pattern: Duplicate Optimistic Locking
JPA biasanya memakai @Version.
MyBatis bisa memakai manual version check:
UPDATE quote
SET status = #{status}, version = version + 1
WHERE id = #{id}
AND version = #{expectedVersion}
Keduanya valid jika masing-masing menjadi owner write path.
Anti-pattern terjadi ketika keduanya update version untuk aggregate yang sama tanpa koordinasi.
Contoh:
JPA increments version at flush.
MyBatis also increments version earlier.
JPA flush now has stale version.
Atau lebih buruk:
MyBatis update does not increment version.
JPA later overwrites because optimistic lock cannot detect external change.
Review Questions
- Apakah semua write path increment version?
- Apakah JPA tahu versi terbaru setelah MyBatis write?
- Apakah MyBatis WHERE clause memeriksa expected version?
- Apakah exception stale object dipetakan ke error domain yang benar?
- Apakah retry behavior aman?
Jika jawabannya tidak jelas, jangan mix write path.
7. Anti-Pattern: Duplicate Validation
Contoh:
// JPA/domain path
quote.approve(); // validates status transition
// MyBatis path
quoteMapper.updateStatus(id, "APPROVED"); // bypasses domain validation
JPA path menjaga invariant.
MyBatis path mengubah state langsung.
Semakin banyak validation duplicate, semakin besar risiko drift.
Typical Drift
- JPA mencegah approval quote expired, MyBatis tidak.
- JPA mengecek catalog version, MyBatis tidak.
- JPA mengecek tenant access, MyBatis lupa predicate tenant.
- JPA mengisi audit fields, MyBatis tidak.
- MyBatis update status tanpa outbox event.
Better Pattern
Letakkan invariant penting di:
- domain/application service,
- database constraint jika memungkinkan,
- single command repository owner,
- shared validation component bila benar-benar diperlukan.
Jangan mengandalkan dua persistence framework untuk menjaga invariant secara paralel.
8. Anti-Pattern: Duplicate Audit
Audit yang inconsistent adalah bug compliance dan debugging.
Contoh:
JPA entity listener sets updated_at and updated_by.
MyBatis SQL manually sets updated_at but forgets updated_by.
Database trigger also writes audit row.
Hasil:
- audit row ganda,
updated_bykosong,- timestamp tidak konsisten,
- audit source tidak jelas,
- event history tidak cocok dengan final state,
- incident investigation menjadi sulit.
Rule
One audit ownership model per table or aggregate.
Pilihan bisa:
- application-level audit,
- JPA listener,
- MyBatis explicit SQL,
- database trigger,
- outbox/change history.
Tetapi jangan campur tanpa contract.
9. Anti-Pattern: Inconsistent Soft Delete
Soft delete tampak sederhana:
deleted = false
Dalam sistem campuran, soft delete sering bocor.
Contoh:
JPA uses @Where or filter deleted = false.
MyBatis query forgets deleted = false.
Akibat:
- API search menampilkan data deleted,
- command memakai row yang seharusnya tidak aktif,
- uniqueness check salah,
- reporting tidak konsisten,
- cache menyimpan data deleted.
Smell
- predicate soft delete tersebar manual di banyak mapper,
- JPA filter tidak punya equivalent MyBatis contract,
- integration test hanya memakai data non-deleted,
- index tidak mempertimbangkan partial active rows.
Required Safeguard
- standard predicate,
- mapper fragment/shared SQL jika sesuai,
- test dengan deleted row,
- database constraint/index awareness,
- explicit exception untuk admin/audit query yang memang melihat deleted rows.
10. Anti-Pattern: Inconsistent Tenant Filter
Ini lebih serius daripada soft delete.
Cross-tenant leak adalah security incident.
Contoh:
JPA tenant filter automatically adds tenant_id.
MyBatis mapper uses WHERE id = #{id} only.
Jika ID global tidak cukup atau bug input terjadi, data tenant lain bisa terbaca.
Rule
Every MyBatis query in tenant-scoped data must bind tenant context explicitly unless database-level RLS enforces it.
Dan jika JPA memakai tenant filter, jangan berasumsi MyBatis otomatis ikut.
Review Checklist
- Apakah query MyBatis memiliki
tenant_id = ?? - Apakah update/delete juga tenant-scoped?
- Apakah cache key memasukkan tenant?
- Apakah integration test mencoba tenant lain?
- Apakah PostgreSQL Row-Level Security digunakan?
- Apakah service account DB bisa bypass RLS?
11. Anti-Pattern: Inconsistent Security Filter
Selain tenant, query bisa membutuhkan filter keamanan lain:
- owner user,
- organization,
- business unit,
- product family,
- data classification,
- role-based visibility,
- regulatory scope,
- region/legal entity.
JPA bisa punya filter atau specification.
MyBatis bisa lupa menambahkan predicate.
Anti-pattern:
Access control lives inside JPA query abstraction, but MyBatis query bypasses it.
Better:
- enforce access in application service before repository call,
- use database constraint/RLS jika sesuai,
- require security predicate in mapper contract,
- write negative integration tests.
12. Anti-Pattern: Inconsistent Timestamp Handling
Timestamp field tampak kecil, tetapi penting untuk audit, sync, event ordering, and SLA.
Contoh drift:
| Path | updated_at Source |
|---|---|
| JPA | application server clock |
| MyBatis | now() PostgreSQL |
| Trigger | database server clock |
| Batch | external job clock |
Semua bisa valid.
Yang berbahaya adalah tidak konsisten tanpa disadari.
Akibat:
- ordering event salah,
- CDC/outbox processing salah,
- cache invalidation salah,
- conflict resolution salah,
- audit timeline membingungkan.
Review Rule
For each table, define whether timestamps come from application, database, trigger, or event source.
13. Anti-Pattern: Mapper Read Uses Different Semantics Than JPA Read
Contoh:
JPA findActiveQuote(id) returns only non-deleted, tenant-scoped, latest version.
MyBatis findQuoteSummary(id) joins all rows and chooses max(updated_at).
Keduanya terlihat membaca quote.
Tetapi semantics berbeda.
Akibat:
- command page dan summary page beda status,
- approval decision memakai data berbeda dari display,
- user melihat total berbeda dari yang diproses,
- incident susah direproduksi karena endpoint berbeda membaca model berbeda.
Fix
Tentukan query semantics:
- active vs all,
- current vs historical,
- tenant-scoped vs admin,
- command state vs projection state,
- read-after-write consistent vs eventual consistent,
- includes deleted/expired or not.
14. Anti-Pattern: MyBatis Used to Patch Around Bad JPA Mapping
Kadang developer memakai MyBatis karena JPA mapping lambat atau sulit.
Ini bisa benar.
Tetapi bisa juga menjadi tambalan yang menyembunyikan problem desain.
Smell:
JPA relationship causes N+1.
Instead of fixing fetch strategy or projection boundary,
new MyBatis query is added inside the same aggregate command flow.
Pertanyaan review:
- Apakah MyBatis query benar-benar read projection?
- Apakah relationship mapping JPA harus diperbaiki?
- Apakah aggregate terlalu besar?
- Apakah endpoint seharusnya query-only?
- Apakah DTO projection lebih tepat daripada entity loading?
MyBatis bukan duct tape untuk ORM model yang tidak dipahami.
15. Anti-Pattern: Native Query in JPA and MyBatis Duplicate the Same SQL
Kadang codebase memiliki:
QuoteRepository.java -> @NamedNativeQuery / entityManager native query
QuoteMapper.xml -> same SELECT with small differences
Akibat:
- satu query dioptimasi, yang lain tertinggal,
- predicate tenant/soft-delete berbeda,
- pagination berbeda,
- column alias berbeda,
- performance regression hanya di salah satu endpoint,
- sulit mengetahui query mana yang canonical.
Rule
Do not maintain duplicate canonical SQL in JPA native query and MyBatis mapper unless there is a documented reason.
Jika query SQL-first, biasanya MyBatis lebih jujur.
Jika query entity lifecycle, JPA lebih sesuai.
16. Anti-Pattern: MyBatis Bulk Update While Hibernate Second-Level Cache Is Active
Ini sangat berbahaya.
Flow:
Hibernate caches QuoteEntity in L2 cache.
MyBatis bulk updates quote.status directly in DB.
Hibernate later reads QuoteEntity from L2 cache.
Application sees old status.
PostgreSQL benar.
MyBatis benar.
Hibernate cache juga “benar” menurut informasi yang ia punya.
Sistem secara keseluruhan salah.
Fix Options
- jangan aktifkan L2 cache untuk table yang dimutasi MyBatis,
- evict cache region setelah MyBatis write,
- jangan MyBatis write table cacheable,
- pakai TTL hanya jika stale acceptable,
- pindahkan write ownership ke satu framework.
Untuk quote/order command data, stale cache biasanya tidak acceptable.
17. Anti-Pattern: MyBatis Local Cache Misunderstood
MyBatis juga punya local cache per SqlSession.
Dalam flow kompleks, developer bisa salah memahami kapan query result di-cache atau di-clear.
Risiko meningkat jika:
- SqlSession scope tidak jelas,
- mapper method dipanggil berkali-kali dalam transaction,
- update terjadi melalui JPA di antara MyBatis select,
- MyBatis local cache scope bukan
STATEMENTketika dibutuhkan, - query result dianggap selalu fresh.
Checklist:
- Cek MyBatis
localCacheScope. - Cek SqlSession lifecycle.
- Cek apakah mapper read harus fresh after write.
- Cek apakah update statement clear local cache.
- Cek test read-after-write.
18. Anti-Pattern: Mixed Persistence Without Query Visibility
MyBatis biasanya unggul dalam SQL visibility.
JPA/Hibernate bisa menghasilkan SQL tersembunyi.
Anti-pattern terjadi ketika:
- JPA generated SQL tidak di-log,
- MyBatis SQL tidak diobservasi,
- parameter logging tidak aman,
- query count tidak dimonitor,
- slow query tidak bisa ditrace ke repository/method,
- PR tidak menyertakan SQL plan untuk query kritikal.
Mixing memperbesar jumlah SQL source.
Observability harus lebih baik, bukan lebih buruk.
19. Anti-Pattern: Different Connection/DataSource for Same Command Flow
Kadang MyBatis dan JPA dikonfigurasi ke DataSource berbeda.
Ini valid untuk read replica atau multi-database architecture.
Anti-pattern jika terjadi tidak sengaja.
Contoh failure:
JPA writes primary database.
MyBatis reads read replica.
Command endpoint returns stale summary due to replication lag.
Atau:
JPA uses tenant-specific datasource.
MyBatis uses default datasource.
Akibat bisa fatal.
Review Questions
- Apakah JPA dan MyBatis memakai DataSource yang sama?
- Jika berbeda, apakah itu disengaja?
- Apakah read replica lag acceptable?
- Apakah transaction bisa mencakup keduanya?
- Apakah tenant routing sama?
- Apakah credential permission sama?
20. Anti-Pattern: Transactional Outbox Split Incorrectly
Outbox harus berada dalam transaction yang sama dengan state change yang menghasilkan event.
Anti-pattern:
JPA updates quote.
Transaction commits.
MyBatis inserts outbox event later.
Atau:
MyBatis inserts outbox with REQUIRES_NEW.
JPA aggregate transaction later rolls back.
Akibat:
- event published untuk state yang tidak pernah commit,
- state commit tanpa event,
- duplicate reconciliation manual,
- downstream system inconsistent.
Rule
Aggregate state change and outbox insert must commit or rollback together.
Jika MyBatis dipakai untuk outbox insert, pastikan transaction boundary sama.
21. Anti-Pattern: “It Works in Test” With Mock Database
Persistence mixing bugs sering tidak terlihat jika test memakai:
- mock repository,
- H2 menggantikan PostgreSQL tanpa compatibility awareness,
- unit test tanpa transaction manager asli,
- no concurrency test,
- no rollback test,
- no SQL log assertion,
- no cache enabled test.
Bug stale state dan transaction ordering membutuhkan integration test dengan database nyata.
Untuk PostgreSQL enterprise system, gunakan real PostgreSQL via Testcontainers atau environment integration yang representatif.
22. Anti-Pattern: Same Mapper Used by Command and Reporting
Mapper yang sama dipakai untuk command mutation dan reporting query sering berubah menjadi god mapper.
Contoh:
QuoteMapper
- findQuoteSummary
- searchQuotes
- updateQuoteStatus
- updateQuoteLinePrice
- insertAudit
- findMonthlyReport
- lockNextQuoteJob
Masalah:
- ownership tidak jelas,
- transaction expectation berbeda,
- query/reporting concern bercampur command concern,
- review sulit,
- testing matrix membesar,
- developer baru tidak tahu method mana aman dipakai.
Better:
QuoteProjectionMapper
QuoteSearchMapper
QuoteCommandSqlMapper // only if MyBatis is explicit write owner
QuoteOperationalJobMapper
23. Anti-Pattern: JPA Entity Exposed to MyBatis ResultMap
Kadang MyBatis query memetakan hasil langsung ke JPA entity class.
Contoh:
<select id="findQuote" resultType="com.company.QuoteEntity">
SELECT * FROM quote WHERE id = #{id}
</select>
Ini berbahaya karena object hasil MyBatis bukan managed entity JPA.
Developer bisa salah mengira:
This is QuoteEntity, so JPA will track changes.
Padahal tidak.
Akibat:
- mutation tidak tersimpan,
- detached-like object membingungkan,
- lazy relationship/proxy tidak valid,
- equals/hashCode behavior bisa aneh,
- entity lifecycle callback tidak berjalan,
- audit/version semantics bypassed.
Rule
MyBatis should map to DTO/projection/record/command object, not JPA managed entity class, unless there is an explicit and very rare reason.
24. Anti-Pattern: JPA Entity Used as API DTO After MyBatis Mutation
Flow buruk:
QuoteEntity quote = entityManager.find(QuoteEntity.class, id);
quoteMapper.updateQuoteCalculatedFields(id);
return quote; // serialized by JAX-RS
Response bisa berisi stale data.
Selain itu, expose entity ke API membawa risiko:
- lazy loading during serialization,
- N+1 saat JSON serialization,
- leaking internal fields,
- circular reference,
- tenant/security field exposure,
- inconsistent state after mapper mutation.
Gunakan response DTO eksplisit.
25. Anti-Pattern: Hidden Database Trigger Plus Mixed Framework Writes
Jika database trigger mengubah data, mixing makin sulit.
Contoh:
JPA insert quote.
Trigger fills quote_number and audit row.
MyBatis reads quote_number before JPA refresh.
JPA entity still has null quote_number.
Atau:
MyBatis update fires trigger updating updated_at.
JPA managed entity still has old updated_at.
JPA flush later overwrites value.
Jika trigger ada:
- dokumentasikan side effect,
- refresh entity setelah DB-generated side effect jika dibutuhkan,
- jangan duplicate audit in application,
- test DB trigger behavior,
- pastikan migration versioning mencakup trigger.
26. Anti-Pattern: Using Mixing to Avoid Domain Modelling
Tanda paling berbahaya:
Business logic moves into scattered SQL updates because entity/domain model is inconvenient.
Contoh:
Quote approval logic:
- one status update in JPA
- one discount recalculation in MyBatis
- one audit insert in trigger
- one event insert in another repository
- one validation in resource class
Tidak ada satu tempat yang menjelaskan lifecycle approval.
Ini bukan persistence optimization.
Ini domain logic fragmentation.
Fix
Buat application service yang eksplisit:
ApproveQuoteService
- validate command
- load aggregate or command state
- apply transition
- persist changes via one write owner
- write outbox/audit consistently
- return command result
27. Anti-Pattern Detection Matrix
| Smell | Risk | Likely Fix |
|---|---|---|
| MyBatis and JPA both update same table | stale state, lost update | single write owner |
| MyBatis maps to JPA entity class | detached confusion | map to DTO/projection |
| Mapper write after entity load | stale persistence context | clear/refresh or redesign |
| JPA mutation then mapper read without flush | old DB read | explicit flush or avoid re-query |
| L2 cache active and mapper writes table | stale cache | evict/disable/avoid write |
| Tenant predicate missing in mapper | data leak | explicit tenant filter/RLS/test |
| Audit fields set differently | compliance gap | one audit ownership model |
| Outbox in separate transaction | event/state inconsistency | same transaction boundary |
| Same SQL duplicated in JPA native and MyBatis | drift | choose canonical owner |
| Mock-only tests | hidden transaction bugs | PostgreSQL integration tests |
28. Production Symptoms
Anti-pattern mixing sering muncul sebagai gejala berikut:
- endpoint response berbeda dari database final state,
- status berubah balik setelah terlihat sukses,
- audit trail hilang atau ganda,
- optimistic lock exception tidak konsisten,
- duplicate event terkirim,
- event terkirim tanpa state change,
- search result menampilkan deleted/expired data,
- tenant leakage di query tertentu,
- cache menampilkan state lama,
- query count naik setelah “simple fix”,
- rollback tidak mengembalikan semua perubahan,
- incident sulit direproduksi di unit test.
Jika symptom seperti ini terjadi, cari mixed persistence ownership sebelum menyalahkan PostgreSQL.
29. Production Debugging Flow
Saat mencurigai bug mixing MyBatis/JPA:
1. Identify endpoint/use case.
2. List all repository/mapper/entity manager calls in order.
3. Identify transaction boundary.
4. Identify physical connection/DataSource if possible.
5. List all tables touched.
6. Identify JPA entities loaded into persistence context.
7. Identify MyBatis writes to same rows/tables.
8. Check flush/clear/refresh points.
9. Check cache involvement.
10. Check audit/version/tenant/soft-delete predicates.
11. Reproduce with SQL logging and integration test.
12. Verify final DB state, response state, audit state, and event state.
Jangan hanya membaca Java code.
Baca SQL order.
PostgreSQL hanya peduli SQL yang benar-benar dikirim dan urutan transaction-nya.
30. PR Review Red Flags
Berhati-hati jika PR memiliki salah satu ini:
- menambahkan MyBatis mapper untuk table yang sudah punya JPA entity,
- menambahkan JPA entity untuk table yang sudah banyak dimutasi mapper,
- menambahkan
flush()tanpa penjelasan, - menambahkan
clear()tanpa test, - mengubah status/amount/version/audit via SQL manual,
- memakai
${}dynamic SQL untuk sorting/filtering, - query MyBatis tanpa tenant/soft-delete predicate,
- native query JPA duplicate dengan mapper SQL,
- outbox insert di transaction berbeda,
- test hanya unit/mock,
- tidak ada SQL log/plan untuk query kritikal,
- tidak ada migration compatibility check.
Red flag bukan berarti reject otomatis.
Red flag berarti perlu design explanation.
31. Internal Verification Checklist
Gunakan checklist ini untuk memeriksa codebase/team convention. Jangan mengarang detail internal; verifikasi langsung di repo, config, CI, dashboard, dan diskusi tim.
Ownership
- Table mana yang dimap JPA entity?
- Table mana yang dimutasi MyBatis mapper?
- Apakah ada overlap write ownership?
- Apakah ada dokumentasi repository ownership?
- Apakah command/query repository dipisah?
Transaction
- Apakah MyBatis dan JPA share transaction manager?
- Apakah ada manual SqlSession/connection management?
- Apakah autocommit pernah digunakan di service path?
- Apakah rollback test mencakup JPA dan MyBatis sekaligus?
- Apakah propagation digunakan dengan sadar?
State and Cache
- Apakah mapper write terjadi setelah entity load?
- Apakah ada explicit flush/clear/refresh?
- Apakah Hibernate L2/query cache aktif?
- Apakah MyBatis local/second-level cache aktif?
- Apakah Redis cache terpengaruh oleh mapper/JPA write?
Invariants
- Apakah validation tersebar di JPA entity, mapper SQL, resource, dan trigger?
- Apakah audit fields konsisten?
- Apakah version field konsisten?
- Apakah soft delete predicate konsisten?
- Apakah tenant/security filter konsisten?
PostgreSQL
- Apakah trigger/function mengubah data yang juga dimanage JPA?
- Apakah constraints mendukung invariant penting?
- Apakah query menggunakan lock/timeout dengan benar?
- Apakah bulk update menyentuh table JPA?
- Apakah migration mengubah table yang punya dua access path?
Testing
- Apakah ada integration test PostgreSQL nyata?
- Apakah ada concurrent update test?
- Apakah ada rollback test?
- Apakah ada stale context test?
- Apakah ada tenant/soft delete negative test?
- Apakah query count/performance regression diuji?
Observability
- Apakah SQL JPA dan MyBatis bisa ditrace ke endpoint?
- Apakah parameter logging aman terhadap PII?
- Apakah slow query log mencakup query mapper dan generated SQL?
- Apakah dashboard bisa menunjukkan pool, lock, deadlock, timeout?
- Apakah incident notes menyebut stale state/cache/transaction ordering?
32. Refactoring Paths
Jika menemukan anti-pattern, jangan langsung rewrite besar.
Pilih refactoring bertahap.
Path A: Make Ownership Explicit
Before:
QuoteRepository mixes JPA and MyBatis write methods.
After:
QuoteCommandRepository owns writes.
QuoteProjectionRepository owns reads.
Forbidden mapper writes documented.
Path B: Convert Mapper Write to JPA Command
Jika mapper write mengubah aggregate lifecycle:
Move logic into domain/application service.
Use JPA aggregate method.
Keep MyBatis for read projection only.
Path C: Convert JPA Query to MyBatis Projection
Jika JPA dipakai untuk reporting dan menyebabkan N+1:
Create MyBatis projection query.
Return DTO.
Do not load aggregate entity.
Path D: Isolate Bulk Job
Jika MyBatis bulk update diperlukan:
Run outside JPA managed flow.
Disable/evict cache.
Update audit/version consistently.
Add integration and rollback tests.
Path E: Move Invariant to Database Constraint
Jika invariant non-negotiable dan banyak write path:
Add database constraint/check/unique FK where possible.
Keep application validation for better error messages.
33. Senior Engineer Summary
Anti-pattern mixing MyBatis dan JPA hampir selalu berakar pada satu masalah:
Unclear ownership.
Framework berbeda membawa model berbeda:
- JPA/Hibernate mengelola entity state, persistence context, dirty checking, flush, lifecycle, cache.
- MyBatis mengeksekusi SQL eksplisit dan mapping langsung tanpa entity lifecycle.
- PostgreSQL mengeksekusi SQL berdasarkan urutan transaction yang benar-benar terjadi.
Jika satu use case menyentuh data yang sama melalui dua model ini tanpa aturan, sistem akan memiliki lebih dari satu kebenaran sementara.
Dan “lebih dari satu kebenaran sementara” adalah akar stale state, lost update, audit drift, cache conflict, dan production incident.
Review senior harus menolak desain yang hanya menjawab:
It works.
Dan meminta jawaban atas:
Who owns the write?
Who owns the invariant?
Who owns the transaction?
Who owns the cache?
Who owns audit/version/tenant/soft delete?
What happens on rollback?
What SQL runs, in what order?
What test proves this?
Jika pertanyaan itu bisa dijawab, mixing mungkin defensible.
Jika tidak, mixing adalah anti-pattern yang sedang menunggu traffic production.
You just completed lesson 22 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.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.