Contoh Clean Architecture

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

Clean Architecture merupakan 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. Dengan begini framework jadi lebih mudah dibongkar-pasang dengan minim perubahan pada layer di dalamnya.

Clean Architecture adalah arsitektur desain software yang memisahkan logic bisnis dari detail teknologi agar bagian inti dari software tidak terikat pada framework atau teknologi tertentu.

Clean Architecture

Entities

Entities adalah layer paling dalam yang sangat independen. Ga ada dependensi apa pun seperti frameworks, UI, DB, atau sejenisnya. 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 berisi logic untuk bisnis seperti validasi bisnis, perubahan state, dan aturan bisnis sejenisnya. Selain itu di layer ini juga ada Constant, Enum, Value Objek, dan sejenisnya.

Use Case

Use Case adalah layer kedua yang bergantung pada Entities doang. Layer ini berisi inti dari logic teknis aplikasi dan mengoordinasikan logic dari Entities. Di sini juga ga ada frameworks, UI, DB, atau sejenisnya. Di dalam layer ini terdapat interface sebagai penghubung dengan layer di atasnya. Use Case ga terhubung secara langsung dengan dunia luar, melainkan berinteraksi lewat Port Interface. Misalnya pada flow registrasi tugasnya adalah mengeksekusi logic Entities, cek validasi email unik ke DB, hashing password, men-trigger DB untuk menyimpan data, men-trigger event untuk kirim email, hingga memberikan output.

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, Library lain, 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 cek email harus unik 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. Event Framework juga sama, kita bisa bikin implementasi pake Kafka, RabbitMQ, Redis Stream, atau sejenisnya. Selain itu di sini juga ada implementasi dari Presenter yang berfungsi menerjemahkan output dari Use Case ke berbagai format.

Frameworks & Drivers

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

Contoh Kasus

Kita akan buat flow registrasi user yang simple:

  1. User menginput username & tanggal lahir;
  2. Username ga boleh kosong dan minimal 5 huruf;
  3. Tanggal lahir ga boleh kosong dan minimal 18 tahun;
  4. Cek username harus unik ke DB;
  5. Simpan ke DB dengan status aktif dan tanggal pembuatan waktu sekarang;
  6. Tampilkan hasilnya;

Lalu kita buat flow untuk menampilkan data user berdasarkan username:

  1. User menginput username;
  2. Username ga boleh kosong;
  3. Cari data berdasarkan username;
  4. Tampilkan data username, status, dan tanggal lahir dengan pola EEEE, dd MMMM yyyy;

Struktur Modul

Strukturnya kurang lebih seperti ini:

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

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>user-repository-spring-data-jpa</module>
	<module>user-service-spring-boot-web</module>
</modules>

<properties>
	<java.version>21</java.version>
	<maven.compiler.source>21</maven.compiler.source>
	<maven.compiler.target>21</maven.compiler.target>
</properties>

<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-dependencies</artifactId>
			<version>3.5.0</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>
</dependencyManagement>

Oh ya, Arsitektur ini mengimplementasi Screaming Architecture, jadi di tiap layer nanti struktur foldernya dipisah per fitur.

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 simple tanpa dependency 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");
		}
		if (value.length() < 5) {
			throw new InvalidUsernameException("Username must be at least 5 characters");
		}
	}

}

Begitu juga dengan tanggal lahir:

Domain BirthDate
Copy
public record BirthDate(LocalDate value) {

	public BirthDate{
		if (value == null) {
			throw new IllegalBirthDateException("Birth date must not be blank");
		}
		if (LocalDate.now().minusYears(18).isBefore(value)) {
			throw new IllegalBirthDateException("Birth date must be at least 18 years old");
		}
	}

}

Lanjut dengan UserDomain untuk logic bisnis registrasi dan construct object:

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

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

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

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

	public LocalDate birthDateValue() {
		return birthDate.value();
	}

}

Terakhir kita buat Exception untuk validasi:

InvalidUsernameException
Copy
public class InvalidUsernameException extends RuntimeException {

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

}
IllegalBirthDateException
Copy
public class IllegalBirthDateException extends RuntimeException {

