Start HereOrdered learning track

Layering vs Hexagonal vs Clean Architecture

Learn Java Microservices Design and Architect - Part 017

Membandingkan layered architecture, hexagonal architecture, dan clean architecture untuk Java microservices production-grade, dengan fokus dependency direction, ports/adapters, domain protection, dan implementasi nyata.

11 min read2156 words
PrevNext
Lesson 17100 lesson track01–18 Start Here
#java#microservices#architecture#hexagonal-architecture+4 more

Part 017 — Layering vs Hexagonal vs Clean Architecture

1. Core Problem

Banyak Java microservice terlihat rapi di awal:

controller -> service -> repository

Tetapi setelah beberapa bulan, struktur itu sering berubah menjadi:

controller -> service -> repository -> mapper -> http client -> kafka producer -> utility -> static context -> config -> more service

Lalu semua orang berkata, “ini layered architecture”. Padahal yang terjadi bukan architecture. Itu hanya folder convention.

Masalah utama bukan nama layer. Masalah utama adalah arah dependency.

Service yang sehat harus menjawab pertanyaan ini dengan tegas:

Apakah business rule inti bisa hidup tanpa HTTP, database, Kafka, framework, vendor SDK, dan deployment platform?

Kalau jawabannya tidak, maka domain logic sedang ditarik keluar oleh teknologi. Akibatnya:

  • business rule susah dites tanpa Spring context;
  • perubahan database bocor ke use case;
  • perubahan API eksternal merusak domain model;
  • model bisnis menjadi DTO dengan validasi tipis;
  • service sulit dipindahkan dari REST ke messaging, dari synchronous ke async, atau dari JPA ke MyBatis;
  • kode terlihat modular, tetapi dependency-nya tidak modular.

Part ini membahas tiga pendekatan yang sering dipakai dalam Java microservices:

  1. Layered architecture
  2. Hexagonal architecture / ports and adapters
  3. Clean architecture

Tujuannya bukan memilih dogma. Tujuannya adalah membuat kamu bisa mendesain service yang:

  • change-tolerant;
  • testable;
  • operationally explicit;
  • tidak framework-driven;
  • tidak berubah menjadi distributed CRUD script;
  • tetap pragmatis untuk tim enterprise.

2. The Mental Model: Architecture Is Dependency Control

Dalam microservices, service boundary sudah mahal karena melewati network, deployment, observability, data ownership, dan team ownership. Jangan membuat boundary internal di dalam service ikut kacau.

Arsitektur internal service harus menjaga tiga hal:

HalPertanyaan
Business rule stabilityApakah rule inti aman dari perubahan transport, persistence, dan vendor?
Change isolationApakah perubahan adapter tidak menyebar ke application/domain layer?
TestabilityApakah use case dan invariant bisa dites tanpa runtime production?

Architecture yang baik bukan banyak folder. Architecture yang baik adalah kode yang menunjukkan apa yang boleh bergantung pada apa.

Diagram mentalnya:

Prinsipnya sederhana:

Perubahan teknologi boleh mengguncang adapter, tetapi tidak boleh langsung mengguncang business rule.


3. Layered Architecture

Layered architecture membagi sistem berdasarkan tanggung jawab teknis:

API / Controller Layer
Application / Service Layer
Domain Layer
Persistence / Repository Layer
Database

Bentuk umum di Java:

com.acme.casehandling
├── controller
├── service
├── repository
├── entity
├── dto
├── mapper
└── config

Ini familiar, mudah diajarkan, dan cukup untuk sistem sederhana.

3.1 Layered Architecture yang Sehat

Layered architecture sehat jika dependency-nya tetap terkendali:

Ciri sehat:

  • controller hanya menerjemahkan transport ke command/query;
  • application service mengorkestrasi use case;
  • domain object menjaga invariant;
  • repository interface berada dekat application/domain need;
  • implementation detail berada di infrastructure;
  • DTO tidak menjadi domain model;
  • entity persistence tidak otomatis menjadi aggregate.

3.2 Layered Architecture yang Sakit

Layered architecture mulai sakit saat layer hanya menjadi folder, bukan dependency rule.

Contoh smell:

