Contoh 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:
- User mendaftarkan diri lewat web;
- User input username & password;
- Usernama ga boleh kosong;
- Password ga boleh kosong, minimal 8 karakter, dan harus ada minimal 1 angka dan 1 huruf;
- Username harus unik;
- Simpan ke DB dengan status aktif dan created at waktu sekarang;
- Tampilkan hasilnya;
Lalu kita buat flow untuk get User by username:
- User menginput username;
- Username ga boleh kosong;
- Cari data berdasarkan username;
- Tampilkan data username, status, dan created at dengan format
EEEE, dd MMMM yyyy 'at' HH:mmdalam zona Jakarta;
Struktur Modul
Strukturnya kurang lebih seperti ini:
so-clean/
├── user-domain/
├── user-usecase/
├── spring-data-jpa-user-repository/
└── spring-boot-user-service/Parent pom.xml nya kurang lebih begini:
<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.
<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:
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:
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:
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:
public class InvalidUsernameException extends RuntimeException {
public InvalidUsernameException(String message) {
super(message);
}
}public class WeakPasswordException extends RuntimeException {
public WeakPasswordException(String message) {
super(message);
}
}public class UsernameAlreadyTakenException extends RuntimeException {
public UsernameAlreadyTakenException(Username username) {
super("Username already taken: " + username.value());
}
}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.
<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:
public record RegisterUserRequest(String username, String password) {
}Di dalam record RegisterUserResult kita butuh result berupa objek User:
public record RegisterUserResult(UserDomain user) {
}Di dalam GetUserDetailRequest kita butuh request username:
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:
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:
public interface RegisterUserGateway {
UserDomain save(UserDomain user);
boolean existsByUsername(Username username);
}Di interface GetUserDetailGateway ada fungsi untuk mengambil data user berdasarkan username ke DB:
public interface GetUserDetailGateway {
Optional<UserDomain> findByUsername(Username username);
}Lalu kita bikin interface Presenter masing-masing untuk terhubung dengan Controller:
public interface RegisterUserPresenter {
void present(RegisterUserResult result);
}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:
public interface RegisterUserUseCase {
void execute(RegisterUserRequest request, RegisterUserPresenter presenter);
}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.
@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.
@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:
<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:
@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.
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.
@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:
@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:
<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:
@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);
}
}@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:
public record RegisterUserResponse(String username, String status) {
}Pada endpoint detail user kita kirimkan response username, status, dan waktu registrasi:
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:
@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:
@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.
@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:
@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:
<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:
<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:
<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:
@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:
@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:
@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:
@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:
<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:
<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:
@SpringBootApplication
@CommandScan
public class UserCliApplication{
public static void main(String[] args) {
SpringApplication.run(UserCliApplication.class, args);
}
}@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:
@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.
@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:
@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:
@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.
