Step-by-Step Membuat Blog API dengan Spring Boot Menggunakan Clean Architecture
| Ilustrasi ngoding di Intellij IDEA Ultimate |
Pada artikel sebelumnya kita membahas konsep Clean Architecture dan bagaimana pola tersebut digunakan dalam contoh aplikasi Blog sederhana.
Github repository ada di
https://github.com/yoesoff/clean_blog
Sekarang kita akan membuat project Blog REST API dari nol menggunakan Spring Boot, sambil menerapkan struktur Clean Architecture yang sama seperti yang dijelaskan sebelumnya.
Tujuan dari tutorial ini adalah:
- Memahami bagaimana layer Clean Architecture diimplementasikan secara nyata
- Membuat CRUD Article
- Memahami bagaimana controller, use case, domain, dan repository bekerja bersama
Dengan latihan ini, Anda akan lebih siap membaca struktur kode di proyek nyata yang lebih kompleks.
Project ini menggunakan Clean Architecture: pola arsitektur yang memisahkan sistem ke beberapa layer berdasarkan tanggung jawab, bukan berdasarkan teknologi.
Di implementasi kita, layer domain menyimpan aturan bisnis inti (Article, ArticleRepository sebagai port), usecase menangani alur aplikasi (CreateArticleUseCase), adapter.in.web menerima HTTP request (controller), adapter.out.persistence menghubungkan ke MongoDB (repo adapter, mapper, entity), dan config mengatur wiring dependency Spring. Jadi secara what, kita telah membangun fondasi backend yang jelas batas antar layer-nya: business logic tetap di pusat, sementara framework dan database berada di sisi luar.
Secara why, arsitektur ini dipakai agar kode lebih mudah dipahami, dites, dan diubah tanpa efek domino besar; misalnya saat database diganti, domain dan use case tetap aman.
Secara when, Clean Architecture paling tepat digunakan ketika aplikasi diproyeksikan bertumbuh, tim lebih dari satu orang, atau kebutuhan bisnis kemungkinan sering berubah.
Secara where, pola ini umum dipakai pada backend service seperti sistem blog, e-commerce, fintech, dan enterprise app yang butuh maintainability jangka panjang.
Dalam project kita sendiri, arsitektur ini sudah terlihat jelas di package com.example.blog melalui pemisahan domain, usecase, adapter, dan config.
1. Membuat Project Spring Boot
Buat project menggunakan Spring Initializr.
Dependencies yang digunakan:
- Spring Web
- Spring Data MongoDB
- Lombok
- Validation
Project info:
Group : com.example
Artifact : blog
Java : 21
Build : Maven
Setelah dibuat, struktur project awal akan terlihat seperti ini:
blog
├── src/main/java/com/example/blog
│ └── BlogApplication.java
│
├── src/main/resources
│ └── application.properties
│
└── pom.xml
Pada kasus ini, project dibuat menggunakan Cursor AI Editor.
Buat folder project lalu buka dengan Cursor
2. Setup application.properties
Untuk contoh ini kita menggunakan MongoDB.
application.properties
spring.application.name=clean_blog
server.port=8080
spring.data.mongodb.uri=mongodb://localhost:27017/blogdb
spring.data.mongodb.auto-index-creation=true
Jika MongoDB belum berjalan, jalankan:
docker run -p 27017:27017 mongo
Konfirmasi bahwa database sedang berjalan
Buat database di MongoDB
3. Membuat Struktur Clean Architecture
Sekarang kita buat struktur package yang mengikuti Clean Architecture.
com.example.blog
├── domain
├── usecase
│ └── dto
├── adapter
│ ├── in
│ │ └── web
│ └── out
│ └── persistence
└── config
Penjelasan singkat:
| Package | Tujuan |
|---|---|
| domain | Entity dan business rule |
| usecase | Application logic |
| adapter.in | REST controller |
| adapter.out | Database adapter |
| config | Wiring Spring |
Struktur Clean Architecture di Cursor
4. Membuat Domain Entity
Domain tidak boleh bergantung pada framework.
Buat file domain/Article.java:
package com.example.blog.domain;
import java.time.LocalDateTime;
import java.util.UUID;
public class Article {
private UUID id;
private String title;
private String body;
private LocalDateTime createdAt;
public Article(UUID id, String title, String body) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Title required");
}
this.id = id;
this.title = title;
this.body = body;
this.createdAt = LocalDateTime.now();
}
public UUID getId() {
return id;
}
public String getTitle() {
return title;
}
public String getBody() {
return body;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
}
Article adalah domain entity — representasi inti bisnis untuk artikel di aplikasi blog.
- Menyimpan data penting (
id,title,body,createdAt) dan business rule dasar (judul wajib ada). - Independen dari framework: tidak ada anotasi Spring/JPA/Mongo, sehingga domain tidak "terkunci" ke teknologi tertentu.
- Menjaga konsistensi: validasi di constructor memastikan objek
Articleselalu valid sejak dibuat. - Mudah dites: karena pure Java class, unit test bisa langsung berjalan tanpa Spring context atau database.
- Fondasi layer lain: use case memakai
Articleuntuk logika aplikasi, sedangkan adapter hanya menerjemahkan ke/dari HTTP atau database.
5. Membuat Domain Repository (Port)
Domain hanya mendefinisikan interface repository.
Buat file domain/ArticleRepository.java:
package com.example.blog.domain;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ArticleRepository {
Article save(Article article);
Optional<Article> findById(UUID id);
List<Article> findAll();
void deleteById(UUID id);
}
Ini disebut port dalam Clean Architecture.
- Interface murni di layer domain.
- Tanpa anotasi atau dependency framework.
- Siap diimplementasikan nanti oleh adapter out (misalnya MongoDB).
6. Membuat Use Case Layer
UseCase berisi workflow aplikasi. Buat folder usecase beserta sub-folder dto.
DTO Input — usecase/dto/CreateArticleCommand.java
package com.example.blog.usecase.dto;
public record CreateArticleCommand(
String title,
String body
) {}
DTO Output — usecase/dto/ArticleResponse.java
package com.example.blog.usecase.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public record ArticleResponse(
UUID id,
String title,
String body,
LocalDateTime createdAt
) {}
Use Case — usecase/CreateArticleUseCase.java
package com.example.blog.usecase;
import com.example.blog.domain.Article;
import com.example.blog.domain.ArticleRepository;
import com.example.blog.usecase.dto.ArticleResponse;
import com.example.blog.usecase.dto.CreateArticleCommand;
import java.util.UUID;
public class CreateArticleUseCase {
private final ArticleRepository repo;
public CreateArticleUseCase(ArticleRepository repo) {
this.repo = repo;
}
public ArticleResponse execute(CreateArticleCommand cmd) {
Article article = new Article(
UUID.randomUUID(),
cmd.title(),
cmd.body()
);
Article saved = repo.save(article);
return new ArticleResponse(
saved.getId(),
saved.getTitle(),
saved.getBody(),
saved.getCreatedAt()
);
}
}
File yang dibuat pada layer use case:
src/main/java/com/example/blog/usecase/dto/CreateArticleCommand.javasrc/main/java/com/example/blog/usecase/dto/ArticleResponse.javasrc/main/java/com/example/blog/usecase/CreateArticleUseCase.java
7. Adapter Persistence (MongoDB)
Sekarang kita membuat adapter untuk database.
7.1 Mongo Entity — adapter/out/persistence/ArticleEntity.java
package com.example.blog.adapter.out.persistence;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document("articles")
public class ArticleEntity {
@Id
private UUID id;
private String title;
private String body;
private LocalDateTime createdAt;
}
7.2 Spring Data Repository — adapter/out/persistence/SpringArticleRepo.java
package com.example.blog.adapter.out.persistence;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.UUID;
public interface SpringArticleRepo extends
MongoRepository<ArticleEntity, UUID> {
}
7.3 Mapper — adapter/out/persistence/ArticleMapper.java
Mapper mengubah Domain ↔ Entity.
package com.example.blog.adapter.out.persistence;
import com.example.blog.domain.Article;
public class ArticleMapper {
public ArticleEntity toEntity(Article article) {
return new ArticleEntity(
article.getId(),
article.getTitle(),
article.getBody(),
article.getCreatedAt()
);
}
public Article toDomain(ArticleEntity entity) {
return new Article(
entity.getId(),
entity.getTitle(),
entity.getBody()
);
}
}
7.4 Repository Adapter — adapter/out/persistence/ArticleRepoAdapter.java
package com.example.blog.adapter.out.persistence;
import com.example.blog.domain.Article;
import com.example.blog.domain.ArticleRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public class ArticleRepoAdapter implements ArticleRepository {
private final SpringArticleRepo springRepo;
private final ArticleMapper mapper;
public ArticleRepoAdapter(SpringArticleRepo springRepo, ArticleMapper mapper) {
this.springRepo = springRepo;
this.mapper = mapper;
}
@Override
public Article save(Article article) {
ArticleEntity entity = mapper.toEntity(article);
return mapper.toDomain(springRepo.save(entity));
}
@Override
public Optional<Article> findById(UUID id) {
return springRepo.findById(id)
.map(mapper::toDomain);
}
@Override
public List<Article> findAll() {
return springRepo.findAll()
.stream()
.map(mapper::toDomain)
.toList();
}
@Override
public void deleteById(UUID id) {
springRepo.deleteById(id);
}
}
File yang ditambahkan pada layer persistence:
src/main/java/com/example/blog/adapter/out/persistence/ArticleEntity.javasrc/main/java/com/example/blog/adapter/out/persistence/SpringArticleRepo.javasrc/main/java/com/example/blog/adapter/out/persistence/ArticleMapper.javasrc/main/java/com/example/blog/adapter/out/persistence/ArticleRepoAdapter.java
8. REST Controller
Controller berada di adapter.in.web.
Buat file adapter/in/web/ArticleController.java:
package com.example.blog.adapter.in.web;
import com.example.blog.usecase.CreateArticleUseCase;
import com.example.blog.usecase.dto.ArticleResponse;
import com.example.blog.usecase.dto.CreateArticleCommand;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/articles")
public class ArticleController {
private final CreateArticleUseCase createArticleUseCase;
public ArticleController(CreateArticleUseCase useCase) {
this.createArticleUseCase = useCase;
}
@PostMapping
public ArticleResponse create(@RequestBody CreateArticleCommand cmd) {
return createArticleUseCase.execute(cmd);
}
}
Yang perlu diperhatikan:
- Endpoint:
POST /articles - Controller hanya menerima request dan mendelegasikan ke
CreateArticleUseCase - Tidak berisi business logic
9. Wiring dengan Spring Configuration
Terakhir, kita membuat konfigurasi bean.
Buat file config/BeanConfig.java:
package com.example.blog.config;
import com.example.blog.adapter.out.persistence.ArticleMapper;
import com.example.blog.adapter.out.persistence.ArticleRepoAdapter;
import com.example.blog.adapter.out.persistence.SpringArticleRepo;
import com.example.blog.domain.ArticleRepository;
import com.example.blog.usecase.CreateArticleUseCase;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BeanConfig {
@Bean
public ArticleMapper articleMapper() {
return new ArticleMapper();
}
@Bean
public ArticleRepository articleRepository(
SpringArticleRepo repo,
ArticleMapper mapper
) {
return new ArticleRepoAdapter(repo, mapper);
}
@Bean
public CreateArticleUseCase
createArticleUseCase(ArticleRepository repo) {
return new CreateArticleUseCase(repo);
}
}
Bean yang didaftarkan:
ArticleMapperArticleRepository— implementasinya adalahArticleRepoAdapterCreateArticleUseCase
Peran BeanConfig dalam Clean Architecture:
- Wiring di satu tempat: penyambungan domain, use case, dan adapter dilakukan di layer config, bukan di domain.
- Menjaga batas layer: domain dan use case tetap bersih dari anotasi infrastruktur Spring.
- Dependency Inversion: use case bergantung ke interface (
ArticleRepository), sementara implementasi konkretnya dipilih di konfigurasi. - Mudah diganti/ditest: implementasi repository bisa ditukar (misalnya in-memory, SQL, Mongo) tanpa mengubah logika use case.
Ringkasan konsep Port & Use Case:
Port adalah kontrak (interface) yang mendefinisikan apa yang dibutuhkan oleh business logic, tanpa peduli bagaimana cara implementasinya. Contohnya ArticleRepository di layer domain: use case hanya tahu ada operasi save, findById, findAll, dan deleteById. Implementasi nyatanya (MongoDB, SQL, in-memory) diletakkan di adapter, sehingga domain tetap bersih dan tidak bergantung pada teknologi tertentu.
Sementara itu, use case adalah skenario atau alur kerja aplikasi yang merepresentasikan tindakan bisnis spesifik. Contohnya CreateArticleUseCase: menerima input (CreateArticleCommand) dengan execute(CreateArticleCommand cmd), membuat Article, memanggil port repository, lalu mengembalikan hasil (ArticleResponse).
Singkatnya: port = antarmuka kebutuhan, use case = logika proses bisnis yang memakai port tersebut. Pendekatan ini membuat kode lebih mudah dipahami, dites, dan diubah tanpa efek domino ke seluruh sistem.
Yang sudah kita bangun adalah implementasi Clean Architecture sederhana untuk fitur pembuatan artikel blog, dengan pemisahan tanggung jawab yang jelas. Di pusatnya ada layer domain yang berisi Article dan ArticleRepository (port), yaitu aturan bisnis inti yang sengaja dibuat murni Java tanpa ketergantungan Spring atau MongoDB. Di atasnya ada layer usecase (CreateArticleUseCase, CreateArticleCommand, ArticleResponse) yang mengatur alur aplikasi: menerima input, membuat objek domain, menyimpan lewat interface repository, lalu mengembalikan output. Dengan cara ini, business logic tetap fokus pada kebutuhan aplikasi, bukan detail framework.
Lalu kita punya layer adapter sebagai penghubung ke dunia luar. adapter.in.web berisi ArticleController untuk menerima HTTP request POST /articles, dan adapter.out.persistence berisi implementasi akses data MongoDB (ArticleEntity, SpringArticleRepo, ArticleMapper, ArticleRepoAdapter). Terakhir, config/BeanConfig bertugas melakukan wiring dependency agar use case mendapatkan implementasi repository yang tepat saat runtime. Hasil akhirnya: sistem jadi lebih rapi, mudah dites, dan lebih fleksibel jika suatu saat database, framework, atau cara expose API ingin diganti tanpa merombak domain dan use case inti.
10. Running
| Curl via postman |
10. Catatan Tambahan: Input Port, Output Port, Use Case, dan Adapter
Dalam arsitektur seperti Clean Architecture atau Hexagonal Architecture, ada beberapa konsep penting yang sering muncul:
-
Use Case
-
Input Port
-
Output Port
-
Adapter
Konsep ini membantu kita membuat kode yang rapi, mudah diuji, dan tidak terikat langsung pada framework atau database tertentu.
Mari kita bahas satu per satu dengan contoh sederhana dari project Blog API.
10.1. Use Case
Use Case adalah logika bisnis utama dari aplikasi.
Use Case menjawab pertanyaan:
Apa yang bisa dilakukan oleh aplikasi ini?
Contoh pada aplikasi blog:
- Create Article (CreateArticleUseCase)
- Update Article (UpdateArticleUseCase)
- Delete Article (DeleteArticleUseCase)
- Get Article (GetArticleUseCase)
- List Articles (ListArticlesUseCase)
Setiap aksi biasanya direpresentasikan sebagai satu class.
Contoh:
public class CreateArticleUseCase {
private final ArticleRepository repo;
public CreateArticleUseCase(ArticleRepository repo) {
this.repo = repo;
}
public ArticleResponse execute(CreateArticleCommand cmd) {
Article article = new Article(
UUID.randomUUID(),
cmd.title(),
cmd.body()
);
Article saved = repo.save(article);
return new ArticleResponse(
saved.getId(),
saved.getTitle(),
saved.getBody(),
saved.getCreatedAt()
);
}
}Di sini Use Case bertugas untuk:
-
menerima input dari aplikasi
-
menjalankan logika bisnis
-
menggunakan repository
-
menghasilkan output
Use Case tidak tahu database apa yang dipakai.
10.2. Input Port
Input Port adalah cara dunia luar memanggil Use Case.
Biasanya Input Port berupa:
-
interface Use Case atau
-
class Use Case langsung
Pada contoh sederhana kita:
CreateArticleUseCase (Controller akan memanggil Use Case ini.)
Contoh:
@RestController
@RequestMapping("/articles")
public class ArticleController {
private final CreateArticleUseCase createArticleUseCase;
public ArticleController(CreateArticleUseCase useCase) {
this.createArticleUseCase = useCase;
}
@PostMapping
public ArticleResponse create(@RequestBody CreateArticleCommand cmd) {
return createArticleUseCase.execute(cmd);
}
}Alurnya:
Client↓Controller↓UseCase
Jadi Input Port adalah pintu masuk ke aplikasi.
10.3. Output Port
Output Port adalah cara Use Case berbicara dengan dunia luar.
Contohnya:
-
database
-
message broker
-
external API
-
filesystem
Output Port biasanya berupa interface.
Contoh:
public interface ArticleRepository {
Article save(Article article);
Optional<Article> findById(UUID id);
List<Article> findAll();
void deleteById(UUID id);
}Use Case hanya tahu interface ini.
Artinya Use Case tidak peduli apakah data disimpan di:
- MongoDB
- PostgreSQL
- MySQL
- File
- API lain
Yang penting adalah kontraknya.
10.4. Adapter
Adapter adalah penghubung antara aplikasi dan teknologi luar.
Adapter menerjemahkan kebutuhan Use Case ke implementasi nyata.
Contoh adapter untuk repository:
public class ArticleRepoAdapter implements ArticleRepository {
private final SpringArticleRepo springRepo;
private final ArticleMapper mapper;
public ArticleRepoAdapter(
SpringArticleRepo springRepo,
ArticleMapper mapper
) {
this.springRepo = springRepo;
this.mapper = mapper;
}
@Override
public Article save(Article article) {
ArticleEntity entity = mapper.toEntity(article);
return mapper.toDomain(springRepo.save(entity));
}
@Override
public Optional<Article> findById(UUID id) {
return springRepo.findById(id)
.map(mapper::toDomain);
}
@Override
public List<Article> findAll() {
return springRepo.findAll()
.stream()
.map(mapper::toDomain)
.toList();
}
@Override
public void deleteById(UUID id) {
springRepo.deleteById(id);
}
}10.5. Contoh Alur Lengkap
Mari lihat alur lengkap ketika user membuat artikel.
Client
↓
HTTP Request
↓
Controller (Input Adapter / Driving Adapter)
↓
CreateArticleUseCase (Application / Use Case)
↓
ArticleRepository (Output Port)
↓
ArticleRepoAdapter (Driven Adapter)
↓
SpringArticleRepo (Driven Adapter / Infrastructure)
↓
MongoDB (External System / Driven)
Dengan pola ini:
-
Controller hanya menangani HTTP
-
Use Case berisi logika bisnis
-
Repository adalah kontrak (Output Port)
-
Adapter menghubungkan ke database
10.6. Kenapa Pendekatan Ini Baik?
Pendekatan ini membuat aplikasi: Mudah diuji dan Use Case bisa dites tanpa database.
Mudah diganti teknologinya
Misalnya nanti ingin mengganti:
- MongoDB → PostgreSQL
Perubahan hanya terjadi di Adapter dan Use Case tidak perlu diubah.
Struktur kode lebih jelas
Setiap bagian punya tanggung jawab yang berbeda.
10.7. Ringkasan Singkat
Berikut peran masing-masing komponen:
| Komponen | Fungsi |
|---|---|
| Use Case | menjalankan logika bisnis |
| Input Port | pintu masuk ke Use Case |
| Output Port | kontrak untuk akses dunia luar |
| Adapter | penghubung antara aplikasi dan teknologi |
Cara mudah mengingatnya:
- Client → Input → UseCase → Output → Infrastructure
atau secara sederhana:
- User → Aplikasi → Database
tetapi dengan struktur yang lebih rapi dan fleksibel.
Comments
Post a Comment