Contoh Clean Architecture

Thu. Jun 18th, 2026 09:56 PM20 mins read
Contoh Clean Architecture
Source: Gemini Nano Banana Pro - clean architecture

Clean Architecture adalah evolusi dari Onion Architecture, Hexagonal Architecture, dan Screaming Architecture. Ide ini diperkenalkan oleh Uncle Bob. Di sini modul dibagi jadi beberapa layer. Setidaknya ada 4 layer: Entities, Use Case, Interface Adapters, dan Frameworks & Drivers. Dependensi antar layer selalu mengarah ke dalam. Layer-layer tersebut tidak bergantung pada layer di luarnya. Layer Entities adalah layer paling dalam yang tidak bergantung dengan layer manapun. Dengan arsitektur begini modul jadi lebih mudah dibongkar-pasang dengan minim perubahan terutama dengan layer paling dalam.

Entities

Entities adalah layer paling dalam yang sangat independen. Ga ada dependensi apa pun di sini. Ga ada frameworks, UI, DB, atau sejenisnya. Isinya pure objek Java doang. Entities berisi Domain Objek yang menjadi inti dari bisnis. Contohnya pada E-Commerce berarti layer ini isinya objek SalesOrder, Customer, Product, dan lainnya. Di dalam Domain Objek itu berisi logic untuk bisnis seperti validasi bisnis, perubahan state, dan logic aturan bisnis sejenisnya. Selain itu di dalamnya juga ada Constant, Enum, Value Objek, dan objek sejenis yang bisa di-share antar layer.

Use Case

Use Case adalah layer kedua yang bergantung pada Entities doang. Layer ini berisi logic teknis aplikasi dan mengoordinasikan domain logic dari Entities. Di dalam layer ini juga terdapat interface sebagai port penghubung dengan layer di atasnya. Di sini juga ga ada frameworks, UI, DB, atau sejenisnya. Use Case itu ga terhubung secara langsung dengan dunia luar, melainkan berinteraksi lewat port interface. Inilah manfaat dari Dependency Inversion sesungguhnya. Misalnya pada flow registrasi tugasnya adalah memanggil dan mengeksekusi logic Entities, melakukan validasi untuk database, melakukan hashing password, mengoordinasikan data untuk disimpan ke database, men-trigger event untuk kirim email, hingga memberikan result.

Interface Adapters

Interface Adapters adalah layer ketiga yang bergantung pada Entities & Use Case. Layer ini berisi logic implementasi Framework untuk menghubungkan Use Case dengan dunia luar seperti Web Framework, DB Framework, Event Framework, dan sejenisnya. Sebelumnya kan di layer Use Case terdapat interface sebagai port untuk berinteraksi dengan framework, nah di layer ini kita tulis implementasinya. Misalnya pada fitur registrasi ada flow untuk validasi email ke DB. Di layer Use Case hanya ada interface doang, implementasinya di sini. Implementasinya bisa macam-macam, bisa implementasi pake Spring Data JPA, MyBatis, QueryDSL, jOOQ, dan lainnya. Begitu juga dengan Controller, kita bisa bikin implementasi pake Spring Boot, Quarkus, Micronaut, Java EE, atau lainnya di sini. Event Framework juga sama, kita bisa implementasi pake Kafka, RabbitMQ, Redis Stream, atau sejenisnya. Selain itu di sini juga ada implementasi dari Presenter yang berfungsi menerjemahkan output dari Use Case.

Frameworks & Drivers

Bagian ini adalah layer terluar. Ini isinya adalah Framework, Library, dan Driver itu sendiri seperti Spring Boot, Quarkus, Micronaut, MyBatis, QueryDSL, jOOQ, PostgreSql, MySql, Redis, Cassandra, Kafka, RabbitMQ, atau sejenisnya. Bagian ini biasanya kita tinggal pake yang udah ada aja.

Contoh Kasus

Sekarang kita ke contoh kasusnya. Kita akan menggunakan flow Registrasi User aja yang simple:

  1. User mendaftarkan diri lewat web;
  2. User input username & password;
  3. Usernama ga boleh kosong;
  4. Password ga boleh kosong, minimal 8 karakter, dan harus ada minimal 1 angka dan 1 huruf;
  5. Username harus unik;
  6. Simpan ke DB dengan status aktif dan created at waktu sekarang;
  7. Tampilkan hasilnya;

