Proyek ini membangun aplikasi web manajemen tugas dengan cara kerja Spring Boot sebagai peta jalan: lapisan controller, service, dan repository yang tersambung oleh container. Lingkungan kursus tidak menyertakan runtime Spring, jadi kita membangun dulu inti telanjang — aplikasi berlapis yang sama dengan Java murni — lalu meletakkan padanan anotasi Spring di sampingnya sebagai rancangan. Bagian 1 menyiapkan pondasinya: struktur direktori standar Maven, arsitektur berlapis, kontrak antar lapisan, dan perakitan yang bisa dijalankan.

Scaffold Proyek Spring Boot #

Aplikasi Spring Boot baru lahir dari Spring Initializr: pilih Maven, Java, dan starter web + validation + data-jpa + security. Tata letak baku hasilnya penting karena dokumentasi, tutorial, dan anggota tim membaca proyek dengan struktur yang sama:

text
aplikasi-web-spring-boot/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/
    │   │   └── com/
    │   │       └── example/
    │   │           └── tugas/
    │   │               ├── AplikasiTugasApplication.java   @SpringBootApplication
    │   │               ├── web/                            lapisan controller
    │   │               ├── layanan/                        lapisan service
    │   │               ├── data/                           lapisan repository
    │   │               └── domain/                         entitas + DTO
    │   └── resources/
    │       └── application.properties                      konfigurasi
    └── test/
        └── java/
            └── com/example/tugas/
                └── ...                                     pengujian

Berkas pom.xml induknya memakai pola parent POM spring-boot-starter-parent, sehingga versi seluruh pustaka dikelola satu angka versi Boot. Empat starter di bawah menutup kebutuhan keempat bagian proyek ini:

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>aplikasi-web-spring-boot</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
    </dependencies>
</project>

Arsitektur Berlapis Controller-Service-Repository #

Prinsip intinya pemisahan tanggung jawab. Permintaan HTTP masuk lewat lapisan web, diterjemahkan menjadi pemanggilan method pada lapisan layanan, lalu berakhir di lapisan penyimpanan. Setiap lapisan hanya mengenal lapisan di bawahnya lewat tipe yang stabil — interface atau record domain — sehingga format kirim-terima, aturan bisnis, dan teknologi penyimpanan bisa berubah tanpa merambat.

LapisanTanggung jawabPadanan SpringPadanan inti telanjang
Webmengikat HTTP: method, path, body, status@RestControllerRouterWeb (Bagian 2)
Layananaturan bisnis dan validasi bermakna@ServiceLayananTugasWeb
Penyimpananmenyimpan dan mengambil entitas@Repository, Spring Data JPARepositoryTugasWeb

Mengapa repot pada aplikasi sekecil ini? Karena Bagian 3 menukar penyimpanan memori dengan berkas dan membungkus web dengan filter keamanan, sedangkan Bagian 4 menguji tiap lapisan terpisah — tanpa merambat ke lapisan lain.

Model Domain dan Kontrak Penyimpanan #

Tiga tipe kecil membentuk model domain. Record TugasWeb adalah entitas dengan invarian pada compact constructor: seluruh field wajib terisi dan teks dipangkas spasinya. Record PermintaanTugas menampung teks mentah dari klien — peran DTO pada Spring sebelum divalidasi. Enum StatusTugasWeb membatasi status ke dua nilai sah:

/**
 * Status daur hidup tugas pada aplikasi web manajemen tugas.
 * TODO berarti tugas masih terbuka, SELESAI berarti tugas tuntas.
 */
public enum StatusTugasWeb {
    TODO,
    SELESAI
}
java TugasWeb.java
import java.time.LocalDate;

/**
 * Satu tugas sebagai entitas domain: judul, pemilik, status, dan tenggat.
 * Record dipilih karena tugas adalah nilai yang tidak berubah di tempat;
 * perubahan dilakukan lewat method salinan di bawah, persis pola wither.
 */