@RestController
class EscalationController {
    private final CaseRepository caseRepository;
    private final ExternalRiskClient riskClient;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @PostMapping("/cases/{id}/escalate")
    public ResponseEntity<?> escalate(@PathVariable String id, @RequestBody EscalateRequest request) {
        CaseEntity entity = caseRepository.findById(UUID.fromString(id)).orElseThrow();
        RiskResponse risk = riskClient.calculate(entity.getPartyId(), request.reason());

        entity.setStatus("ESCALATED");
        entity.setRiskScore(risk.score());
        caseRepository.save(entity);

        kafkaTemplate.send("case-events", new CaseEscalatedEvent(id, risk.score()));
        return ResponseEntity.accepted().build();
    }
}

Kode ini bekerja. Tetapi secara architecture, ia buruk karena:

  • controller tahu persistence model;
  • controller tahu external client;
  • controller tahu event publication;
  • domain invariant tersebar sebagai setter;
  • tidak ada use case boundary;
  • transaction dan side effect tidak jelas;
  • test use case harus membawa HTTP/Spring/Kafka/JPA concern.

Layered architecture sering gagal bukan karena layering salah, tetapi karena tim berhenti di struktur folder dan tidak menjaga dependency direction.


4. Hexagonal Architecture / Ports and Adapters

Hexagonal architecture, juga dikenal sebagai ports and adapters, memisahkan application core dari dunia luar.

Mental model-nya:

Hexagonal architecture membalik cara berpikir:

  • REST bukan pusat service;
  • database bukan pusat service;
  • Kafka bukan pusat service;
  • pusat service adalah use case dan domain rule;
  • dunia luar berkomunikasi melalui port;
  • teknologi dipasang melalui adapter.

4.1 Port

Port adalah kontrak percakapan antara application core dan luar.

Ada dua jenis umum:

Jenis PortArahContoh
Input portOutside calls applicationEscalateCaseUseCase
Output portApplication calls outside capabilityCaseRepository, RiskScoringPort, AuditLogPort

Contoh input port:

package com.acme.casehandling.application.port.in;

public interface EscalateCaseUseCase {
    EscalateCaseResult escalate(EscalateCaseCommand command);
}

Contoh output port:

package com.acme.casehandling.application.port.out;

import java.util.Optional;

public interface CaseRepository {
    Optional<Case> findById(CaseId caseId);
    void save(Case regulatoryCase);
}
package com.acme.casehandling.application.port.out;

public interface RiskScoringPort {
    RiskAssessment assess(CaseSnapshot snapshot);
}

Port harus menggunakan bahasa application/domain, bukan bahasa vendor.

Buruk:

public interface RiskScoringPort {
    ExternalVendorRiskResponse callVendorRiskApi(VendorRiskRequest request);
}

Lebih baik:

public interface RiskScoringPort {
    RiskAssessment assess(CaseSnapshot snapshot);
}

Kenapa?

Karena application core butuh risk assessment, bukan butuh vendor response.

4.2 Adapter

Adapter menerjemahkan teknologi luar ke port.

Inbound adapter:

@RestController
@RequestMapping("/cases")
class CaseEscalationController {
    private final EscalateCaseUseCase useCase;

    CaseEscalationController(EscalateCaseUseCase useCase) {
        this.useCase = useCase;
    }

    @PostMapping("/{caseId}/escalation")
    ResponseEntity<EscalateCaseResponse> escalate(
            @PathVariable String caseId,
            @RequestBody EscalateCaseRequest request
    ) {
        EscalateCaseCommand command = new EscalateCaseCommand(
                CaseId.from(caseId),
                request.reason(),
                request.actorId(),
                request.requestId()
        );

        EscalateCaseResult result = useCase.escalate(command);

        return ResponseEntity.accepted().body(
                EscalateCaseResponse.from(result)
        );
    }
}

Outbound adapter:

@Component
class VendorRiskScoringAdapter implements RiskScoringPort {
    private final VendorRiskHttpClient client;

    VendorRiskScoringAdapter(VendorRiskHttpClient client) {
        this.client = client;
    }

    @Override
    public RiskAssessment assess(CaseSnapshot snapshot) {
        VendorRiskRequest request = VendorRiskRequest.from(snapshot);
        VendorRiskResponse response = client.calculateRisk(request);

        return new RiskAssessment(
                RiskLevel.fromVendorCode(response.levelCode()),
                response.score(),
                response.explanation()
        );
    }
}

Adapter boleh tahu Spring, HTTP, JSON, Kafka, JPA, vendor SDK. Core tidak boleh.


5. Clean Architecture

Clean architecture menekankan dependency rule:

Source code dependencies point inward. Inner policy must not depend on outer mechanism.

Dalam Java microservice, biasanya diterjemahkan seperti ini:

Clean architecture lebih eksplisit tentang policy vs mechanism:

