Start HereOrdered learning track

Repository and Module Topology

Learn Production Grade Contract-First Java Orchestration Platform - Part 005

Repository dan Maven module topology untuk platform Java contract-first production-grade, termasuk pemisahan contract, generated code, API, workflow, persistence, eventing, deployment, dan testing module.

18 min read3490 words
PrevNext
Lesson 0540 lesson track0108 Start Here
#java#maven#multi-module#architecture+9 more

Part 005 — Repository and Module Topology

Repository structure adalah arsitektur yang dipaksa menjadi nyata.

Diagram bagus bisa menyembunyikan coupling. Dokumen arsitektur bisa mengklaim boundary yang rapi. Tetapi struktur repository, module dependency, package boundary, Maven lifecycle, dan CI gate akan menunjukkan kebenaran sistem:

  • siapa boleh depend ke siapa;
  • contract mana yang menjadi sumber kebenaran;
  • generated code boleh bocor ke mana;
  • persistence detail terlihat atau tersembunyi;
  • workflow engine menjadi core domain atau sekadar orkestrator;
  • test bisa dijalankan cepat atau semua hal harus boot seluruh dunia;
  • release satu service bisa dilakukan tanpa merusak semua module lain.

Part ini membangun topology repository untuk studi kasus kita: Regulatory Enforcement & Case Orchestration Platform.

Kita tidak sedang membuat struktur folder yang terlihat rapi. Kita sedang membuat mechanical boundary: aturan yang dipaksa oleh Maven, dependency graph, package naming, test scope, dan CI.


1. Problem: Repository yang Terlihat Rapi tapi Boundary-nya Bohong

Banyak project enterprise punya folder seperti ini:

src/main/java/com/company/project
├── controller
├── service
├── repository
├── model
├── dto
└── util

Struktur ini terlihat familiar, tetapi untuk sistem production-grade contract-first, struktur seperti ini terlalu miskin informasi.

Masalahnya:

  1. model sering berisi campuran DTO HTTP, entity database, Kafka event, Camunda variable, dan internal command.
  2. service menjadi tempat semua logika: transaksi, workflow, validation, event publishing, authorization, dan mapper call.
  3. repository menyembunyikan SQL tetapi tidak menyembunyikan coupling data.
  4. controller terlihat tipis, tetapi error semantics dan idempotency sering tersebar.
  5. util menjadi tempat pembuangan logic yang tidak punya owner.
  6. Semua class bisa import semua class lain.

Akibatnya, contract-first hanya menjadi label. Secara mekanis, sistem tetap code-first dan coupling-first.

Kita butuh repository yang menjawab:

  • kontrak HTTP didefinisikan di mana?
  • kontrak event didefinisikan di mana?
  • BPMN process definition disimpan dan dites di mana?
  • SQL migration dan PL/pgSQL function menjadi bagian release apa?
  • module mana yang boleh tahu Camunda API?
  • module mana yang boleh tahu MyBatis?
  • module mana yang boleh tahu Kafka client?
  • generated code boleh dipakai langsung oleh domain atau harus diadaptasi?
  • test contract, integration, dan system ditempatkan di mana?

2. Prinsip Topology

Gunakan prinsip berikut.

2.1 Contract is an input, not an afterthought

Kontrak tidak lahir setelah code selesai. Kontrak adalah input build.

Artinya:

  • OpenAPI file versioned di repository;
  • AsyncAPI/event schema versioned di repository;
  • BPMN definition versioned di repository;
  • DB migration versioned di repository;
  • Java types hasil generate tidak diedit manual;
  • CI bisa menolak perubahan kontrak yang breaking.

2.2 Generated code is not domain code

Generated code berguna, tetapi tidak boleh menjadi pusat domain.

Generated DTO biasanya membawa bentuk kontrak eksternal. Domain model membawa invariant internal. Dua hal ini boleh mirip, tetapi tidak identik.

Contoh:

OpenAPI CreateCaseRequest
        │
        ▼
api-generated model
        │ mapped by adapter
        ▼
domain command SubmitCaseCommand
        │ validated by domain/application service
        ▼
PostgreSQL row + audit + outbox

Generated model boleh berubah mengikuti contract evolution. Domain command berubah mengikuti business invariant. Jangan satukan keduanya tanpa boundary.

2.3 Infrastructure dependency points inward? Not exactly

Di Clean Architecture klasik, dependency mengarah ke domain. Itu benar sebagai prinsip umum, tetapi sistem enterprise contract-first butuh beberapa boundary tambahan:

  • contract module boleh berada di luar domain;
  • generated module boleh dipakai API adapter;
  • BPMN module boleh memiliki process definition tetapi tidak boleh menyimpan domain decision;
  • persistence module boleh mengimplementasikan port, tetapi SQL tetap first-class artifact;
  • deployment module tidak boleh depend ke application code.

Jangan dogmatis. Yang penting: domain invariant tidak dikendalikan oleh transport, engine, atau mapper.

2.4 Build graph must encode architecture

Kalau architecture rule hanya ada di dokumentasi, rule itu akan dilanggar.

Maven module graph harus membuat pelanggaran menjadi sulit.

Contoh rule:

case-domain tidak boleh depend ke jersey, kafka, camunda, mybatis, servlet, jakarta.ws.rs
case-application boleh depend ke domain dan port, tetapi tidak ke jersey resource
case-api-runtime boleh depend ke jersey dan generated API model
case-persistence-mybatis boleh depend ke mybatis dan postgresql driver
case-workflow-camunda boleh depend ke camunda engine API
case-events-kafka boleh depend ke kafka client

Rule ini nanti bisa diperkuat dengan Maven Enforcer, ArchUnit, dan CI.