	public IllegalBirthDateException(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, tanggal lahir, dan user kita bedain agar Single Responsibility. Dengan begini tiap kita ingin construct objek UserDomain, maka harus melewati validasi dari Domain Objek Username & BirthDate😎. Ini sebenarnya bagian dari DDD (Domain-Driven Design) dan tergantung kasus. Keduanya punya Domain Objek sendiri karena pada kasus ini dalam keadaan apa pun dan di use case mana pun validasinya harus tetap sama. Validasinya di constructor Domain Objek agar ga ada invalid value yang bisa masuk ke objek ini.

User Use Case

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

user-usecase 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 UserRegistrationRequest kita butuh input username & tanggal lahir:

Record UserRegistrationRequest
Copy
public record UserRegistrationRequest(String username, LocalDate birthDate) {

}

Di dalam record UserRegistrationResponse kita butuh output berupa objek User:

Record UserRegistrationResponse
Copy
public record UserRegistrationResponse(UserDomain user) {

}

Di dalam UserDetailRequest kita butuh input username:

Record UserDetailRequest
Copy
public record UserDetailRequest(String username) {

}

Di dalam UserDetailResponse kita juga butuh output berupa objek User aja. Aslinya ini bisa bermacam data tergantung bisnis, tapi dalam hal ini kita bikin simple aja:

Record UserDetailResponse
Copy
public record UserDetailResponse(UserDomain user) {

}

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

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

Di interface UserDetailGateway ada method untuk mengambil data user berdasarkan username ke DB:

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

Lalu kita bikin interface Presenter untuk formatting hasilnya:

Interface UserRegistrationPresenter
Copy
public interface UserRegistrationPresenter {
	void present(UserRegistrationResponse response);
}
Interface UserDetailPresenter
Copy
public interface UserDetailPresenter {
	void present(UserDetailResponse response);
}

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

Interface UserRegistrationUseCase
Copy
public interface UserRegistrationUseCase {
	void execute(UserRegistrationRequest request, UserRegistrationPresenter presenter);
}
Interface UserDetailUseCase
Copy
public interface UserDetailUseCase {
	void execute(UserDetailRequest request, UserDetailPresenter presenter);
}

Setelah itu kita bikin implementasinya menggunakan Command Design Pattern. Di dalam UserRegistrationDefaultUseCase kita bikin logic aplikasinya. Dalam hal ini kita eksekusi logic bisnis di Domain Objek Username & BirthDate, lalu cek usernamenya lewat Gateway, kemudian simpan ke DB, dan kirim hasilnya lewat Presenter. Untuk mengakses DB kita menggunakan interface yang diinjek sesuai prinsip Dependency Inversion. Use case hanya mengenal interface dan ga peduli implementasinya apa, entah pake Spring Data JPA, jOOQ, QueryDSL, MyBatis, Hibernate, atau apa pun itu. Untuk formatting output ke Controller juga lewat interface. Use Case hanya menyuruh Presenter sesuai Prinsip “Tell, Don't Ask!” untuk formatting data tanpa perlu tahu format akhirnya seperti apa, bisa jadi String, JSON, HTML, atau objek tertentu menyesuaikan framework.

Class UserRegistrationDefaultUseCase
Copy
@RequiredArgsConstructor
public class UserRegistrationDefaultUseCase implements UserRegistrationUseCase {

	private final UserRegistrationGateway userRegistrationGateway;

	@Override
	public void execute(UserRegistrationRequest request, UserRegistrationPresenter presenter) {
		Username username = new Username(request.username());
		BirthDate birthDate = new BirthDate(request.birthDate());
		if (userRegistrationGateway.existsByUsername(username)) {
			throw new UsernameAlreadyTakenException(username);
		}
		UserDomain saved = userRegistrationGateway.save(UserDomain.register(username, birthDate));
		presenter.present(new UserRegistrationResponse(saved));
	}

}

Lalu kita bikin implementasi use case UserDetailDefaultUseCase. Flownya adalah eksekusi logic bisnis di Domain Objek Username, cari datanya lewat Gateway, lalu kirim hasilnya lewat Presenter:

Class UserDetailDefaultUseCase
Copy
@RequiredArgsConstructor
public class UserDetailDefaultUseCase implements UserDetailUseCase {

	private final UserDetailGateway userDetailGateway;

	@Override
	public void execute(UserDetailRequest request, UserDetailPresenter presenter) {
		Username username = new Username(request.username());
		UserDomain user = userDetailGateway.findByUsername(username)
				.orElseThrow(() -> new UserNotFoundException(username));
		presenter.present(new UserDetailResponse(user));
	}

}

User Repository Spring Data JPA

Untuk layer Interface Adapters kita bagi 2 modul. Dalam hal ini kita hanya berinteraksi dengan DB aja. Biasanya selain DB ada infrastruktur lain seperti Redis untuk cache, Kafka untuk Event, dan sebagainya. Kalau butuh tinggal dibikin aja modul implementasinya masing-masing. Pertama kita bikin modul untuk interaksi ke DB pake Spring Data JPA & driver PostgreSql. Kita juga pake Lombok. Di sini ada dependency dengan modul user-domain & user-usecase:

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

<artifactId>user-repository-spring-data-jpa</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>
	</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 JPA 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 LocalDate birthDate;

	@Column(nullable = false)
	private boolean active;

	@Column(nullable = false)
	private Instant createdAt;

}

Lalu kita bikin JPA repositorynya. Ini implementasinya akan dibuat otomatis oleh Spring Data JPA.

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

Lanjut bikin implementasi dari Gateway menggunakan Façade Design Pattern. Pertama UserRegistrationJpaGateway, inilah implementasi dari interface UserRegistrationGateway di modul user-usecase tadi. Tugasnya hanya sebagai perantara ke Spring Data JPA. Ga ada logic bisnis.

Class UserRegistrationJpaGateway
Copy
@RequiredArgsConstructor
public class UserRegistrationJpaGateway implements UserRegistrationGateway {