Lalu kita buat flow untuk get User by username:

  1. User menginput username;
  2. Username ga boleh kosong;
  3. Cari data berdasarkan username;
  4. Tampilkan data username, status, dan created at dengan format EEEE, dd MMMM yyyy 'at' HH:mm dalam zona Jakarta;

Struktur Modul

Strukturnya kurang lebih seperti ini:

Copy
so-clean/
├── user-domain/
├── user-usecase/
├── spring-data-jpa-user-repository/
└── spring-boot-user-service/

Parent pom.xml nya kurang lebih begini:

parent pom.xml
Copy
<groupId>com.example</groupId>
<artifactId>so-clean</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>

<modules>
	<module>user-domain</module>
	<module>user-usecase</module>
	<module>spring-data-jpa-user-repository</module>
	<module>spring-boot-user-service</module>
</modules>

<properties>
	<java.version>21</java.version>
	<maven.compiler.source>21</maven.compiler.source>
	<maven.compiler.target>21</maven.compiler.target>
	<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	<spring-boot.version>3.5.0</spring-boot.version>
	<jooq.version>3.19.34</jooq.version>
	<lombok.version>1.18.38</lombok.version>
</properties>

User Domain

User Domain adalah layer Entities. Isinya berupa Domain Objek, Constant, Enum, Business Exception, dan Value Objek. Karena isinya pure Java objek doang, jadi pom.xml nya juga simple tanpa library dan dependensi apa pun.

user-domain pom.xml
Copy
<parent>
	<groupId>com.example</groupId>
	<artifactId>so-clean</artifactId>
	<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>user-domain</artifactId>

Lalu kita bikin Domain Objek Username untuk validasi username:

Domain Username
Copy
public record Username(String value) {

	public Username {
		if (value == null || value.isBlank()) {
			throw new InvalidUsernameException("Username must not be blank");
		}
	}

}

Begitu juga untuk validasi password:

Domain Password
Copy
public record Password(String value) {

	public Password {
		if (value == null || value.isBlank()) {
			throw new WeakPasswordException("Password must not be blank");
		}
		if (value.length() < 8) {
			throw new WeakPasswordException("Password must be at least 8 characters");
		}
		if (!value.matches(".*[a-zA-Z].*") || !value.matches(".*\\d.*")) {
			throw new WeakPasswordException("Password must contain at least one letter and one number");
		}
	}

}

Lanjut dengan UserDomain untuk logic bisnis registrasi dan cosntruct object:

Domain UserDomain
Copy
public record UserDomain(Long id, Username username, Password password, boolean active, Instant createdAt) {

	public static UserDomain register(Username username, Password password) {
		return new UserDomain(null, username, password, true, Instant.now());
	}

	public static UserDomain construct(Long id, Username username, Password password, boolean active, Instant createdAt) {
		return new UserDomain(id, username, password, active, createdAt);
	}

	public String usernameValue() {
		return username.value();
	}

	public String passwordValue() {
		return password.value();
	}

}

Terakhir kita buat Exception untuk validasi:

InvalidUsernameException
Copy
public class InvalidUsernameException extends RuntimeException {

	public InvalidUsernameException(String message) {
		super(message);
	}

}
WeakPasswordException
Copy
public class WeakPasswordException extends RuntimeException {

	public WeakPasswordException(String message) {
		super(message);
	}

}
UsernameAlreadyTakenException
Copy
public class UsernameAlreadyTakenException extends RuntimeException {

	public UsernameAlreadyTakenException(Username username) {
		super("Username already taken: " + username.value());
	}

}
UserNotFoundException
Copy
public class UserNotFoundException extends RuntimeException {

	public UserNotFoundException(Username username) {
		super("User not found: " + username.value());
	}

}

Domain antara Username, Password, dan User kita bedain agar Single Responsibility. Dengan begini tiap kita ingin construct objek User, maka harus melewati validasi dari Domain Objek Username & Password. Ini sebenarnya bagian dari DDD, mungkin lain waktu gw bakal bahas DDD juga.

User Use Case

Modul ini adalah layer Use Case. Di sini isinya Use Case, Value Objek sebagai DTO, serta Port Interface untuk terhubung dengan layer Interface Adapters. Isi pom.xml nya juga simple, hanya dependensi ke modul user-domain dan gw pake Project Lombok untuk mengurangi boilerplate.