3. High-Level Repository Topology

Kita pakai satu repository untuk satu platform learning ini agar dependency antar-bagian terlihat. Dalam production nyata, organisasi bisa memilih monorepo atau multi-repo. Untuk belajar sistem kompleks, monorepo lebih efektif karena kita bisa melihat kontrak, code, DB, BPMN, deployment, dan test dalam satu tempat.

regulatory-enforcement-platform/
├── pom.xml
├── README.md
├── docs/
├── contracts/
├── platform-bom/
├── platform-parent/
├── case-domain/
├── case-application/
├── case-contract-openapi/
├── case-contract-events/
├── case-api-generated/
├── case-api-runtime/
├── case-persistence-contract/
├── case-persistence-mybatis/
├── case-workflow-contract/
├── case-workflow-camunda7/
├── case-events-contract/
├── case-events-kafka/
├── case-outbox-publisher/
├── case-inbox-consumer/
├── case-operational-api/
├── case-app/
├── database/
├── deployment/
├── test-fixtures/
├── test-contract/
├── test-integration/
├── test-system/
└── tools/

Ini terlihat banyak. Tetapi banyak bukan berarti rumit. Banyak module kecil dengan boundary jelas sering lebih mudah dikendalikan daripada satu module besar yang semua class-nya saling import.


4. Maven Aggregator, Parent, dan BOM

Sebelum masuk module bisnis, bedakan tiga konsep Maven yang sering dicampur.

4.1 Aggregator POM

Aggregator POM menentukan module apa saja yang dibangun bersama.

Root pom.xml biasanya punya packaging pom dan daftar <modules>.

<project>
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example.regulatory</groupId>
  <artifactId>regulatory-enforcement-platform</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <packaging>pom</packaging>

  <modules>
    <module>platform-bom</module>
    <module>platform-parent</module>
    <module>case-domain</module>
    <module>case-application</module>
    <module>case-contract-openapi</module>
    <module>case-contract-events</module>
    <module>case-api-generated</module>
    <module>case-api-runtime</module>
    <module>case-persistence-contract</module>
    <module>case-persistence-mybatis</module>
    <module>case-workflow-contract</module>
    <module>case-workflow-camunda7</module>
    <module>case-events-contract</module>
    <module>case-events-kafka</module>
    <module>case-outbox-publisher</module>
    <module>case-inbox-consumer</module>
    <module>case-operational-api</module>
    <module>case-app</module>
    <module>database</module>
    <module>test-fixtures</module>
    <module>test-contract</module>
    <module>test-integration</module>
    <module>test-system</module>
  </modules>
</project>

Aggregator menjawab: “kalau build seluruh repo, module apa saja yang ikut?”

4.2 Parent POM

Parent POM membawa konfigurasi build yang diwariskan:

  • Java version;
  • compiler configuration;
  • surefire/failsafe configuration;
  • pluginManagement;
  • checkstyle/spotbugs/error-prone jika dipakai;
  • source encoding;
  • reproducible build setting;
  • default test naming convention.

platform-parent/pom.xml:

<project>
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>com.example.regulatory</groupId>
    <artifactId>regulatory-enforcement-platform</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <relativePath>../pom.xml</relativePath>
  </parent>

  <artifactId>platform-parent</artifactId>
  <packaging>pom</packaging>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  </properties>

  <build>
    <pluginManagement>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>${maven.compiler.plugin.version}</version>
          <configuration>
            <release>${maven.compiler.release}</release>
          </configuration>
        </plugin>

        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>${maven.surefire.plugin.version}</version>
        </plugin>

        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-failsafe-plugin</artifactId>
          <version>${maven.failsafe.plugin.version}</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

Parent menjawab: “bagaimana module dibangun?”

4.3 BOM

BOM atau Bill of Materials mengontrol versi dependency.

platform-bom/pom.xml:

<project>
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example.regulatory</groupId>
  <artifactId>platform-bom</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <packaging>pom</packaging>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.glassfish.jersey</groupId>
        <artifactId>jersey-bom</artifactId>
        <version>${jersey.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>

      <dependency>
        <groupId>org.junit</groupId>
        <artifactId>junit-bom</artifactId>
        <version>${junit.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

BOM menjawab: “versi dependency apa yang legal?”

Jangan campur semua ke root POM. Root aggregator, parent, dan BOM punya fungsi berbeda.


5. Module Graph Utama

Dependency graph ideal:

Baca diagram dari bawah ke atas secara hati-hati: module runtime menggabungkan adapter. Domain tidak tahu runtime.

Catatan: Maven dependency arah panah bisa digambar berbeda tergantung konvensi. Yang penting adalah aturan konseptual: domain dan application core tidak depend ke framework adapter.


6. Module-by-Module Design

6.1 case-domain

Isi:

case-domain/
└── src/main/java/com/example/regulatory/case/domain/
    ├── CaseId.java
    ├── CaseNumber.java
    ├── EnforcementCase.java
    ├── CaseLifecycleStatus.java
    ├── RiskLevel.java
    ├── DecisionType.java
    ├── CaseTransitionPolicy.java
    ├── CaseInvariantViolation.java
    └── events/
        ├── DomainEvent.java
        ├── CaseAccepted.java
        ├── CaseRejected.java
        └── CaseEscalated.java

Rule:

  • tidak ada jakarta.ws.rs;
  • tidak ada org.apache.kafka;
  • tidak ada org.camunda;
  • tidak ada org.mybatis;
  • tidak ada JDBC;
  • tidak ada servlet;
  • tidak ada generated OpenAPI DTO;
  • boleh punya pure Java records/classes/enums;
  • boleh punya domain exception atau typed result;
  • harus bisa dites tanpa container.

Contoh domain type:

public record CaseId(UUID value) {
    public CaseId {
        if (value == null) {
            throw new IllegalArgumentException("case id is required");
        }
    }
}

Contoh domain policy:

public final class CaseTransitionPolicy {
    public void requireCanStartInvestigation(EnforcementCase c) {
        if (c.status() != CaseLifecycleStatus.ACCEPTED) {
            throw new CaseInvariantViolation(
                "Only accepted case can enter investigation"
            );
        }
    }
}

Domain module menjawab: “apa yang benar secara bisnis?”

6.2 case-application

Isi:

case-application/
└── src/main/java/com/example/regulatory/case/application/
    ├── command/
    │   ├── SubmitCaseCommand.java
    │   ├── AcceptCaseCommand.java
    │   └── EscalateCaseCommand.java
    ├── handler/
    │   ├── SubmitCaseHandler.java
    │   └── EscalateCaseHandler.java
    ├── port/
    │   ├── CaseRepository.java
    │   ├── CaseAuditPort.java
    │   ├── CaseWorkflowPort.java
    │   ├── CaseEventPort.java
    │   └── TransactionPort.java
    └── result/
        ├── ApplicationResult.java
        └── CaseSubmittedResult.java

Application module mengorkestrasi use case tanpa tahu transport dan infrastructure.

Contoh command:

public record SubmitCaseCommand(
    String idempotencyKey,
    String externalReference,
    String complainantId,
    String allegationType,
    String summary,
    Instant receivedAt
) {}

Contoh port:

public interface CaseRepository {
    Optional<EnforcementCase> findByExternalReference(String externalReference);
    CaseId nextIdentity();
    void insert(EnforcementCase c);
}

Contoh handler:

public final class SubmitCaseHandler {
    private final CaseRepository cases;
    private final CaseAuditPort audit;
    private final CaseEventPort events;
    private final TransactionPort tx;

    public CaseSubmittedResult handle(SubmitCaseCommand command) {
        return tx.required(() -> {
            cases.findByExternalReference(command.externalReference())
                .ifPresent(existing -> {
                    throw new DuplicateCaseSubmission(existing.id());
                });

            EnforcementCase c = EnforcementCase.submit(
                cases.nextIdentity(),
                command.externalReference(),
                command.complainantId(),
                command.allegationType(),
                command.summary(),
                command.receivedAt()
            );

            cases.insert(c);
            audit.caseSubmitted(c.id(), command.receivedAt());
            events.recordCaseSubmitted(c);

            return new CaseSubmittedResult(c.id(), c.caseNumber());
        });
    }
}

Application module menjawab: “satu use case harus menjalankan langkah apa secara atomik dan berurutan?”

6.3 case-contract-openapi

Isi:

case-contract-openapi/
└── src/main/openapi/
    ├── case-api.yaml
    ├── components/
    │   ├── schemas.yaml
    │   ├── parameters.yaml
    │   ├── responses.yaml
    │   └── errors.yaml
    └── examples/
        ├── submit-case-request.json
        └── submit-case-response.json

Rule:

  • tidak ada Java source kecuali helper test opsional;
  • kontrak HTTP adalah artifact;
  • CI memvalidasi syntax;
  • CI membandingkan compatibility dengan versi sebelumnya;
  • contoh request/response harus valid terhadap schema.

Module ini menjawab: “HTTP API yang dijanjikan ke consumer seperti apa?”

6.4 case-api-generated

Isi:

case-api-generated/
└── target/generated-sources/openapi/...

Rule:

  • code generated dari case-contract-openapi;
  • tidak diedit manual;
  • tidak boleh mengandung business logic;
  • package jelas, misalnya com.example.regulatory.case.api.generated;
  • dipakai oleh case-api-runtime, bukan oleh case-domain.

Contoh POM generator:

<plugin>
  <groupId>org.openapitools</groupId>
  <artifactId>openapi-generator-maven-plugin</artifactId>
  <version>${openapi.generator.version}</version>
  <executions>
    <execution>
      <goals>
        <goal>generate</goal>
      </goals>
      <configuration>
        <inputSpec>${project.basedir}/../case-contract-openapi/src/main/openapi/case-api.yaml</inputSpec>
        <generatorName>jaxrs-spec</generatorName>
        <modelPackage>com.example.regulatory.case.api.generated.model</modelPackage>
        <apiPackage>com.example.regulatory.case.api.generated.api</apiPackage>
        <generateSupportingFiles>false</generateSupportingFiles>
      </configuration>
    </execution>
  </executions>
</plugin>

Generated module menjawab: “bentuk Java dari kontrak eksternal seperti apa?”

6.5 case-api-runtime

Isi:

case-api-runtime/
└── src/main/java/com/example/regulatory/case/api/runtime/
    ├── resource/
    │   └── CaseResource.java
    ├── mapper/
    │   ├── SubmitCaseMapper.java
    │   └── ApiErrorMapper.java
    ├── filter/
    │   ├── CorrelationIdFilter.java
    │   └── RequestAuditFilter.java
    └── provider/
        ├── JsonProvider.java
        └── ExceptionMapperProvider.java

Rule:

  • boleh depend ke Jersey/JAX-RS;
  • boleh depend ke generated OpenAPI model;
  • boleh call application handler;
  • tidak boleh menulis SQL;
  • tidak boleh publish Kafka langsung;
  • tidak boleh call Camunda API langsung kecuali lewat application/workflow port;
  • mapping error harus eksplisit.

Contoh resource:

@Path("/v1/cases")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public final class CaseResource {
    private final SubmitCaseHandler submitCase;
    private final SubmitCaseMapper mapper;

    @POST
    public Response submit(
        @HeaderParam("Idempotency-Key") String idempotencyKey,
        SubmitCaseRequest request
    ) {
        SubmitCaseCommand command = mapper.toCommand(idempotencyKey, request);
        CaseSubmittedResult result = submitCase.handle(command);
        return Response.status(Response.Status.CREATED)
            .entity(mapper.toResponse(result))
            .build();
    }
}

API runtime menjawab: “bagaimana kontrak HTTP dijalankan oleh application use case?”

6.6 case-persistence-contract

Isi:

case-persistence-contract/
└── src/main/java/com/example/regulatory/case/persistence/contract/
    ├── CaseRecord.java
    ├── CaseSearchCriteria.java
    ├── CaseRowVersion.java
    └── CasePersistenceError.java

Module ini opsional, tetapi berguna untuk memisahkan shape persistence dari MyBatis implementation. Jangan samakan dengan domain.

Persistence record bisa menyimpan field teknis:

public record CaseRecord(
    UUID id,
    String caseNumber,
    String lifecycleStatus,
    long rowVersion,
    Instant createdAt,
    Instant updatedAt
) {}

Domain tidak perlu tahu rowVersion jika itu murni persistence concern. Application mungkin perlu tahu versi untuk optimistic locking. Pilih dengan sadar.

6.7 case-persistence-mybatis

Isi:

case-persistence-mybatis/
└── src/main/
    ├── java/com/example/regulatory/case/persistence/mybatis/
    │   ├── mapper/
    │   │   ├── CaseSqlMapper.java
    │   │   └── OutboxSqlMapper.java
    │   ├── repository/
    │   │   └── MyBatisCaseRepository.java
    │   ├── tx/
    │   │   └── MyBatisTransactionPort.java
    │   └── typehandler/
    │       ├── CaseIdTypeHandler.java
    │       └── JsonbTypeHandler.java
    └── resources/com/example/regulatory/case/persistence/mybatis/mapper/
        ├── CaseSqlMapper.xml
        └── OutboxSqlMapper.xml

Rule:

  • boleh depend ke MyBatis;
  • boleh depend ke PostgreSQL JDBC driver;
  • boleh tahu SQL table/function;
  • boleh implement CaseRepository;
  • tidak boleh expose MyBatis mapper ke API runtime;
  • tidak boleh mengubah domain invariant diam-diam.

Contoh mapper interface:

public interface CaseSqlMapper {
    void insertCase(CaseRecord record);
    Optional<CaseRecord> findById(UUID id);
    Optional<CaseRecord> findByExternalReference(String externalReference);
}

Contoh XML:

<mapper namespace="com.example.regulatory.case.persistence.mybatis.mapper.CaseSqlMapper">
  <insert id="insertCase" parameterType="com.example.regulatory.case.persistence.contract.CaseRecord">
    insert into enforcement_case (
      id,
      case_number,
      lifecycle_status,
      row_version,
      created_at,
      updated_at
    ) values (
      #{id},
      #{caseNumber},
      #{lifecycleStatus},
      #{rowVersion},
      #{createdAt},
      #{updatedAt}
    )
  </insert>
</mapper>

Persistence module menjawab: “bagaimana domain/application state committed ke PostgreSQL?”

6.8 database

Isi:

database/
└── src/main/resources/db/migration/
    ├── V001__create_case_core.sql
    ├── V002__create_case_audit.sql
    ├── V003__create_outbox.sql
    ├── V004__create_inbox.sql
    ├── V005__create_idempotency_key.sql
    └── V006__create_case_functions.sql

Walau kita belum memilih migration tool secara detail di daftar isi, repository tetap harus punya tempat untuk SQL migration. Untuk seri ini, database artifact adalah bagian release, bukan catatan manual DBA di luar code.

Rule:

  • DDL versioned;
  • PL/pgSQL function versioned;
  • index creation direncanakan;
  • migration diuji dengan database nyata via Testcontainers di integration test;
  • migration tidak boleh bergantung pada state developer lokal.

Database module menjawab: “bentuk storage contract yang dirilis seperti apa?”

6.9 case-workflow-contract

Isi:

case-workflow-contract/
└── src/main/java/com/example/regulatory/case/workflow/contract/
    ├── CaseWorkflowPort.java
    ├── StartCaseProcessCommand.java
    ├── CorrelateCaseEventCommand.java
    ├── CaseProcessVariable.java
    └── WorkflowError.java

Rule:

  • tidak depend ke Camunda;
  • mendefinisikan bahasa workflow internal;
  • application boleh depend ke module ini;
  • Camunda implementation berada di module lain.

Contoh:

public interface CaseWorkflowPort {
    void startCaseLifecycle(StartCaseProcessCommand command);
    void correlateInvestigationCompleted(CorrelateCaseEventCommand command);
}

Workflow contract menjawab: “use case butuh orkestrasi workflow apa tanpa tahu engine-nya?”

6.10 case-workflow-camunda7

Isi:

case-workflow-camunda7/
└── src/main/
    ├── java/com/example/regulatory/case/workflow/camunda7/
    │   ├── CamundaCaseWorkflowAdapter.java
    │   ├── delegate/
    │   │   ├── ValidateCaseDelegate.java
    │   │   └── PublishAssessmentRequestedDelegate.java
    │   ├── variable/
    │   │   └── CamundaVariableMapper.java
    │   └── incident/
    │       └── WorkflowIncidentClassifier.java
    └── resources/bpmn/
        ├── case-lifecycle.bpmn
        └── case-escalation.bpmn

Rule:

  • boleh depend ke Camunda 7 API;
  • BPMN XML versioned;
  • process variables mapped explicitly;
  • delegate tidak boleh berisi domain logic besar;
  • delegate harus kecil, deterministic, dan testable;
  • Camunda technical error tidak boleh bocor sebagai domain error.

Workflow Camunda module menjawab: “bagaimana orkestrasi dijalankan di Camunda 7?”

6.11 case-contract-events

Isi:

case-contract-events/
└── src/main/asyncapi/
    ├── case-events.yaml
    ├── schemas/
    │   ├── case-submitted.schema.json
    │   ├── case-accepted.schema.json
    │   └── case-escalated.schema.json
    └── examples/
        ├── case-submitted.json
        └── case-accepted.json

Rule:

  • kontrak event versioned;
  • schema payload + envelope jelas;
  • topic, key, header, dan semantic compatibility terdokumentasi;
  • example valid terhadap schema.

Module ini menjawab: “fakta apa yang dipublikasikan ke event stream?”

6.12 case-events-contract

Isi Java contract internal untuk event:

case-events-contract/
└── src/main/java/com/example/regulatory/case/events/contract/
    ├── CaseEventEnvelope.java
    ├── CaseSubmittedEvent.java
    ├── CaseAcceptedEvent.java
    ├── CaseEventMetadata.java
    └── CaseEventPublisher.java

Rule:

  • tidak depend ke Kafka client;
  • bisa generated dari schema atau ditulis manual dengan mapping ketat;
  • dipakai application port;
  • payload tidak boleh mengambil domain object mentah lalu serialize sembarangan.

6.13 case-events-kafka

Isi:

case-events-kafka/
└── src/main/java/com/example/regulatory/case/events/kafka/
    ├── KafkaCaseEventPublisher.java
    ├── KafkaCaseEventConsumer.java
    ├── KafkaHeaderMapper.java
    ├── KafkaSerializationAdapter.java
    └── KafkaErrorClassifier.java

Rule:

  • boleh depend ke Kafka client;
  • Kafka exception diklasifikasi;
  • serialization eksplisit;
  • header propagation eksplisit;
  • tidak boleh langsung mutate domain state tanpa application handler.

6.14 case-outbox-publisher

Isi:

case-outbox-publisher/
└── src/main/java/com/example/regulatory/case/outbox/
    ├── OutboxPoller.java
    ├── OutboxPublisher.java
    ├── OutboxClaimStrategy.java
    ├── OutboxRetryPolicy.java
    └── OutboxMetrics.java

Rule:

  • membaca outbox table;
  • claim batch dengan locking strategy;
  • publish Kafka;
  • mark published;
  • retry dan poison handling;
  • tidak menjalankan business decision.

Outbox publisher adalah operational component, bukan domain service.

6.15 case-inbox-consumer

Isi:

case-inbox-consumer/
└── src/main/java/com/example/regulatory/case/inbox/
    ├── InboxConsumer.java
    ├── InboxDeduplicator.java
    ├── InboxRetryPolicy.java
    ├── IncomingEventRouter.java
    └── InboxMetrics.java

Rule:

  • menerima event dari Kafka;
  • deduplicate via inbox table;
  • route ke application handler;
  • commit offset hanya setelah durable handling sesuai strategi;
  • idempotency wajib.

6.16 case-operational-api

Isi:

case-operational-api/
└── src/main/java/com/example/regulatory/case/ops/
    ├── HealthResource.java
    ├── ReadinessResource.java
    ├── OutboxOpsResource.java
    ├── WorkflowIncidentResource.java
    └── BuildInfoResource.java

Rule:

  • health/readiness bukan business API;
  • expose operational info yang aman;
  • tidak bocorkan data sensitif;
  • endpoint repair harus sangat hati-hati, authorized, audited.

6.17 case-app

Isi:

case-app/
└── src/main/java/com/example/regulatory/case/app/
    ├── Main.java
    ├── Bootstrap.java
    ├── ModuleWiring.java
    ├── RuntimeConfig.java
    └── ShutdownHooks.java

Module ini menyatukan semua adapter.

Rule:

  • dependency paling banyak ada di runtime assembly;
  • wiring ada di sini;
  • business logic tidak ada di sini;
  • lifecycle startup/shutdown dikelola di sini;
  • container entrypoint mengarah ke sini.

7. Contract Directory vs Contract Module

Kita punya contracts/ dan module case-contract-*. Apakah redundant?

Tidak, kalau perannya jelas.

contracts/
├── openapi/
│   └── case-api.yaml
├── asyncapi/
│   └── case-events.yaml
└── bpmn/
    └── case-lifecycle.bpmn

contracts/ bisa dipakai sebagai source canonical linting/documentation lintas module.

Module case-contract-openapi dan case-contract-events adalah packaging/build boundary. Dalam repo kecil, kontrak bisa langsung berada di module. Dalam repo besar, contracts/ bisa menjadi workspace-level source dan module hanya membungkus validation/generation.

Untuk seri ini, pilih satu saja agar tidak membingungkan:

case-contract-openapi/src/main/openapi
case-contract-events/src/main/asyncapi
case-workflow-camunda7/src/main/resources/bpmn

Artinya kontrak disimpan dekat module yang membangun artifact-nya.


8. Package Naming Strategy

Package harus menunjukkan boundary, bukan sekadar layer generic.

Buruk:

com.example.project.service
com.example.project.dto
com.example.project.util

Lebih baik:

com.example.regulatory.case.domain
com.example.regulatory.case.application.command
com.example.regulatory.case.application.handler
com.example.regulatory.case.application.port
com.example.regulatory.case.api.runtime.resource
com.example.regulatory.case.api.runtime.mapper
com.example.regulatory.case.persistence.mybatis.mapper
com.example.regulatory.case.workflow.camunda7.delegate
com.example.regulatory.case.events.kafka
com.example.regulatory.case.outbox

Package naming menjadi peta mental. Saat membaca import, engineer harus langsung tahu dependency ini melintasi boundary apa.


9. Dependency Rules yang Harus Dipaksa

Rule dependency minimal:

From moduleBoleh depend keTidak boleh depend ke
case-domainJDK only, maybe small validation libJersey, Kafka, Camunda, MyBatis, JDBC, generated DTO
case-applicationdomain, ports/contractsJersey resource, Kafka client, Camunda engine, MyBatis mapper
case-api-runtimeapplication, api-generated, JAX-RS/JerseyMyBatis mapper XML, Kafka producer direct, Camunda RuntimeService direct
case-persistence-mybatisapplication port, domain, MyBatis, JDBCJersey resource, API generated DTO
case-workflow-camunda7workflow contract, application, Camunda APIJersey resource, API generated DTO unless explicit adapter
case-events-kafkaevent contract, Kafka clientJersey resource, MyBatis mapper direct for business mutation
case-appall runtime modulesbusiness logic implementation
test-integrationruntime modules, Testcontainersproduction-only secrets

Jika rule ini tidak dipaksa, repository akan membusuk perlahan.


10. Enforcing Architecture with Tests

Gunakan ArchUnit atau mekanisme serupa untuk membuat rule menjadi executable.

Contoh rule konseptual:

@Test
void domain_must_not_depend_on_frameworks() {
    JavaClasses classes = new ClassFileImporter()
        .importPackages("com.example.regulatory.case.domain");

    noClasses()
        .that().resideInAPackage("..case.domain..")
        .should().dependOnClassesThat().resideInAnyPackage(
            "jakarta.ws.rs..",
            "org.glassfish.jersey..",
            "org.apache.kafka..",
            "org.camunda..",
            "org.mybatis.."
        )
        .check(classes);
}

Ini bukan cosmetic test. Ini pagar arsitektur.


11. Test Module Topology

Sistem production-grade butuh beberapa jenis test. Jangan campur semua dalam satu folder.

test-fixtures/
├── CaseFixture.java
├── ApiFixture.java
├── EventFixture.java
└── DatabaseFixture.java

test-contract/
├── OpenApiCompatibilityTest.java
├── OpenApiExampleValidationTest.java
├── EventSchemaCompatibilityTest.java
└── BpmnVariableContractTest.java

test-integration/
├── PostgresMigrationIT.java
├── MyBatisRepositoryIT.java
├── OutboxPublisherIT.java
├── KafkaConsumerIT.java
└── CamundaProcessIT.java

test-system/
├── CaseLifecycleSystemIT.java
├── CaseEscalationSystemIT.java
├── FailureRecoverySystemIT.java
└── DeploymentSmokeIT.java

11.1 Unit test

Dijalankan cepat. Tidak butuh container.

Target:

  • domain policy;
  • application handler dengan fake port;
  • mapper pure function;
  • error classifier;
  • idempotency decision logic.

11.2 Contract test

Menjamin kontrak valid dan tidak berubah sembarangan.

Target:

  • OpenAPI syntax;
  • schema example validity;
  • breaking-change detection;
  • AsyncAPI/event schema validity;
  • BPMN variable contract consistency.

11.3 Integration test

Butuh dependency nyata.

Target:

  • PostgreSQL migration;
  • MyBatis mapper;
  • transaction behavior;
  • Kafka serialization/deserialization;
  • Camunda process execution;
  • outbox/inbox with real database.

11.4 System test

Menjalankan slice besar.

Target:

  • submit case via HTTP;
  • persist case;
  • create audit;
  • write outbox;
  • publish event;
  • start/correlate workflow;
  • verify projection;
  • simulate failure.

12. Maven Lifecycle Strategy

Gunakan konvensi:

  • unit tests: mvn test;
  • integration tests: mvn verify dengan Failsafe;
  • generated code: generate-sources;
  • contract validation: validate atau verify tergantung berat;
  • packaging app: package;
  • container image build: profile eksplisit, bukan default untuk semua developer.

Contoh naming:

*Test.java     -> unit test, surefire
*IT.java       -> integration test, failsafe
*SystemIT.java -> system test, failsafe/profile khusus

Contoh failsafe:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-failsafe-plugin</artifactId>
  <executions>
    <execution>
      <goals>
        <goal>integration-test</goal>
        <goal>verify</goal>
      </goals>
    </execution>
  </executions>
</plugin>

Jangan membuat mvn test selalu menyalakan Kafka, PostgreSQL, Camunda, dan Kubernetes. Developer akan berhenti menjalankannya.


13. Generated Source Discipline

Generated source punya tiga aturan.

13.1 Generated output harus deterministik

Kalau generator dijalankan dua kali tanpa perubahan input, output harus sama.

Jika tidak, review diff menjadi noisy dan trust menurun.

13.2 Generated code tidak diedit manual

Kalau generated code perlu disesuaikan, ubah input contract atau generator config, bukan hasil generate.

13.3 Generated model tidak menjadi domain model

Buruk:

public void submit(SubmitCaseRequest request) {
    domainService.submit(request);
}

Lebih baik:

public void submit(SubmitCaseRequest request) {
    SubmitCaseCommand command = mapper.toCommand(request);
    submitCaseHandler.handle(command);
}

Mapping terasa membosankan, tetapi mapping adalah boundary. Boundary memang harus eksplisit.


14. Deployment Module Topology

Deployment artifact tidak boleh tercecer.

deployment/
├── docker/
│   └── case-app.Dockerfile
├── k8s/
│   ├── base/
│   │   ├── deployment.yaml
│   │   ├── service.yaml
│   │   ├── configmap.yaml
│   │   ├── secret.example.yaml
│   │   └── pdb.yaml
│   └── overlays/
│       ├── local/
│       ├── staging/
│       └── production/
└── nginx/
    ├── nginx.conf
    └── routes.conf

Rule:

  • Kubernetes manifests versioned;
  • NGINX route config versioned;
  • secret contoh boleh ada, secret asli tidak;
  • environment differences explicit;
  • manifest tidak menjadi tempat business config liar.

Deployment module menjawab: “bagaimana artifact ini dijalankan di environment nyata?”


15. Runtime Wiring Pattern

Ada dua ekstrem buruk:

  1. semua dependency dibuat dengan new di mana-mana;
  2. semua dependency diserahkan ke framework DI tanpa struktur.

Untuk seri ini, kita pakai explicit composition root.

public final class ModuleWiring {
    public CaseResource caseResource(RuntimeConfig config) {
        DataSource dataSource = dataSource(config.database());
        SqlSessionFactory sqlSessionFactory = myBatis(dataSource);

        CaseRepository caseRepository = new MyBatisCaseRepository(sqlSessionFactory);
        TransactionPort tx = new MyBatisTransactionPort(sqlSessionFactory);
        CaseAuditPort audit = new PostgresCaseAuditPort(sqlSessionFactory);
        CaseEventPort events = new OutboxCaseEventPort(sqlSessionFactory);

        SubmitCaseHandler submitCase = new SubmitCaseHandler(
            caseRepository,
            audit,
            events,
            tx
        );

        return new CaseResource(submitCase, new SubmitCaseMapper());
    }
}

Pada production nyata, Anda bisa memakai DI framework. Tetapi mental model composition root tetap penting: ada satu tempat yang menyatukan adapter, bukan adapter saling mencari dependency sendiri.


16. CI Pipeline Topology

Pipeline minimal:

Pipeline bukan sekadar automation. Pipeline adalah governance.

Gate yang wajib ada:

  • contract syntax valid;
  • generated code up-to-date;
  • no forbidden dependency;
  • unit tests pass;
  • integration tests pass;
  • migration can apply to clean database;
  • image builds reproducibly;
  • critical vulnerabilities handled;
  • smoke test passes.

17. Release Artifact Strategy

Jangan release source code. Release artifact.

Artifact penting:

ArtifactBentukOwner
HTTP contractOpenAPI YAMLAPI owner
Event contractAsyncAPI/schemaEvent owner
BPMN processBPMN XMLWorkflow owner
DB migrationSQL migrationData owner
Java appJAR/container imageService owner
Kubernetes manifestYAML/Kustomize/HelmPlatform/service owner
NGINX configconf/ingressEdge/platform owner
Test evidencereportsDelivery owner

Release production bukan “merge to main lalu semoga jalan”. Release production adalah promosi artifact yang sudah diverifikasi.


18. Naming Conventions

Gunakan nama yang stabil.

18.1 Module naming

case-domain
case-application
case-api-runtime
case-persistence-mybatis
case-workflow-camunda7
case-events-kafka

Format:

<bounded-context>-<concern>-<implementation-if-any>

Contoh:

  • case-persistence-mybatis, bukan case-db;
  • case-workflow-camunda7, bukan case-process;
  • case-events-kafka, bukan case-messaging.

Nama harus menunjukkan coupling teknologi.

18.2 Package naming

Package harus mengikuti module.

com.example.regulatory.case.persistence.mybatis

Jangan membuat package com.example.regulatory.common untuk semua hal. Common sering menjadi global coupling.

18.3 Artifact naming

Artifact Maven harus bisa dibaca di dependency tree.

com.example.regulatory:case-domain
com.example.regulatory:case-application
com.example.regulatory:case-workflow-camunda7

Saat melihat mvn dependency:tree, engineer harus bisa mengerti struktur sistem.


19. Common Module: Kapan Boleh?

Module common sering berbahaya. Ia menjadi tempat semua hal yang malas diberi owner.

Boleh punya common jika isinya sangat stabil dan tidak domain-specific:

platform-time
platform-result
platform-observability
platform-json
platform-test-fixtures

Buruk:

common/
├── dto/
├── service/
├── validator/
├── mapper/
├── constants/
└── util/

Lebih baik:

case-application/result
case-api-runtime/mapper
case-domain/policy
case-persistence-mybatis/typehandler

Rule: kalau satu class punya alasan berubah karena satu bounded context, letakkan di bounded context itu. Jangan masukkan ke common.


20. Configuration File Placement

Configuration harus punya owner.

case-app/src/main/resources/application.yaml
case-persistence-mybatis/src/main/resources/mybatis-config.xml
case-workflow-camunda7/src/main/resources/camunda.cfg.xml
case-api-runtime/src/main/resources/logging-api.yaml

Tetapi konfigurasi environment-specific sebaiknya tidak hardcoded di JAR.

Gunakan environment variable atau mounted config untuk:

  • database URL;
  • Kafka bootstrap servers;
  • Camunda job executor tuning;
  • NGINX timeout;
  • feature toggle;
  • security settings.

Kita akan bahas detail di part konfigurasi.


21. Anti-Pattern Repository

21.1 The everything app module

case-app/
└── src/main/java/...
    ├── controller
    ├── service
    ├── repository
    ├── kafka
    ├── camunda
    └── util

Semua cepat di awal, mahal di akhir.

21.2 The fake contract module

Kontrak ada, tetapi generated code diedit manual dan implementation tidak divalidasi terhadap contract.

Ini bukan contract-first. Ini contract-decoration.

21.3 The shared DTO trap

Satu DTO dipakai untuk:

  • HTTP request;
  • Kafka event;
  • DB row;
  • Camunda variable;
  • UI projection.

DTO terlihat reuse, tetapi sebenarnya menyatukan lifecycle yang berbeda.

21.4 The framework-in-domain leak

Domain import:

import jakarta.ws.rs.core.Response;
import org.camunda.bpm.engine.delegate.DelegateExecution;
import org.apache.kafka.clients.producer.ProducerRecord;

Jika ini terjadi, boundary rusak.

21.5 The unowned SQL folder

SQL migration ada di folder random, tidak diuji, tidak jelas bagian release apa.

Database adalah bagian sistem. Treat it as code.


22. Worked Example: Submit Case Dependency Path

Saat API POST /v1/cases dipanggil, dependency path ideal:

Tidak ada Kafka publish langsung di HTTP thread jika kita memakai outbox. Tidak ada Camunda variable mutation langsung dari resource. Tidak ada SQL dari resource.


23. Build Order Mental Model

Maven reactor akan membangun module berdasarkan dependency.

Secara konseptual:

platform-bom / platform-parent
        ↓
contracts
        ↓
generated contract code
        ↓
domain + application + contracts/ports
        ↓
adapters: api, persistence, workflow, events
        ↓
runtime app
        ↓
tests

Jika module graph membuat case-domain menunggu case-api-runtime, ada yang salah.


24. Incremental Development Path

Agar tidak tenggelam, bangun repository secara incremental.

Step 1 — Root, parent, BOM

Buat root aggregator, parent POM, BOM.

Success condition:

mvn -q validate

berhasil.

Step 2 — Domain + application kosong

Buat module domain dan application dengan satu command handler dummy.

Success condition:

mvn -pl case-domain,case-application test

berhasil tanpa database, Kafka, Camunda.

Step 3 — OpenAPI contract + generated module

Tambahkan contract minimal untuk POST /v1/cases.

Success condition:

  • contract valid;
  • generated source compile;
  • API runtime bisa mapping request ke command.

Step 4 — PostgreSQL + MyBatis integration

Tambahkan migration dan mapper insert/find.

Success condition:

  • Testcontainers PostgreSQL naik;
  • migration apply;
  • mapper insert/find pass.

Step 5 — Outbox event recording

Tambahkan outbox table dan application event port.

Success condition:

  • submit case menulis case + audit + outbox dalam satu transaction.

Step 6 — Camunda process start

Tambahkan process definition minimal.

Success condition:

  • process instance bisa start dengan business key;
  • variables contract benar;
  • no business logic hidden in BPMN.

Step 7 — Runtime app

Satukan resource, handler, persistence, workflow, dan config.

Success condition:

  • app start lokal;
  • health endpoint OK;
  • submit case end-to-end pass.

25. Production Failure Model dari Topology

Topology yang benar membantu saat failure.

FailureModule yang diinvestigasi duluKenapa
API 400 salahcase-contract-openapi, case-api-runtimeMungkin schema/validation/mapping error.
Domain transition salahcase-domain, case-applicationInvariant/policy/use case error.
Data tidak committedcase-persistence-mybatis, databaseTransaction/mapper/migration issue.
Event tidak terkirimcase-outbox-publisher, case-events-kafkaOutbox claim/publish/retry issue.
Duplicate processingcase-inbox-consumer, databaseInbox/idempotency issue.
Process stuckcase-workflow-camunda7BPMN job/incident/correlation issue.
Deployment gagaldeployment, case-appRuntime config/image/probe issue.

Jika topology kabur, incident triage kabur.


26. Production Checklist

Sebelum lanjut ke contract detail, repository harus memenuhi checklist ini.

Architecture boundary

  • Domain tidak import framework runtime.
  • Application handler tidak import Jersey, Kafka client, Camunda engine, MyBatis mapper.
  • Adapter module punya nama teknologi eksplisit.
  • Generated code tidak dipakai sebagai domain model.
  • SQL migration versioned.
  • BPMN definition versioned.
  • OpenAPI dan AsyncAPI versioned.

Maven build

  • Root aggregator jelas.
  • Parent POM mengontrol plugin behavior.
  • BOM mengontrol dependency versions.
  • Unit dan integration test dipisah.
  • Generated sources masuk lifecycle dengan deterministik.
  • Dependency convergence dicek.

Testing

  • Unit test bisa jalan tanpa container.
  • Contract test bisa validasi API/event examples.
  • Integration test bisa apply migration ke PostgreSQL nyata.
  • Camunda process bisa dites terpisah.
  • System test punya path submit case end-to-end.

Operations

  • Deployment manifests versioned.
  • Secret asli tidak committed.
  • Health/readiness endpoint terpisah dari business API.
  • Runtime config punya owner.
  • Build info bisa dilacak ke commit/artifact.

27. What You Should Internalize

Repository topology bukan preferensi estetika. Ia adalah control surface.

Sistem production-grade membutuhkan boundary yang bisa diverifikasi:

  • contract boundary;
  • domain boundary;
  • application boundary;
  • persistence boundary;
  • workflow boundary;
  • eventing boundary;
  • runtime boundary;
  • deployment boundary;
  • test boundary.

Jika boundary tidak muncul di module graph, ia hanya muncul di niat engineer. Niat tidak cukup untuk sistem yang tumbuh besar.

Part berikutnya masuk ke kontrak HTTP secara konkret: kita akan menulis dan menganalisis OpenAPI untuk case intake, validation, error model, idempotency, pagination, search, dan compatibility.

Lesson Recap

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