LayerIsiTidak boleh tahu
DomainEntity, value object, invariantSpring, SQL, HTTP, Kafka
ApplicationUse case, orchestration, portController, JPA implementation, vendor DTO
Interface adapterController, repository impl, mapperBusiness decision detail tersembunyi
FrameworkSpring Boot, Jackson, Hibernate, Kafka clientDomain rule

Hexagonal dan Clean Architecture sering overlap. Perbedaannya lebih pada emphasis:

AspekHexagonalClean Architecture
Fokus utamaPorts/adapters around application coreDependency rule and policy isolation
BahasaPort, adapter, input, outputEntity, use case, interface adapter, framework
Bentuk visualCore surrounded by adaptersConcentric circles
Cocok untukMengisolasi integration pointsMenjaga dependency direction dan policy purity

Dalam praktik Java enterprise, kamu tidak perlu fanatik. Kamu bisa memakai Clean Architecture sebagai rule, dan Hexagonal sebagai implementation style.


6. The Golden Rule for Java Microservices

Gunakan rule ini:

Transport depends on application.
Persistence depends on application/domain needs.
External clients depend on application ports.
Application depends on domain and ports.
Domain depends on nothing business-irrelevant.

Diagram dependency yang diinginkan:

Yang harus dihindari:


7. A Practical Java Package Structure

Satu service bernama case-handling-service dapat dimulai seperti ini:

com.acme.casehandling
├── CaseHandlingApplication.java
├── application
│   ├── port
│   │   ├── in
│   │   │   ├── EscalateCaseUseCase.java
│   │   │   └── AssignCaseUseCase.java
│   │   └── out
│   │       ├── CaseRepository.java
│   │       ├── RiskScoringPort.java
│   │       ├── AuditLogPort.java
│   │       └── CaseEventPublisher.java
│   ├── command
│   │   ├── EscalateCaseCommand.java
│   │   └── AssignCaseCommand.java
│   ├── handler
│   │   ├── EscalateCaseHandler.java
│   │   └── AssignCaseHandler.java
│   └── query
│       └── GetCaseSummaryHandler.java
├── domain
│   ├── model
│   │   ├── Case.java
│   │   ├── CaseId.java
│   │   ├── CaseStatus.java
│   │   └── EscalationReason.java
│   ├── event
│   │   └── CaseEscalated.java
│   └── policy
│       └── EscalationPolicy.java
├── adapter
│   ├── in
│   │   ├── web
│   │   │   ├── CaseController.java
│   │   │   └── dto
│   │   └── messaging
│   │       └── CaseCommandConsumer.java
│   └── out
│       ├── persistence
│       │   ├── JpaCaseRepositoryAdapter.java
│       │   ├── CaseJpaEntity.java
│       │   └── SpringDataCaseJpaRepository.java
│       ├── externalrisk
│       │   ├── VendorRiskScoringAdapter.java
│       │   └── VendorRiskClient.java
│       └── event
│           └── KafkaCaseEventPublisher.java
└── config
    ├── TransactionConfig.java
    └── ObservabilityConfig.java

Ini bukan template mutlak. Ini adalah starting point yang mengkomunikasikan dependency direction.


8. Walkthrough: Escalate Case Use Case

Kita mulai dari domain.

8.1 Domain Model

package com.acme.casehandling.domain.model;

import java.time.Instant;
import java.util.Objects;

public final class Case {
    private final CaseId id;
    private CaseStatus status;
    private AssigneeId assigneeId;
    private RiskLevel riskLevel;
    private Instant escalatedAt;

    public Case(CaseId id, CaseStatus status, AssigneeId assigneeId, RiskLevel riskLevel) {
        this.id = Objects.requireNonNull(id);
        this.status = Objects.requireNonNull(status);
        this.assigneeId = assigneeId;
        this.riskLevel = riskLevel;
    }

    public void escalate(EscalationReason reason, RiskAssessment risk, Instant now) {
        if (status == CaseStatus.CLOSED) {
            throw new CaseCannotBeEscalated("Closed case cannot be escalated");
        }
        if (!reason.isValidFor(risk.level())) {
            throw new CaseCannotBeEscalated("Escalation reason is not valid for risk level");
        }

        this.status = CaseStatus.ESCALATED;
        this.riskLevel = risk.level();
        this.escalatedAt = now;
    }

    public CaseSnapshot snapshot() {
        return new CaseSnapshot(id, status, assigneeId, riskLevel);
    }

    public CaseId id() {
        return id;
    }
}