public record TugasWeb(int id, String judul, String pemilik, StatusTugasWeb status,
        LocalDate tenggat) {

    public TugasWeb {
        if (judul == null || judul.isBlank()) {
            throw new IllegalArgumentException("judul tugas wajib diisi");
        }
        if (pemilik == null || pemilik.isBlank()) {
            throw new IllegalArgumentException("pemilik tugas wajib diisi");
        }
        if (status == null) {
            throw new IllegalArgumentException("status tugas wajib diisi");
        }
        if (tenggat == null) {
            throw new IllegalArgumentException("tenggat tugas wajib diisi");
        }
        judul = judul.strip();
        pemilik = pemilik.strip();
    }

    /** Salinan tugas dengan id berbeda; dipakai repository saat menetapkan id. */
    public TugasWeb denganId(int idBaru) {
        return new TugasWeb(idBaru, judul, pemilik, status, tenggat);
    }

    /** Salinan tugas dengan status berbeda; dipakai layanan saat transisi status. */
    public TugasWeb denganStatus(StatusTugasWeb statusBaru) {
        return new TugasWeb(id, judul, pemilik, statusBaru, tenggat);
    }

    /** Ringkasan satu baris untuk pencatatan di terminal. */
    public String formatRingkas() {
        return "#" + id + " [" + status + "] " + judul
                + " (pemilik " + pemilik + ", tenggat " + tenggat + ")";
    }
}
/**
 * Muatan permintaan pembuatan dan pembaruan tugas: teks mentah dari klien
 * sebelum divalidasi. Record ini berperan seperti DTO pada controller
 * Spring; pemeriksaan isinya dilakukan lapisan layanan, bukan record ini.
 */
public record PermintaanTugas(String judul, String pemilik, String tenggat) {
}

Kontrak penyimpanan berbentuk interface lima operasi; cari dan perbarui mengembalikan Optional karena gagal mencari adalah hasil normal, bukan keadaan darurat:

import java.util.List;
import java.util.Optional;

/**
 * Kontrak lapisan penyimpanan untuk resource tugas. Layanan hanya mengenal
 * interface ini sehingga implementasi bebas ditukar: memori untuk pengujian,
 * berkas untuk persistensi Bagian 3, atau JDBC/JPA pada aplikasi nyata.
 */
public interface RepositoryTugasWeb {

    /** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
    TugasWeb simpanBaru(TugasWeb tugas);

    /** Mencari satu tugas berdasarkan id. */
    Optional<TugasWeb> cari(int id);

    /** Seluruh tugas terurut menaik berdasarkan id. */
    List<TugasWeb> semua();

    /** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ada. */
    Optional<TugasWeb> perbarui(TugasWeb tugas);

    /** Menghapus tugas; false bila id tidak ada. */
    boolean hapus(int id);
}
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Penyimpanan dalam memori: LinkedHashMap menjaga urutan sisip sama dengan
 * urutan id, penghitung sederhana menetapkan id berurutan mulai dari 1.
 */
public class RepositoryTugasWebMemori implements RepositoryTugasWeb {

    private final Map<Integer, TugasWeb> data = new LinkedHashMap<>();
    private int urutan = 0;

    @Override
    public TugasWeb simpanBaru(TugasWeb tugas) {
        urutan++;
        TugasWeb tersimpan = tugas.denganId(urutan);
        data.put(urutan, tersimpan);
        return tersimpan;
    }

    @Override
    public Optional<TugasWeb> cari(int id) {
        return Optional.ofNullable(data.get(id));
    }

    @Override
    public List<TugasWeb> semua() {
        return new ArrayList<>(data.values());
    }

    @Override
    public Optional<TugasWeb> perbarui(TugasWeb tugas) {
        if (!data.containsKey(tugas.id())) {
            return Optional.empty();
        }
        data.put(tugas.id(), tugas);
        return Optional.of(tugas);
    }

    @Override
    public boolean hapus(int id) {
        return data.remove(id) != null;
    }
}

Layanan sebagai Pemilik Aturan Bisnis #

Semua keputusan yang bukan sekadar bentuk data tinggal di LayananTugasWeb. Tiga aturan bisnisnya: tugas baru selalu berstatus TODO karena klien tidak boleh menetapkan status sendiri; pemilik dibatasi BATAS_TUGAS_AKTIF tugas terbuka; transisi status hanya boleh TODO ke SELESAI sekali. Pelanggaran dinyatakan lewat exception pembawa status — ApiException induk, TugasTidakDitemukanException untuk 404, AturanBisnisException untuk 409:

