Anatomy of a Java Microservice
Learn Java Microservices Design and Architect - Part 015
Anatomy of a production-grade Java microservice: internal architecture, runtime contract, dependency direction, observability surface, operational readiness, and implementation skeleton.
Part 015 — Anatomy of a Java Microservice
Microservice yang production-grade bukan sekadar aplikasi Java kecil dengan REST controller.
Microservice adalah unit bisnis, unit ownership, unit deployment, unit observability, unit failure, dan unit perubahan. Kalau kita hanya melihatnya sebagai folder controller-service-repository, kita akan melewatkan hal terpenting: service harus bisa hidup, gagal, dioperasikan, berevolusi, dan dipertanggungjawabkan sebagai bagian dari sistem terdistribusi.
Part ini menjawab satu pertanyaan praktis:
Kalau kita harus membangun satu Java microservice yang benar-benar siap masuk enterprise production, bentuk internal dan kontraknya seperti apa?
Kita tidak akan mengulang detail Spring Security, authorization, data contract, testing, Maven, Kubernetes, atau observability dari nol. Bagian ini hanya akan menghubungkan hal-hal itu ke anatomi service.
1. Target Mental Model
Setelah part ini, kita ingin punya model yang tajam:
- Microservice bukan hanya codebase.
- Struktur package harus memperlihatkan boundary, bukan sekadar layer teknis.
- Framework harus menjadi runtime shell, bukan pusat domain.
- Setiap service punya beberapa surface: API, domain, persistence, integration, observability, operation, dan ownership.
- Desain internal service harus membuat dependency direction, invariant, transaction boundary, dan failure mode terlihat.
- Production readiness harus menjadi bagian dari desain sejak awal, bukan task DevOps di akhir sprint.
Kalimat sederhananya:
Microservice yang baik adalah program kecil yang menjalankan satu business capability secara mandiri, dengan kontrak eksternal jelas, state ownership jelas, failure behavior jelas, dan operasi harian yang bisa dipahami tanpa membaca semua source code.
2. Anatomi Besar: Service sebagai 7 Surface
Jangan mulai dari package. Mulai dari surface.
Satu service production-grade minimal punya tujuh surface berikut.
Penjelasan ringkas:
| Surface | Pertanyaan desain |
|---|---|
| API surface | Bagaimana consumer berinteraksi dengan service? |
| Application surface | Use case apa yang service jalankan? |
| Domain surface | Invariant bisnis apa yang dijaga? |
| Persistence surface | State apa yang dimiliki service? |
| Integration surface | Dependency keluar apa yang dipanggil/dipublish? |
| Observability surface | Bagaimana perilaku service terlihat dari luar? |
| Operational surface | Bagaimana service dijalankan, dihentikan, diskalakan, dan dipulihkan? |
Kesalahan umum engineer menengah adalah hanya mendesain tiga surface pertama: controller, service, repository. Engineer senior harus melihat semua surface, karena failure production hampir selalu muncul dari surface yang diabaikan: timeout, readiness palsu, metric tidak cukup, config salah, dependency downstream lambat, atau ownership state kabur.
3. Service Anatomy Bukan Struktur Folder Saja
Struktur folder hanya representasi. Yang lebih penting adalah aturan di baliknya.
Service harus punya beberapa boundary internal:
Aturan dependency yang sehat:
inbound adapter -> application -> domain
application -> port interface
outbound adapter -> port interface
framework -> adapter/application wiring
Yang tidak sehat:
domain -> spring annotation everywhere
domain -> database entity
domain -> HTTP DTO
domain -> Kafka record
domain -> external API model
Bukan berarti domain tidak boleh pakai annotation sama sekali. Tetapi jika domain hanya bisa dipahami setelah memahami framework, persistence, transport, dan serialization, maka domain sudah tidak lagi menjadi pusat bisnis. Ia hanya menjadi bentuk lain dari DTO.
4. Minimum Production-Grade Service Skeleton
Berikut struktur yang bisa dipakai sebagai starting point. Ini bukan template mutlak, tetapi menunjukkan dependency direction dan ownership.
case-intake-service/
src/main/java/com/acme/enforcement/caseintake/
CaseIntakeApplication.java
api/
http/
CaseIntakeController.java
CaseIntakeRequest.java
CaseIntakeResponse.java
ApiExceptionHandler.java
messaging/
EvidenceReceivedConsumer.java
PartyRiskChangedConsumer.java
application/
command/
SubmitCaseCommand.java
SubmitCaseHandler.java
AttachEvidenceCommand.java
AttachEvidenceHandler.java
query/
GetCaseSummaryQuery.java
GetCaseSummaryHandler.java
workflow/
CaseIntakeProcessService.java
policy/
IntakeEligibilityPolicy.java
port/
CaseRepository.java
CaseNumberGenerator.java
RiskScoringClient.java
DomainEventPublisher.java
ClockProvider.java
domain/
model/
CaseFile.java
CaseStatus.java
Allegation.java
EvidenceReference.java
PartyReference.java
event/
CaseSubmitted.java
EvidenceAttached.java
exception/
InvalidCaseTransition.java
DuplicateCaseSubmission.java
value/
CaseId.java
CaseNumber.java
OfficerId.java
infrastructure/
persistence/
JpaCaseRepositoryAdapter.java
CaseJpaEntity.java
CaseJpaMapper.java
client/
HttpRiskScoringClient.java
RiskScoringClientProperties.java
messaging/
KafkaDomainEventPublisher.java
CaseEventEnvelope.java
time/
SystemClockProvider.java
config/
CaseIntakeProperties.java
observability/
CaseIntakeMetrics.java
CaseIntakeTraceAttributes.java
StructuredLogFields.java
ops/
CaseIntakeHealthIndicator.java
CaseIntakeReadinessIndicator.java
Ada beberapa prinsip di sini:
apiberisi inbound adapter, bukan business logic.applicationberisi use case orchestration.domainberisi invariant dan business state transition.infrastructureberisi implementasi teknis port.observabilityeksplisit, bukan sisipan logging acak.opseksplisit, karena service production harus bisa diperiksa runtime state-nya.
Untuk service kecil, struktur ini bisa terasa berat. Itu wajar. Jangan memakai struktur ini secara dogmatis. Yang penting bukan jumlah folder, tetapi separation of responsibility.
5. Rule: Framework Is a Shell, Not the Architecture
Dalam service Java modern, kita mungkin memakai Spring Boot, Quarkus, Micronaut, atau Jakarta EE runtime. Tetapi arsitektur service tidak boleh identik dengan framework.
Contoh salah:
@RestController
@RequestMapping("/cases")
class CaseController {
@Autowired
private CaseRepository repository;
@PostMapping
@Transactional
public CaseJpaEntity submit(@RequestBody CaseJpaEntity request) {
request.setStatus("SUBMITTED");
return repository.save(request);
}
}
Masalahnya bukan hanya style.
Masalah strukturalnya:
- API menerima entity persistence.
- Controller memutuskan state bisnis.
- Transaction boundary hidup di adapter layer tanpa use case jelas.
- Tidak ada command intention.
- Tidak ada invariant domain.
- Tidak ada idempotency.
- Tidak ada event yang menjelaskan perubahan bisnis.
- Tidak ada separation antara transport error dan domain error.
Contoh yang lebih sehat:
@RestController
@RequestMapping("/cases")
final class CaseIntakeController {
private final SubmitCaseHandler submitCase;
CaseIntakeController(SubmitCaseHandler submitCase) {
this.submitCase = submitCase;
}
@PostMapping
ResponseEntity<CaseIntakeResponse> submit(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody SubmitCaseRequest request
) {
var command = new SubmitCaseCommand(
request.reporterId(),
request.subjectPartyId(),
request.allegations(),
idempotencyKey
);
var result = submitCase.handle(command);
return ResponseEntity
.accepted()
.body(CaseIntakeResponse.from(result));
}
}
Controller hanya menerjemahkan HTTP ke command. Use case ada di handler.
public final class SubmitCaseHandler {
private final CaseRepository cases;
private final CaseNumberGenerator numbers;
private final DomainEventPublisher events;
private final ClockProvider clock;
public SubmitCaseHandler(
CaseRepository cases,
CaseNumberGenerator numbers,
DomainEventPublisher events,
ClockProvider clock
) {
this.cases = cases;
this.numbers = numbers;
this.events = events;
this.clock = clock;
}
@Transactional
public SubmitCaseResult handle(SubmitCaseCommand command) {
if (cases.existsByIdempotencyKey(command.idempotencyKey())) {
return cases.findResultByIdempotencyKey(command.idempotencyKey());
}
var caseFile = CaseFile.submit(
CaseId.newId(),
numbers.next(),
command.reporterId(),
command.subjectPartyId(),
command.allegations(),
clock.now()
);
cases.save(caseFile, command.idempotencyKey());
events.publishAll(caseFile.releaseEvents());
return SubmitCaseResult.from(caseFile);
}
}
Domain menjaga invariant.
public final class CaseFile {
private final CaseId id;
private final CaseNumber number;
private CaseStatus status;
private final List<Allegation> allegations;
private final List<DomainEvent> events = new ArrayList<>();
private CaseFile(
CaseId id,
CaseNumber number,
CaseStatus status,
List<Allegation> allegations
) {
this.id = id;
this.number = number;
this.status = status;
this.allegations = List.copyOf(allegations);
}
public static CaseFile submit(
CaseId id,
CaseNumber number,
ReporterId reporterId,
PartyId subjectPartyId,
List<Allegation> allegations,
Instant submittedAt
) {
if (allegations == null || allegations.isEmpty()) {
throw new CaseMustContainAtLeastOneAllegation();
}
var caseFile = new CaseFile(id, number, CaseStatus.SUBMITTED, allegations);
caseFile.events.add(new CaseSubmitted(id, number, reporterId, subjectPartyId, submittedAt));
return caseFile;
}
public List<DomainEvent> releaseEvents() {
var copy = List.copyOf(events);
events.clear();
return copy;
}
}
Perhatikan pola berpikirnya:
HTTP request -> command -> use case -> domain transition -> persistence -> event -> response
Bukan:
HTTP request -> entity -> repository.save()
6. Inbound Adapter: Menerima Dunia Luar Tanpa Membiarkan Dunia Luar Masuk
Inbound adapter adalah tempat service menerima interaksi. Bentuknya bisa:
- REST controller.
- gRPC endpoint.
- message consumer.
- scheduled job.
- CLI command.
- batch trigger.
- workflow activity endpoint.
Inbound adapter punya tugas sempit:
- Parse transport input.
- Validate syntax-level input.
- Build command/query.
- Call application use case.
- Translate result/error ke transport response.
Inbound adapter tidak boleh:
- Memutuskan business transition kompleks.
- Mengakses repository langsung untuk mutation utama.
- Membentuk domain object dari persistence entity.
- Menentukan retry terhadap downstream dependency tanpa koordinasi application policy.
- Publish event domain tanpa use case.
Contoh request validation yang baik:
public record SubmitCaseRequest(
@NotBlank String reporterId,
@NotBlank String subjectPartyId,
@NotEmpty List<AllegationRequest> allegations
) {
}
Ini syntax validation. Tetapi rule seperti “case tidak boleh diajukan jika party sedang under sealed investigation” bukan syntax validation. Itu policy/domain rule dan harus hidup di application/domain layer.
7. Application Layer: Use Case, Transaction, dan Coordination Boundary
Application layer menjawab:
Untuk menjalankan satu user/business intention, apa saja langkah yang harus dikoordinasikan?
Application layer biasanya berisi:
- Command handler.
- Query handler.
- Use case service.
- Process coordinator.
- Policy orchestration.
- Transaction boundary.
- Port interface.
Contoh command handler:
public interface CommandHandler<C, R> {
R handle(C command);
}
Application layer bukan tempat menaruh semua business logic. Ia mengeksekusi alur. Domain tetap menjaga invariant.
Pemisahan yang baik:
| Concern | Lokasi |
|---|---|
| Parse HTTP request | inbound adapter |
| Validate JSON shape | inbound adapter |
| Check duplicate command | application |
| Load aggregate | application |
| Enforce aggregate invariant | domain |
| Start transaction | application |
| Save state | persistence adapter via port |
| Publish integration event | application via event port/outbox |
| Map domain error to HTTP 409 | inbound adapter exception handler |
Transaction boundary biasanya ada di application layer karena application layer tahu satu use case butuh atomicity seperti apa.
@Transactional
public AssignCaseResult handle(AssignCaseCommand command) {
var caseFile = cases.get(command.caseId());
var officer = officers.get(command.officerId());
caseFile.assignTo(officer, command.assignedBy(), clock.now());
cases.save(caseFile);
events.publishAll(caseFile.releaseEvents());
return AssignCaseResult.from(caseFile);
}
Catatan penting: kalau event publish dilakukan langsung ke broker di dalam transaction database, kita membuka risiko database commit berhasil tetapi publish gagal. Nanti Part 035 akan membahas outbox/inbox. Di part ini cukup pahami bahwa event publishing adalah bagian dari service anatomy, bukan logging tambahan.
8. Domain Layer: Tempat Invariant Tinggal
Domain layer menjawab:
Apa yang tidak boleh dilanggar oleh service ini, apa pun transport, database, dan framework-nya?
Invariant contoh:
- Case tidak boleh masuk
UNDER_REVIEWsebelum minimal satu allegation valid. - Case tidak boleh di-close jika ada mandatory review task yang belum selesai.
- Escalation hanya bisa dilakukan dari status tertentu.
- Evidence sealed tidak boleh terlihat oleh role tertentu.
- Decision final tidak boleh diubah tanpa reopening process.
Domain layer tidak harus selalu kaya. Untuk service yang hanya integration adapter, domain bisa tipis. Tetapi untuk core business capability, domain yang terlalu tipis akan membuat business rule tersebar di controller, mapper, SQL, dan workflow script.
Contoh state transition:
public void escalate(OfficerId requestedBy, EscalationReason reason, Instant at) {
if (!status.canEscalate()) {
throw new InvalidCaseTransition(id, status, CaseAction.ESCALATE);
}
if (reason == null || reason.isBlank()) {
throw new EscalationRequiresReason(id);
}
this.status = CaseStatus.ESCALATED;
this.events.add(new CaseEscalated(id, requestedBy, reason, at));
}
Di sini domain tidak peduli apakah command datang dari REST, Kafka, atau workflow engine. Itu indikator sehat.
9. Port Interface: Kontrak Internal ke Dunia Teknis
Port adalah interface yang dibutuhkan application/domain untuk berinteraksi dengan dunia luar.
public interface CaseRepository {
Optional<CaseFile> findById(CaseId id);
CaseFile get(CaseId id);
void save(CaseFile caseFile);
boolean existsByIdempotencyKey(String key);
SubmitCaseResult findResultByIdempotencyKey(String key);
}
Port harus ditulis dalam bahasa domain/application, bukan bahasa vendor.
Buruk:
public interface CaseRepository {
JpaRepository<CaseJpaEntity, UUID> delegate();
}
Lebih baik:
public interface CaseRepository {
CaseFile get(CaseId id);
void save(CaseFile caseFile);
}
Port bukan abstraksi palsu untuk semua hal. Jangan membuat port untuk StringUtils, LocalDate, atau operasi trivial. Port bernilai jika:
- Mengisolasi dependency eksternal.
- Membuat testing seam.
- Mengubah bahasa teknis menjadi bahasa domain.
- Melindungi service dari vendor/runtime detail.
- Mengontrol failure behavior.
10. Outbound Adapter: Semua Dependency Keluar Harus Dapat Dikendalikan
Outbound adapter bisa berupa:
- Database adapter.
- HTTP client adapter.
- Kafka publisher.
- S3/object storage client.
- Email/SMS client.
- Policy engine adapter.
- Legacy system adapter.
Outbound adapter bukan hanya “client wrapper”. Ia harus mengontrol:
- Timeout.
- Retry eligibility.
- Error translation.
- Circuit breaker integration.
- Observability tags.
- Payload mapping.
- Authentication context.
- Idempotency/correlation header.
Contoh adapter client:
public final class HttpRiskScoringClient implements RiskScoringClient {
private final WebClient client;
private final RiskScoringClientProperties properties;
public HttpRiskScoringClient(WebClient client, RiskScoringClientProperties properties) {
this.client = client;
this.properties = properties;
}
@Override
public RiskScore score(PartyId partyId, CaseType caseType) {
try {
return client.post()
.uri("/risk-score")
.header("X-Correlation-Id", Correlation.current())
.bodyValue(new RiskScoreRequest(partyId.value(), caseType.name()))
.retrieve()
.bodyToMono(RiskScoreResponse.class)
.timeout(properties.requestTimeout())
.map(RiskScoreResponse::toDomain)
.block();
} catch (TimeoutException ex) {
throw new RiskScoringUnavailable("Risk scoring timed out", ex);
} catch (WebClientResponseException.TooManyRequests ex) {
throw new RiskScoringOverloaded("Risk scoring overloaded", ex);
}
}
}
Hal penting: adapter menerjemahkan error teknis menjadi error yang application bisa pahami. Application layer tidak perlu tahu detail HTTP 429, socket timeout, atau JSON parse exception.
11. Persistence Surface: Database Bukan Detail Kecil
Dalam microservices, database adalah bagian dari boundary. Service owns its data. Artinya:
- Schema dimiliki service.
- Write model dimiliki service.
- Migration dimiliki service.
- Data invariant dijaga service.
- Service lain tidak membaca tabel langsung.
Persistence adapter harus memetakan domain model ke persistence model. Untuk service kecil, entity dan domain kadang bisa sama. Tetapi default enterprise-grade yang lebih aman adalah memisahkan:
Domain object != JPA entity != API DTO != event payload
Mengapa?
Karena masing-masing berubah karena alasan berbeda:
| Model | Berubah karena |
|---|---|
| Domain object | business invariant berubah |
| JPA entity | schema/index/query berubah |
| API DTO | consumer contract berubah |
| Event payload | integration contract berubah |
Contoh mapper persistence:
final class CaseJpaMapper {
CaseJpaEntity toEntity(CaseFile caseFile) {
var entity = new CaseJpaEntity();
entity.setId(caseFile.id().value());
entity.setCaseNumber(caseFile.number().value());
entity.setStatus(caseFile.status().name());
entity.setVersion(caseFile.version());
return entity;
}
CaseFile toDomain(CaseJpaEntity entity) {
return CaseFile.rehydrate(
new CaseId(entity.getId()),
new CaseNumber(entity.getCaseNumber()),
CaseStatus.valueOf(entity.getStatus()),
entity.getVersion()
);
}
}
Kalau mapping terasa berat, cek apakah domain terlalu anemia atau schema terlalu bocor ke domain. Mapping itu biaya. Tetapi dalam sistem yang berubah lama, biaya mapping sering lebih murah daripada coupling jangka panjang.
12. API Surface: Contract yang Tidak Membocorkan Internal Model
API surface harus menyatakan intention consumer, bukan internal table.
Buruk:
POST /case-table
PUT /case-table/{id}
PATCH /case-table/{id}/status
Lebih baik:
POST /cases
POST /cases/{caseId}/assignment
POST /cases/{caseId}/escalation
POST /cases/{caseId}/closure-request
GET /cases/{caseId}/summary
API bukan database CRUD remote. API harus memperlihatkan capability.
Response error juga kontrak:
{
"type": "https://acme.example/problems/invalid-case-transition",
"title": "Invalid case transition",
"status": 409,
"detail": "Case CASE-2026-00091 cannot move from CLOSED to UNDER_REVIEW.",
"instance": "/cases/CASE-2026-00091/escalation",
"correlationId": "01JZ7S7CZ0Q2ZJABZV6MSW6C7M"
}
Mapping error:
| Domain/Application Error | HTTP |
|---|---|
| Validation syntax invalid | 400 |
| Authentication missing | 401 |
| Authorization denied | 403 |
| Resource not found | 404 |
| State transition conflict | 409 |
| Idempotency key conflict | 409 |
| Downstream dependency unavailable | 503 |
| Internal unexpected error | 500 |
Jangan mengembalikan 500 untuk business conflict. Itu membuat consumer dan operator salah memahami masalah.
13. Observability Surface: Service Harus Bisa Bicara Saat Sakit
Service production-grade harus menjawab pertanyaan operator:
- Apakah service menerima traffic?
- Endpoint mana lambat?
- Dependency mana gagal?
- Apakah queue menumpuk?
- Apakah database saturated?
- Apakah error berasal dari input, dependency, atau bug?
- Tenant/consumer mana yang terkena dampak?
- Apakah deployment baru menyebabkan regresi?
Minimal telemetry:
Logs:
- structured JSON logs
- correlationId
- traceId/spanId
- command name
- domain key where safe
- error classification
Metrics:
- request rate
- error rate
- latency percentiles
- dependency latency
- dependency error rate
- queue lag/depth
- JVM memory/GC/thread metrics
- business counters
Traces:
- inbound span
- database span
- outbound HTTP/gRPC span
- message publish/consume span
- workflow/activity span
Contoh structured logging field:
public final class StructuredLogFields {
public static final String CORRELATION_ID = "correlation_id";
public static final String CASE_ID = "case_id";
public static final String COMMAND = "command";
public static final String FAILURE_CLASS = "failure_class";
public static final String DEPENDENCY = "dependency";
private StructuredLogFields() {
}
}
Log buruk:
failed to submit case
Log lebih berguna:
{
"level": "WARN",
"message": "case submission rejected by domain rule",
"correlation_id": "01JZ7S7CZ0Q2ZJABZV6MSW6C7M",
"command": "SubmitCase",
"case_id": "CASE-2026-00091",
"failure_class": "DOMAIN_CONFLICT",
"rule": "CASE_MUST_CONTAIN_ALLEGATION"
}
Hati-hati: observability tidak boleh melanggar privacy. Domain key boleh membantu debugging, tetapi PII dan sensitive evidence harus direduksi atau di-redact.
14. Operational Surface: Runtime Contract Service
Operational surface menjawab:
- Bagaimana platform tahu service siap menerima traffic?
- Bagaimana service shutdown tanpa merusak in-flight request?
- Bagaimana service menyatakan dependency critical sedang down?
- Bagaimana config invalid dideteksi sebelum traffic masuk?
- Bagaimana deployment aman dilakukan?
- Bagaimana operator menemukan version, build, commit, dan feature flag state?
Minimal operational endpoints:
/health/live -> process masih hidup
/health/ready -> siap menerima traffic
/metrics -> metrics endpoint
/info -> build/service metadata
Dalam Spring Boot, production-ready features seperti health, metrics, auditing, dan management endpoint biasanya disediakan melalui Actuator. Dalam platform lain, konsepnya sama: service harus menyediakan sinyal runtime yang bisa dibaca platform dan operator.
Readiness harus jujur. Jangan tandai ready jika:
- Migration wajib belum selesai.
- Config mandatory invalid.
- Connection pool critical tidak bisa dibuat.
- Cache warm-up wajib belum selesai.
- Message consumer belum siap tetapi service sudah menerima command yang bergantung pada event.
Tetapi readiness juga tidak boleh terlalu sensitif. Jika readiness memanggil semua downstream dependency secara agresif, dependency kecil bisa membuat seluruh service dikeluarkan dari load balancer walaupun masih bisa melayani sebagian fungsi.
Prinsipnya:
liveness = should the process be restarted?
readiness = should this instance receive traffic now?
health = what is the observed operational state?
15. Configuration Surface: Config adalah Runtime Contract
Config bukan tempat menaruh string acak. Config menentukan perilaku runtime.
Contoh config production-grade:
case-intake:
submission:
max-allegations-per-case: 50
duplicate-window: PT24H
risk-scoring:
base-url: https://risk-scoring.internal
request-timeout: PT750MS
retry:
max-attempts: 2
backoff: PT100MS
events:
topic: enforcement.case-events.v1
publish-timeout: PT500MS
Config harus:
- Typed.
- Validated at startup.
- Have safe defaults only when safe.
- Fail fast for mandatory missing values.
- Visible in sanitized operational diagnostics.
- Versioned when it changes behavior.
Contoh typed properties:
@ConfigurationProperties(prefix = "case-intake.risk-scoring")
@Validated
public record RiskScoringClientProperties(
@NotBlank String baseUrl,
@NotNull Duration requestTimeout,
Retry retry
) {
public record Retry(
@Min(0) int maxAttempts,
@NotNull Duration backoff
) {}
}
Smell: config yang mengubah business rule besar tanpa audit atau approval. Untuk regulatory domain, beberapa config adalah policy dan harus punya governance, bukan sekadar environment variable.
16. Request Lifecycle dalam Service yang Sehat
Mari lihat lifecycle request dari awal sampai akhir.
Beberapa keputusan penting tampak di diagram:
- Idempotency dicek sebelum mutation.
- Domain transition terjadi sebelum persistence.
- Event tidak langsung menjadi side effect tak terkendali.
- API response tidak menunggu semua downstream selesai jika prosesnya long-running.
- Boundary antara synchronous command dan asynchronous propagation jelas.
17. Command vs Query Anatomy
Dalam microservice, command dan query punya sifat berbeda.
Command:
- Mengubah state.
- Perlu idempotency.
- Punya transaction boundary.
- Menghasilkan event atau state transition.
- Harus punya audit trail.
- Failure bisa meninggalkan partial progress jika keluar service boundary.
Query:
- Membaca state.
- Harus eksplisit soal freshness/staleness.
- Bisa memakai read model.
- Biasanya butuh pagination/filtering.
- Failure tidak boleh mengubah state.
Contoh command:
public record EscalateCaseCommand(
CaseId caseId,
OfficerId requestedBy,
EscalationReason reason,
String idempotencyKey
) {}
Contoh query:
public record SearchCasesQuery(
Optional<CaseStatus> status,
Optional<OfficerId> assignedOfficer,
PageRequest page
) {}
Jangan campur mutation dalam query handler. Query yang “sekalian update last viewed” terdengar praktis, tetapi membuat behavior sulit diprediksi dan bisa merusak cache, audit, serta retry safety.
18. Error Taxonomy Internal Service
Production-grade service butuh error taxonomy, bukan sekadar exception random.
ServiceException
DomainException
InvalidTransition
InvariantViolation
DuplicateBusinessAction
ApplicationException
IdempotencyConflict
PolicyDecisionUnavailable
CommandRejected
DependencyException
DependencyTimeout
DependencyOverloaded
DependencyInvalidResponse
InfrastructureException
PersistenceFailure
MessagePublishFailure
ConfigInvalid
Mapping ke transport harus eksplisit.
@RestControllerAdvice
final class ApiExceptionHandler {
@ExceptionHandler(InvalidCaseTransition.class)
ResponseEntity<ProblemDetail> handleInvalidTransition(InvalidCaseTransition ex) {
var problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
problem.setTitle("Invalid case transition");
problem.setDetail(ex.getMessage());
problem.setProperty("failureClass", "DOMAIN_CONFLICT");
problem.setProperty("correlationId", Correlation.current());
return ResponseEntity.status(HttpStatus.CONFLICT).body(problem);
}
}
Tujuan taxonomy:
- Consumer tahu apakah boleh retry.
- Operator tahu apakah ini bug, dependency, overload, atau input problem.
- Alert bisa dipilah.
- Dashboard bisa membaca failure class.
- Incident review punya data yang bisa dianalisis.
19. Owned State dan Transaction Boundary
Service harus tahu state mana yang benar-benar ia miliki.
Untuk case-intake-service:
Owned state:
- Case intake draft.
- Intake submission record.
- Duplicate submission marker.
- Outbox event for intake.
Not owned:
- Party master data.
- Officer roster.
- Evidence binary content.
- Risk score model.
- Final enforcement decision.
Service boleh menyimpan reference atau snapshot untuk kebutuhan historis, tetapi tidak boleh berpura-pura menjadi owner dari data tersebut.
Contoh:
public record PartyReference(
PartyId partyId,
String displayNameSnapshot,
Instant snapshotAt
) {}
displayNameSnapshot bukan sumber kebenaran terbaru. Ia adalah snapshot untuk audit/context. Naming harus jujur agar developer masa depan tidak salah pakai.
20. Service Startup Anatomy
Startup bukan hanya main().
Startup production-grade melewati beberapa fase:
Apa yang harus fail fast?
- Mandatory config missing.
- Invalid credential format.
- Invalid topic/table name.
- Required schema migration incompatible.
- Port binding failure.
- Invalid feature flag combination.
Apa yang tidak selalu harus fail startup?
- Optional downstream service unavailable.
- Non-critical cache unavailable.
- Analytics sink temporarily down.
- Degraded-mode dependency unavailable.
Perbedaan ini harus dinyatakan dalam design, bukan dibiarkan sebagai kebetulan startup framework.
21. Graceful Shutdown Anatomy
Shutdown juga bagian dari correctness.
Saat instance menerima signal shutdown:
- Stop menerima traffic baru.
- Mark readiness false.
- Drain in-flight request.
- Stop polling message baru.
- Finish atau safely checkpoint current message.
- Flush telemetry.
- Close connection pool.
- Exit dalam termination grace period.
Jika service punya message consumer, shutdown yang buruk bisa menyebabkan:
- Duplicate processing.
- Event commit sebelum DB commit.
- Lost progress.
- Lock tidak dilepas.
- Consumer group rebalance storm.
Pola yang sehat:
SIGTERM
-> readiness=false
-> stop inbound HTTP acceptance
-> pause message consumer
-> complete current unit of work
-> commit offset only after durable write
-> close resources
-> exit
22. Internal Modularity di Dalam Satu Service
Microservice bukan alasan untuk menulis spaghetti kecil.
Untuk service yang tumbuh, internal modularity penting:
caseintake/
submission/
api/
application/
domain/
infrastructure/
assignment/
api/
application/
domain/
infrastructure/
escalation/
api/
application/
domain/
infrastructure/
shared/
value/
observability/
Tetapi hati-hati dengan shared. Folder shared sering menjadi tempat semua coupling bersembunyi.
Rule:
- Shared value object boleh jika benar-benar stabil.
- Shared utility harus minimal.
- Shared domain service harus dicurigai.
- Shared DTO antar feature hampir selalu smell.
- Shared mapper sering menjadi coupling magnet.
Kalau satu service punya banyak subdomain internal yang berubah secara independen, mungkin service terlalu besar. Tetapi jangan langsung split. Pertama jadikan modular monolith kecil di dalam service, lalu ukur volatility dan ownership.
23. Build Artifact dan Runtime Metadata
Service artifact harus bisa menjawab:
- Service apa ini?
- Version berapa?
- Commit apa?
- Dibangun kapan?
- Config profile apa?
- Feature flag apa aktif?
- Schema version apa yang didukung?
- API version apa yang diekspos?
Minimal metadata:
{
"service": "case-intake-service",
"version": "1.18.3",
"commit": "8fd2a71",
"buildTime": "2026-07-05T09:10:00Z",
"runtime": "java-21",
"contractVersion": "case-intake-api-v3",
"schemaCompatibility": "2026.07"
}
Ini bukan hiasan. Saat incident, metadata mempercepat jawaban: “instance mana yang menjalankan versi bermasalah?”
24. Security Hooks Tanpa Mengulang Materi Security
Walaupun security dibahas di bagian lain, anatomy service harus menyiapkan hook:
- Principal/current actor propagation.
- Authorization check location.
- Tenant context validation.
- Audit event capture.
- Sensitive data redaction.
- Secure outbound call identity.
Contoh actor context:
public record ActorContext(
ActorId actorId,
Set<String> roles,
Optional<TenantId> tenantId,
String correlationId
) {}
Jangan mengakses security context framework dari domain object. Application layer boleh menerima ActorContext, lalu domain menerima data yang relevan untuk rule.
Buruk:
SecurityContextHolder.getContext().getAuthentication()
di dalam domain object.
Lebih baik:
caseFile.close(actor.actorId(), closeReason, clock.now());
25. Anatomy of a Service Design Document
Sebelum coding, satu service production-grade seharusnya punya design document singkat.
Template:
# Service: Case Intake Service
## Purpose
Owns intake lifecycle for regulatory cases before formal investigation starts.
## Business Capability
Case intake, duplicate detection, initial allegation capture.
## Owned Data
- case_intake
- intake_submission
- intake_outbox
## Not Owned Data
- party master data
- final enforcement decision
- evidence binary content
## APIs
- POST /cases
- GET /cases/{id}/summary
- POST /cases/{id}/evidence-references
## Events Published
- CaseSubmitted.v1
- EvidenceReferenceAttached.v1
## Events Consumed
- PartyRiskChanged.v1
## Critical Dependencies
- party-service
- risk-scoring-service
- PostgreSQL
- Kafka
## Consistency Model
- local ACID for intake state
- eventual propagation through outbox events
- duplicate detection within 24h window
## Failure Behavior
- risk scoring unavailable: accept submission as pending risk assessment
- database unavailable: reject command with 503
- event broker unavailable: persist outbox, retry publishing
## SLO
- POST /cases p95 < 400ms excluding downstream degraded mode
- availability 99.9% monthly
## Operational Notes
- readiness requires DB connectivity and migration compatibility
- Kafka outage does not fail readiness if outbox can persist
Design document bukan bureaucracy. Ia adalah cara memastikan service punya identity sebelum menjadi code.
26. Common Smells
Smell 1 — Controller Does Everything
Gejala:
- Controller panjang.
- Banyak repository call.
- Banyak
if status == .... - Banyak try-catch dependency.
- Test controller menjadi test semua rule.
Diagnosis: application/domain layer tidak ada atau tidak dipercaya.
Smell 2 — Entity as Universal Model
Gejala:
- JPA entity dipakai sebagai request body.
- Entity dipublish sebagai Kafka event.
- Entity dipakai sebagai domain object.
- Schema change memecahkan API.
Diagnosis: internal model bocor ke semua surface.
Smell 3 — Service Has No Owned Decision
Gejala:
- Service hanya pass-through.
- Semua rule ada di downstream.
- Tidak ada state/invariant.
- Tidak jelas kenapa service berdiri sendiri.
Diagnosis: service mungkin hanya technical proxy. Gabungkan ke edge/BFF/integration layer atau ubah purpose-nya.
Smell 4 — Repository Called from Everywhere
Gejala:
- Repository dipakai controller, scheduler, consumer, helper, mapper.
- Mutation terjadi di banyak tempat.
- Transaction boundary tidak konsisten.
Diagnosis: use case boundary hilang.
Smell 5 — Readiness Lies
Gejala:
/readyselalu 200.- Service menerima traffic walau DB migration gagal.
- Deployment sukses tetapi request pertama gagal.
Diagnosis: operational surface hanya formalitas.
Smell 6 — Event Is Just Database Row Dump
Gejala:
- Event bernama
CaseUpdatedberisi seluruh row. - Consumer harus diff sendiri.
- Tidak jelas business meaning.
Diagnosis: event tidak merepresentasikan domain occurrence.
Smell 7 — Business Logic in Mapper
Gejala:
- Mapper mengubah status.
- Mapper memutuskan default policy.
- Mapper memfilter field berdasarkan role.
Diagnosis: mapper menjadi hidden application/domain layer.
27. Practical Build Sequence
Kalau membangun service baru, jangan mulai dari database table. Ikuti urutan ini:
1. Define capability and owner
2. Define commands and queries
3. Define state lifecycle
4. Define invariants
5. Define owned data
6. Define API contract
7. Define events published/consumed
8. Define application use cases
9. Define ports
10. Implement domain model
11. Implement adapters
12. Add observability
13. Add operational endpoints
14. Add config validation
15. Add deployment/runtime metadata
16. Add runbook notes
Dalam praktik, langkah ini iteratif. Tetapi urutan mentalnya penting. Jika mulai dari table, biasanya service menjadi CRUD wrapper. Jika mulai dari capability dan lifecycle, service lebih mungkin memiliki boundary yang sehat.
28. Case Study Mini: Case Intake Service
Misalkan kita membangun case-intake-service.
Capability
Menerima laporan awal, melakukan validasi minimal, mencegah duplikasi submission, membuat case intake record, dan memulai assessment awal.
Commands
| Command | Meaning |
|---|---|
| SubmitCase | Reporter mengajukan case baru |
| AttachEvidenceReference | Menambahkan referensi evidence |
| WithdrawSubmission | Reporter menarik submission sebelum review |
| MarkDuplicate | Intake officer menandai sebagai duplicate |
Queries
| Query | Meaning |
|---|---|
| GetCaseIntakeSummary | Melihat ringkasan intake |
| SearchPendingIntakes | Melihat intake yang menunggu review |
| GetSubmissionHistory | Melihat riwayat submission |
Events
| Event | Meaning |
|---|---|
| CaseSubmitted | Case intake berhasil dibuat |
| EvidenceReferenceAttached | Evidence reference ditambahkan |
| CaseMarkedDuplicate | Submission dinyatakan duplicate |
| IntakeWithdrawn | Submission ditarik |
Owned state
| Table | Purpose |
|---|---|
| case_intake | aggregate state |
| case_intake_submission | idempotency and submission metadata |
| case_intake_evidence_ref | evidence references only |
| outbox_event | event publishing durability |
Critical design decision
Risk scoring unavailable tidak boleh selalu menggagalkan submission. Untuk domain enforcement, lebih baik menerima submission sebagai PENDING_RISK_ASSESSMENT daripada kehilangan laporan. Ini bukan keputusan teknis; ini keputusan bisnis dan operasional.
29. Architecture Review Checklist
Gunakan checklist ini untuk membaca satu Java microservice.
Purpose
- Service punya business capability yang jelas.
- Service bukan sekadar wrapper table.
- Owner team jelas.
- Consumer utama jelas.
Boundary
- Owned data jelas.
- Non-owned data jelas.
- Tidak ada shared database write.
- External model tidak bocor ke domain.
Internal Architecture
- Inbound adapter tipis.
- Use case boundary eksplisit.
- Domain invariant tidak tersebar.
- Ports memakai bahasa domain/application.
- Outbound adapter mengontrol timeout/error translation.
API
- API task/capability-oriented.
- Error response konsisten.
- Idempotency untuk command penting.
- Versioning/compatibility strategy ada.
Data and Consistency
- Transaction boundary jelas.
- Event publishing strategy jelas.
- Duplicate handling jelas.
- Read model staleness dijelaskan jika ada.
Reliability
- Timeout ada untuk outbound call.
- Retry tidak sembarangan.
- Graceful degradation didefinisikan.
- Shutdown behavior aman.
Observability
- Structured logs punya correlation ID.
- Metrics mencakup RED/dependency/business signal.
- Trace propagation aktif.
- Failure taxonomy terlihat di telemetry.
Operations
- Liveness/readiness benar.
- Config tervalidasi.
- Runtime metadata tersedia.
- Runbook minimal ada.
30. Latihan
Ambil satu service di sistemmu. Jawab tanpa membuka code terlebih dahulu:
- Service ini capability apa?
- State apa yang benar-benar ia miliki?
- Command apa yang mengubah state?
- Query apa yang hanya membaca state?
- Event apa yang dipublish?
- Dependency mana yang critical?
- Apa failure mode paling berbahaya?
- Apa invariant domain paling penting?
- Apa yang terjadi jika downstream utama timeout?
- Bagaimana operator tahu service ini sehat?
Setelah itu baru buka code. Jika jawaban mentalmu tidak cocok dengan struktur code, berarti service architecture belum cukup ekspresif.
31. Ringkasan
Anatomi Java microservice production-grade tidak berhenti di Controller, Service, dan Repository.
Model yang lebih kuat:
Microservice = capability owner
+ deployment unit
+ state owner
+ contract provider
+ runtime participant
+ observable object
+ operational responsibility
Struktur internal yang sehat membuat hal-hal berikut terlihat:
- Inbound adapter menerima dunia luar tanpa membocorkan transport ke domain.
- Application layer menjalankan use case dan transaction boundary.
- Domain layer menjaga invariant.
- Ports mengungkap dependency yang dibutuhkan use case.
- Outbound adapters mengendalikan dependency teknis.
- Persistence surface menjaga owned state.
- Observability surface membuat behavior bisa dibaca.
- Operational surface membuat service bisa hidup aman di platform.
Part berikutnya akan membahas pilihan platform: Spring Boot vs Jakarta EE vs MicroProfile vs Quarkus vs Micronaut. Fokusnya bukan “mana yang paling bagus”, tetapi “framework mana cocok untuk constraint arsitektur tertentu”.
References
- Spring Boot Reference Documentation — Production-ready Features: https://docs.spring.io/spring-boot/reference/actuator/index.html
- Spring Boot Reference Documentation — Actuator: https://docs.spring.io/spring-boot/reference/actuator/enabling.html
- Jakarta EE Specifications: https://jakarta.ee/specifications/
- MicroProfile: https://microprofile.io/
You just completed lesson 15 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.