Mapper Interface, XML Organization, Naming, Parameters, and Ownership
MyBatis Mapper Design
Desain mapper interface dan XML yang maintainable, ownership-aware, SQL-visible, transaction-aware, dan aman untuk enterprise production review.
MyBatis Mapper Design
1. Core Thesis
Mapper design adalah salah satu faktor terbesar yang menentukan apakah MyBatis menjadi alat yang tajam atau menjadi sumber technical debt.
MyBatis memberi kebebasan besar: engineer bisa menulis SQL apa pun, mengembalikan object apa pun, dan membuat dynamic SQL sangat fleksibel. Kebebasan ini harus dikompensasi dengan desain mapper yang disiplin.
Prinsip utama:
Mapper adalah boundary eksplisit antara Java use case dan SQL ownership. Mapper yang baik membuat query mudah ditemukan, mudah dipahami, mudah diuji, mudah di-review, dan mudah dikaitkan ke production behavior.
Mapper yang buruk biasanya memiliki gejala:
- satu mapper berisi ratusan statement tanpa struktur,
- nama method generik seperti
getData,update,findList, - SQL duplikat di banyak XML,
- query read dan write bercampur tanpa ownership,
- parameter terlalu banyak dan tidak jelas,
- dynamic SQL terlalu kompleks,
- DTO projection tidak punya tujuan jelas,
- tenant/soft-delete/status/effective-date filter tidak konsisten,
- mapper dipakai langsung oleh banyak layer,
- SQL sulit dikaitkan dengan endpoint atau use case,
- perubahan migration tidak sinkron dengan mapper.
Mapper design yang baik membuat PR review persistence menjadi objektif. Reviewer bisa bertanya: “query ini milik use case apa, transaction-nya di mana, invariants apa yang dijaga, dan apa dampaknya ke PostgreSQL?”
2. Mapper as Contract, Not Convenience Wrapper
Mapper bukan sekadar wrapper SQL. Mapper adalah contract.
Contract mapper menjawab:
- operasi persistence apa yang tersedia,
- parameter apa yang wajib/opsional,
- return shape apa yang dijanjikan,
- table/index apa yang disentuh,
- filtering mandatory apa yang selalu berlaku,
- locking/versioning apa yang digunakan,
- apakah operation read-only atau write,
- apakah operation aman dipakai dalam transaction tertentu.
Contoh mapper yang lemah:
public interface QuoteMapper {
List<Map<String, Object>> query(Map<String, Object> params);
int update(Map<String, Object> params);
}
Masalah:
- contract tidak jelas,
- parameter bebas,
- return shape tidak typed,
- SQL injection risk lebih sulit direview,
- validation tersebar,
- refactor sulit,
- test tidak fokus.
Contoh lebih sehat:
public interface QuoteMapper {
QuoteDetailRow findDetailById(FindQuoteByIdQuery query);
List<QuoteSearchRow> searchQuotes(QuoteSearchQuery query);
int transitionStatus(UpdateQuoteStatusCommand command);
int markExpired(MarkExpiredQuotesCommand command);
}
Lebih baik karena:
- method menggambarkan use case,
- query/command object typed,
- return type eksplisit,
- update mengembalikan affected row count,
- lebih mudah membuat test per operation,
- lebih mudah membaca ownership.
3. Mapper Granularity
Salah satu keputusan awal adalah: mapper dibuat per apa?
Pilihan umum:
- per table,
- per aggregate,
- per bounded context/module,
- per use case/read model,
- per integration/reporting area.
Tidak ada satu jawaban universal. Tetapi setiap pilihan punya konsekuensi.
3.1 Mapper per Table
Contoh:
QuoteTableMapper
QuoteLineTableMapper
CustomerTableMapper
Kelebihan:
- mudah dicari berdasarkan table,
- cocok untuk CRUD sederhana,
- mapping dekat dengan schema,
- mudah untuk generated/simple operations.
Kekurangan:
- use case yang menyentuh banyak table tersebar,
- bisa mendorong anemic transaction script,
- business query menjadi terpecah,
- join complex tidak punya owner jelas,
- raw table thinking bisa mengalahkan domain boundary.
Cocok untuk:
- lookup sederhana,
- table reference,
- CRUD internal kecil,
- schema yang sangat table-centric.
3.2 Mapper per Aggregate
Contoh:
QuoteMapper
OrderMapper
ProductCatalogMapper
Kelebihan:
- sejalan dengan domain ownership,
- query command/read sekitar aggregate terkumpul,
- cocok untuk transaction boundary aggregate,
- mudah review invariant.
Kekurangan:
- mapper bisa membengkak,
- reporting query lintas aggregate bisa tidak cocok,
- aggregate boundary harus jelas.
Cocok untuk:
- command operation,
- state transition,
- aggregate lifecycle,
- optimistic locking,
- audit-aware update.
3.3 Mapper per Use Case / Query Model
Contoh:
QuoteSearchMapper
QuoteApprovalQueueMapper
QuoteReportingMapper
Kelebihan:
- query kompleks punya owner eksplisit,
- projection DTO jelas,
- cocok untuk read model/reporting,
- mudah performance tune per endpoint.
Kekurangan:
- risiko duplicate SQL,
- naming harus disiplin,
- schema ownership bisa tersebar.
Cocok untuk:
- search endpoint,
- queue/list page,
- reporting query,
- dashboard query,
- read-heavy projection.
3.4 Recommended Enterprise Pattern
Untuk sistem enterprise kompleks, pola campuran biasanya paling sehat:
- mapper command per aggregate/domain area,
- mapper read per projection/use case besar,
- mapper reference per stable lookup table,
- mapper reporting terpisah dari transactional mapper,
- mapper integration/procedure terpisah jika ada database logic khusus.
Contoh struktur:
persistence/
quote/
QuoteCommandMapper.java
QuoteCommandMapper.xml
QuoteQueryMapper.java
QuoteQueryMapper.xml
QuoteApprovalQueueMapper.java
QuoteApprovalQueueMapper.xml
catalog/
ProductCatalogQueryMapper.java
PriceEffectiveDateMapper.java
reference/
CurrencyReferenceMapper.java
reporting/
QuoteOrderReportingMapper.java
Prinsipnya bukan banyak file demi banyak file, tetapi ownership jelas.
4. Mapper Interface Design
Mapper interface harus ringkas, typed, dan intensional.
4.1 Method Naming
Nama method harus menjelaskan persistence intent.
Kurang baik:
getQuote(...)
find(...)
update(...)
save(...)
list(...)
Lebih baik:
findQuoteDetailById(...)
searchQuoteSummaries(...)
findPendingApprovalQueue(...)
transitionQuoteStatus(...)
incrementQuoteVersion(...)
insertQuoteAuditEvent(...)
Guideline:
findBy...untuk single optional result.getBy...hanya jika missing dianggap exceptional, dan convention jelas.search...untuk filter/pagination.list...untuk bounded list sederhana.insert...,update...,delete...untuk table-level operation eksplisit.transition...,mark...,expire...,approve...untuk command yang punya business state semantics.- Hindari
savejika tidak jelas insert atau update. - Hindari
upsertkecuali benar-benar memakai semantics upsert yang disengaja.
4.2 Return Type
Return type adalah bagian dari contract.
Untuk read:
QuoteDetailRow findDetailById(FindQuoteByIdQuery query);
List<QuoteSummaryRow> search(QuoteSearchQuery query);
Optional<QuoteVersionRow> findVersionById(UUID quoteId);
Untuk write:
int updateStatus(UpdateQuoteStatusCommand command);
int deleteDraft(DeleteDraftQuoteCommand command);
UUID insertQuote(CreateQuoteCommand command);
QuoteRow insertQuoteReturning(CreateQuoteCommand command);
Rules:
- update/delete sebaiknya return affected row count,
- insert boleh return generated id atau row via
RETURNING, - query yang mungkin tidak ada harus punya convention null/Optional yang jelas,
- list query harus punya batas/pagination kecuali guaranteed small,
- jangan return
Map<String,Object>untuk production query kecuali alasan kuat.
4.3 Parameter Shape
Untuk method sederhana, satu parameter primitive/value object bisa diterima:
QuoteRow findById(UUID quoteId);
Untuk query/command yang lebih dari 2-3 parameter, gunakan object:
List<QuoteSearchRow> search(QuoteSearchQuery query);
int transitionStatus(TransitionQuoteStatusCommand command);
Kenapa parameter object lebih sehat:
- method signature tidak membengkak,
- field bisa divalidasi,
- dynamic SQL lebih readable,
- test fixture lebih jelas,
- query contract terdokumentasi,
- parameter bisa dikelompokkan secara domain.
Hindari:
List<QuoteSearchRow> search(
UUID tenantId,
UUID customerId,
String status,
Instant createdFrom,
Instant createdTo,
String sortBy,
String sortDirection,
int limit,
int offset
);
Lebih baik:
List<QuoteSearchRow> search(QuoteSearchQuery query);
5. Query Object and Command Object
Query object dan command object membantu membedakan read intent dan write intent.
5.1 Query Object
Query object untuk read/search.
public record QuoteSearchQuery(
UUID tenantId,
UUID customerId,
QuoteStatus status,
Instant createdFrom,
Instant createdTo,
QuoteSort sort,
PageRequest page
) {}
Query object harus:
- berisi filter read,
- membawa tenant/security context jika dibutuhkan,
- membawa pagination/sorting yang sudah divalidasi,
- tidak berisi mutation intent,
- tidak menerima raw SQL fragment dari client.
5.2 Command Object
Command object untuk write.
public record TransitionQuoteStatusCommand(
UUID tenantId,
UUID quoteId,
QuoteStatus expectedCurrentStatus,
QuoteStatus nextStatus,
long expectedVersion,
String updatedBy
) {}
Command object harus:
- membawa identity target,
- membawa expected version/status jika perlu concurrency guard,
- membawa audit context,
- membawa tenant/security context,
- cukup spesifik untuk use case,
- tidak menjadi generic bag.
5.3 Bad Generic Parameter Object
Hindari object seperti:
public class QueryParams {
Map<String, Object> filters;
Map<String, Object> options;
}
Ini hanya memindahkan problem dari method signature ke runtime map.
6. Mapper XML Organization
Mapper XML harus mudah dibaca dari atas ke bawah.
Struktur yang disarankan:
<mapper namespace="com.example.persistence.quote.QuoteQueryMapper">
<!-- Result maps -->
<resultMap id="QuoteSummaryRowMap" type="...">
...
</resultMap>
<resultMap id="QuoteDetailRowMap" type="...">
...
</resultMap>
<!-- Reusable SQL fragments -->
<sql id="BaseQuoteSummaryColumns">
q.id,
q.quote_number,
q.status,
q.created_at,
q.updated_at
</sql>
<sql id="MandatoryQuoteFilters">
q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
</sql>
<!-- Select statements -->
<select id="findDetailById" resultMap="QuoteDetailRowMap">
...
</select>
<select id="searchQuoteSummaries" resultMap="QuoteSummaryRowMap">
...
</select>
</mapper>
Recommended order:
- result maps,
- reusable column fragments,
- reusable filter fragments,
- select statements,
- insert statements,
- update statements,
- delete statements,
- procedure/function call statements.
Namun jika mapper dipisah command/query, XML command tidak perlu punya select block besar.
7. Namespace Discipline
Namespace XML harus match mapper interface fully qualified name.
<mapper namespace="com.example.persistence.quote.QuoteCommandMapper">
Jika namespace salah:
- mapper method tidak terhubung,
- runtime error sulit dipahami,
- statement id tidak ditemukan,
- refactor package bisa merusak XML.
Internal verification:
- Apakah IDE/compiler/CI memvalidasi mapper XML?
- Apakah rename interface juga mengubah namespace?
- Apakah package scan mapper eksplisit?
- Apakah statement id match method name?
Statement id sebaiknya sama dengan method name:
int transitionStatus(TransitionQuoteStatusCommand command);
<update id="transitionStatus" parameterType="TransitionQuoteStatusCommand">
...
</update>
Hindari statement id yang berbeda tanpa alasan kuat.
8. SQL Naming and Fragment Design
Reusable SQL fragment berguna, tetapi bisa menjadi abstraction yang terlalu pintar.
8.1 Column Fragment
Baik:
<sql id="QuoteSummaryColumns">
q.id,
q.quote_number,
q.status,
q.created_at,
q.updated_at
</sql>
Manfaat:
- menghindari duplicate column list,
- memastikan ResultMap konsisten,
- memudahkan perubahan projection.
Tetapi jangan membuat fragment terlalu generic:
<sql id="AllColumns">
*
</sql>
SELECT * buruk untuk production karena:
- mapping drift sulit terlihat,
- payload database lebih besar,
- column order/ambiguous join risk,
- query plan dan index-only scan bisa terdampak,
- sensitive column bisa ikut terbaca.
8.2 Filter Fragment
Filter wajib seperti tenant dan soft delete dapat difaktorkan.
<sql id="TenantAndActiveFilter">
q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
</sql>
Tetapi fragment juga bisa menyembunyikan logic penting. Reviewer harus tetap bisa melihat apakah query menerapkan filter wajib.
Guideline:
- fragment harus kecil,
- nama fragment harus jelas,
- jangan menyembunyikan join besar,
- jangan membuat fragment yang bergantung pada alias tidak jelas,
- dokumentasikan alias requirement jika perlu.
8.3 Dynamic Fragment
Dynamic fragment harus dipakai hati-hati.
Buruk:
<include refid="DynamicEverything" />
Lebih baik fragment spesifik:
<sql id="QuoteSearchOptionalFilters">
<if test="customerId != null">
AND q.customer_id = #{customerId}
</if>
<if test="status != null">
AND q.status = #{status}
</if>
</sql>
9. ResultMap Naming
ResultMap harus dinamai berdasarkan shape hasil, bukan hanya table.
Kurang baik:
<resultMap id="QuoteMap" type="QuoteDto">
Lebih baik:
<resultMap id="QuoteSummaryRowMap" type="QuoteSummaryRow">
<resultMap id="QuoteDetailRowMap" type="QuoteDetailRow">
<resultMap id="QuoteApprovalQueueRowMap" type="QuoteApprovalQueueRow">
Kenapa?
- satu table bisa punya banyak projection,
- list endpoint tidak perlu detail penuh,
- reporting query punya shape berbeda,
- projection read model harus eksplisit,
- mapping review lebih mudah.
Rule:
RowMapuntuk persistence row/projection internal,DtoMaphanya jika memang DTO API langsung, biasanya dihindari,Detail,Summary,Queue,Report,Exportmenggambarkan use case,- jangan reuse ResultMap yang terlalu besar hanya demi praktis.
10. DTO Projection vs Domain Object Mapping
MyBatis sangat cocok untuk DTO projection.
Contoh projection:
public record QuoteSummaryRow(
UUID id,
String quoteNumber,
QuoteStatus status,
BigDecimal totalAmount,
Instant updatedAt
) {}
SQL:
<select id="searchQuoteSummaries" resultMap="QuoteSummaryRowMap">
SELECT
q.id,
q.quote_number,
q.status,
q.total_amount,
q.updated_at
FROM quote q
WHERE q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
ORDER BY q.updated_at DESC
LIMIT #{page.limit}
OFFSET #{page.offset}
</select>
DTO projection cocok untuk:
- list page,
- search endpoint,
- dashboard,
- reporting,
- export,
- read model,
- query yang join banyak table,
- response shape yang tidak sama dengan aggregate.
Tetapi untuk command/write path, hati-hati jika memakai DTO projection sebagai domain object. Projection sering tidak membawa semua invariant.
Contoh risiko:
QuoteSummaryRow quote = mapper.findSummaryById(id);
// Lalu dipakai untuk validasi approval.
Masalah:
- summary mungkin tidak membawa status detail,
- tidak membawa version,
- tidak membawa tenant/owner/security attributes,
- tidak membawa line items,
- tidak cukup untuk invariant approval.
Prinsip:
Projection untuk membaca bukan berarti cukup untuk memutuskan state transition.
11. Command Mapper Design
Command mapper adalah mapper untuk write path.
Ia harus lebih ketat daripada read mapper karena menyentuh correctness.
Contoh:
public interface QuoteCommandMapper {
int transitionStatus(TransitionQuoteStatusCommand command);
int updateTotalAmount(UpdateQuoteTotalCommand command);
int softDeleteDraft(DeleteDraftQuoteCommand command);
QuoteRow insertDraftQuote(CreateDraftQuoteCommand command);
}
Write SQL harus eksplisit menjaga invariants.
Contoh status transition:
<update id="transitionStatus" parameterType="TransitionQuoteStatusCommand">
UPDATE quote
SET
status = #{nextStatus},
version = version + 1,
updated_by = #{updatedBy},
updated_at = now()
WHERE tenant_id = #{tenantId}
AND id = #{quoteId}
AND status = #{expectedCurrentStatus}
AND version = #{expectedVersion}
AND deleted_at IS NULL
</update>
Hal penting:
- tenant filter ada,
- target id ada,
- expected status ada,
- expected version ada,
- soft delete filter ada,
- audit field diupdate,
- affected row count harus dicek.
Service:
int updated = quoteCommandMapper.transitionStatus(command);
if (updated != 1) {
throw new QuoteTransitionRejectedException(command.quoteId());
}
Jika affected row count diabaikan, seluruh guard di WHERE kehilangan makna.
12. Query Mapper Design
Query mapper adalah mapper untuk read path.
Contoh:
public interface QuoteQueryMapper {
QuoteDetailRow findDetailById(FindQuoteByIdQuery query);
List<QuoteSummaryRow> searchSummaries(QuoteSearchQuery query);
List<QuoteApprovalQueueRow> findApprovalQueue(ApprovalQueueQuery query);
}
Read mapper harus jelas soal:
- projection shape,
- pagination,
- sorting,
- mandatory filter,
- consistency expectation,
- read-only assumption,
- query cost.
Untuk list/search endpoint, query mapper sebaiknya tidak mengembalikan aggregate penuh jika hanya perlu summary.
Bad smell:
List<QuoteDetailRow> searchQuotes(QuoteSearchQuery query);
Jika detail row besar dan melibatkan line items, endpoint list bisa menjadi mahal. Lebih baik:
List<QuoteSummaryRow> searchQuoteSummaries(QuoteSearchQuery query);
Lalu detail endpoint memakai query berbeda:
QuoteDetailRow findQuoteDetailById(FindQuoteByIdQuery query);
13. Package Structure
Package structure harus membuat dependency direction terlihat.
Contoh struktur yang sehat:
com.company.quoteorder.quote
api/
QuoteResource.java
QuoteRequest.java
QuoteResponse.java
application/
QuoteApprovalService.java
QuoteSearchService.java
domain/
QuoteStatus.java
QuoteTransitionPolicy.java
persistence/
QuoteRepository.java
mybatis/
QuoteCommandMapper.java
QuoteQueryMapper.java
QuoteCommandMapper.xml
QuoteQueryMapper.xml
row/
QuoteDetailRow.java
QuoteSummaryRow.java
command/
TransitionQuoteStatusCommand.java
query/
QuoteSearchQuery.java
Ini hanya contoh konseptual, bukan asumsi struktur internal CSG.
Yang perlu diverifikasi di internal codebase:
- apakah mapper berada dekat domain module,
- apakah XML dekat interface,
- apakah row/query/command object terorganisir,
- apakah mapper dapat dipakai lintas module tanpa kontrol,
- apakah API DTO bercampur dengan persistence row,
- apakah repository menyembunyikan mapper dari service.
14. Repository Layer Above Mapper
Dalam banyak sistem, mapper sebaiknya tidak dipanggil langsung dari application service. Repository memberi boundary yang lebih domain-aware.
Contoh:
public class QuoteRepository {
private final QuoteCommandMapper commandMapper;
private final QuoteQueryMapper queryMapper;
public QuoteDetail findDetail(QuoteId quoteId, TenantId tenantId) {
QuoteDetailRow row = queryMapper.findDetailById(
new FindQuoteByIdQuery(tenantId.value(), quoteId.value())
);
return QuoteDetailMapper.toDomain(row);
}
public void transitionStatus(QuoteStatusTransition transition) {
int updated = commandMapper.transitionStatus(
TransitionQuoteStatusCommand.from(transition)
);
if (updated != 1) {
throw new ConcurrentQuoteModificationException(transition.quoteId());
}
}
}
Repository berguna untuk:
- menyembunyikan MyBatis detail,
- memeriksa affected row count,
- mapping row ke domain object,
- menyatukan command/query mapper,
- menerapkan domain-specific exception,
- menjaga service tidak tergantung XML/query detail.
Tetapi repository juga bisa menjadi leaky abstraction jika hanya pass-through:
public List<QuoteSummaryRow> search(QuoteSearchQuery query) {
return quoteQueryMapper.search(query);
}
Pass-through tidak selalu salah. Untuk read model sederhana, bisa acceptable. Tetapi harus sadar bahwa repository tersebut tidak menambah abstraction berarti.
15. Mapper Ownership
Setiap mapper harus punya owner.
Ownership dapat berarti:
- domain team,
- bounded context,
- module,
- feature area,
- technical owner,
- DBA review owner untuk query berat.
Tanpa ownership:
- mapper jadi tempat semua orang menaruh SQL,
- duplicate query muncul,
- filter convention rusak,
- performance regression tidak ada yang merasa memiliki,
- migration impact tidak ditelusuri.
Ownership harus terlihat dari:
- package/module,
- naming,
- CODEOWNERS jika ada,
- PR reviewer convention,
- test ownership,
- dashboard/alert mapping.
Pertanyaan review:
- Mapper ini milik bounded context apa?
- Siapa yang harus review query ini?
- Apakah query ini menyentuh table milik module lain?
- Apakah ada dependency lintas service/schema?
- Apakah ini command mapper atau reporting mapper?
16. Handling Shared Tables
Enterprise system sering punya shared/reference table. Mapper design harus hati-hati.
Contoh shared/reference:
- currency,
- country,
- product catalog reference,
- tenant metadata,
- status code,
- configuration table.
Risiko:
- banyak mapper membaca table sama dengan filter berbeda,
- cache tidak konsisten,
- migration impact luas,
- write ownership tidak jelas,
- reference data update tidak diaudit.
Pattern sehat:
- buat reference mapper khusus,
- read-only repository jika table tidak boleh diubah oleh service,
- tulis ownership write secara jelas,
- test filter/tenant jika reference tenant-specific,
- hindari duplicate reference query.
Contoh:
public interface CurrencyReferenceMapper {
CurrencyRow findActiveCurrencyByCode(String code);
List<CurrencyRow> listActiveCurrencies();
}
Jangan biarkan setiap mapper domain menulis query currency sendiri kecuali ada alasan kuat.
17. Handling Joins
MyBatis mendorong engineer menulis join eksplisit. Ini kekuatan sekaligus risiko.
Guideline untuk join:
- selalu gunakan alias table yang konsisten,
- alias column untuk menghindari ambiguity,
- pilih column yang dibutuhkan saja,
- pastikan join cardinality benar,
- waspadai duplicate row akibat one-to-many join,
- pastikan index mendukung join key,
- pastikan filter wajib diterapkan pada table yang benar,
- hindari join lintas ownership tanpa alasan.
Contoh alias baik:
SELECT
q.id AS quote_id,
q.quote_number AS quote_number,
q.status AS quote_status,
c.id AS customer_id,
c.name AS customer_name
FROM quote q
JOIN customer c ON c.id = q.customer_id
WHERE q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
ResultMap:
<resultMap id="QuoteWithCustomerRowMap" type="QuoteWithCustomerRow">
<id property="quoteId" column="quote_id" />
<result property="quoteNumber" column="quote_number" />
<result property="quoteStatus" column="quote_status" />
<result property="customerId" column="customer_id" />
<result property="customerName" column="customer_name" />
</resultMap>
Hindari alias ambigu:
SELECT q.id, c.id, q.name, c.name
Ini membuat mapping rentan salah.
18. Dynamic Search Mapper Design
Search endpoint biasanya menjadi mapper paling kompleks.
Masalah umum:
- terlalu banyak optional filter,
- dynamic sorting tidak aman,
- count query mahal,
- offset besar lambat,
- query plan berubah drastis,
- filter mandatory lupa,
- parameter object tidak divalidasi,
- API mengizinkan kombinasi filter yang tidak masuk akal.
Pattern sehat:
public record QuoteSearchQuery(
UUID tenantId,
QuoteSearchFilter filter,
QuoteSort sort,
PageRequest page
) {}
public record QuoteSearchFilter(
UUID customerId,
QuoteStatus status,
Instant createdFrom,
Instant createdTo,
String quoteNumberPrefix
) {}
Sorting harus whitelist:
public enum QuoteSortField {
CREATED_AT("q.created_at"),
UPDATED_AT("q.updated_at"),
QUOTE_NUMBER("q.quote_number");
}
XML dynamic SQL harus tetap mudah dibaca:
<where>
q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
<if test="filter.customerId != null">
AND q.customer_id = #{filter.customerId}
</if>
<if test="filter.status != null">
AND q.status = #{filter.status}
</if>
<if test="filter.createdFrom != null">
AND q.created_at >= #{filter.createdFrom}
</if>
<if test="filter.createdTo != null">
AND q.created_at < #{filter.createdTo}
</if>
</where>
Jika search terlalu kompleks, pertimbangkan:
- memecah endpoint,
- membuat query khusus untuk scenario utama,
- memakai materialized/read model,
- memakai search engine jika memang bukan relational search biasa,
- membatasi kombinasi filter.
19. Mapper Design for Pagination
Pagination mapper harus eksplisit soal ordering.
Buruk:
SELECT ... FROM quote
LIMIT #{limit}
OFFSET #{offset}
Tanpa ORDER BY, pagination tidak stabil.
Lebih baik:
SELECT ...
FROM quote q
WHERE q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
ORDER BY q.updated_at DESC, q.id DESC
LIMIT #{limit}
OFFSET #{offset}
Tambahkan tie-breaker seperti id agar ordering stabil.
Untuk data besar, pertimbangkan keyset pagination:
SELECT ...
FROM quote q
WHERE q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
AND (
q.updated_at, q.id
) < (
#{cursor.updatedAt}, #{cursor.id}
)
ORDER BY q.updated_at DESC, q.id DESC
LIMIT #{limit}
Mapper design harus menunjukkan apakah pagination:
- offset-based,
- keyset/cursor-based,
- membutuhkan count query,
- punya stable ordering,
- punya index pendukung.
20. Mapper Design for Batch Operations
Batch mapper harus memperhatikan transaction size, lock, retry, dan memory.
Contoh batch insert sederhana:
<insert id="insertQuoteLines" parameterType="java.util.List">
INSERT INTO quote_line (
quote_id,
line_number,
product_id,
quantity,
created_at
) VALUES
<foreach collection="list" item="line" separator=",">
(
#{line.quoteId},
#{line.lineNumber},
#{line.productId},
#{line.quantity},
now()
)
</foreach>
</insert>
Concern:
- ukuran list harus dibatasi,
- SQL statement terlalu besar bisa bermasalah,
- transaction terlalu panjang menahan lock,
- partial failure harus dipikirkan,
- retry harus idempotent,
- unique constraint bisa membantu deduplication,
- affected row count harus diperiksa.
Untuk batch besar, lebih baik chunking di service/job layer daripada satu mapper call raksasa.
for (List<QuoteLineCommand> chunk : chunks(lines, 500)) {
quoteLineMapper.insertQuoteLines(chunk);
}
Tetapi chunking mengubah atomicity. Apakah seluruh batch harus all-or-nothing? Atau per chunk acceptable? Itu keputusan transaction design, bukan sekadar mapper design.
21. Mapper Design for Locking
Mapper write yang rawan concurrency harus membuat locking terlihat.
21.1 Optimistic Locking
<update id="updateQuoteAmount" parameterType="UpdateQuoteAmountCommand">
UPDATE quote
SET
total_amount = #{totalAmount},
version = version + 1,
updated_at = now(),
updated_by = #{updatedBy}
WHERE tenant_id = #{tenantId}
AND id = #{quoteId}
AND version = #{expectedVersion}
AND deleted_at IS NULL
</update>
Method:
int updateQuoteAmount(UpdateQuoteAmountCommand command);
Service wajib check:
if (mapper.updateQuoteAmount(command) != 1) {
throw new OptimisticLockFailureException();
}
21.2 Pessimistic Locking
<select id="findQuoteForUpdate" resultMap="QuoteRowMap">
SELECT
q.id,
q.status,
q.version,
q.total_amount
FROM quote q
WHERE q.tenant_id = #{tenantId}
AND q.id = #{quoteId}
AND q.deleted_at IS NULL
FOR UPDATE
</select>
Concern:
- harus dipanggil dalam transaction,
- lock ditahan sampai commit/rollback,
- jangan lakukan external API call saat lock ditahan,
- lock timeout harus dipahami,
- deadlock order harus konsisten.
Mapper name harus menunjukkan lock:
findQuoteForUpdate,claimNextPendingJobForUpdateSkipLocked,lockOrderById,- bukan
findByIdbiasa.
22. Mapper Design for Audit Fields
Write mapper harus konsisten mengisi audit.
Contoh:
<update id="transitionStatus">
UPDATE quote
SET
status = #{nextStatus},
version = version + 1,
updated_by = #{updatedBy},
updated_at = now()
WHERE ...
</update>
Jika audit dilakukan oleh trigger, mapper tetap harus sadar side effect.
Pertanyaan review:
- Apakah
created_at/created_bydiisi saat insert? - Apakah
updated_at/updated_bydiisi saat update? - Apakah timezone konsisten?
- Apakah actor/user context tersedia?
- Apakah trigger menimpa value aplikasi?
- Apakah audit table/outbox ikut transaction sama?
- Apakah MyBatis dan JPA punya audit convention yang sama?
Anti-pattern:
- sebagian mapper mengisi audit manual,
- sebagian mengandalkan trigger,
- sebagian lupa audit,
- JPA entity listener dan MyBatis update menghasilkan format berbeda,
- audit user tidak tersedia pada async job.
23. Mapper Design for Tenant and Security Filters
Jika sistem multi-tenant atau permission-aware, mapper design harus mencegah query tanpa filter wajib.
Guideline:
- tenantId masuk query/command object,
- filter tenant ada di WHERE untuk semua query tenant-scoped,
- jangan rely pada caller untuk post-filtering,
- gunakan test dengan dua tenant,
- dynamic SQL tidak boleh menghapus mandatory filter,
- cache key harus tenant-aware,
- sort/filter input harus divalidasi.
Contoh:
WHERE q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
Bad smell:
<select id="findById">
SELECT ... FROM quote WHERE id = #{quoteId}
</select>
Jika quoteId globally unique, ini mungkin technically aman, tetapi tetap perlu diverifikasi. Dalam enterprise review, tanya:
- Apakah ID global across tenant?
- Apakah authorization dilakukan di layer lain?
- Apakah query ini bisa dipakai di endpoint tenant-scoped?
- Apakah test membuktikan tenant isolation?
24. Mapper Design for Soft Delete and Effective Dating
Soft delete dan effective dating adalah sumber bug diam-diam.
Query aktif:
WHERE deleted_at IS NULL
Temporal/effective query:
WHERE valid_from <= #{asOf}
AND (valid_to IS NULL OR valid_to > #{asOf})
Catalog/price use case sering membutuhkan asOf time. Jangan gunakan now() sembarangan jika use case membutuhkan quote creation time, order submission time, atau customer effective date.
Pertanyaan review:
- Apakah query harus melihat data aktif saja?
- Apakah query harus melihat data historis?
- Apakah
asOfberasal dari request/use case atau database current time? - Apakah timezone jelas?
- Apakah partial unique index diperlukan untuk active rows?
- Apakah JPA dan MyBatis memakai rule soft delete yang sama?
Mapper method harus jelas:
PriceRow findEffectivePrice(FindEffectivePriceQuery query);
List<PriceHistoryRow> findPriceHistory(PriceHistoryQuery query);
Jangan pakai findPrice jika semantics effective date penting.
25. XML Readability Rules
Mapper XML harus readable karena ia adalah bagian dari production-critical code.
Rules praktis:
- explicit column list,
- satu column per line untuk query besar,
- alias table pendek tetapi jelas,
- alias column untuk join,
- WHERE mandatory filter di atas optional filter,
- ORDER BY eksplisit,
- LIMIT/OFFSET eksplisit,
- fragment tidak terlalu abstrak,
- dynamic SQL tidak terlalu nested,
- komentar hanya untuk menjelaskan non-obvious decision,
- hindari business essay di XML.
Contoh layout:
<select id="searchQuoteSummaries" parameterType="QuoteSearchQuery" resultMap="QuoteSummaryRowMap">
SELECT
q.id AS quote_id,
q.quote_number AS quote_number,
q.status AS quote_status,
q.total_amount AS total_amount,
q.updated_at AS updated_at
FROM quote q
WHERE q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
<if test="filter.status != null">
AND q.status = #{filter.status}
</if>
ORDER BY q.updated_at DESC, q.id DESC
LIMIT #{page.limit}
OFFSET #{page.offset}
</select>
26. Avoiding Duplicate SQL
Duplicate SQL adalah masalah maintainability dan correctness.
Contoh duplicate berbahaya:
- dua mapper punya query active quote tetapi satu lupa
deleted_at IS NULL, - dua update status tetapi satu tidak check version,
- dua search query tetapi sorting berbeda tanpa alasan,
- dua price effective query dengan time boundary berbeda.
Cara mengurangi:
- mapper ownership jelas,
- shared fragment kecil untuk mandatory filter,
- repository method reusable untuk command penting,
- query object standar,
- jangan copy-paste XML tanpa review,
- test untuk invariant penting,
- documentation untuk canonical query.
Namun jangan over-abstract SQL. Kadang dua query mirip tetapi punya semantics berbeda. Memaksa satu generic query bisa lebih buruk.
Prinsip:
Duplicate syntax belum tentu buruk. Duplicate business semantics yang divergen adalah bahaya.
27. Mapper and Migration Coupling
Setiap migration dapat memengaruhi mapper.
Contoh perubahan schema:
- add column,
- rename column,
- drop column,
- change type,
- add constraint,
- add index,
- split table,
- change enum values,
- change JSONB structure,
- change function signature.
Review migration harus mencari mapper impact:
- ResultMap perlu update?
- SQL column list perlu update?
- TypeHandler perlu update?
- query filter perlu berubah?
- insert/update harus mengisi column baru?
- default database cukup aman?
- backfill memengaruhi query?
- compatibility window selama deployment aman?
Mapper test harus gagal jika migration merusak SQL. Jika tidak, CI belum cukup melindungi persistence layer.
28. Mapper and PostgreSQL Index Awareness
Mapper design tidak boleh buta index.
Untuk query penting, tanyakan:
- WHERE clause memakai column indexed?
- JOIN key indexed?
- ORDER BY didukung index?
- filter tenant + status + updated_at perlu composite index?
- partial index cocok untuk active rows?
- expression index dibutuhkan untuk JSONB/function?
- count query mahal?
- offset besar menyebabkan scan mahal?
Contoh query:
WHERE tenant_id = ?
AND status = ?
AND deleted_at IS NULL
ORDER BY updated_at DESC, id DESC
LIMIT ?
Mungkin membutuhkan index seperti:
CREATE INDEX ... ON quote (tenant_id, status, updated_at DESC, id DESC)
WHERE deleted_at IS NULL;
Jangan menambahkan index sembarangan. Validasi dengan EXPLAIN, cardinality, write overhead, dan DBA/team convention.
29. Mapper and Observability
Mapper harus bisa dikaitkan dengan production signal.
Yang ideal:
- slow query log bisa dikaitkan ke mapper id,
- application log menyebut use case atau statement id,
- trace span memuat repository/mapper operation name,
- parameter sensitif tidak bocor,
- query duration dan row count bisa diamati,
- error SQLState dipetakan.
Naming mapper membantu observability.
Buruk:
Mapper.query
Mapper.select
Baik:
QuoteQueryMapper.searchQuoteSummaries
QuoteCommandMapper.transitionStatus
ApprovalQueueMapper.claimNextPendingApproval
Saat incident, nama baik menghemat waktu.
30. Mapper Design When MyBatis Coexists with JPA
Jika sistem memakai MyBatis dan JPA, mapper design harus menandai ownership lebih eksplisit.
Checklist tambahan:
- Apakah mapper menyentuh table yang juga punya JPA entity?
- Apakah mapper hanya read projection?
- Apakah mapper melakukan write pada table entity?
- Apakah JPA persistence context bisa stale?
- Apakah explicit flush diperlukan sebelum mapper read?
- Apakah clear/detach diperlukan setelah mapper write?
- Apakah second-level cache bisa inconsistent?
- Apakah optimistic locking/audit/soft delete convention sama?
Pattern aman:
JPA:
QuoteEntityRepository -> command lifecycle sederhana
MyBatis:
QuoteReportingMapper -> read-only projection/reporting
Pattern bahaya:
JPA:
QuoteEntity loaded and modified in transaction
MyBatis:
QuoteMapper.updateStatus same table in same transaction
Result:
flush ordering, stale state, lost audit/version, hidden update risk
Mapper yang menyentuh table milik JPA entity harus punya komentar/ADR jika alasannya tidak obvious.
31. Example: Good Mapper Design for Quote Search
Java interface
public interface QuoteSearchMapper {
List<QuoteSearchRow> searchQuotes(QuoteSearchQuery query);
long countQuotes(QuoteSearchQuery query);
}
Query object
public record QuoteSearchQuery(
UUID tenantId,
QuoteSearchFilter filter,
QuoteSort sort,
PageRequest page
) {}
XML shape
<mapper namespace="com.example.persistence.quote.QuoteSearchMapper">
<resultMap id="QuoteSearchRowMap" type="com.example.persistence.quote.row.QuoteSearchRow">
<id property="quoteId" column="quote_id" />
<result property="quoteNumber" column="quote_number" />
<result property="status" column="quote_status" />
<result property="customerName" column="customer_name" />
<result property="totalAmount" column="total_amount" />
<result property="updatedAt" column="updated_at" />
</resultMap>
<sql id="QuoteSearchFromAndWhere">
FROM quote q
JOIN customer c ON c.id = q.customer_id
WHERE q.tenant_id = #{tenantId}
AND q.deleted_at IS NULL
<if test="filter.customerId != null">
AND q.customer_id = #{filter.customerId}
</if>
<if test="filter.status != null">
AND q.status = #{filter.status}
</if>
</sql>
<select id="searchQuotes" resultMap="QuoteSearchRowMap">
SELECT
q.id AS quote_id,
q.quote_number AS quote_number,
q.status AS quote_status,
c.name AS customer_name,
q.total_amount AS total_amount,
q.updated_at AS updated_at
<include refid="QuoteSearchFromAndWhere" />
ORDER BY q.updated_at DESC, q.id DESC
LIMIT #{page.limit}
OFFSET #{page.offset}
</select>
<select id="countQuotes" resultType="long">
SELECT count(*)
<include refid="QuoteSearchFromAndWhere" />
</select>
</mapper>
Review notes:
- mandatory tenant/deleted filter exists,
- projection is explicit,
- count query shares same WHERE,
- ordering stable,
- no raw
${}, - query still needs index review,
- count cost still needs production volume review.
32. Example: Good Mapper Design for State Transition
Java interface
public interface QuoteLifecycleMapper {
QuoteLifecycleRow findLifecycleForUpdate(FindQuoteForUpdateQuery query);
int transitionStatus(TransitionQuoteStatusCommand command);
}
SQL
<select id="findLifecycleForUpdate" resultMap="QuoteLifecycleRowMap">
SELECT
q.id AS quote_id,
q.status AS status,
q.version AS version,
q.updated_at AS updated_at
FROM quote q
WHERE q.tenant_id = #{tenantId}
AND q.id = #{quoteId}
AND q.deleted_at IS NULL
FOR UPDATE
</select>
<update id="transitionStatus">
UPDATE quote
SET
status = #{nextStatus},
version = version + 1,
updated_by = #{updatedBy},
updated_at = now()
WHERE tenant_id = #{tenantId}
AND id = #{quoteId}
AND status = #{expectedStatus}
AND version = #{expectedVersion}
AND deleted_at IS NULL
</update>
Review notes:
- method name reveals lock,
- update has status guard,
- update has version guard,
- update has tenant/deleted guard,
- audit updated,
- affected row count must be checked,
- transaction boundary must cover both statements,
- lock timeout/deadlock handling should be known.
33. Common Mapper Design Anti-Patterns
33.1 God Mapper
One mapper for everything:
CommonMapper
BaseMapper
QuoteMapper with 200 methods
Impact:
- no ownership,
- hard review,
- high merge conflict,
- query duplication,
- hidden coupling.
33.2 Generic Map Parameters
List<Row> search(Map<String, Object> params);
Impact:
- no compile-time contract,
- runtime error,
- injection risk,
- hard tests.
33.3 Select Star
SELECT * FROM quote
Impact:
- mapping drift,
- sensitive data risk,
- inefficient read,
- ambiguous joins.
33.4 Ignored Row Count
mapper.updateStatus(command);
return success;
Impact:
- failed update treated as success,
- optimistic locking useless,
- invalid transition hidden.
33.5 Mapper Does Business Workflow
XML with complex conditional business rules and status transitions scattered across dynamic SQL.
Impact:
- hard to test domain logic,
- hidden behavior,
- duplicated invariants,
- review misses business semantics.
33.6 Inconsistent Mandatory Filters
Some queries include tenant/soft-delete/effective-date; others do not.
Impact:
- data leak,
- stale/deleted data appears,
- compliance risk.
33.7 Read Model Used for Command Decision
Using projection summary to decide state transition.
Impact:
- invariant incomplete,
- concurrent update missed,
- version/status not checked.
34. PR Review Checklist
Mapper ownership
- Mapper ini milik domain/module/use case apa?
- Apakah nama mapper menggambarkan ownership?
- Apakah query menyentuh table milik area lain?
- Apakah reviewer yang tepat dilibatkan?
Interface design
- Apakah method name intensional?
- Apakah parameter typed query/command object?
- Apakah return type jelas?
- Apakah update/delete return affected row count?
- Apakah method terlalu generic?
XML organization
- Apakah namespace match interface?
- Apakah statement id match method?
- Apakah ResultMap diorganisir jelas?
- Apakah SQL fragment kecil dan jelas?
- Apakah XML readable?
SQL correctness
- Apakah explicit column list digunakan?
- Apakah join cardinality benar?
- Apakah alias column jelas?
- Apakah mandatory filters ada?
- Apakah effective-date/soft-delete/tenant semantics benar?
Transaction and concurrency
- Apakah mapper dipakai dalam transaction benar?
- Apakah read-modify-write aman?
- Apakah lock/optimistic version diperlukan?
- Apakah affected row count dicek?
- Apakah external call terjadi saat lock/transaction terbuka?
Security
- Apakah semua value memakai
#{}? - Apakah
${}benar-benar whitelist? - Apakah dynamic ORDER BY aman?
- Apakah sensitive data tidak ikut select/log?
- Apakah tenant isolation terjaga?
Performance
- Apakah query punya index support?
- Apakah pagination stable?
- Apakah count query mahal?
- Apakah nested select menciptakan N+1?
- Apakah query plan perlu dilampirkan?
Migration impact
- Apakah migration terkait mapper ini?
- Apakah ResultMap berubah?
- Apakah insert/update perlu column baru?
- Apakah backward compatibility aman?
- Apakah test mapper ikut diperbarui?
MyBatis + JPA coexistence
- Apakah table juga dikelola JPA entity?
- Apakah mapper read-only atau write?
- Apakah flush/clear/cache rule jelas?
- Apakah audit/version/soft-delete convention sama?
35. Internal Verification Checklist
Gunakan checklist ini di codebase/team internal. Jangan mengarang detail internal; verifikasi langsung.
Package and file layout
- Di mana mapper interface berada?
- Di mana mapper XML berada?
- Apakah XML colocated dengan interface atau resource folder terpisah?
- Apakah ada package
mapper,dao,repository,persistence, atau convention lain? - Apakah row/query/command object punya folder khusus?
Naming convention
- Apakah mapper dinamai per table, aggregate, use case, atau module?
- Apakah statement id selalu sama dengan method name?
- Apakah ResultMap punya naming convention?
- Apakah SQL fragment punya naming convention?
Ownership
- Siapa owner mapper tertentu?
- Apakah ada CODEOWNERS atau reviewer convention?
- Apakah DBA/platform harus review query tertentu?
- Apakah mapper lintas module diperbolehkan?
Transaction convention
- Apakah mapper dipanggil langsung dari service atau via repository?
- Di mana transaction annotation/configuration berada?
- Apakah command mapper selalu dipanggil dalam transaction?
- Apakah read mapper boleh read-only transaction?
Mandatory filters
- Bagaimana tenant filter diterapkan?
- Bagaimana soft delete filter diterapkan?
- Bagaimana effective-date/catalog version diterapkan?
- Apakah ada shared SQL fragment atau manual per query?
- Apakah ada tests untuk filter wajib?
Testing
- Apakah setiap mapper punya integration test?
- Apakah dynamic search branch dites?
- Apakah write mapper mengecek affected row count?
- Apakah lock/version behavior dites?
- Apakah migration menjalankan mapper test di CI?
PostgreSQL performance
- Apakah query penting punya EXPLAIN evidence?
- Apakah index dibuat bersama query baru?
- Apakah slow query log bisa dikaitkan ke mapper?
- Apakah pagination/count query diperiksa?
MyBatis and JPA coexistence
- Apakah mapper menyentuh table yang punya JPA entity?
- Apakah ada rule internal untuk MyBatis+JPA mixing?
- Apakah second-level cache Hibernate dipakai?
- Apakah mapper write bisa membuat JPA entity stale?
36. Key Takeaways
- Mapper adalah contract persistence, bukan convenience wrapper.
- Granularity mapper harus mengikuti ownership: table, aggregate, use case, atau reporting, sesuai konteks.
- Method name harus menggambarkan intent dan behavior, terutama lock/write/state transition.
- Query object dan command object membuat contract lebih kuat daripada parameter panjang atau map generic.
- Mapper XML harus readable, explicit, dan reviewable.
- ResultMap harus menggambarkan projection shape, bukan sekadar table.
- Write mapper harus mengekspos affected row count dan menjaga status/version/audit/tenant filters.
- Search mapper harus aman terhadap dynamic SQL, pagination instability, dan count query cost.
- Mapper design harus aware terhadap migration, index, observability, security, dan production debugging.
- Jika MyBatis hidup bersama JPA, mapper ownership dan table access rule harus jauh lebih eksplisit.
You just completed lesson 06 in start here. 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.