/**
 * Exception unchecked pembawa status HTTP. Ditangkap di satu titik pada batas
 * router, lalu diterjemahkan menjadi respons JSON berstatus sesuai. Peran ini
 * dipegang @ExceptionHandler pada aplikasi Spring Boot sungguhan.
 */
public class ApiException extends RuntimeException {

    private final int status;

    public ApiException(int status, String pesan) {
        super(pesan);
        this.status = status;
    }

    public int status() {
        return status;
    }
}
/**
 * Id tugas yang diminta tidak ada di penyimpanan.
 * Diterjemahkan router menjadi respons 404 Not Found.
 */
public class TugasTidakDitemukanException extends ApiException {

    public TugasTidakDitemukanException(int id) {
        super(404, "tugas dengan id " + id + " tidak ditemukan");
    }
}
/**
 * Operasi menabrak aturan bisnis yang bergantung pada kondisi terkini:
 * transisi status yang sudah lampau atau batas tugas aktif per pemilik.
 * Diterjemahkan router menjadi respons 409 Conflict.
 */
public class AturanBisnisException extends ApiException {

    public AturanBisnisException(String pesan) {
        super(409, pesan);
    }
}
import java.time.LocalDate;
import java.util.List;

/**
 * Lapisan layanan: pemilik aturan bisnis aplikasi web manajemen tugas.
 * Seluruh operasi lewat sini; lapisan web tidak pernah menyentuh repository
 * secara langsung, dan repository tidak pernah tahu bahwa HTTP ada.
 * Hari operasional disuntik lewat konstruktor agar aturan "tenggat lampau"
 * bisa diuji dengan tanggal beku, bukan LocalDate.now().
 */
public class LayananTugasWeb {

    /** Batas tugas berstatus TODO per pemilik: aturan bisnis lapisan layanan. */
    public static final int BATAS_TUGAS_AKTIF = 5;

    private final RepositoryTugasWeb repository;
    private final LocalDate hariIni;

    public LayananTugasWeb(RepositoryTugasWeb repository, LocalDate hariIni) {
        this.repository = repository;
        this.hariIni = hariIni;
    }

    /** Hari operasional aplikasi, disuntik lewat konstruktor. */
    public LocalDate hariIni() {
        return hariIni;
    }

    /**
     * Menyusun tugas baru: status selalu TODO (klien tidak boleh menetapkannya),
     * tenggat wajib belum lampau, dan pemilik belum menyentuh batas tugas aktif.
     * Validasi bentuk field menyusul di Bagian 2.
     */
    public TugasWeb buat(PermintaanTugas permintaan) {
        long aktif = repository.semua().stream()
                .filter(t -> t.pemilik().equals(permintaan.pemilik()))
                .filter(t -> t.status() == StatusTugasWeb.TODO)
                .count();
        if (aktif >= BATAS_TUGAS_AKTIF) {
            throw new AturanBisnisException("pemilik " + permintaan.pemilik()
                    + " sudah memiliki " + BATAS_TUGAS_AKTIF + " tugas TODO");
        }
        TugasWeb baru = new TugasWeb(0, permintaan.judul(), permintaan.pemilik(),
                StatusTugasWeb.TODO, LocalDate.parse(permintaan.tenggat()));
        return repository.simpanBaru(baru);
    }

    /** Seluruh tugas terurut id. */
    public List<TugasWeb> daftar() {
        return repository.semua();
    }

    /** Satu tugas berdasarkan id; 404 bila tidak ada. */
    public TugasWeb cari(int id) {
        return repository.cari(id)
                .orElseThrow(() -> new TugasTidakDitemukanException(id));
    }

    /**
     * Memperbarui judul, pemilik, dan tenggat; status dipertahankan apa adanya
     * karena transisi status hanya boleh lewat tandaiSelesai.
     */
    public TugasWeb ubah(int id, PermintaanTugas permintaan) {
        TugasWeb tugas = cari(id);
        TugasWeb baru = new TugasWeb(id, permintaan.judul(), permintaan.pemilik(),
                tugas.status(), LocalDate.parse(permintaan.tenggat()));
        return repository.perbarui(baru)
                .orElseThrow(() -> new TugasTidakDitemukanException(id));
    }

