Contoh 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:
- User menginput username & tanggal lahir;
- Username ga boleh kosong dan minimal 5 huruf;
- Tanggal lahir ga boleh kosong dan minimal 18 tahun;
- Cek username harus unik ke DB;
- Simpan ke DB dengan status aktif dan tanggal pembuatan waktu sekarang;
- Tampilkan hasilnya;
Lalu kita buat flow untuk menampilkan data user berdasarkan username:
- User menginput username;
- Username ga boleh kosong;
- Cari data berdasarkan username;
- Tampilkan data username, status, dan tanggal lahir dengan pola
EEEE, dd MMMM yyyy;
Struktur Modul
Strukturnya kurang lebih seperti ini:
so-clean/
├── user-domain/
├── user-usecase/
├── user-repository-spring-data-jpa/
└── user-service-spring-boot-web/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>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.
<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");
}
if (value.length() < 5) {
throw new InvalidUsernameException("Username must be at least 5 characters");
}
}
}Begitu juga dengan tanggal lahir:
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:
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:
public class InvalidUsernameException extends RuntimeException {
public InvalidUsernameException(String message) {
super(message);
}
}public class IllegalBirthDateException extends RuntimeException {
public IllegalBirthDateException(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, 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.
<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:
public record UserRegistrationRequest(String username, LocalDate birthDate) {
}Di dalam record UserRegistrationResponse kita butuh output berupa objek User:
public record UserRegistrationResponse(UserDomain user) {
}Di dalam UserDetailRequest kita butuh input username:
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:
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:
public interface UserRegistrationGateway {
UserDomain save(UserDomain user);
boolean existsByUsername(Username username);
}Di interface UserDetailGateway ada method untuk mengambil data user berdasarkan username ke DB:
public interface UserDetailGateway {
Optional<UserDomain> findByUsername(Username username);
}Lalu kita bikin interface Presenter untuk formatting hasilnya:
public interface UserRegistrationPresenter {
void present(UserRegistrationResponse response);
}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:
public interface UserRegistrationUseCase {
void execute(UserRegistrationRequest request, UserRegistrationPresenter presenter);
}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.
@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:
@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:
<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:
@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.
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.
@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:
@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:
<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:
@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);
}
}@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:
public record UserRegistrationWebResponse(String username, String status) {
}Pada endpoint detail user kita siapin response username, status, dan tanggal lahir:
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”:
@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:
@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.
@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:
@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:
<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:
<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:
<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:
@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.
@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:
@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:
@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:
<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:
<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:
@SpringBootApplication
@CommandScan
public class UserCliApplication{
public static void main(String[] args) {
SpringApplication.run(UserCliApplication.class, args);
}
}@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:
@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.
@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:
@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:
@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.