Perhatikan:

  • tidak ada @Entity;
  • tidak ada @Service;
  • tidak ada ResponseEntity;
  • tidak ada KafkaTemplate;
  • tidak ada vendor DTO;
  • rule escalation berada di domain, bukan controller.

Domain model harus bicara business language.

8.2 Application Handler

package com.acme.casehandling.application.handler;

import com.acme.casehandling.application.command.EscalateCaseCommand;
import com.acme.casehandling.application.port.in.EscalateCaseUseCase;
import com.acme.casehandling.application.port.out.AuditLogPort;
import com.acme.casehandling.application.port.out.CaseEventPublisher;
import com.acme.casehandling.application.port.out.CaseRepository;
import com.acme.casehandling.application.port.out.RiskScoringPort;
import com.acme.casehandling.domain.event.CaseEscalated;
import com.acme.casehandling.domain.model.Case;

import java.time.Clock;

public final class EscalateCaseHandler implements EscalateCaseUseCase {
    private final CaseRepository caseRepository;
    private final RiskScoringPort riskScoringPort;
    private final CaseEventPublisher eventPublisher;
    private final AuditLogPort auditLogPort;
    private final Clock clock;

    public EscalateCaseHandler(
            CaseRepository caseRepository,
            RiskScoringPort riskScoringPort,
            CaseEventPublisher eventPublisher,
            AuditLogPort auditLogPort,
            Clock clock
    ) {
        this.caseRepository = caseRepository;
        this.riskScoringPort = riskScoringPort;
        this.eventPublisher = eventPublisher;
        this.auditLogPort = auditLogPort;
        this.clock = clock;
    }

    @Override
    public EscalateCaseResult escalate(EscalateCaseCommand command) {
        Case regulatoryCase = caseRepository.findById(command.caseId())
                .orElseThrow(() -> new CaseNotFound(command.caseId()));

        RiskAssessment risk = riskScoringPort.assess(regulatoryCase.snapshot());

        regulatoryCase.escalate(command.reason(), risk, clock.instant());

        caseRepository.save(regulatoryCase);

        CaseEscalated event = CaseEscalated.from(regulatoryCase, command.requestId());
        eventPublisher.publish(event);

        auditLogPort.record(AuditRecord.caseEscalated(command, risk));

        return EscalateCaseResult.from(regulatoryCase);
    }
}

Application handler mengorkestrasi. Ia tahu urutan use case. Tetapi ia tidak tahu cara HTTP, SQL, Kafka, atau vendor API bekerja.

8.3 Spring Wiring

Spring boleh dipakai. Tetapi jangan biarkan Spring mendefinisikan domain.

@Configuration
class CaseHandlingUseCaseConfig {

    @Bean
    EscalateCaseUseCase escalateCaseUseCase(
            CaseRepository caseRepository,
            RiskScoringPort riskScoringPort,
            CaseEventPublisher eventPublisher,
            AuditLogPort auditLogPort,
            Clock clock
    ) {
        return new EscalateCaseHandler(
                caseRepository,
                riskScoringPort,
                eventPublisher,
                auditLogPort,
                clock
        );
    }
}

Dalam service kecil, kamu boleh memakai @Service langsung di handler. Tetapi untuk service yang ingin menjaga domain purity, explicit wiring membuat dependency lebih terlihat.

Pragmatisnya:

ContextPilihan wajar
Team kecil, service sederhana@Service di application handler masih acceptable
Domain kompleks/regulatory/long-livedExplicit configuration lebih aman
Banyak adapter / vendor / testing seamPorts and adapters lebih kuat
CRUD admin toolLayered architecture sederhana cukup

9. Where Should Transaction Live?

Di Java microservice, transaction boundary sering membuat dependency kacau.

Rule praktis:

Transaction adalah application use case concern, bukan domain concern dan bukan controller concern.

Buruk:

@RestController
@Transactional
class CaseController {
    // business transaction hidden in HTTP layer
}

Lebih baik:

@Service
class TransactionalEscalateCaseUseCase implements EscalateCaseUseCase {
    private final EscalateCaseHandler delegate;

    @Transactional
    @Override
    public EscalateCaseResult escalate(EscalateCaseCommand command) {
        return delegate.escalate(command);
    }
}

Atau cukup:

@Service
class EscalateCaseHandler implements EscalateCaseUseCase {
    @Transactional
    public EscalateCaseResult escalate(EscalateCaseCommand command) {
        // use case transaction boundary
    }
}

