Series MapLesson 02 / 30
Start HereOrdered learning track

Learn Java Mybatis Part 002 Architecture Positioning And Persistence Boundaries

17 min read3384 words
PrevNext
Lesson 0230 lesson track0106 Start Here

title: Learn Java MyBatis - Part 002 description: Architecture positioning, persistence boundaries, repository-vs-mapper design, and anti-leakage rules for production MyBatis systems. series: learn-java-mybatis seriesTitle: Learn Java MyBatis, Patterns, Anti-Patterns, and Production Persistence Mapping order: 2 partTitle: Architecture Positioning and Persistence Boundaries tags:

  • java
  • mybatis
  • persistence
  • architecture
  • clean-architecture
  • hexagonal-architecture
  • repository
  • mapper
  • advanced-engineering date: 2026-06-27

Learn Java MyBatis - Part 002

Architecture Positioning and Persistence Boundaries

1. Tujuan Part Ini

Part sebelumnya membangun mental model: MyBatis adalah explicit SQL mapper. Part ini menjawab pertanyaan yang lebih architecture-level:

  • Di layer mana MyBatis seharusnya berada?
  • Apakah mapper sama dengan repository?
  • Apakah service boleh inject MyBatis mapper langsung?
  • Kapan perlu persistence gateway di atas mapper?
  • Bagaimana mencegah SQL concern bocor ke domain/application layer?
  • Bagaimana membagi mapper untuk aggregate, read model, workflow, search, dan reporting?
  • Bagaimana membuat boundary yang tetap testable, evolvable, dan tidak over-engineered?

MyBatis bisa dipakai dengan arsitektur sederhana maupun kompleks. Masalah muncul ketika tim tidak sadar bahwa setiap mapper method adalah bagian dari boundary design.


2. Posisi MyBatis dalam Architecture

Secara production-grade, MyBatis biasanya berada di infrastructure/data access layer.

Application/domain layer tidak seharusnya tahu detail berikut:

  • nama table;
  • nama column;
  • SQL join;
  • ResultMap;
  • dynamic SQL branch;
  • database-specific pagination;
  • MyBatis SqlSession;
  • mapper XML namespace;
  • @Param detail;
  • executor type;
  • cache flag.

Diagram clean boundary:

Catatan:

  • Domain tidak bergantung pada MyBatis.
  • Application layer bergantung pada port/repository abstraction jika kompleksitas membutuhkannya.
  • Infrastructure implementation memakai MyBatis mapper.
  • Mapper XML adalah detail infrastructure.

3. Mapper vs Repository vs DAO vs Gateway

Istilah ini sering campur aduk. Untuk codebase kecil, mapper bisa langsung berperan seperti repository. Untuk codebase besar, perbedaannya penting.

IstilahFokusLevel AbstraksiBiasanya Berisi
MyBatis MapperMapped SQL statementRendah-menengahSQL-bound method, parameter object, projection row
DAOData access objectRendah-menengahCRUD/table-oriented access, legacy style
RepositoryCollection-like access untuk aggregate/domainMenengah-tinggiLoad/save aggregate, enforce persistence boundary
GatewayInterface ke external/data subsystemMenengahQuery service, reporting source, legacy database access
Read Model GatewayQuery-specific read accessMenengahSearch, dashboard, queue, report projection

Dalam MyBatis, mapper sering terlalu dekat dengan SQL untuk langsung disebut repository domain. Itu tidak selalu buruk, tetapi perlu sadar konsekuensinya.


4. Tiga Model Penggunaan MyBatis

4.1 Model A: Service Inject Mapper Langsung

@Service
public class CaseQueryService {
    private final CaseReadMapper caseReadMapper;

    public List<CaseHeaderRow> search(CaseHeaderSearchQuery query) {
        return caseReadMapper.searchCaseHeaders(query);
    }
}

Cocok jika:

  • use case query sederhana;
  • return type adalah read model/projection;
  • tidak ada domain reconstruction kompleks;
  • mapper contract sudah cukup stabil;
  • tim menerima bahwa application service tahu mapper interface.

Risiko:

  • MyBatis concern mudah menyebar;
  • service bisa mulai tergantung pada detail query;
  • sulit mengganti persistence strategy;
  • mapper menjadi API application tanpa desain port.

Gunakan untuk:

  • internal admin read model;
  • dashboard query;
  • reporting ringan;
  • service kecil;
  • bounded context dengan lifetime pendek.

4.2 Model B: Repository Implementation Menggunakan Mapper

public interface CaseRepository {
    Optional<CaseAggregate> findById(CaseId caseId, TenantId tenantId);
    void save(CaseAggregate aggregate);
}
@Repository
public class MyBatisCaseRepository implements CaseRepository {
    private final CaseMapper caseMapper;
    private final CasePartyMapper casePartyMapper;
    private final CaseEvidenceMapper evidenceMapper;