    /** Menandai tugas selesai; 409 bila tugas memang sudah SELESAI. */
    public TugasWeb tandaiSelesai(int id) {
        TugasWeb tugas = cari(id);
        if (tugas.status() == StatusTugasWeb.SELESAI) {
            throw new AturanBisnisException("tugas #" + id
                    + " sudah SELESAI, tidak bisa ditandai selesai ulang");
        }
        TugasWeb selesai = tugas.denganStatus(StatusTugasWeb.SELESAI);
        return repository.perbarui(selesai)
                .orElseThrow(() -> new TugasTidakDitemukanException(id));
    }

    /** Menghapus tugas; 404 bila id tidak ada. */
    public void hapus(int id) {
        cari(id);
        repository.hapus(id);
    }
}

Satu suntikan tampak aneh: konstruktor layanan menerima LocalDate hariIni, bukan memanggil LocalDate.now() di dalam. Hari operasional adalah dependensi seperti repository — disuntik, aturan “tenggat lampau” bisa diuji pada tanggal beku mana pun. Di Spring, prinsip ini dipegang @Value dan profil.

Perakitan Manual lewat Konstruktor #

Kelas AplikasiWeb adalah perakit: main membuat repository, menyuntikkannya ke layanan, lalu menggiring operasi lewat layanan. Tanpa container, urutan penyambungan terlihat telanjang — dan justru itu tujuannya: inilah pekerjaan yang kelak dikerjakan ApplicationContext secara otomatis:

java AplikasiWeb.java
import java.time.LocalDate;
import java.util.List;

/**
 * Titik masuk inti telanjang Bagian 1: perakit seluruh lapisan secara manual
 * lewat constructor injection, lalu menggiring beberapa operasi lewat layanan
 * untuk membuktikan bahwa lapisan tersambung dan penolakan aturan bisnis
 * tidak menghentikan program. Lapisan web (HTTP) menyusul di Bagian 2.
 */
public class AplikasiWeb {

    public static void main(String[] args) {
        System.out.println("=== AplikasiWeb: perakitan lapisan secara manual ===");
        RepositoryTugasWeb repository = new RepositoryTugasWebMemori();
        LayananTugasWeb layanan = new LayananTugasWeb(repository, LocalDate.of(2026, 1, 15));
        System.out.println("[Perakit] RepositoryTugasWebMemori dibuat");
        System.out.println("[Perakit] LayananTugasWeb dibuat; repository dan hari"
                + " operasional 2026-01-15 disuntik lewat konstruktor");

        System.out.println();
        System.out.println("=== Tiga tugas baru lewat layanan ===");
        layanan.buat(new PermintaanTugas("Belajar arsitektur berlapis", "Budi", "2026-02-01"));
        layanan.buat(new PermintaanTugas("Menulis dokumentasi API", "Siti", "2026-01-20"));
        layanan.buat(new PermintaanTugas("Riset keamanan aplikasi web", "Dewi", "2026-01-25"));
        for (TugasWeb tugas : layanan.daftar()) {
            System.out.println(tugas.formatRingkas());
        }

        System.out.println();
        System.out.println("=== Transisi status lewat layanan ===");
        TugasWeb selesai = layanan.tandaiSelesai(2);
        System.out.println(selesai.formatRingkas());

        System.out.println();
        System.out.println("=== Penolakan yang tetap sopan ===");
        try {
            layanan.tandaiSelesai(2);
        } catch (AturanBisnisException e) {
            System.out.println("Ditolak " + e.status() + ": " + e.getMessage());
        }
        try {
            layanan.cari(99);
        } catch (TugasTidakDitemukanException e) {
            System.out.println("Ditolak " + e.status() + ": " + e.getMessage());
        }

        System.out.println();
        System.out.println("=== Ubah dan hapus ===");
        TugasWeb revisi = layanan.ubah(1, new PermintaanTugas(
                "Belajar arsitektur berlapis (revisi)", "Budi", "2026-02-08"));
        System.out.println(revisi.formatRingkas());
        layanan.hapus(3);
        List<TugasWeb> sisa = layanan.daftar();
        System.out.println("Setelah hapus #3, tersisa " + sisa.size() + " tugas");
        for (TugasWeb tugas : sisa) {
            System.out.println(tugas.formatRingkas());
        }
    }
}

