karawaci.kode

2026-07-07 · 9 min

Spring Boot 3 Modular Monolith: Bank Indonesia Migration

Setahun lalu, klien bank menengah Indonesia (BPR + payment service, ~1.4 juta nasabah aktif, 480 cabang) datang dengan masalah klasik: 14 microservice, tim 18 engineer, 5 cross-service incident per bulan, deploy butuh koordinasi 3 tim. Saya rekomendasi: konsolidasi ke modular monolith Spring Boot 3.4.

10 bulan kemudian, deploy ke production. Share keputusan teknis, hasil 6 bulan post-deploy, dan kapan modular monolith bukan jawaban.

Konteks bank

  • Workload: payment processing, account management, ledger, notification, reporting.
  • Traffic: ~1.800 TPS peak (jam siang Jumat), avg 600 TPS.
  • Latency SLA: p99 < 800ms untuk transaksi pembayaran (mandate BI / regulator).
  • Compliance: PCI-DSS Level 1, ISO 27001, BI regulation untuk core banking.
  • Tim engineering: 18 engineer (8 backend, 4 frontend, 3 SRE, 3 QA), 1 architect, 0 dedicated platform team.

Arsitektur lama (microservices):

  • 14 service di Kubernetes (AWS EKS)
  • Inter-service via REST (HTTP) + Kafka untuk async event
  • Postgres 14 (3 cluster) + MongoDB (1 cluster) + Redis Cluster
  • Total cost AWS: $42k/bulan
  • Total deploy cadence: ~2/minggu (slow karena coordination overhead)

Pain point yang motivasi migrasi

  1. Latency cross-service: trace menunjukkan 38% total request time adalah inter-service HTTP roundtrip (median 8 hop per transaksi pembayaran).
  2. Distributed transaction nightmare: saga pattern untuk reversal payment butuh 4-5 service coordinate. Failure mode kompleks, debug butuh full distributed trace.
  3. Tim too small: 8 backend engineer untuk 14 service = ~0.6 engineer per service. Knowledge silo, on-call burnout.
  4. Database per service orthodox tidak ter-implement consistent: 11 service shared Postgres cluster, 3 yang dedicated. Worst-of-both-worlds.
  5. Operational overhead: 14 Helm chart, 14 CI pipeline, 14 alert config. Drift antar service.

Keputusan: modular monolith

Bukan “balik ke monolith biasa”. Modular monolith = single deployable, tapi module boundary di-enforce di code level.

Tooling yang dipakai:

Architecture: Spring Modulith

Struktur package:

com.bankxyz/
├── BankXyzApplication.java
├── payment/                  # module
│   ├── package-info.java     # @ApplicationModule
│   ├── api/                  # public API (other modules can call)
│   │   ├── PaymentService.java
│   │   └── events/
│   │       └── PaymentCompletedEvent.java
│   └── internal/             # implementation (cannot be called outside module)
│       ├── PaymentServiceImpl.java
│       ├── PaymentRepository.java
│       └── PaymentEntity.java
├── account/
│   ├── package-info.java
│   ├── api/
│   └── internal/
├── ledger/
├── notification/
├── reporting/
└── shared/                   # cross-cutting (audit, errors)

package-info.java:

@ApplicationModule(
    displayName = "Payment Processing",
    allowedDependencies = { "account", "ledger", "shared" }
)
package com.bankxyz.payment;

import org.springframework.modulith.ApplicationModule;

Spring Modulith enforce di build time: payment module tidak boleh import dari notification.internal package.

Inter-module communication

Sebelumnya REST call. Sekarang: Spring ApplicationEventPublisher untuk async, direct method call untuk sync.

@Service
public class PaymentServiceImpl implements PaymentService {
    
    private final AccountService accountService;  // dari module account.api
    private final LedgerService ledgerService;
    private final ApplicationEventPublisher eventPublisher;
    
    @Transactional
    public PaymentResult process(PaymentRequest req) {
        // Sync call: direct method invocation, no HTTP overhead
        var account = accountService.debit(req.fromAccount(), req.amount());
        var ledgerEntry = ledgerService.record(account, req);
        
        // Async event: in-process, transactional
        eventPublisher.publishEvent(new PaymentCompletedEvent(
            req.id(), req.amount(), Instant.now()
        ));
        
        return new PaymentResult(ledgerEntry.id(), "completed");
    }
}

Event handler di module lain (notification):

@Component
public class NotificationEventListener {
    
    @ApplicationModuleListener  // async, transactional outbox semantics
    void on(PaymentCompletedEvent event) {
        // Send SMS, email, push notification
        notificationService.notify(event);
    }
}

@ApplicationModuleListener itu special — Spring Modulith handle:

  • Async execution (different thread, virtual thread di Java 21)
  • Transactional outbox: event persist ke DB dalam same transaction, dispatch async setelah commit
  • Retry policy
  • Dead letter queue

Failure mode jadi tractable: tidak ada lagi “payment completed tapi notification tidak terkirim karena network blip antar pod”. Outbox memastikan eventually delivered.

Data: shared schema, isolated module access

Single Postgres cluster, satu schema per module:

postgres://bank-prod/
├── schema: payment       (only payment module can access tables here)
├── schema: account
├── schema: ledger
├── schema: notification
└── schema: reporting

Enforced via Postgres role permissions:

CREATE ROLE payment_module LOGIN PASSWORD '...';
GRANT USAGE ON SCHEMA payment TO payment_module;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA payment TO payment_module;
REVOKE ALL ON SCHEMA account FROM payment_module;

Application uses one DataSource per module via @Qualifier. Cross-module data access HARUS via module API (service method), bukan direct SQL.