user use case pom.xml
Copy
<parent>
	<groupId>com.example</groupId>
	<artifactId>so-clean</artifactId>
	<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>user-usecase</artifactId>

<dependencies>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-domain</artifactId>
		<version>${project.version}</version>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
	</dependency>
</dependencies>

Pertama kita buat Value Objek terlebih dahulu untuk komunikasi data antar layer. Di dalam record RegisterUserRequest kita butuh request username & password:

Record RegisterUserRequest
Copy
public record RegisterUserRequest(String username, String password) {

}

Di dalam record RegisterUserResult kita butuh result berupa objek User:

Record RegisterUserResult
Copy
public record RegisterUserResult(UserDomain user) {

}

Di dalam GetUserDetailRequest kita butuh request username:

Record GetUserDetailRequest
Copy
public record GetUserDetailRequest(String username) {

}

Di dalam GetUserDetailResult kita juga butuh result berupa objek User aja. Aslinya result ini bisa bermacam data sih yang ditampilkan tergantung bisnis, tapi dalam hal ini kita bikin simple dulu aja:

Record GetUserDetailResult
Copy
public record GetUserDetailResult(UserDomain user) {

}

Lanjut kita buat interface Gateway untuk terhubung dengan koneksi dunia luar seperti DB, API luar, Event, atau sebagainya di layer Interface Adapters. Di interface RegisterUserGateway ada fungsi untuk menyimpan data user dan ngecek username ke DB:

Interface RegisterUserGateway
Copy
public interface RegisterUserGateway {
	UserDomain save(UserDomain user);
	boolean existsByUsername(Username username);
}

Di interface GetUserDetailGateway ada fungsi untuk mengambil data user berdasarkan username ke DB:

Interface GetUserDetailGateway
Copy
public interface GetUserDetailGateway {
	Optional<UserDomain> findByUsername(Username username);
}

Lalu kita bikin interface Presenter masing-masing untuk terhubung dengan Controller:

Interface RegisterUserPresenter
Copy
public interface RegisterUserPresenter {
	void present(RegisterUserResult result);
}
Interface GetUserDetailPresenter
Copy
public interface GetUserDetailPresenter {
	void present(GetUserDetailResult result);
}

Terakhir kita bikin Use Casenya. Kita perlu bikin interfacenya juga biar suatu saat jika ada varian baru kita bisa bikin implementasi baru dengan interface yang sama:

Interface RegisterUserUseCase
Copy
public interface RegisterUserUseCase {
	void execute(RegisterUserRequest request, RegisterUserPresenter presenter);
}
Interface GetUserDetailUseCase
Copy
public interface GetUserDetailUseCase {
	void execute(GetUserDetailRequest request, GetUserDetailPresenter presenter);
}

Baru setelah itu kita bikin implementasinya. Di dalam RegisterUserUseCaseImpl kita bikin logic aplikasinya. Dalam hal ini kita eksekusi logic bisnis di Domain Objek Username & Password, lalu cek usernamenya di DB, kemudian simpan ke DB, dan kirim hasilnya lewat Presenter. Di sini untuk mengakses ke DB kita menggunakan interface yang diinjek sesuai prinsip Dependency Inversion. Use case hanya mengenal interface, mereka ga peduli implementasinya apa, entah pake Spring Data JPA, jOOQ, QueryDSL, MyBatis, Hibernate, atau apa pun itu. Untuk formatting data ke Controller juga lewat interface. Kita menggunakan prinsip “Tell, Don't Ask”, jadi hasilnya ga di-return dari method, tapi dialihkan ke Presenter. Use case ga perlu tahu format akhirnya seperti apa, bisa jadi String, json, HTML, tanggal dengan format tertentu, atau format lainnya. Tugas tersebut dialihkan ke Presenter agar Single Responsibility.

Class RegisterUserUseCaseImpl
Copy
@RequiredArgsConstructor
public class RegisterUserUseCaseImpl implements RegisterUserUseCase {

	private final RegisterUserGateway registerUserGateway;

	@Override
	public void execute(RegisterUserRequest request, RegisterUserPresenter presenter) {
		Username username = new Username(request.username());
		Password password = new Password(request.password());
		if (registerUserGateway.existsByUsername(username)) {
			throw new UsernameAlreadyTakenException(username);
		}
		UserDomain saved = registerUserGateway.save(UserDomain.register(username, password));
		presenter.present(new RegisterUserResult(saved));
	}

}