Output yang Dihasilkan:

text
=== AplikasiWeb: perakitan lapisan secara manual ===
[Perakit] RepositoryTugasWebMemori dibuat
[Perakit] LayananTugasWeb dibuat; repository dan hari operasional 2026-01-15 disuntik lewat konstruktor

=== Tiga tugas baru lewat layanan ===
#1 [TODO] Belajar arsitektur berlapis (pemilik Budi, tenggat 2026-02-01)
#2 [TODO] Menulis dokumentasi API (pemilik Siti, tenggat 2026-01-20)
#3 [TODO] Riset keamanan aplikasi web (pemilik Dewi, tenggat 2026-01-25)

=== Transisi status lewat layanan ===
#2 [SELESAI] Menulis dokumentasi API (pemilik Siti, tenggat 2026-01-20)

=== Penolakan yang tetap sopan ===
Ditolak 409: tugas #2 sudah SELESAI, tidak bisa ditandai selesai ulang
Ditolak 404: tugas dengan id 99 tidak ditemukan

=== Ubah dan hapus ===
#1 [TODO] Belajar arsitektur berlapis (revisi) (pemilik Budi, tenggat 2026-02-08)
Setelah hapus #3, tersisa 2 tugas
#1 [TODO] Belajar arsitektur berlapis (revisi) (pemilik Budi, tenggat 2026-02-08)
#2 [SELESAI] Menulis dokumentasi API (pemilik Siti, tenggat 2026-01-20)

Mari kita bedah alurnya:

  1. Dua baris perakit: new RepositoryTugasWebMemori() lalu new LayananTugasWeb(repository, ...) menuntaskan seluruh graf dependensi — pada Spring, pekerjaan ini dikerjakan container lewat pemindaian anotasi dan resolusi konstruktor.
  2. Bagian tiga tugas: tiga panggilan buat menghasilkan id 1-3 berstatus TODO; urutan keluaran mengikuti LinkedHashMap sehingga selalu stabil.
  3. Bagian penolakan: tandaiSelesai(2) kedua ditangkap sebagai 409 dan cari(99) sebagai 404 — program tetap berjalan; di Bagian 2, tangkapan seperti ini pindah ke router dan menjadi respons HTTP.
  4. Bagian ubah dan hapus: ubah mengganti judul dan tenggat tetapi mempertahankan status SELESAI pada #2, bukti transisi status memang hanya boleh lewat tandaiSelesai.

Padanan Anotasi Spring #

Pada proyek Spring Boot sungguhan, peran ketiga lapisan ditandai anotasi dan perakitan diambil alih container. Blok berikut tampil sebagai rancangan dan tidak dieksekusi di lingkungan ini:

java
@SpringBootApplication
public class AplikasiTugasApplication {

    public static void main(String[] args) {
        SpringApplication.run(AplikasiTugasApplication.class, args);
    }
}
java
@Repository
public class RepositoryTugasMemori implements TugasRepository {

    private final Map<Integer, Tugas> data = new LinkedHashMap<>();

    public RepositoryTugasMemori() {
    }

    // ... operasi tambah/cari/simpan/hapus sama dengan versi telanjang
}
java
@Service
public class LayananTugas {

    private final TugasRepository repository;
    private final LocalDate hariIni;

    public LayananTugas(TugasRepository repository,
            @Value("${app.hari-operasional}") LocalDate hariIni) {
        this.repository = repository;
        this.hariIni = hariIni;
    }

    // ... aturan bisnis sama dengan versi telanjang
}

Padanannya satu-ke-satu: @Repository, @Service, dan @RestController hanyalah stempel pada kelas yang sudah kita tulis; SpringApplication.run menjalankan perakitan yang di inti telanjang dikerjakan main manual; @Value mengambil hari operasional dari application.properties menggantikan LocalDate.of(2026, 1, 15) yang disuntik langsung. Domain dan aturan bisnisnya tidak berubah — framework mengganti perekat, bukan isi.

Struktur dan lapisan kini berdiri; yang masih hilang adalah lapisan web. Bagian 2 menghidupkan RouterWeb di atas HttpServer bawaan JDK — REST API CRUD dengan validasi payload terstruktur, diuji in-process dari klien HTTP dalam proses yang sama.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ApiException.java