	private final UserJpaRepository userJpaRepository;

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

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

}

Implementasi UserDetailGatewayImpl juga kurang lebih sama:

Class UserDetailJpaGateway
Copy
@RequiredArgsConstructor
public class UserDetailJpaGateway implements UserDetailGateway {

	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 BirthDate(entity.getBirthDate()),
				entity.isActive(),
				entity.getCreatedAt()
		);
	}

}

User Service Spring Boot Web

Ini juga bagian dari layer Interface Adapters. Tugasnya sebagai perantara dengan Web pake Spring Boot Web. Di sini ada dependency modul-modul di atas:

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

<artifactId>user-service-spring-boot-web</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>user-repository-spring-data-jpa</artifactId>
		<version>${project.version}</version>
	</dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
	</dependency>
</dependencies>

Modul ini isinya Controller sebagai gerbang masuk endpoint dari Web, implementasi Presenter untuk formatting output, Value Objek untuk menyimpan data output, dan konfigurasi framework. Pertama kita konfigurasi dulu objek-objek yang diperlukan:

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

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

}
Class UserWebConfig
Copy
@Configuration
public class UserWebConfig {

	@Bean
	UserRegistrationUseCase userRegistrationUseCase(UserRegistrationGateway userRegistrationGateway) {
		return new UserRegistrationDefaultUseCase(userRegistrationGateway);
	}

	@Bean
	UserRegistrationGateway userRegistrationGateway(UserJpaRepository userJpaRepository){
		return new UserRegistrationJpaGateway(userJpaRepository);
	}

	@Bean
	UserDetailGateway userDetailGateway(UserJpaRepository userJpaRepository){
		return new UserDetailJpaGateway(userJpaRepository);
	}

	@Bean
	UserDetailUseCase userDetailUseCase(UserDetailGateway userDetailGateway) {
		return new UserDetailDefaultUseCase(userDetailGateway);
	}

}

Lalu kita siapin Value Objek untuk response yang berisi username yang berhasil beserta statusnya:

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

}

Pada endpoint detail user kita siapin response username, status, dan tanggal lahir:

Record UserDetailWebResponse
Copy
public record UserDetailWebResponse(String username, String status, String birthDate) {

}

Selanjutnya kita bikin implementasi Presenter dari interface yang ada di use case. Ini berisi tentang formatting output dari use case dan ga ada logic lain. Karena kita menggunakan Spring Boot Web, maka format yang ditampilkan berupa ResponseEntity dengan response status CREATED. Status user yang ingin ditampilkan pada response adalah String seperti “Active” atau “Inactive”:

Class UserRegistrationWebPresenter
Copy
@Getter
public class UserRegistrationWebPresenter implements UserRegistrationPresenter {

	private ResponseEntity<UserRegistrationWebResponse> responseEntity;

	@Override
	public void present(UserRegistrationResponse response) {
		UserDomain user = response.user();
		String status = user.active() ? "Active" : "Inactive";
		responseEntity = ResponseEntity.status(HttpStatus.CREATED)
				.body(new UserRegistrationWebResponse(user.usernameValue(), status));
	}

}