Yang penting:

  • transaction membungkus perubahan state lokal;
  • external call yang lambat tidak sembarangan berada di dalam transaction;
  • event publication harus jelas: langsung, outbox, atau post-commit;
  • compensation tidak disamakan dengan database rollback;
  • distributed transaction tidak disembunyikan seolah local transaction.

10. Where Should Mapping Live?

Mapping biasanya terlihat sepele, tapi sering menjadi sumber coupling.

Ada beberapa mapping berbeda:

MappingDariKeLokasi
HTTP request mappingREST DTOCommandinbound web adapter
Persistence mappingJPA/entity/rowDomain modelpersistence adapter
Vendor mappingVendor DTODomain/application typeexternal adapter
Event mappingDomain eventIntegration eventevent adapter / publisher
Query response mappingRead modelAPI responsequery adapter/application depending complexity

Jangan membuat satu CommonMapper raksasa.

Buruk:

com.acme.common.mapper.GlobalMapper

Lebih baik:

adapter.in.web.CaseWebMapper
adapter.out.persistence.CasePersistenceMapper
adapter.out.externalrisk.VendorRiskMapper
adapter.out.event.CaseEventMapper

Kenapa?

Karena setiap mapping melindungi boundary berbeda.


11. Layered vs Hexagonal vs Clean: Decision Guide

Gunakan tabel ini secara praktis.

KondisiArchitecture style yang cocok
Service sangat sederhana, mostly CRUD, umur pendekLayered sederhana
Service punya domain rule, workflow, audit, policyHexagonal/Clean
Banyak external integration/vendorHexagonal kuat
Banyak transport: REST, Kafka, scheduler, batchHexagonal kuat
Domain harus dites tanpa frameworkClean/Hexagonal
Team sering salah dependency directionClean architecture rule eksplisit
Banyak modul dalam servicePackage-by-capability + ports/adapters
Prototype internal kecilJangan over-engineer

Keputusan terbaik sering hybrid:

Package by capability
+ application use cases
+ domain model
+ ports for external dependency
+ adapters for infrastructure
+ pragmatic Spring Boot wiring

12. Common Failure Modes

12.1 Domain Depends on Persistence

@Entity
public class Case {
    @ManyToOne
    private Officer officer;

    public void escalate() {
        // business rule + lazy loading side effect
    }
}

Risiko:

  • lazy loading muncul sebagai business behavior;
  • unit test butuh JPA context;
  • persistence constraint membentuk domain model;
  • aggregate boundary kacau.

Bukan berarti JPA entity selalu buruk. Tetapi untuk domain kompleks, pisahkan persistence entity dari domain object.

12.2 Application Depends on Transport DTO

public EscalateCaseResult escalate(EscalateCaseRequest request) {
    // application layer now depends on REST contract
}

Risiko:

  • REST contract menjadi use case contract;
  • Kafka/scheduler sulit reuse use case;
  • API versioning menyeret application layer.

12.3 Port Leaks Vendor Language

interface PaymentPort {
    StripeChargeResponse charge(StripeChargeRequest request);
}

Risiko:

  • vendor becomes architecture;
  • replacement mahal;
  • domain language kalah oleh SDK language.

Lebih baik:

interface PaymentPort {
    PaymentAuthorization authorize(PaymentAttempt attempt);
}

12.4 Over-Abstracting Everything

Tidak semua dependency perlu port.

Jangan membuat port untuk:

  • Clock jika cukup inject langsung;
  • simple JSON utility;
  • internal pure function;
  • framework object yang tidak bocor keluar adapter;
  • library stabil yang memang bagian dari language/runtime idiom.

Architecture yang baik bukan menambah interface sebanyak mungkin. Architecture yang baik menaruh seam di tempat perubahan benar-benar mungkin terjadi.


13. Testing Implication

Architecture sehat menghasilkan testing pyramid yang lebih murah.

Contoh use case test:

class EscalateCaseHandlerTest {
    private final InMemoryCaseRepository cases = new InMemoryCaseRepository();
    private final FakeRiskScoringPort risk = new FakeRiskScoringPort();
    private final CapturingCaseEventPublisher events = new CapturingCaseEventPublisher();
    private final CapturingAuditLogPort audit = new CapturingAuditLogPort();
    private final Clock clock = Clock.fixed(Instant.parse("2026-07-05T00:00:00Z"), ZoneOffset.UTC);