Unduh berkas
java
/**
 * Exception unchecked pembawa status HTTP. Ditangkap di satu titik pada batas
 * router, lalu diterjemahkan menjadi respons JSON berstatus sesuai. Peran ini
 * dipegang @ExceptionHandler pada aplikasi Spring Boot sungguhan.
 */
public class ApiException extends RuntimeException {

    private final int status;

    public ApiException(int status, String pesan) {
        super(pesan);
        this.status = status;
    }

    public int status() {
        return status;
    }
}

AplikasiWeb.java

Unduh berkas
java
import java.time.LocalDate;
import java.util.List;

/**
 * Titik masuk inti telanjang Bagian 1: perakit seluruh lapisan secara manual
 * lewat constructor injection, lalu menggiring beberapa operasi lewat layanan
 * untuk membuktikan bahwa lapisan tersambung dan penolakan aturan bisnis
 * tidak menghentikan program. Lapisan web (HTTP) menyusul di Bagian 2.
 */
public class AplikasiWeb {

    public static void main(String[] args) {
        System.out.println("=== AplikasiWeb: perakitan lapisan secara manual ===");
        RepositoryTugasWeb repository = new RepositoryTugasWebMemori();
        LayananTugasWeb layanan = new LayananTugasWeb(repository, LocalDate.of(2026, 1, 15));
        System.out.println("[Perakit] RepositoryTugasWebMemori dibuat");
        System.out.println("[Perakit] LayananTugasWeb dibuat; repository dan hari"
                + " operasional 2026-01-15 disuntik lewat konstruktor");

        System.out.println();
        System.out.println("=== Tiga tugas baru lewat layanan ===");
        layanan.buat(new PermintaanTugas("Belajar arsitektur berlapis", "Budi", "2026-02-01"));
        layanan.buat(new PermintaanTugas("Menulis dokumentasi API", "Siti", "2026-01-20"));
        layanan.buat(new PermintaanTugas("Riset keamanan aplikasi web", "Dewi", "2026-01-25"));
        for (TugasWeb tugas : layanan.daftar()) {
            System.out.println(tugas.formatRingkas());
        }

        System.out.println();
        System.out.println("=== Transisi status lewat layanan ===");
        TugasWeb selesai = layanan.tandaiSelesai(2);
        System.out.println(selesai.formatRingkas());

        System.out.println();
        System.out.println("=== Penolakan yang tetap sopan ===");
        try {
            layanan.tandaiSelesai(2);
        } catch (AturanBisnisException e) {
            System.out.println("Ditolak " + e.status() + ": " + e.getMessage());
        }
        try {
            layanan.cari(99);
        } catch (TugasTidakDitemukanException e) {
            System.out.println("Ditolak " + e.status() + ": " + e.getMessage());
        }

        System.out.println();
        System.out.println("=== Ubah dan hapus ===");
        TugasWeb revisi = layanan.ubah(1, new PermintaanTugas(
                "Belajar arsitektur berlapis (revisi)", "Budi", "2026-02-08"));
        System.out.println(revisi.formatRingkas());
        layanan.hapus(3);
        List<TugasWeb> sisa = layanan.daftar();
        System.out.println("Setelah hapus #3, tersisa " + sisa.size() + " tugas");
        for (TugasWeb tugas : sisa) {
            System.out.println(tugas.formatRingkas());
        }
    }
}

AturanBisnisException.java

Unduh berkas
java
/**
 * Operasi menabrak aturan bisnis yang bergantung pada kondisi terkini:
 * transisi status yang sudah lampau atau batas tugas aktif per pemilik.
 * Diterjemahkan router menjadi respons 409 Conflict.
 */
public class AturanBisnisException extends ApiException {

    public AturanBisnisException(String pesan) {
        super(409, pesan);
    }
}

LayananTugasWeb.java

Unduh berkas
java
import java.time.LocalDate;
import java.util.List;

/**
 * Lapisan layanan: pemilik aturan bisnis aplikasi web manajemen tugas.
 * Seluruh operasi lewat sini; lapisan web tidak pernah menyentuh repository
 * secara langsung, dan repository tidak pernah tahu bahwa HTTP ada.
 * Hari operasional disuntik lewat konstruktor agar aturan "tenggat lampau"
 * bisa diuji dengan tanggal beku, bukan LocalDate.now().
 */