    @Override
    public Optional<CaseAggregate> findById(CaseId caseId, TenantId tenantId) {
        CaseRecord caseRecord = caseMapper.findRecordById(caseId, tenantId).orElse(null);
        if (caseRecord == null) {
            return Optional.empty();
        }

        List<CasePartyRecord> parties = casePartyMapper.findByCaseId(caseId, tenantId);
        List<EvidenceRecord> evidence = evidenceMapper.findByCaseId(caseId, tenantId);

        return Optional.of(CaseAggregate.rehydrate(caseRecord, parties, evidence));
    }

    @Override
    public void save(CaseAggregate aggregate) {
        caseMapper.updateCaseHeader(aggregate.toHeaderUpdateCommand());
        casePartyMapper.replaceParties(aggregate.toPartyRows());
        evidenceMapper.syncEvidence(aggregate.toEvidenceRows());
    }
}

Cocok jika:

  • domain aggregate penting;
  • reconstruction membutuhkan beberapa query;
  • application layer tidak boleh tahu persistence row;
  • save operation punya semantic lebih tinggi dari satu SQL statement;
  • ingin menjaga domain tetap MyBatis-free.

Risiko:

  • repository bisa menjadi terlalu gemuk;
  • save aggregate dengan banyak child bisa kompleks;
  • butuh transaction boundary jelas di application service;
  • perlu disiplin agar repository tidak menjadi business service.

4.3 Model C: Read Model Gateway + Command Repository

Untuk sistem besar, pisahkan read/query dari command/write.

public interface CaseCommandRepository {
    Optional<CaseAggregate> findForUpdate(CaseId caseId, TenantId tenantId);
    void saveAfterTransition(CaseAggregate aggregate);
}
public interface CaseQueueReadGateway {
    List<CaseQueueRow> findInvestigatorQueue(CaseQueueQuery query);
    List<SlaBreachCandidateRow> findSlaBreachCandidates(SlaBreachQuery query);
}

Cocok jika:

  • read model sangat berbeda dari domain aggregate;
  • query dashboard/search/reporting sangat banyak;
  • write side punya invariant dan transition ketat;
  • performance query perlu tuning sendiri;
  • tim ingin CQRS ringan tanpa membuat platform event-sourcing penuh.

Risiko:

  • jumlah interface bertambah;
  • butuh naming convention kuat;
  • bisa over-engineered untuk modul kecil.

5. Decision Rule: Boleh Inject Mapper Langsung atau Tidak?

Gunakan rule berikut.

KondisiMapper Langsung ke ServiceRepository/Gateway di Atas Mapper
Query read-only sederhanaYaOpsional
Projection khusus screenYa, jika jelasYa, jika screen penting/stabil
Domain aggregate reconstructionTidak idealYa
Multi-query write operationTidakYa
Transaction semantic kompleksTidakYa
SQL-specific reportingYaGateway lebih baik
Legacy database integrationMungkinGateway lebih baik
Need swap persistence implementationTidakYa
Tim kecil/modul kecilYaOpsional
Regulated/audited critical workflowSebaiknya tidakYa

Heuristic praktis:

Jika mapper method hanya menjawab read model yang memang query-specific, inject mapper langsung masih masuk akal. Jika operation membawa semantic domain, lifecycle, atau consistency, bungkus dengan repository/gateway.


6. Boundary Anti-Leakage Rules

Rule 1: Jangan Biarkan Domain Mengimpor MyBatis

Domain object tidak boleh memiliki:

import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.type.Alias;

Domain sebaiknya bebas dari framework persistence.

Buruk:

@Alias("Case")
public class CaseAggregate {
    private String case_id;
    private String tenant_id;
}

Lebih sehat:

public final class CaseAggregate {
    private final CaseId id;
    private final TenantId tenantId;
    private CaseStatus status;
    private Version version;
}

Persistence row terpisah:

public record CaseRecord(
    String caseId,
    String tenantId,
    String statusCode,
    long version
) {}

Rule 2: Jangan Bocorkan Table/Column Naming ke Application Layer

Buruk:

caseService.search("case_number", "OPEN", "created_at desc");

Lebih sehat:

caseService.search(new CaseSearchRequest(
    CaseSearchKeyword.of("AML-2026"),
    Set.of(CaseStatus.OPEN),
    CaseSort.RECENTLY_UPDATED
));

Mapper/repository menerjemahkan CaseSort.RECENTLY_UPDATED menjadi SQL ordering yang aman.

Rule 3: Jangan Pakai Map<String, Object> sebagai Boundary Utama

Buruk:

List<CaseHeaderRow> search(Map<String, Object> params);

Masalah:

  • tidak ada compile-time safety;
  • parameter wajib tidak terlihat;
  • typo key baru ketahuan runtime;
  • reviewer tidak tahu contract;
  • test data mudah salah;
  • dynamic SQL menjadi rapuh.

Lebih sehat:

public record CaseHeaderSearchQuery(
    TenantId tenantId,
    Optional<CaseStatus> status,
    Optional<String> normalizedKeyword,
    CaseSort sort,
    PageRequest page
) {}

Rule 4: Jangan Jadikan Mapper Tempat Orchestration

Buruk:

void escalateCase(EscalateCaseRequest request);