Untuk join lintas module (analytical query): pakai materialized view di schema reporting yang refresh periodik. Tidak ideal tapi acceptable trade-off untuk module isolation.

Migrasi: 10 bulan

PhaseDurasiAktivitas
Phase 0: planning + architecture review4 mingguADR, module boundary design, BI compliance review
Phase 1: scaffolding monolith4 mingguSpring Boot 3.4 setup, Modulith, CI/CD baru
Phase 2: migrate module bottom-up22 mingguaccount → ledger → payment → notification → reporting
Phase 3: shadow traffic 8 minggu8 mingguMirror production → monolith, diff verify
Phase 4: cutover + ramp4 mingguGradual traffic shift 5% → 100% via load balancer
Phase 5: decommission lama2 mingguCluster microservices shutdown

Total: 44 minggu (~10 bulan kalender).

Hasil 6 bulan post-deploy

MetricMicroservicesModular MonolithDelta
P50 latency (transfer)180ms95ms-47%
P95 latency (transfer)480ms245ms-49%
P99 latency (transfer)1.2s700ms-42%
Throughput peak1.800 TPS2.400 TPS+33%
Infra cost AWS$42k/mo$26k/mo-38%
Deploy frequency2/minggu8-12/minggu5x
Cross-service incident5/bulan0.6/bulan-88%
MTTR avg38 min14 min-63%
On-call burden (page/week)123-75%

Latency turun karena: (a) no HTTP overhead inter-service, (b) Java 21 virtual threads handle concurrency lebih efisien daripada thread pool fixed di Spring lama (lihat juga Java Virtual Threads di Spring).

Cost turun karena: 14 deployment → 6 deployment (3 region × 2 AZ HA). Resource utilization rata-rata naik dari 18% ke 42%.

Yang break

1. Transaction boundary lebih besar

Modular monolith berarti @Transactional bisa span banyak module. Bug awal: payment + ledger + notification dalam satu transaction. Saat notification gagal (vendor SMS timeout), transaction rolled back, payment dianggap gagal padahal sudah debit.

Fix: notification HARUS via @ApplicationModuleListener (post-commit, di luar transaction).

Refactor 3 minggu kerja untuk separate sync (account + ledger) vs async (notification + reporting).

2. Single point of failure perception

Audit BI initial worry: “kalau monolith down, semua down”.

Argumen yang work: cluster monolith deploy 6 instance behind load balancer, multi-AZ. Failure isolation tetap di-handle, just bukan via service boundary. Plus deployable smaller (1.2GB image vs 14×500MB), spin-up cepat.

BI accept setelah pen-test + chaos test.

3. Build time explosion

Single monolith codebase 380k LOC Java. Cold Maven build awalnya 14 menit. Tidak acceptable.

Fix:

  • Gradle migration (saving 4-5 menit).
  • Modul build caching via Gradle build cache (saving 6 menit di warm cache).
  • Test sharding (parallel JUnit 5).

Final: cold 8 menit, warm 90 detik untuk incremental change.

4. Database connection pool sizing

Awalnya semua module share single HikariCP pool (200 connection). Bulan ke-2 production, peak hour deadlock: payment module hold connection waiting for ledger module yang sedang slow query.

Fix: separate pool per module via Spring @ConfigurationProperties:

spring:
  datasource:
    payment:
      url: jdbc:postgresql://...
      hikari:
        maximum-pool-size: 60
    ledger:
      hikari:
        maximum-pool-size: 80
    # ... per module

Sum semua pool ~280 connection. Per-module bottleneck visible di monitoring.

5. Spring Modulith verification fail di CI

Modulith punya verifies() test yang check architecture compliance:

@Test
void verifyModularStructure() {
    var modules = ApplicationModules.of(BankXyzApplication.class);
    modules.verify();
}

Awal-awal, banyak violation legacy code yang import internal package. Saya pakai temporary @AllowedDependency exception, plan fix iteratif. Setelah 4 bulan, 0 violation.

6. Database migration coordination

Tim sebelumnya pakai Flyway per service. Sekarang single Flyway di monolith. Tapi 5 module tetap deploy migration di branch berbeda. Konflik V134__add_payment_status.sql vs V134__update_account_index.sql.

Fix: prefix module-aware versioning (V134_payment_, V135_account_). Plus pre-merge hook yang check version conflict.

Kapan modular monolith bukan jawaban

  1. Tim > 50 engineer: Conway’s Law efek koordinasi single codebase mulai painful.
  2. Workload polyglot necessity: kalau butuh Python (ML), Go (high-throughput proxy), Node (BFF), monolith Java tidak fit.
  3. Independent scaling kebutuhan ekstrim: kalau 1 service butuh 100x scale dibanding sisanya, microservices lebih efisien.
  4. Compliance isolation hard requirement: PCI-DSS sometimes butuh hard infrastructure boundary, bukan code-level.
  5. Team org structure terlanjur Conway-aligned ke microservices: rewrite arsitektur tidak akan fix org issue.

Verdict

Untuk bank menengah Indonesia dengan tim 15-25 engineer dan traffic < 5k TPS: modular monolith Spring Boot 3 + Modulith adalah arsitektur yang tepat. Performance, cost, dan operational complexity semua menang.

Microservices itu solusi untuk scaling problem org level FAANG, bukan default architecture choice untuk semua kasus. Mayoritas organisasi Indonesia tidak butuh, tapi adopt karena ikutan trend.

Bukan magic. Migrasi 10 bulan, modulth boundary butuh disiplin enforce continuous, dan saya hampir kena PR review hell di bulan ke-7 saat refactor transaction boundary. Worth, tapi mata harus terbuka.

Lihat juga ADR untuk tim 15 engineer untuk konteks bagaimana keputusan ini didokumentasi.

Ditulis oleh Reza Pradipta