    @Test
    void escalatesOpenCaseAndPublishesEvent() {
        CaseId caseId = CaseId.newId();
        cases.save(Case.open(caseId));
        risk.nextAssessment(RiskAssessment.high("pattern matched"));

        EscalateCaseHandler handler = new EscalateCaseHandler(cases, risk, events, audit, clock);

        handler.escalate(new EscalateCaseCommand(caseId, EscalationReason.PUBLIC_RISK, "officer-1", "req-123"));

        assertThat(cases.get(caseId).status()).isEqualTo(CaseStatus.ESCALATED);
        assertThat(events.published()).hasSize(1);
        assertThat(audit.records()).hasSize(1);
    }
}

Test ini tidak butuh:

  • Spring Boot context;
  • HTTP server;
  • database;
  • Kafka broker;
  • vendor API.

Itulah payoff dari dependency direction.


14. Architecture Review Checklist

Gunakan checklist ini saat review Java service:

Dependency Direction

  • Apakah domain bebas dari Spring, JPA, HTTP, Kafka, vendor SDK?
  • Apakah application layer memakai command/query/use-case type, bukan REST DTO?
  • Apakah outbound dependency diwakili port yang memakai bahasa domain/application?
  • Apakah adapter bergantung ke port, bukan sebaliknya?

Boundary Quality

  • Apakah controller hanya menerjemahkan transport?
  • Apakah repository implementation hanya persistence concern?
  • Apakah external client response diterjemahkan sebelum masuk core?
  • Apakah event publication memiliki boundary jelas?

Transaction and Side Effects

  • Apakah transaction boundary berada di use case?
  • Apakah external call di dalam transaction memang disengaja?
  • Apakah event publication aman terhadap rollback?
  • Apakah retry/idempotency tidak tersembunyi di adapter secara sembarangan?

Maintainability

  • Apakah package structure mengungkap capability?
  • Apakah mapping tersebar sesuai boundary, bukan global mapper?
  • Apakah domain test bisa berjalan tanpa framework?
  • Apakah ada adapter test untuk integration detail?

15. Practical Heuristics

  1. Kalau class domain punya @Entity, pikirkan dua kali.
  2. Kalau application service menerima HttpServletRequest, arsitektur bocor.
  3. Kalau use case menerima REST DTO, transport sudah masuk core.
  4. Kalau port mengembalikan vendor response, vendor sudah masuk core.
  5. Kalau controller mengandung business decision, use case hilang.
  6. Kalau repository implementation dipanggil langsung dari controller, dependency direction rusak.
  7. Kalau semua disebut Service, language architecture tidak jelas.
  8. Kalau semua dependency diberi interface, mungkin over-engineered.
  9. Kalau tidak bisa mengetes invariant tanpa Spring, domain terlalu framework-coupled.
  10. Kalau mengganti adapter menyebabkan domain berubah, port tidak stabil.

16. Exercise

Ambil satu endpoint dari service nyata, lalu klasifikasikan kodenya:

Endpoint: POST /cases/{caseId}/escalation

Isi tabel:

ConcernLokasi saat iniLokasi idealRisiko
Request validation
Authorization decision
Use case orchestration
Domain invariant
Persistence load/save
External risk call
Event publication
Audit recording
Error mapping

Lalu jawab:

  1. Apa yang saat ini berada di controller tetapi seharusnya di application/domain?
  2. Apa yang saat ini berada di domain tetapi sebenarnya infrastructure detail?
  3. Port apa yang dibutuhkan?
  4. Adapter apa yang dibutuhkan?
  5. Apa yang bisa dites tanpa Spring?
  6. Apa yang memang harus dites dengan integration test?

17. Summary

Layered architecture, hexagonal architecture, dan clean architecture bukan agama. Ketiganya adalah cara mengendalikan complexity.

Kesimpulan praktis:

  • Layered architecture cukup untuk sistem sederhana, tetapi sering berubah menjadi folder convention tanpa dependency discipline.
  • Hexagonal architecture kuat saat service punya banyak integration point dan domain core perlu dilindungi.
  • Clean architecture kuat sebagai rule dependency: policy tidak boleh bergantung pada mechanism.
  • Java microservice production-grade sebaiknya memisahkan transport, application use case, domain rule, dan infrastructure adapter.
  • Port harus memakai bahasa application/domain, bukan bahasa vendor/framework.
  • Adapter adalah tempat teknologi boleh kotor.
  • Domain adalah tempat business invariant harus jernih.

Top engineer tidak memilih pattern karena populer. Mereka memilih struktur yang membuat perubahan mahal tetap terkendali.


References

Lesson Recap

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