Jika di balik method ini ada update case, insert audit, update task, insert notification, dan assign reviewer, mapper sudah menjadi workflow engine tersembunyi.

Lebih sehat:

@Transactional
public void escalate(EscalateCaseCommand command) {
    CaseAggregate aggregate = caseRepository.findForUpdate(command.caseId(), command.tenantId())
        .orElseThrow(CaseNotFoundException::new);

    aggregate.escalate(command.reason(), command.actorId(), clock.now());

    caseRepository.saveAfterEscalation(aggregate);
    auditRepository.append(aggregate.releaseAuditEvents());
    taskRepository.createEscalationReviewTask(command);
}

Mapper tetap menyediakan statement kecil:

int updateStatusIfVersionMatches(UpdateCaseStatusCommand command);
void insertAuditEvent(AuditEventRow row);
void insertReviewTask(ReviewTaskRow row);

Rule 5: Jangan Campur Read Model dan Domain Save Tanpa Alasan

Read model cenderung projection-heavy. Write model cenderung invariant-heavy.

Buruk:

public interface CaseMapper {
    CaseAggregate findAggregate(...);
    List<CaseDashboardRow> dashboard(...);
    List<CaseExportRow> export(...);
    int updateStatus(...);
    List<InvestigatorWorkloadRow> workload(...);
}

Lebih sehat:

public interface CaseAggregateMapper { ... }
public interface CaseStatusCommandMapper { ... }
public interface CaseDashboardMapper { ... }
public interface CaseExportMapper { ... }
public interface InvestigatorWorkloadMapper { ... }

Tidak harus ekstrem satu mapper per query, tetapi jangan biarkan satu mapper menjadi semua hal.


7. Layering Pattern untuk MyBatis

7.1 Simple Query Service Pattern

Gunakan untuk read-only use case yang tidak membawa domain invariant.

Contoh:

@Service
public class CaseDashboardQueryService {
    private final CaseDashboardMapper mapper;

    public CaseDashboardView getDashboard(CaseDashboardRequest request) {
        CaseDashboardQuery query = CaseDashboardQuery.from(request);
        List<CaseDashboardRow> rows = mapper.findDashboardRows(query);
        return CaseDashboardView.from(rows);
    }
}

Baik jika:

  • query spesifik untuk view;
  • tidak ada domain mutation;
  • parameter sudah divalidasi;
  • mapper method jelas.

7.2 Repository Adapter Pattern

Gunakan untuk domain aggregate.

Repository implementation boleh tahu banyak detail persistence. Domain dan application tidak perlu tahu.

7.3 Command Mapper Pattern

Gunakan untuk write operation yang perlu atomic guard.

public interface CaseStatusCommandMapper {
    int markEscalatedIfOpenAndVersionMatches(MarkEscalatedCommand command);
    int closeIfAllTasksResolved(CloseCaseCommand command);
    int assignIfUnassigned(AssignCaseCommand command);
}

Return int penting karena affected row count adalah signal consistency.

7.4 Read Model Mapper Pattern

Gunakan untuk screen/report/queue.

public interface CaseQueueReadMapper {
    List<CaseQueueRow> findQueue(CaseQueueQuery query);
    long countQueue(CaseQueueCountQuery query);
}

Read model mapper tidak harus mengembalikan aggregate.

7.5 Legacy Gateway Pattern

Gunakan jika database bukan milik bounded context ini.

public interface LegacyPartyGateway {
    Optional<LegacyPartySnapshot> findPartySnapshot(PartyExternalId id);
}

Implementation memakai MyBatis, tetapi application melihatnya sebagai gateway ke sistem legacy.


8. Package Structure Production-Grade

Tidak ada satu struktur universal, tetapi berikut baseline yang sehat untuk Spring + MyBatis.

com.acme.enforcement
  ├── casecore
  │   ├── domain
  │   │   ├── CaseAggregate.java
  │   │   ├── CaseStatus.java
  │   │   ├── CasePolicy.java
  │   │   └── ...
  │   ├── application
  │   │   ├── EscalateCaseService.java
  │   │   ├── AssignCaseService.java
  │   │   └── port
  │   │       ├── CaseRepository.java
  │   │       └── CaseAuditRepository.java
  │   └── infrastructure
  │       └── persistence
  │           ├── mybatis
  │           │   ├── mapper
  │           │   │   ├── CaseAggregateMapper.java
  │           │   │   ├── CaseStatusCommandMapper.java
  │           │   │   └── CaseAuditMapper.java
  │           │   ├── row
  │           │   │   ├── CaseRecord.java
  │           │   │   ├── CasePartyRecord.java
  │           │   │   └── AuditEventRow.java
  │           │   ├── command
  │           │   │   ├── MarkEscalatedCommand.java
  │           │   │   └── InsertAuditEventCommand.java
  │           │   ├── typehandler
  │           │   │   └── CaseStatusTypeHandler.java
  │           │   └── MyBatisCaseRepository.java
  │           └── config
  │               └── MyBatisCasePersistenceConfig.java
  └── casequery
      ├── application
      │   └── CaseSearchQueryService.java
      └── infrastructure
          └── persistence
              └── mybatis
                  ├── mapper
                  │   ├── CaseSearchMapper.java
                  │   ├── CaseDashboardMapper.java
                  │   └── CaseQueueMapper.java
                  ├── query
                  │   ├── CaseSearchQuery.java
                  │   └── CaseQueueQuery.java
                  └── row
                      ├── CaseHeaderRow.java
                      ├── CaseQueueRow.java
                      └── CaseDashboardRow.java