Lalu kita bikin juga implementasi use case GetUserDetailUseCaseImpl. Flownya eksekusi logic bisnis di Domain Objek Username, cari datanya lewat Gateway, lalu tampilkan hasilnya lewat Presenter.

Class GetUserDetailUseCaseImpl
Copy
@RequiredArgsConstructor
public class GetUserDetailUseCaseImpl implements GetUserDetailUseCase {

	private final GetUserDetailGateway getUserDetailGateway;

	@Override
	public void execute(GetUserDetailRequest request, GetUserDetailPresenter presenter) {
		Username username = new Username(request.username());
		UserDomain user = getUserDetailGateway.findByUsername(username)
				.orElseThrow(() -> new UserNotFoundException(username));
		presenter.present(new GetUserDetailResult(user));
	}

}

User Repository Spring Data JPA

Untuk layer Interface Adapters kita bagi 2. Pertama kita bikin dulu modul untuk koneksi ke DB. Kita menggunakan Spring Data JPA. Kita pake driver PostgreSql untuk DB. Kita juga pake Project Lombok. Di sini ada dependency dengan modul user-domain & user-usecase:

spring data jpa user repository pom.xml
Copy
<parent>
	<groupId>com.example</groupId>
	<artifactId>so-clean</artifactId>
	<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-data-jpa-user-repository</artifactId>

<dependencies>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-domain</artifactId>
		<version>${project.version}</version>
	</dependency>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-usecase</artifactId>
		<version>${project.version}</version>
	</dependency>

	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<scope>provided</scope>
	</dependency>

	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-jpa</artifactId>
	</dependency>
	<dependency>
		<groupId>org.postgresql</groupId>
		<artifactId>postgresql</artifactId>
		<scope>runtime</scope>
	</dependency>
</dependencies>

Kita bikin dulu Data Objek Entity buat nyimpan data ke DB:

Class UserEntity
Copy
@Entity
@Table(name = "users")
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public class UserEntity {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(nullable = false, unique = true)
	private String username;

	@Column(nullable = false)
	private String password;

	@Column(nullable = false)
	private boolean active;

	@Column(nullable = false)
	private Instant createdAt;

}

Lalu kita bikin JPA repositorynya. Ini ga ada implementasinya karena nanti akan dibuat oleh Spring Data JPA lewat Proxy secara otomatis.

Interface UserJpaRepository
Copy
public interface UserJpaRepository extends JpaRepository<UserEntity, Long> {
	boolean existsByUsername(String username);
	Optional<UserEntity> findByUsername(String username);
}

Lanjut bikin implementasi dari Gateway. Pertama RegisterUserGatewayImpl, inilah implementasi dari interface RegisterUserGateway di modul user-usecase tadi. Lewat inilah use case akan berinteraksi dengan DB tanpa mengenal detail implementasinya. Isinya hanya sebagai perantara ke Spring Data JPA dan mapping value UserDomain dengan Entity DB. Ga ada logic bisnis.

Class RegisterUserGatewayImpl
Copy
@RequiredArgsConstructor
public class RegisterUserGatewayImpl implements RegisterUserGateway {

	private final UserJpaRepository userJpaRepository;

	@Override
	public UserDomain save(UserDomain user) {
		UserEntity entity = new UserEntity();
		entity.setUsername(user.usernameValue());
		entity.setPassword(user.passwordValue());
		entity.setActive(user.active());
		entity.setCreatedAt(user.createdAt());
		UserEntity saved = userJpaRepository.save(entity);
		return UserDomain.construct(
				saved.getId(),
				new Username(saved.getUsername()),
				new Password(saved.getPassword()),
				saved.isActive(),
				saved.getCreatedAt()
		);
	}

	@Override
	public boolean existsByUsername(Username username) {
		return userJpaRepository.existsByUsername(username.value());
	}

}

Implementasi GetUserDetailGatewayImpl juga kurang lebih sama:

Class GetUserDetailGatewayImpl
Copy
@RequiredArgsConstructor
public class GetUserDetailGatewayImpl implements GetUserDetailGateway {

	private final UserJpaRepository userJpaRepository;

	@Override
	public Optional<UserDomain> findByUsername(Username username) {
		return userJpaRepository.findByUsername(username.value())
				.map(this::toUser);
	}