Pada implementasi Presenter detail user, kita ingin menampilkan response status OK, username, status dengan format String, dan tanggal lahir menggunakan pola EEEE, dd MMMM yyyy:

Class UserDetailWebPresenter
Copy
@Getter
public class UserDetailWebPresenter implements UserDetailPresenter{

	private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("EEEE, dd MMMM yyyy");

	private ResponseEntity<UserDetailWebResponse> responseEntity;

	@Override
	public void present(UserDetailResponse response) {
		UserDomain user = response.user();
		String status = user.active() ? "Active" : "Inactive";
		String birthDate = DATE_FORMATTER.format(user.birthDateValue());
		responseEntity = ResponseEntity.ok(new UserDetailWebResponse(user.usernameValue(), status, birthDate));
	}

}

Terakhir kita buat Controller sebagai gerbang masuk endpoint registrasi. Tugasnya hanya untuk mengeksekusi Use Case dan mengembalikan response. Ga ada logic di sini.

Class UserRegistrationController
Copy
@RestController
@RequiredArgsConstructor
public class UserRegistrationController {

	private final UserRegistrationUseCase userRegistrationUseCase;

	@PostMapping("/api/users/register")
	public ResponseEntity<UserRegistrationWebResponse> register(@RequestBody UserRegistrationRequest request) {
		UserRegistrationWebPresenter presenter = new UserRegistrationWebPresenter();
		userRegistrationUseCase.execute(request, presenter);
		return presenter.getResponseEntity();
	}

}

Controller untuk endpoint detail juga kurang lebih sama isinya:

Class UserDetailController
Copy
@RestController
@RequiredArgsConstructor
public class UserDetailController {

	private final UserDetailUseCase userDetailUseCase;

	@GetMapping("/api/users/detail")
	public ResponseEntity<UserDetailWebResponse> getUserDetail(UserDetailRequest request) {
		UserDetailWebPresenter presenter = new UserDetailWebPresenter();
		userDetailUseCase.execute(request, presenter);
		return presenter.getResponseEntity();
	}

}

Migrasi Framework Database ke jOOQ

Kelebihan dari arsitektur ini adalah kita bisa migrasi framework tanpa mengubah logic 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>user-repository-jooq</module>
	<module>user-service-spring-boot-web</module>
</modules>

Setelah itu kita buat modul integrasi ke jOOQ:

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

<artifactId>user-repository-jooq</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>
	</dependency>
	<dependency>
		<groupId>org.jooq</groupId>
		<artifactId>jooq-meta-extensions</artifactId>
		<version>3.19.34</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 user-service-spring-boot-web ganti dependency ke modul jOOQ:

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

Ga lupa juga kita bersihkan config dari Spring Data JPA yang masih tertinggal di modul user-service-spring-boot-web:

Class UserWebApplication
Copy
@SpringBootApplication
public class UserWebApplication{

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

}

Kita hanya perlu bikin implementasi UserRegistrationJooqGateway dari use case di sini dengan jOOQ. Di sini ada objek Users yang digenerate otomatis oleh jOOQ sesuai tabel untuk eksekusi ke DB.

Class UserRegistrationJooqGateway
Copy
@RequiredArgsConstructor
public class UserRegistrationJooqGateway implements UserRegistrationGateway {

	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.usernameValue())
				.set(USERS.BIRTH_DATE, user.birthDateValue())
				.set(USERS.ACTIVE, user.active())
				.set(USERS.CREATED_AT, createdAt)
				.returningResult(USERS.ID, USERS.USERNAME, USERS.BIRTH_DATE, USERS.ACTIVE, USERS.CREATED_AT)
				.fetchOne();

		return UserDomain.construct(
				record.get(USERS.ID),
				new Username(record.get(USERS.USERNAME)),
				new BirthDate(record.get(USERS.BIRTH_DATE)),
				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 UserDetailGatewayJooqImpl:

Class UserDetailJooqGateway
Copy
@RequiredArgsConstructor
public class UserDetailJooqGateway implements UserDetailGateway {

	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.BIRTH_DATE, 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 BirthDate(record.get(USERS.BIRTH_DATE)),
				record.get(USERS.ACTIVE),
				record.get(USERS.CREATED_AT).toInstant(ZoneOffset.UTC)
		);
	}

}

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

Class UserWebConfig
Copy
@Configuration
public class UserWebConfig {