Mapper XML:

src/main/resources/mappers/casecore/CaseAggregateMapper.xml
src/main/resources/mappers/casecore/CaseStatusCommandMapper.xml
src/main/resources/mappers/casecore/CaseAuditMapper.xml
src/main/resources/mappers/casequery/CaseSearchMapper.xml
src/main/resources/mappers/casequery/CaseDashboardMapper.xml
src/main/resources/mappers/casequery/CaseQueueMapper.xml

Prinsip:

  • domain tidak bergantung pada infrastructure;
  • row/query/command persistence object berada dekat mapper;
  • XML dipisah berdasarkan bounded context atau module;
  • read query dan command mapper tidak dicampur sembarangan;
  • config explicit untuk scan path dan mapper location.

9. Domain Model vs Persistence Model vs DTO

Kesalahan besar dalam MyBatis adalah memakai satu class untuk semua kebutuhan.

9.1 Domain Model

Domain model membawa behavior dan invariant.

public final class CaseAggregate {
    private final CaseId id;
    private final TenantId tenantId;
    private CaseStatus status;
    private Version version;
    private final List<CaseParty> parties;

    public void escalate(EscalationReason reason, ActorId actorId, Instant now) {
        if (!status.canEscalate()) {
            throw new InvalidCaseTransitionException(status, CaseStatus.ESCALATED);
        }
        this.status = CaseStatus.ESCALATED;
        this.version = version.next();
        // collect domain event / audit intent
    }
}

Domain model tidak harus match table.

9.2 Persistence Row

Persistence row merepresentasikan database shape.

public record CaseRecord(
    String caseId,
    String tenantId,
    String caseNumber,
    String statusCode,
    String priorityCode,
    Instant createdAt,
    Instant updatedAt,
    long version
) {}

Persistence row boleh lebih dekat ke column naming, tetapi tetap jangan terlalu mentah jika bisa dihindari.

9.3 Query DTO / Read Model

Read model merepresentasikan kebutuhan screen/report/API.

public record CaseQueueRow(
    String caseId,
    String caseNumber,
    String statusLabel,
    String priorityLabel,
    Instant slaDueAt,
    int openTaskCount,
    String assignedInvestigatorName
) {}

Read model tidak harus bisa disimpan kembali.

9.4 API DTO

API DTO adalah contract external.

public record CaseQueueResponse(
    String id,
    String number,
    String status,
    String priority,
    String slaDueAt,
    int openTasks,
    String investigator
) {}

Jangan langsung expose persistence row jika API contract perlu stabil.


10. Mapping Flow yang Sehat

Untuk read-only query sederhana, flow bisa dipersingkat:

Yang penting: pemotongan layer harus sadar, bukan karena malas.


11. Aggregate Loading Strategy

Jika memakai MyBatis untuk domain aggregate, pilih strategi loading.

11.1 Single Join Query

SELECT
    c.case_id,
    c.status,
    p.party_id,
    p.party_role,
    e.evidence_id,
    e.evidence_type
FROM enforcement_case c
LEFT JOIN case_party p ON p.case_id = c.case_id
LEFT JOIN case_evidence e ON e.case_id = c.case_id
WHERE c.case_id = #{caseId}
  AND c.tenant_id = #{tenantId}

Kelebihan:

  • satu round trip;
  • semua data terlihat di satu SQL;
  • bisa cocok untuk aggregate kecil.

Risiko:

  • duplicate parent row;
  • cartesian multiplication jika banyak collection;
  • mapping object graph lebih kompleks;
  • memory membesar.

11.2 Multiple Focused Queries

CaseRecord caseRecord = caseMapper.findCaseRecord(query).orElseThrow();
List<PartyRecord> parties = partyMapper.findPartiesByCase(query);
List<EvidenceRecord> evidence = evidenceMapper.findEvidenceByCase(query);

Kelebihan:

  • query lebih sederhana;
  • mapping lebih jelas;
  • collection tidak saling mengalikan;
  • mudah tuning per bagian.

Risiko:

  • beberapa round trip;
  • butuh transaction consistency jika data berubah;
  • bisa berubah menjadi N+1 jika tidak hati-hati.

11.3 Decision Heuristic

KondisiPilihan Awal
Aggregate kecil, one-to-one atau one-to-fewJoin query
Banyak collectionMultiple focused queries
Butuh lock parentLock parent dulu, load child sesuai kebutuhan
Read-only dashboardProjection query, bukan aggregate
Export besarStreaming/chunked query, bukan aggregate
Workflow commandLoad minimal state yang dibutuhkan untuk decision