	private UserDomain toUser(UserEntity entity) {
		return UserDomain.construct(
				entity.getId(),
				new Username(entity.getUsername()),
				new Password(entity.getPassword()),
				entity.isActive(),
				entity.getCreatedAt()
		);
	}

}

User Service Spring Boot Web

Ini juga bagian dari layer Interface Adapters. Tugasnya adalah untuk terhubung dengan Web. Dalam hal ini kita make Spring Boot Web. Di dalam modul ini ada dependency modul-modul di atas:

spring boot user service pom.xml
Copy
<parent>
	<groupId>com.example</groupId>
	<artifactId>so-clean</artifactId>
	<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-boot-user-service</artifactId>

<dependencies>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-domain</artifactId>
		<version>${project.version}</version>
	</dependency>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-usecase</artifactId>
		<version>${project.version}</version>
	</dependency>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>spring-data-jpa-user-repository</artifactId>
		<version>${project.version}</version>
	</dependency>
		<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
</dependencies>

Modul ini isinya Controller sebagai gerbang masuk endpoint dari Web, implementasi Presenter untuk formatting response, Value Objek untuk menyimpan data response, dan config-config framework. Pertama kita setup dulu objek-objek yang perlu dikonfigurasi:

Class UserApplication
Copy
@SpringBootApplication
@EntityScan("com.example.soclean.repository")
@EnableJpaRepositories("com.example.soclean.repository")
public class UserApplication{

	public static void main(String[] args) {
		SpringApplication.run(UserApplication.class, args);
	}

}
Class UserConfig
Copy
@Configuration
public class UserConfig {

	@Bean
	public RegisterUserUseCase registerUserUseCase(RegisterUserGateway registerUserGateway) {
		return new RegisterUserUseCaseImpl(registerUserGateway);
	}

	@Bean
	RegisterUserGateway registerUserGateway(UserJpaRepository userJpaRepository){
		return new RegisterUserGatewayImpl(userJpaRepository);
	}

	@Bean
	GetUserDetailGateway userDetailGateway(UserJpaRepository userJpaRepository){
		return new GetUserDetailGatewayImpl(userJpaRepository);
	}

	@Bean
	public GetUserDetailUseCase getUserDetailUseCase(GetUserDetailGateway getUserDetailGateway) {
		return new GetUserDetailUseCaseImpl(getUserDetailGateway);
	}

}

Lalu kita siapin Value Objek untuk response. Dalam hal ini pada registrasi kita kirimkan response username yang berhasil beserta statusnya:

Record RegisterUserResponse
Copy
public record RegisterUserResponse(String username, String status) {

}

Pada endpoint detail user kita kirimkan response username, status, dan waktu registrasi:

Record GetUserDetailResponse
Copy
public record GetUserDetailResponse(String username, String status, String createdAt) {

}

Selanjutnya kita bikin implementasi Presenter dari interface yang ada di use case. Ini berisi tentang formatting hasil eksekusi dari use case dan ga ada logic lain. Dalam hal ini, dari domain use case status yang dikirim adalah boolean, tapi yang ingin ditampilkan pada response adalah String seperti “Active” atau “Inactive”. Di implementasi Presenterlah hal itu dilakukan:

Class RegisterUserWebPresenter
Copy
@Getter
public class RegisterUserWebPresenter implements RegisterUserPresenter {

	private RegisterUserResponse response;

	@Override
	public void present(RegisterUserResult result) {
		UserDomain user = result.user();
		String status = user.active() ? "Active" : "Inactive";
		response = new RegisterUserResponse(user.username().value(), status);
	}

}

Pada implementasi Presenter detail user, kita ingin menampilkan username, status dengan format String, dan tanggal pembuatan menggunakan pola EEEE, dd MMMM yyyy 'at' HH:mm dengan zona Jakarta:

Class GetUserDetailPresenterImpl
Copy
@Getter
public class GetUserDetailPresenterImpl implements GetUserDetailPresenter {

	private static final DateTimeFormatter DATE_FORMATTER =
			DateTimeFormatter.ofPattern("EEEE, dd MMMM yyyy 'at' HH:mm").withZone(ZoneId.of("Asia/Jakarta"));

	private GetUserDetailResponse response;

