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
- Latency cross-service: trace menunjukkan 38% total request time adalah inter-service HTTP roundtrip (median 8 hop per transaksi pembayaran).
- Distributed transaction nightmare: saga pattern untuk reversal payment butuh 4-5 service coordinate. Failure mode kompleks, debug butuh full distributed trace.
- Tim too small: 8 backend engineer untuk 14 service = ~0.6 engineer per service. Knowledge silo, on-call burnout.
- Database per service orthodox tidak ter-implement consistent: 11 service shared Postgres cluster, 3 yang dedicated. Worst-of-both-worlds.
- 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:
- Spring Boot 3.4 + Spring Modulith 1.3
- Java 21 (virtual threads untuk I/O-heavy workload, lihat juga Java Virtual Threads di production)
- ArchUnit untuk runtime architecture rule
- JPMS (Java Platform Module System) selective untuk hard boundary
- Postgres 17 single cluster (lihat juga Postgres 17 replication enterprise)
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
| Phase | Durasi | Aktivitas |
|---|---|---|
| Phase 0: planning + architecture review | 4 minggu | ADR, module boundary design, BI compliance review |
| Phase 1: scaffolding monolith | 4 minggu | Spring Boot 3.4 setup, Modulith, CI/CD baru |
| Phase 2: migrate module bottom-up | 22 minggu | account → ledger → payment → notification → reporting |
| Phase 3: shadow traffic 8 minggu | 8 minggu | Mirror production → monolith, diff verify |
| Phase 4: cutover + ramp | 4 minggu | Gradual traffic shift 5% → 100% via load balancer |
| Phase 5: decommission lama | 2 minggu | Cluster microservices shutdown |
Total: 44 minggu (~10 bulan kalender).
Hasil 6 bulan post-deploy
| Metric | Microservices | Modular Monolith | Delta |
|---|---|---|---|
| P50 latency (transfer) | 180ms | 95ms | -47% |
| P95 latency (transfer) | 480ms | 245ms | -49% |
| P99 latency (transfer) | 1.2s | 700ms | -42% |
| Throughput peak | 1.800 TPS | 2.400 TPS | +33% |
| Infra cost AWS | $42k/mo | $26k/mo | -38% |
| Deploy frequency | 2/minggu | 8-12/minggu | 5x |
| Cross-service incident | 5/bulan | 0.6/bulan | -88% |
| MTTR avg | 38 min | 14 min | -63% |
| On-call burden (page/week) | 12 | 3 | -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
- Tim > 50 engineer: Conway’s Law efek koordinasi single codebase mulai painful.
- Workload polyglot necessity: kalau butuh Python (ML), Go (high-throughput proxy), Node (BFF), monolith Java tidak fit.
- Independent scaling kebutuhan ekstrim: kalau 1 service butuh 100x scale dibanding sisanya, microservices lebih efisien.
- Compliance isolation hard requirement: PCI-DSS sometimes butuh hard infrastructure boundary, bukan code-level.
- 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