12. Read Model Boundary

Read model adalah salah satu alasan MyBatis kuat.

Contoh kebutuhan UI:

Investigator Queue
- case number
- priority
- SLA due date
- subject display name
- open task count
- latest activity timestamp
- assigned team

Ini bukan domain aggregate. Ini query projection.

Mapper:

public interface InvestigatorQueueMapper {
    List<InvestigatorQueueRow> findQueue(InvestigatorQueueQuery query);
}

Query object:

public record InvestigatorQueueQuery(
    TenantId tenantId,
    InvestigatorId investigatorId,
    Set<CaseStatus> statuses,
    Instant dueBefore,
    int limit,
    String cursorCaseId,
    Instant cursorSlaDueAt
) {}

Row:

public record InvestigatorQueueRow(
    String caseId,
    String caseNumber,
    String priority,
    Instant slaDueAt,
    String subjectDisplayName,
    int openTaskCount,
    Instant latestActivityAt
) {}

Application service bisa mengubah row menjadi response. Domain aggregate tidak perlu dilibatkan.


13. Write Boundary dan Command Semantics

Untuk write operation, jangan hanya berpikir update table. Pikirkan semantic.

Buruk:

void updateCase(CaseRecord record);

Lebih baik:

int markCaseEscalated(MarkCaseEscalatedCommand command);

Command object:

public record MarkCaseEscalatedCommand(
    TenantId tenantId,
    CaseId caseId,
    CaseStatus expectedCurrentStatus,
    Version expectedVersion,
    ActorId actorId,
    Instant escalatedAt
) {}

SQL:

<update id="markCaseEscalated">
  UPDATE enforcement_case
  SET status = 'ESCALATED',
      escalated_by = #{actorId},
      escalated_at = #{escalatedAt},
      version = version + 1,
      updated_at = #{escalatedAt}
  WHERE tenant_id = #{tenantId}
    AND case_id = #{caseId}
    AND status = #{expectedCurrentStatus}
    AND version = #{expectedVersion}
</update>

Affected row count menjadi signal:

Affected RowsMeaning
1Transition berhasil.
0Case tidak ditemukan, tenant salah, status berubah, atau version conflict.
>1Critical data integrity bug jika primary key benar.

Repository/application layer harus menangani 0 secara eksplisit.


14. Anti-Corruption Layer untuk Database Legacy

Dalam banyak enterprise, database tidak selalu ideal:

  • nama column historis;
  • kode status tidak konsisten;
  • nullable field tanpa alasan jelas;
  • table dipakai banyak aplikasi;
  • soft delete tidak konsisten;
  • date/time disimpan dalam format lama;
  • status lifecycle tersebar di beberapa table.

MyBatis mapper sebaiknya menjadi bagian dari anti-corruption layer.

Buruk: domain mengikuti legacy shape.

public record Case(
    String c_id,
    String sts_cd,
    String flg_del,
    String dt_upd
) {}

Lebih sehat:

public record LegacyCaseRow(
    String cId,
    String statusCode,
    String deletedFlag,
    String updatedDateText
) {}

Adapter menerjemahkan:

public CaseSnapshot toSnapshot(LegacyCaseRow row) {
    return new CaseSnapshot(
        CaseId.of(row.cId()),
        LegacyStatusMapper.toCaseStatus(row.statusCode()),
        LegacyBooleanMapper.isDeleted(row.deletedFlag()),
        LegacyDateParser.parse(row.updatedDateText())
    );
}

Tujuan: legacy corruption berhenti di infrastructure boundary.


15. Multi-Module Boundary

Untuk sistem besar, mapper harus mengikuti ownership module.

Buruk:

com.acme.persistence.mapper.GlobalMapper
com.acme.persistence.mapper.CommonMapper
com.acme.persistence.mapper.ReportMapper

Lebih sehat:

casecore.infrastructure.persistence.mybatis.mapper
casequery.infrastructure.persistence.mybatis.mapper
audit.infrastructure.persistence.mybatis.mapper
party.infrastructure.persistence.mybatis.mapper
reporting.infrastructure.persistence.mybatis.mapper

Kenapa?

  • ownership jelas;
  • schema impact bisa dilacak;
  • review bisa diarahkan ke domain owner;
  • query tidak saling bergantung tanpa sadar;
  • migration lebih mudah dikaitkan dengan module.

16. Mapper Granularity

Tidak ada aturan absolut. Pilihan granularity harus mengikuti change reason.

16.1 Per Table Mapper

CaseTableMapper
CasePartyTableMapper
EvidenceTableMapper

Cocok untuk:

  • generated CRUD;
  • internal maintenance;
  • schema-oriented operation;
  • modul kecil.

Risiko:

  • service harus orchestrate banyak table;
  • domain intent hilang;
  • read model query lintas table jadi canggung.

16.2 Per Aggregate Mapper

CaseAggregateMapper
PartyAggregateMapper

Cocok untuk:

  • aggregate-centric write;
  • domain reconstruction;
  • consistency boundary.