	@Override
	public void present(GetUserDetailResult result) {
		UserDomain user = result.user();
		String status = user.active() ? "Active" : "Inactive";
		String createdAt = DATE_FORMATTER.format(user.createdAt());
		response = new GetUserDetailResponse(user.username().value(), status, createdAt);
	}

}

Terakhir kita buat Controller sebagai gerbang masuk endpoint registrasi. Tugasnya hanya untuk mengeksekusi use case dan mendapatkan response untuk dikirimkan ke user.

Class RegisterUserController
Copy
@RestController
@RequiredArgsConstructor
public class RegisterUserController {

	private final RegisterUserUseCase registerUserUseCase;

	@PostMapping("/api/users/register")
	public ResponseEntity<RegisterUserResponse> register(@RequestBody RegisterUserRequest request) {
		RegisterUserWebPresenter presenter = new RegisterUserWebPresenter();
		registerUserUseCase.execute(request, presenter);
		return ResponseEntity.status(HttpStatus.CREATED).body(presenter.getResponse());
	}

}

Controller untuk endpoint detail juga kurang lebih sama isinya:

Class GetUserDetailController
Copy
@RestController
@RequiredArgsConstructor
public class GetUserDetailController {

	private final GetUserDetailUseCase getUserDetailUseCase;

	@GetMapping("api/users/detail")
	public ResponseEntity<GetUserDetailResponse> getUserDetail(GetUserDetailRequest request) {
		GetUserDetailPresenterImpl presenter = new GetUserDetailPresenterImpl();
		getUserDetailUseCase.execute(request, presenter);
		return ResponseEntity.ok(presenter.getResponse());
	}

}

Migrasi Framework Database ke jOOQ

Kelebihan dari arsitektur ini adalah kita bisa migrasi framework tanpa mengubah logic bisnis di modul Entities maupun Use Case. Misalkan kita ingin migrasi dari Spring Data JPA ke jOOQ. Kita tinggal bikin modul baru:

parent pom.xml
Copy
<modules>
	<module>user-domain</module>
	<module>user-usecase</module>
	<module>jooq-user-repository</module>
	<module>spring-boot-user-service</module>
</modules>

Setelah itu kita buat pom.xml pake jOOQ. Dependencynya kurang lebih sama dengan modul Spring Data JPA tapi kali ini dengan jOOQ:

jooq user repository pom.xml
Copy
<parent>
	<groupId>com.example</groupId>
	<artifactId>so-clean</artifactId>
	<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>jooq-user-repository</artifactId>

<dependencies>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-domain</artifactId>
		<version>${project.version}</version>
	</dependency>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-usecase</artifactId>
		<version>${project.version}</version>
	</dependency>

	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<scope>provided</scope>
	</dependency>

	<dependency>
		<groupId>org.jooq</groupId>
		<artifactId>jooq-meta-extensions</artifactId>
		<version>${jooq.version}</version>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-jooq</artifactId>
	</dependency>
	<dependency>
		<groupId>org.postgresql</groupId>
		<artifactId>postgresql</artifactId>
		<scope>runtime</scope>
	</dependency>
</dependencies>

Lalu di modul spring-boot-user-service perlu kita ganti dependencynya ke modul jOOQ di atas:

spring boot user service pom.xml
Copy
<dependency>
	<groupId>com.example</groupId>
	<artifactId>jooq-user-repository</artifactId>
	<version>${project.version}</version>
</dependency>

Ga lupa juga kita bersihkan config yang masih tertinggal di modul spring-boot-user-service:

Class UserApplication
Copy
@SpringBootApplication
public class UserApplication{

	public static void main(String[] args) {
		SpringApplication.run(UserApplication.class, args);
	}

}

Kita hanya perlu bikin implementasi RegisterUserGatewayJooqImpl dari use case di sini dengan jOOQ:

Class RegisterUserGatewayJooqImpl
Copy
@RequiredArgsConstructor
public class RegisterUserGatewayJooqImpl implements RegisterUserGateway {

	private static final Users USERS = Users.USERS;

	private final DSLContext dsl;

