Logika domain yang menumpuk pada controller atau model membuat keduanya sulit diuji dan sulit digunakan ulang. Topik ini membahas pola service class, yaitu kelas PHP biasa yang menampung logika domain pada satu tempat, sehingga controller cukup menangani HTTP dan model cukup memetakan data. Pembahasan dimulai dari dua bentuk penumpukan logika yang paling lazim, dilanjutkan dengan ekstraksinya ke kelas service pada aplikasi catatan, dan ditutup dengan patokan untuk menentukan penempatan logika. Seluruh contoh dijalankan langsung pada aplikasi catatan sehingga perilakunya dapat diamati dari keluaran aslinya.
Tempat logika domain yang bermasalah#
Controller yang menampung logika domain dikenali dari metode aksinya yang panjang, yaitu mengambil data, menghitung, memformat, lalu merender dalam satu metode. Contoh berikut membuat laporan kategori langsung di dalam metode index().
// tidak-dijalankan
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Note;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index(Request $request)
{
$namaKategori = $request->query("kategori", "Kerja");
$kategori = Category::where("nama", $namaKategori)->firstOrFail();
$catatan = $kategori->notes()->orderBy("judul")->get();
$kata = 0;
$terpanjang = null;
foreach ($catatan as $item) {
$kata += str_word_count($item->isi);
if ($terpanjang === null || mb_strlen($item->isi) > mb_strlen($terpanjang->isi)) {
$terpanjang = $item;
}
}
return view("dashboard", [
"kategori" => $kategori->nama,
"jumlah" => $catatan->count(),
"kata" => $kata,
"terpanjang" => $terpanjang?->judul,
]);
}
}Kode tersebut berjalan dengan benar, tetapi seluruh aturan penghitungannya hanya dapat dipanggil melalui satu jalur, yaitu HTTP. Apabila laporan yang sama diperlukan oleh perintah Artisan, perhitungan tersebut harus dituliskan ulang di tempat lain. Pengujian unit juga ikut sulit karena logikanya tidak dapat dipanggil tanpa menyimulasikan permintaan HTTP. Masalah serupa muncul pada arah sebaliknya, yaitu model yang diberi tugas melampaui pemetaan data.
Model gemuk (fat model) adalah model yang menyimpan aturan domain dan bentuk presentasi di samping tugas aslinya, yaitu memetakan tabel. Contoh berikut menambahkan metode laporan dan perumusan teks pada model Note.
// tidak-dijalankan
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class Note extends Model
{
// kolom, relasi, dan scope ...
public function laporanKoleksi(Collection $catatan): array
{
$kata = 0;
$terpanjang = null;
foreach ($catatan as $item) {
$kata += str_word_count($item->isi);
if ($terpanjang === null || mb_strlen($item->isi) > mb_strlen($terpanjang->isi)) {
$terpanjang = $item;
}
}
return ["kata" => $kata, "terpanjang" => $terpanjang?->judul];
}
public function formatRapor(array $laporan): string
{
return sprintf("Rapor: %d kata, terpanjang \"%s\".", $laporan["kata"], $laporan["terpanjang"]);
}
}Setiap kode yang menggunakan model Note kini membawa beban logika laporan, padahal sebagian besar pemanggilnya hanya membaca atau menyimpan data. Dua contoh di atas menimbulkan pertanyaan yang sama, yaitu tempat logika domain seharusnya berada. Jawabannya adalah kelas tersendiri dengan tugas yang jelas, dan pemanggilnya bebas dari konteks HTTP.
Ekstraksi ke service class#
Service class adalah kelas PHP biasa tanpa pewarisan khusus yang menjalankan satu pekerjaan domain secara utuh. Konvensi umum menempatkannya pada namespace App\Services, dan Laravel menyediakan perintah make:class untuk membuat kerangkanya.
# tidak-dijalankan
# membuat kelas service bernama RaporKategori
cd ~/aplikasi-catatan
php artisan make:class Services/RaporKategoriPerintah tersebut menghasilkan berkas app/Services/RaporKategori.php beserta direktori app/Services apabila belum ada. Keluaran aslinya berbentuk berikut.
INFO Class [app/Services/RaporKategori.php] created successfully. Kerangka yang dihasilkan masih kosong, sehingga metode-metodenya dituliskan ke dalam kerangka tersebut. Kelas hasil ekstraksi laporan kategori berbentuk berikut.
// tidak-dijalankan
<?php
namespace App\Services;
use App\Models\Category;
use App\Models\Note;
use Illuminate\Support\Collection;
class RaporKategori
{
public function susun(Category $kategori): array
{
$catatan = $kategori->notes()->orderBy("judul")->get();
return [
"kategori" => $kategori->nama,
"jumlah" => $catatan->count(),
"kata" => $this->hitungKata($catatan),
"terpanjang" => $this->isiTerpanjang($catatan)?->judul,
];
}
public function teks(Category $kategori): string
{
$rapor = $this->susun($kategori);
return sprintf(
"Rapor kategori %s: %d catatan, %d kata, terpanjang \"%s\".",
$rapor["kategori"],
$rapor["jumlah"],
$rapor["kata"],
$rapor["terpanjang"] ?? "-"
);
}
private function hitungKata(Collection $catatan): int
{
return $catatan->sum(fn (Note $item) => str_word_count($item->isi));
}
private function isiTerpanjang(Collection $catatan): ?Note
{
return $catatan->sortByDesc(fn (Note $item) => mb_strlen($item->isi))->first();
}
}Seluruh aturan laporan kini tersimpan pada satu kelas, mulai dari penghitungan kata, penentuan catatan terpanjang, hingga perumusan teks rapor. Tidak ada satu pun kode HTTP di dalamnya, dan metode teks() cukup mengembalikan string sehingga pemanggil bebas menentukan tujuan keluarannya. Karena kelasnya konkret, container dapat membentuk objeknya tanpa binding apa pun. Perilakunya diuji dengan Tinker, dan data contohnya disiapkan dengan firstOrCreate agar eksekusi ulang menghasilkan keluaran yang sama.
# menguji service class secara langsung dan melalui container
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Category;
use App\Models\Note;
use Illuminate\Support\Facades\App;
$kategori = Category::firstOrCreate(["nama" => "Riset"]);
Note::firstOrCreate(["judul" => "Daftar sumber pustaka"], ["isi" => "Menyusun daftar pustaka awal beserta catatan edisi yang dipakai.", "category_id" => $kategori->id]);
Note::firstOrCreate(["judul" => "Rencana wawancara"], ["isi" => "Menyiapkan daftar pertanyaan, jadwal, dan ringkasan hasil wawancara.", "category_id" => $kategori->id]);
class RaporKategori {
public function susun(Category $kategori): array {
$catatan = $kategori->notes()->orderBy("judul")->get();
return [
"kategori" => $kategori->nama,
"jumlah" => $catatan->count(),
"kata" => $catatan->sum(fn (Note $item) => str_word_count($item->isi)),
"terpanjang" => $catatan->sortByDesc(fn (Note $item) => mb_strlen($item->isi))->first()?->judul,
];
}
public function teks(Category $kategori): string {
$rapor = $this->susun($kategori);
return sprintf("Rapor kategori %s: %d catatan, %d kata, terpanjang \"%s\".", $rapor["kategori"], $rapor["jumlah"], $rapor["kata"], $rapor["terpanjang"] ?? "-");
}
}
$rapor = App::make(RaporKategori::class);
echo $rapor->teks($kategori) . PHP_EOL;
echo json_encode($rapor->susun($kategori)) . PHP_EOL;
'Rapor kategori Riset: 2 catatan, 17 kata, terpanjang "Rencana wawancara".
{"kategori":"Riset","jumlah":2,"kata":17,"terpanjang":"Rencana wawancara"}Kedua baris keluaran dihasilkan dari kelas yang sama, tetapi bentuknya berbeda. Metode teks() menyajikan rapor untuk pembaca manusia, sedangkan metode susun() mengembalikan array yang langsung dapat diubah menjadi JSON untuk kebutuhan API. Inilah keuntungan yang hilang pada controller gemuk, yaitu satu implementasi aturan domain yang dapat dipanggil dari jalur mana pun.
Controller yang tetap ramping#
Dengan service yang sudah ada, tugas controller hanya menerjemahkan HTTP, yaitu membaca masukan, memanggil service, dan memilih bentuk respons. Objek service tidak dibentuk secara manual, melainkan diminta melalui injeksi pada parameter metode aksi sebagaimana mekanisme yang dijelaskan pada topik Service container.
// tidak-dijalankan
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Services\RaporKategori;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index(Request $request, RaporKategori $rapor)
{
$kategori = Category::where("nama", $request->query("kategori", "Kerja"))->firstOrFail();
return view("dashboard", ["rapor" => $rapor->susun($kategori)]);
}
}Metode index() kini hanya berisi tiga langkah, yaitu membaca parameter permintaan, mengambil model Category, dan menyerahkan perhitungan kepada RaporKategori. Apabila aturan rapor berubah, misalnya laporan dihitung per pekan, perubahan cukup dilakukan pada kelas service dan controller tidak tersentuh sama sekali. Sifat yang sama berlaku bagi pemanggil lain, karena controller web, controller API, dan perintah Artisan dapat memanggil kelas RaporKategori yang sama dengan bentuk keluaran yang berbeda-beda.
Kapan logika boleh tinggal di model#
Ekstraksi bukan berarti seluruh kode harus pindah dari model. Logika yang hanya menyentuh satu tabel tetap paling tepat berada pada modelnya, misalnya relasi, accessor, dan scope query. Apabila aturan tersebut dipindahkan ke service, model kehilangan kemampuan yang menjadi identitas tabelnya dan service menjadi perantara yang tidak perlu.
| Jenis logika | Tempat yang tepat |
|---|---|
| Query satu tabel, relasi, dan scope | Model Eloquent |
| Aturan domain multi-langkah lintas model | Service class |
| Pemilihan respons HTTP, view, dan pengalihan | Controller |
| Pemeriksaan bentuk masukan | Form Request |
| Pengubahan bentuk data untuk keluaran API | API Resource |
Pedoman praktisnya berupa satu pertanyaan, yaitu apakah aturan tersebut masih berlaku ketika aplikasi dijalankan tanpa HTTP, misalnya dari perintah Artisan. Aturan laporan kategori lolos uji tersebut, sehingga tempatnya adalah kelas service. Sebaliknya, aturan yang muncul hanya karena bentuk respons tertentu, misalnya penentuan status 404, tetap berada di lapisan HTTP.
Kesalahan umum#
Kesalahan pertama adalah menjadikan metode service statis, misalnya RaporKategori::susun(). Pemanggilan statis melewati container sepenuhnya, sehingga dependensi kelas tersebut tidak pernah di-inject dan uji unit tidak dapat mengganti kelasnya dengan objek pengganti (test double). Tulislah metode sebagai metode instansi, lalu panggil objeknya melalui injeksi.
Kesalahan kedua adalah membiarkan HTTP bocor ke dalam service, misalnya memanggil redirect() atau response() di dalam metode service. Service yang demikian tidak dapat dipanggil dari perintah Artisan karena kode HTTP tidak bermakna dalam konteks tersebut. Service cukup mengembalikan nilai atau melemparkan exception, sedangkan keputusan bentuk respons tetap tugas controller.
Kesalahan ketiga adalah membuat satu service besar yang menampung puluhan metode untuk banyak keperluan sekaligus. Kelas semacam itu hanyalah controller kedua dengan nama berbeda, dan perubahannya mudah merusak pemanggil lain. Pecahlah sesuai tugas, misalnya RaporKategori untuk laporan dan service lain untuk pengarsipan, sehingga setiap kelas memiliki alasan berubah yang jelas.
Ringkasan#
- Service class adalah kelas PHP biasa pada namespace
App\Servicesyang menampung logika domain, sehingga controller cukup menangani HTTP dan model cukup memetakan data. - Kelas service yang konkret di-inject langsung pada parameter metode aksi tanpa binding, dan isi logikanya dapat dipanggil dari controller web, controller API, maupun perintah Artisan.
- Ekstraksi tidak berarti memindahkan segalanya, karena relasi, accessor, dan scope tetap milik model, sedangkan keputusan bentuk respons tetap milik controller.
- Service yang sehat tidak bersifat statis, tidak memanggil fungsi HTTP, dan tidak menampung tugas yang tak berkaitan dalam satu kelas.
Referensi#
- Controllers — Laravel — Dokumentasi injeksi dependensi pada parameter metode aksi controller.
- Eloquent ORM — Laravel — Dokumentasi model, relasi, dan scope sebagai batas pemetaan data.