public class LayananTugasWeb {

    /** Batas tugas berstatus TODO per pemilik: aturan bisnis lapisan layanan. */
    public static final int BATAS_TUGAS_AKTIF = 5;

    private final RepositoryTugasWeb repository;
    private final LocalDate hariIni;

    public LayananTugasWeb(RepositoryTugasWeb repository, LocalDate hariIni) {
        this.repository = repository;
        this.hariIni = hariIni;
    }

    /** Hari operasional aplikasi, disuntik lewat konstruktor. */
    public LocalDate hariIni() {
        return hariIni;
    }

    /**
     * Menyusun tugas baru: status selalu TODO (klien tidak boleh menetapkannya),
     * tenggat wajib belum lampau, dan pemilik belum menyentuh batas tugas aktif.
     * Validasi bentuk field menyusul di Bagian 2.
     */
    public TugasWeb buat(PermintaanTugas permintaan) {
        long aktif = repository.semua().stream()
                .filter(t -> t.pemilik().equals(permintaan.pemilik()))
                .filter(t -> t.status() == StatusTugasWeb.TODO)
                .count();
        if (aktif >= BATAS_TUGAS_AKTIF) {
            throw new AturanBisnisException("pemilik " + permintaan.pemilik()
                    + " sudah memiliki " + BATAS_TUGAS_AKTIF + " tugas TODO");
        }
        TugasWeb baru = new TugasWeb(0, permintaan.judul(), permintaan.pemilik(),
                StatusTugasWeb.TODO, LocalDate.parse(permintaan.tenggat()));
        return repository.simpanBaru(baru);
    }

    /** Seluruh tugas terurut id. */
    public List<TugasWeb> daftar() {
        return repository.semua();
    }

    /** Satu tugas berdasarkan id; 404 bila tidak ada. */
    public TugasWeb cari(int id) {
        return repository.cari(id)
                .orElseThrow(() -> new TugasTidakDitemukanException(id));
    }

    /**
     * Memperbarui judul, pemilik, dan tenggat; status dipertahankan apa adanya
     * karena transisi status hanya boleh lewat tandaiSelesai.
     */
    public TugasWeb ubah(int id, PermintaanTugas permintaan) {
        TugasWeb tugas = cari(id);
        TugasWeb baru = new TugasWeb(id, permintaan.judul(), permintaan.pemilik(),
                tugas.status(), LocalDate.parse(permintaan.tenggat()));
        return repository.perbarui(baru)
                .orElseThrow(() -> new TugasTidakDitemukanException(id));
    }

    /** Menandai tugas selesai; 409 bila tugas memang sudah SELESAI. */
    public TugasWeb tandaiSelesai(int id) {
        TugasWeb tugas = cari(id);
        if (tugas.status() == StatusTugasWeb.SELESAI) {
            throw new AturanBisnisException("tugas #" + id
                    + " sudah SELESAI, tidak bisa ditandai selesai ulang");
        }
        TugasWeb selesai = tugas.denganStatus(StatusTugasWeb.SELESAI);
        return repository.perbarui(selesai)
                .orElseThrow(() -> new TugasTidakDitemukanException(id));
    }

    /** Menghapus tugas; 404 bila id tidak ada. */
    public void hapus(int id) {
        cari(id);
        repository.hapus(id);
    }
}

PermintaanTugas.java

Unduh berkas
java
/**
 * Muatan permintaan pembuatan dan pembaruan tugas: teks mentah dari klien
 * sebelum divalidasi. Record ini berperan seperti DTO pada controller
 * Spring; pemeriksaan isinya dilakukan lapisan layanan, bukan record ini.
 */
public record PermintaanTugas(String judul, String pemilik, String tenggat) {
}

RepositoryTugasWeb.java

Unduh berkas
java
import java.util.List;
import java.util.Optional;

/**
 * Kontrak lapisan penyimpanan untuk resource tugas. Layanan hanya mengenal
 * interface ini sehingga implementasi bebas ditukar: memori untuk pengujian,
 * berkas untuk persistensi Bagian 3, atau JDBC/JPA pada aplikasi nyata.
 */