	@Override
	public UserDomain save(UserDomain user) {
		LocalDateTime createdAt = LocalDateTime.ofInstant(user.createdAt(), ZoneOffset.UTC);
		Record record = dsl.insertInto(USERS)
				.set(USERS.USERNAME, user.username().value())
				.set(USERS.PASSWORD, user.password().value())
				.set(USERS.ACTIVE, user.active())
				.set(USERS.CREATED_AT, createdAt)
				.returningResult(USERS.ID, USERS.USERNAME, USERS.PASSWORD, USERS.ACTIVE, USERS.CREATED_AT)
				.fetchOne();

		return UserDomain.construct(
				record.get(USERS.ID),
				new Username(record.get(USERS.USERNAME)),
				new Password(record.get(USERS.PASSWORD)),
				record.get(USERS.ACTIVE),
				record.get(USERS.CREATED_AT).toInstant(ZoneOffset.UTC)
		);
	}

	@Override
	public boolean existsByUsername(Username username) {
		return dsl.fetchExists(
				dsl.selectOne().from(USERS).where(USERS.USERNAME.eq(username.value()))
		);
	}

}

Begitu juga dengan GetUserDetailGatewayJooqImpl:

Class GetUserDetailGatewayJooqImpl
Copy
@RequiredArgsConstructor
public class GetUserDetailGatewayJooqImpl implements GetUserDetailGateway {

	private static final Users USERS = Users.USERS;

	private final DSLContext dsl;

	@Override
	public Optional<UserDomain> findByUsername(Username username) {
		return dsl.select(USERS.ID, USERS.USERNAME, USERS.PASSWORD, USERS.ACTIVE, USERS.CREATED_AT)
				.from(USERS)
				.where(USERS.USERNAME.eq(username.value()))
				.fetchOptional()
				.map(this::toUser);
	}

	private UserDomain toUser(Record record) {
		return UserDomain.construct(
				record.get(USERS.ID),
				new Username(record.get(USERS.USERNAME)),
				new Password(record.get(USERS.PASSWORD)),
				record.get(USERS.ACTIVE),
				record.get(USERS.CREATED_AT).toInstant(ZoneOffset.UTC)
		);
	}

}

Di config Spring Bean di modul spring-boot-user-service, kita ganti implementasinya menggunakan jOOQ di atas:

Class UserConfig
Copy
@Configuration
public class UserConfig {

	@Bean
	public RegisterUserUseCase registerUserUseCase(RegisterUserGateway registerUserGateway) {
		return new RegisterUserUseCaseImpl(registerUserGateway);
	}

	@Bean
	RegisterUserGateway registerUserGateway(DSLContext dslContext){
		return new RegisterUserGatewayJooqImpl(dslContext);
	}

	@Bean
	GetUserDetailGateway userDetailGateway(DSLContext dslContext){
		return new GetUserDetailGatewayJooqImpl(dslContext);
	}

	@Bean
	public GetUserDetailUseCase getUserDetailUseCase(GetUserDetailGateway getUserDetailGateway) {
		return new GetUserDetailUseCaseImpl(getUserDetailGateway);
	}

}

Meskipun kita ganti framework, perubahannya minim, bahkan ga ada perubahan di layer Entities dan Use Case.

Migrasi Spring Boot Web ke Spring Shell

Migrasi selanjutnya kita ingin menggunakan CLI untuk mengakses aplikasi. Kita tinggal bikin modul baru pake framework Spring Shell:

parent pom.xml
Copy
<modules>
	<module>user-domain</module>
	<module>user-usecase</module>
	<module>jooq-user-repository</module>
	<module>spring-boot-cli-user-service</module>
</modules>

Setelah itu kita buat pom.xml pake Spring Shell:

spring boot cli user service pom.xml
Copy
<parent>
	<groupId>com.example</groupId>
	<artifactId>so-clean</artifactId>
	<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-boot-cli-user-service</artifactId>

<dependencies>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-domain</artifactId>
		<version>${project.version}</version>
	</dependency>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>user-usecase</artifactId>
		<version>${project.version}</version>
	</dependency>
	<dependency>
		<groupId>com.example</groupId>
		<artifactId>jooq-user-repository</artifactId>
		<version>${project.version}</version>
	</dependency>

	<dependency>
		<groupId>org.springframework.shell</groupId>
		<artifactId>spring-shell-starter</artifactId>
		<version>3.4.0</version>
	</dependency>
</dependencies>

Kita setup dulu confignya:

Class UserCliApplication
Copy
@SpringBootApplication
@CommandScan
public class UserCliApplication{

	public static void main(String[] args) {
		SpringApplication.run(UserCliApplication.class, args);
	}

}
Class UserConfig
Copy
@Configuration
public class UserConfig {