Risiko:

  • mapper bisa terlalu besar;
  • reporting/search tidak cocok.

16.3 Per Use Case / Read Model Mapper

CaseQueueMapper
CaseDashboardMapper
CaseExportMapper

Cocok untuk:

  • query kompleks;
  • screen-specific projection;
  • performance tuning terisolasi.

Risiko:

  • jumlah mapper banyak;
  • fragment SQL bisa duplicate;
  • butuh convention naming.

16.4 Per Workflow Command Mapper

CaseAssignmentCommandMapper
CaseEscalationCommandMapper
CaseClosureCommandMapper

Cocok untuk:

  • update dengan guard;
  • lifecycle transition;
  • audit-sensitive command.

Risiko:

  • terlalu fragment jika workflow kecil;
  • perlu memastikan transaction orchestration tetap di application service.

16.5 Heuristic

Mapper TypeChange Reason
Table mapperSchema/table operation berubah
Aggregate mapperAggregate persistence berubah
Read model mapperScreen/report/query berubah
Command mapperWorkflow transition berubah
Gateway mapperExternal/legacy integration berubah

Pilih granularity berdasarkan alasan perubahan, bukan sekadar jumlah file.


17. Persistence Boundary untuk Transaction

Transaction boundary biasanya bukan mapper.

Application service owns:

  • use case orchestration;
  • transaction boundary;
  • policy call;
  • exception mapping;
  • conflict handling.

Repository/mapper owns:

  • SQL execution;
  • persistence mapping;
  • atomic database operation;
  • row-to-domain reconstruction if needed.

18. Common Boundary Smells

Smell 1: Service Contains SQL Concepts

service.search(sortColumn: "updated_at", tableAlias: "c")

Fix:

service.search(sort: CaseSort.RECENTLY_UPDATED)

Smell 2: Mapper Method Name Is CRUD but SQL Is Business-Specific

updateCase(...)

Padahal SQL:

UPDATE enforcement_case
SET status = 'ESCALATED'
WHERE status = 'OPEN'
  AND severity = 'HIGH'
  AND sla_due_at < now()

Fix:

markHighSeverityOpenCaseEscalatedIfSlaBreached(...)

Atau pecah policy di application layer dan update guard di command mapper.

Smell 3: Domain Object Has Database Nullability Everywhere

public class Case {
    private String caseId;
    private String status;
    private String optionalColumn1;
    private String optionalColumn2;
    private String optionalColumn3;
}

Kemungkinan class ini dipakai sebagai entity, row, projection, dan API DTO sekaligus.

Fix: pisahkan domain, row, projection.

Smell 4: Mapper Returns Map

List<Map<String, Object>> report(...)

Bisa diterima untuk generic admin tooling, tetapi buruk untuk core application.

Fix:

List<CaseExposureReportRow> findExposureReport(...)

Smell 5: One Mapper per Database, Not per Boundary

ApplicationMapper
CommonMapper
MasterMapper

Fix berdasarkan bounded context/use case.

Smell 6: XML Fragment Hides Mandatory Predicate

<include refid="CommonWhereClause" />

Jika fragment menyembunyikan tenant/security predicate, reviewer bisa melewatkan bug.

Fix: fragment boleh, tetapi mandatory predicate harus mudah terlihat atau diuji dengan contract test.


19. Boundary Design untuk Multi-Tenant System

Multi-tenant system membutuhkan discipline ekstra.

19.1 TenantId Harus Ada di Query Object

Buruk:

Optional<CaseRecord> findById(CaseId caseId);

Lebih sehat:

Optional<CaseRecord> findById(CaseId caseId, TenantId tenantId);

Atau:

public record CaseIdentityQuery(CaseId caseId, TenantId tenantId) {}

19.2 Tenant Predicate Harus Eksplisit

WHERE case_id = #{caseId}
  AND tenant_id = #{tenantId}

Jangan bergantung pada convention informal.

19.3 Read Model Juga Wajib Tenant-Safe

Search/report/dashboard sering menjadi sumber leak karena dianggap read-only.

Read-only bukan berarti harmless.

19.4 Test Missing Tenant Scenario

Minimal test:

  • tenant A dan tenant B punya case_number sama;
  • query tenant A tidak boleh melihat tenant B;
  • search keyword tidak boleh menembus tenant predicate;
  • count query dan data query harus punya tenant predicate konsisten.

20. Boundary Design untuk Auditability

Dalam regulatory system, auditability bukan fitur tambahan.

Mapper design harus mendukung:

  • siapa mengubah data;
  • kapan berubah;
  • perubahan apa;
  • command apa yang menyebabkan perubahan;
  • correlation id/request id;
  • before/after state jika diperlukan;
  • alasan business decision;
  • affected row count;
  • failure/conflict tracking.

Command object sebaiknya membawa audit metadata:

public record AssignCaseCommand(
    TenantId tenantId,
    CaseId caseId,
    InvestigatorId investigatorId,
    ActorId actorId,
    Instant assignedAt,
    RequestId requestId,
    Version expectedVersion
) {}