	@Bean
	UserRegistrationUseCase userRegistrationUseCase(UserRegistrationGateway userRegistrationGateway) {
		return new UserRegistrationDefaultUseCase(userRegistrationGateway);
	}

	@Bean
	UserRegistrationGateway userRegistrationGateway(DSLContext dslContext){
		return new UserRegistrationJooqGateway(dslContext);
	}

	@Bean
	UserDetailGateway userDetailGateway(DSLContext dslContext){
		return new UserDetailJooqGateway(dslContext);
	}

	@Bean
	UserDetailUseCase userDetailUseCase(UserDetailGateway userDetailGateway) {
		return new UserDetailDefaultUseCase(userDetailGateway);
	}

}

Meskipun kita ganti framework, perubahannya minim. Bahkan ga ada perubahan di layer Entities & 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>user-repository-jooq</module>
	<module>user-service-spring-boot-cli</module>
</modules>

Setelah itu kita buat pom.xml pake Spring Shell:

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

<artifactId>user-service-spring-boot-cli</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>user-repository-jooq</artifactId>
		<version>${project.version}</version>
	</dependency>
	<dependency>
		<groupId>org.springframework.shell</groupId>
		<artifactId>spring-shell-starter</artifactId>
		<version>3.4.0</version>
	</dependency>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
	</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 UserCliConfig
Copy
@Configuration
public class UserCliConfig {

	@Bean
	UserRegistrationUseCase userRegistrationUseCase(UserRegistrationGateway userRegistrationGateway) {
		return new UserRegistrationDefaultUseCase(userRegistrationGateway);
	}

	@Bean
	UserRegistrationGateway userRegistrationGateway(DSLContext dslContext){
		return new UserRegistrationJooqGateway(dslContext);
	}

	@Bean
	UserDetailGateway userDetailGateway(DSLContext dslContext){
		return new UserDetailJooqGateway(dslContext);
	}

	@Bean
	UserDetailUseCase userDetailUseCase(UserDetailGateway userDetailGateway) {
		return new UserDetailDefaultUseCase(userDetailGateway);
	}

}

Lalu kita bikin implementasi Presenternya:

Class UserRegistrationCliPresenter
Copy
@Getter
public class UserRegistrationCliPresenter implements UserRegistrationPresenter {

	private String output;

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

}

Pada Presenter detail user, kita ingin formatnya berupa String dan pola tanggalnya dd/MM/yyyy. Inilah kelebihan menggunakan Presenter, kita bisa bebas menentukan formatnya mau kayak gimana. Kalau misalkan output yang ditulis di Use Case dalam bentuk String, maka saat kita ingin format berbeda seperti ResponseEntity jadi ribet handlenya.

Class UserDetailCliPresenter
Copy
@Getter
public class UserDetailCliPresenter implements UserDetailPresenter{

	private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy");

	private String output;

	@Override
	public void present(UserDetailResponse response) {
		UserDomain user = response.user();
		output = "User detail:\n"
				+ "  Username  : " + user.usernameValue() + '\n'
				+ "  Status    : " + (user.active() ? "Active" : "Inactive") + '\n'
				+ "  Birth Date: " + DATE_FORMATTER.format(user.birthDateValue()) + '\n';
	}

}

Kemudian tinggal tambahkan Command objek sebagai gerbang masuk CLI command register:

Class UserRegistrationCommand
Copy
@Command
@RequiredArgsConstructor
public class UserRegistrationCommand {

	private final UserRegistrationUseCase userRegistrationUseCase;

	@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 birthDate) {
		UserRegistrationCliPresenter presenter = new UserRegistrationCliPresenter();
		userRegistrationUseCase.execute(new UserRegistrationRequest(username, LocalDate.parse(birthDate)), presenter);
		return presenter.getOutput();
	}

}

Terakhir kita bikin juga Command untuk detail user:

Class UserDetailCommand
Copy
@Command
@RequiredArgsConstructor
public class UserDetailCommand {

	private final UserDetailUseCase userDetailUseCase;

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

}

Sekarang endpoint dari web sudah termigrasi ke CLI.

Penutup

Dengan Clean Architecture modul dapat dibongkar-pasang dengan fleksibel. Efek sampingnya minim. Layer Entities & Use Case diisolasikan dari dunia luar dan hanya 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. Unit testing jadi lebih mudah karena ga ada integrasi Frameworks & Drivers di layer Entities & Use Case.

© 2026 · Ferry Suhandri