	@Bean
	public RegisterUserUseCase registerUserUseCase(RegisterUserGateway registerUserGateway) {
		return new RegisterUserUseCaseImpl(registerUserGateway);
	}

	@Bean
	RegisterUserGateway registerUserGateway(DSLContext dslContext){
		return new RegisterUserGatewayJooqImpl(dslContext);
	}

	@Bean
	GetUserDetailGateway userDetailGateway(DSLContext dslContext){
		return new GetUserDetailGatewayJooqImpl(dslContext);
	}

	@Bean
	public GetUserDetailUseCase getUserDetailUseCase(GetUserDetailGateway getUserDetailGateway) {
		return new GetUserDetailUseCaseImpl(getUserDetailGateway);
	}

}

Lalu kita bikin implementasi Presenternya. Misalnya dalam hal ini kita ingin bentuknya String karena ini CLI:

Class RegisterUserCliPresenter
Copy
@Getter
public class RegisterUserCliPresenter implements RegisterUserPresenter {

	private String output;

	@Override
	public void present(RegisterUserResult result) {
		UserDomain user = result.user();
		output = "User registered successfully!\n"
				+ "  Username  : " + user.username().value() + '\n'
				+ "  Status    : " + (user.active() ? "Active" : "Inactive") + '\n';
	}

}

Pada Presenter detail user, kita ingin implementasinya berupa String dan format tanggalnya dd/MM/yyyy HH:mm:ss. Inilah kelebihan menggunakan Presenter, kita bisa bebas menentukan formatnya mau kayak gimana. Kalau misalkan formatting sebelumnya ditulis di Use Case, maka saat kita ingin format berbeda jadi ribet.

Class GetUserDetailCliPresenter
Copy
@Getter
public class GetUserDetailCliPresenter implements GetUserDetailPresenter {

	private static final DateTimeFormatter DATE_FORMATTER =
			DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss").withZone(ZoneId.of("Asia/Jakarta"));

	private String output;

	@Override
	public void present(GetUserDetailResult result) {
		UserDomain user = result.user();
		output = "User detail:\n"
				+ "  Username  : " + user.username().value() + '\n'
				+ "  Status    : " + (user.active() ? "Active" : "Inactive") + '\n'
				+ "  Created at: " + DATE_FORMATTER.format(user.createdAt());
	}

}

Kemudian tinggal tambahkan Command objek sebagai gerbang masuknya perintah CLI untuk command register:

Class RegisterUserCommand
Copy
@Command
@RequiredArgsConstructor
public class RegisterUserCommand {

	private final RegisterUserUseCase registerUserUseCase;

	@Command(command = "register-user", description = "Register a new user")
	public String register(@Option(arity = OptionArity.EXACTLY_ONE) String username,
						  @Option(arity = OptionArity.EXACTLY_ONE) String password) {
		RegisterUserCliPresenter presenter = new RegisterUserCliPresenter();
		registerUserUseCase.execute(new RegisterUserRequest(username, password), presenter);
		return presenter.getOutput();
	}

}

Terakhir kita bikin juga Command untuk detail user:

Class GetUserDetailCommand
Copy
@Command
@RequiredArgsConstructor
public class GetUserDetailCommand {

	private final GetUserDetailUseCase getUserDetailUseCase;

	@Command(command = "get-detail-user", description = "Get user detail by username")
	public String getDetail(@Option(arity = OptionArity.EXACTLY_ONE) String username) {
		GetUserDetailCliPresenter presenter = new GetUserDetailCliPresenter();
		getUserDetailUseCase.execute(new GetUserDetailRequest(username), presenter);
		return presenter.getOutput();
	}

}

Sekarang endpoint dari web sudah termigrasi ke CLI.

Penutup

Dengan Clean Architecture modul dapat dibongkar-pasang dengan fleksibel. Efek samping perubahannya minim. Hanya layer terluar saja yang terdampak tanpa menyentuh layer Entities & Use Case. Kedua modul itu diisolasikan dari dunia luar dan hanya tau berinteraksi lewat port interface. Layernya mengarah ke dalam dan ga bisa berinteraksi langsung dengan layer di atasnya tanpa port interface. Tiap layer hanya bekerja sesuai tugasnya masing-masing.

© 2026 · Ferry Suhandri