Mapper update:

UPDATE enforcement_case
SET assigned_investigator_id = #{investigatorId},
    assigned_by = #{actorId},
    assigned_at = #{assignedAt},
    updated_at = #{assignedAt},
    version = version + 1
WHERE tenant_id = #{tenantId}
  AND case_id = #{caseId}
  AND version = #{expectedVersion}

Audit insert sebaiknya berada dalam transaction yang sama.


21. Boundary Design untuk Reporting

Reporting sering berbeda dari transactional use case.

Jangan paksakan reporting query ke aggregate repository.

Buruk:

caseRepository.findAllCasesForMonthlyExposureReport(...)

Lebih sehat:

public interface MonthlyExposureReportGateway {
    List<ExposureReportRow> findExposureRows(ExposureReportQuery query);
}

Alasan:

  • report punya projection sendiri;
  • query bisa heavy;
  • index/tuning berbeda;
  • pagination/export berbeda;
  • data staleness mungkin punya rule sendiri;
  • report ownership berbeda dari aggregate write.

22. Boundary dengan MyBatis-Spring

Dalam Spring, MyBatis-Spring menyediakan integration layer yang menghubungkan mapper, SqlSession, dan Spring transaction. Secara praktis, application code biasanya tidak perlu membuka SqlSession manual.

Architecture rule:

  • gunakan mapper injection melalui Spring;
  • biarkan transaction manager mengatur transaction;
  • letakkan @Transactional di application service/use case;
  • hindari manual commit/rollback di mapper/repository Spring-managed;
  • jangan campur manual SqlSession lifecycle kecuali ada alasan teknis kuat.

Contoh konfigurasi ringkas:

@Configuration
@MapperScan(
    basePackages = "com.acme.enforcement.casecore.infrastructure.persistence.mybatis.mapper",
    sqlSessionTemplateRef = "caseSqlSessionTemplate"
)
public class CaseMyBatisConfig {
}

Jika multi-datasource, sqlSessionFactoryRef atau sqlSessionTemplateRef harus eksplisit agar mapper terhubung ke datasource yang benar.


23. Architecture Decision Record Template

Untuk modul penting, tulis ADR singkat.

# ADR: Use MyBatis for Case Query Read Models

## Context
Case management requires complex search, queue, dashboard, and SLA queries. Query shape differs from domain aggregate shape. SQL needs to be reviewed and tuned explicitly.

## Decision
Use MyBatis XML mappers for read model queries. Keep domain aggregate persistence behind CaseRepository. Use separate read gateways for dashboard, queue, and reporting.

## Consequences
- SQL remains explicit and reviewable.
- Read models can be optimized independently.
- More mapper files are expected.
- Mapper tests must run against real database schema.
- Tenant predicate must be mandatory in every query object.

## Guardrails
- No Map parameter for core query.
- No raw `${}` except whitelisted identifier rendering.
- No `SELECT *` in production mapper.
- Every list query must have deterministic ordering.
- Every tenant-scoped query must test tenant isolation.

ADR menjaga decision agar tidak hilang saat tim berubah.


24. Code Review Checklist untuk Architecture Boundary

Gunakan checklist ini untuk PR yang menambah mapper/repository.

Layering

  • Apakah MyBatis hanya berada di infrastructure layer?
  • Apakah domain bebas dari MyBatis annotation/import?
  • Apakah application layer memakai abstraction yang sesuai?
  • Apakah mapper direct injection memang disengaja?

Contract

  • Apakah mapper method punya intent jelas?
  • Apakah parameter object eksplisit?
  • Apakah return type sesuai cardinality?
  • Apakah read model dan domain aggregate tidak dicampur sembarangan?

Security/Correctness

  • Apakah tenant predicate wajib ada?
  • Apakah soft-delete/status predicate konsisten?
  • Apakah authorization-related filter tidak raw/dynamic sembarangan?
  • Apakah affected row count ditangani untuk command?

Maintainability

  • Apakah mapper berada di package/module yang tepat?
  • Apakah XML namespace sesuai interface?
  • Apakah SQL fragment tidak menyembunyikan behavior kritis?
  • Apakah nama row/query/command object jelas?

Testing

  • Apakah ada mapper test dengan database nyata?
  • Apakah tenant isolation diuji?
  • Apakah dynamic SQL branch diuji?
  • Apakah transaction behavior diuji di service/repository level?

25. Common Architecture Anti-Patterns

Anti-Pattern 1: Mapper Everywhere

Controller, service, scheduled job, listener, dan domain helper semua inject mapper langsung.

Dampak:

  • persistence concern menyebar;
  • transaction boundary tidak jelas;
  • query duplicate;
  • sulit mengganti atau mengaudit data access.

Perbaikan:

  • gunakan application service sebagai entry point;
  • bungkus operation penting dengan repository/gateway;
  • pisahkan read service dan command service.

Anti-Pattern 2: Repository That Is Just a Pass-Through

public List<CaseHeaderRow> search(CaseHeaderSearchQuery query) {
    return mapper.search(query);
}

