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
domainEntity dan business rule
usecaseApplication logic
adapter.inREST controller
adapter.outDatabase adapter
configWiring 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 Article selalu 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 Article untuk 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:

  1. src/main/java/com/example/blog/usecase/dto/CreateArticleCommand.java
  2. src/main/java/com/example/blog/usecase/dto/ArticleResponse.java
  3. src/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.java
  • src/main/java/com/example/blog/adapter/out/persistence/SpringArticleRepo.java
  • src/main/java/com/example/blog/adapter/out/persistence/ArticleMapper.java
  • src/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:

  • ArticleMapper
  • ArticleRepository — implementasinya adalah ArticleRepoAdapter
  • CreateArticleUseCase

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

Github repository ada di https://github.com/yoesoff/clean_blog 

menjalankan project dengan $ mvn spring-boot:run

Curl via postman
Cek hasilnya di 3 T Studio


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:

  1. menerima input dari aplikasi

  2. menjalankan logika bisnis

  3. menggunakan repository

  4. 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:

  1. MongoDB
  2. PostgreSQL
  3. MySQL
  4. File
  5. 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:

KomponenFungsi
Use Casemenjalankan logika bisnis
Input Portpintu masuk ke Use Case
Output Portkontrak untuk akses dunia luar
Adapterpenghubung 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

Popular posts from this blog

Numpang Kerja Remote dari Bandung Creative Hub

Debugging PHP Web dengan XDebug di Intellij IDEA (PHP STORM)

Numpang Kerja Remote dari Bandung Digital Valley