public interface RepositoryTugasWeb {

    /** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
    TugasWeb simpanBaru(TugasWeb tugas);

    /** Mencari satu tugas berdasarkan id. */
    Optional<TugasWeb> cari(int id);

    /** Seluruh tugas terurut menaik berdasarkan id. */
    List<TugasWeb> semua();

    /** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ada. */
    Optional<TugasWeb> perbarui(TugasWeb tugas);

    /** Menghapus tugas; false bila id tidak ada. */
    boolean hapus(int id);
}

RepositoryTugasWebMemori.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Penyimpanan dalam memori: LinkedHashMap menjaga urutan sisip sama dengan
 * urutan id, penghitung sederhana menetapkan id berurutan mulai dari 1.
 */
public class RepositoryTugasWebMemori implements RepositoryTugasWeb {

    private final Map<Integer, TugasWeb> data = new LinkedHashMap<>();
    private int urutan = 0;

    @Override
    public TugasWeb simpanBaru(TugasWeb tugas) {
        urutan++;
        TugasWeb tersimpan = tugas.denganId(urutan);
        data.put(urutan, tersimpan);
        return tersimpan;
    }

    @Override
    public Optional<TugasWeb> cari(int id) {
        return Optional.ofNullable(data.get(id));
    }

    @Override
    public List<TugasWeb> semua() {
        return new ArrayList<>(data.values());
    }

    @Override
    public Optional<TugasWeb> perbarui(TugasWeb tugas) {
        if (!data.containsKey(tugas.id())) {
            return Optional.empty();
        }
        data.put(tugas.id(), tugas);
        return Optional.of(tugas);
    }

    @Override
    public boolean hapus(int id) {
        return data.remove(id) != null;
    }
}

StatusTugasWeb.java

Unduh berkas
java
/**
 * Status daur hidup tugas pada aplikasi web manajemen tugas.
 * TODO berarti tugas masih terbuka, SELESAI berarti tugas tuntas.
 */
public enum StatusTugasWeb {
    TODO,
    SELESAI
}

TugasTidakDitemukanException.java

Unduh berkas
java
/**
 * Id tugas yang diminta tidak ada di penyimpanan.
 * Diterjemahkan router menjadi respons 404 Not Found.
 */
public class TugasTidakDitemukanException extends ApiException {

    public TugasTidakDitemukanException(int id) {
        super(404, "tugas dengan id " + id + " tidak ditemukan");
    }
}

TugasWeb.java

Unduh berkas
java
import java.time.LocalDate;

/**
 * Satu tugas sebagai entitas domain: judul, pemilik, status, dan tenggat.
 * Record dipilih karena tugas adalah nilai yang tidak berubah di tempat;
 * perubahan dilakukan lewat method salinan di bawah, persis pola wither.
 */
public record TugasWeb(int id, String judul, String pemilik, StatusTugasWeb status,
        LocalDate tenggat) {

    public TugasWeb {
        if (judul == null || judul.isBlank()) {
            throw new IllegalArgumentException("judul tugas wajib diisi");
        }
        if (pemilik == null || pemilik.isBlank()) {
            throw new IllegalArgumentException("pemilik tugas wajib diisi");
        }
        if (status == null) {
            throw new IllegalArgumentException("status tugas wajib diisi");
        }
        if (tenggat == null) {
            throw new IllegalArgumentException("tenggat tugas wajib diisi");
        }
        judul = judul.strip();
        pemilik = pemilik.strip();
    }

    /** Salinan tugas dengan id berbeda; dipakai repository saat menetapkan id. */
    public TugasWeb denganId(int idBaru) {
        return new TugasWeb(idBaru, judul, pemilik, status, tenggat);
    }

    /** Salinan tugas dengan status berbeda; dipakai layanan saat transisi status. */
    public TugasWeb denganStatus(StatusTugasWeb statusBaru) {
        return new TugasWeb(id, judul, pemilik, statusBaru, tenggat);
    }

    /** Ringkasan satu baris untuk pencatatan di terminal. */
    public String formatRingkas() {
        return "#" + id + " [" + status + "] " + judul
                + " (pemilik " + pemilik + ", tenggat " + tenggat + ")";
    }
}