Pass-through tidak selalu buruk, tetapi jika tidak menambah abstraction, jangan membuat layer palsu hanya untuk terlihat clean.

Repository/gateway harus memberi salah satu nilai:

  • semantic domain;
  • orchestration multi-mapper;
  • mapping row ke domain;
  • error/conflict handling;
  • anti-corruption translation;
  • stable port untuk application.

Anti-Pattern 3: Domain Aggregate for Every Read

Semua query load full aggregate walau UI hanya butuh 5 field.

Dampak:

  • query mahal;
  • mapping kompleks;
  • memory besar;
  • lock/concurrency risk meningkat;
  • read model sulit berubah.

Perbaikan:

  • gunakan projection/read model mapper.

Anti-Pattern 4: Table-Centric Everything

Semua mapper dibuat per table dan service menggabungkan sendiri.

Dampak:

  • use case intent hilang;
  • transaction orchestration menyebar;
  • query lintas table menjadi tidak natural;
  • business operation tidak terlihat di contract.

Perbaikan:

  • untuk read, gunakan read model mapper;
  • untuk write, gunakan command mapper atau aggregate repository.

Anti-Pattern 5: Over-Abstracted Persistence

Terlalu banyak interface/layer untuk query sederhana.

Dampak:

  • navigasi code sulit;
  • tidak ada nilai nyata;
  • development lambat;
  • mapper behavior tersembunyi.

Perbaikan:

  • pilih abstraction berdasarkan complexity dan change reason;
  • jangan membuat repository jika mapper direct cukup dan aman.

26. Practical Decision Matrix

Gunakan matrix ini saat membuat fitur baru.

Use CaseRecommended BoundaryReturn ShapeMapper Style
Find case detail for command decisionRepositoryDomain aggregate/minimal decision stateAggregate mapper + child mapper
Search case headersQuery service or read gatewayCaseHeaderRowRead model mapper
Assignment queueRead gatewayCaseQueueRowQuery-specific mapper
Escalate caseApplication service + command repositoryaffected row/resultCommand mapper
Insert audit eventAudit repositoryvoid/idCommand mapper
Monthly reportReport gatewayreport rowReporting mapper
Legacy party lookupGatewaysnapshotAnti-corruption mapper
Admin lookup table CRUDMapper direct or small repositoryrow/entityTable mapper
Bulk lifecycle transitionApplication service + command mapperaffected count summaryBulk command mapper
Export millions of rowsExport gatewaystream/chunk rowSpecialized mapper

27. Latihan Part 002

Drill 1: Boundary Classification

Ambil 10 mapper method dari codebase. Klasifikasikan:

  • table mapper;
  • aggregate mapper;
  • read model mapper;
  • command mapper;
  • report mapper;
  • legacy gateway mapper.

Jika satu mapper memiliki lebih dari 3 kategori, evaluasi apakah perlu dipecah.

Drill 2: Mapper Direct vs Repository

Untuk 5 use case, putuskan apakah service boleh inject mapper langsung.

Format jawaban:

Use case:
Boundary choice:
Reason:
Risk:
Required tests:

Drill 3: Domain Leakage Audit

Cari import berikut di package domain/application:

org.apache.ibatis
java.sql
javax.sql

Jika ada, jelaskan apakah itu legitimate atau leakage.

Drill 4: Query Object Refactor

Ubah method:

List<Map<String, Object>> search(Map<String, Object> params);

Menjadi:

  • query object;
  • projection row;
  • mapper method jelas;
  • optional repository/gateway jika perlu.

Drill 5: Multi-Tenant Boundary Test Design

Rancang test untuk memastikan query search case tidak bocor lintas tenant.

Minimal data:

  • tenant A: case number CASE-001;
  • tenant B: case number CASE-001;
  • search tenant A keyword CASE-001;
  • expected hanya tenant A.

28. Ringkasan Part 002

MyBatis berada paling sehat di infrastructure/data access layer. Mapper boleh digunakan langsung untuk read-only query sederhana, tetapi operation yang membawa domain semantic, aggregate reconstruction, transaction complexity, auditability, atau consistency rule sebaiknya dibungkus repository/gateway.

Prinsip utama:

  1. Domain tidak boleh bergantung pada MyBatis.
  2. Mapper bukan selalu repository.
  3. Read model boleh query-specific.
  4. Write operation harus membawa command semantic.
  5. Multi-tenant predicate adalah security boundary.
  6. Repository/gateway hanya berguna jika memberi abstraction nyata.
  7. Mapper granularity mengikuti change reason.
  8. Transaction orchestration berada di application service.
  9. Anti-corruption layer penting untuk legacy database.
  10. Boundary yang sehat membuat SQL tetap eksplisit tanpa membocorkan SQL concern ke seluruh sistem.

Part berikutnya akan masuk ke project structure, configuration, dan runtime model: SqlSessionFactory, SqlSession, mapper proxy, XML location, type alias, type handler registration, setting penting, dan convention production-grade.


References

Lesson Recap

You just completed lesson 02 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.

Continue The Track

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