Menjadi Seorang Pengembang Yang Baik Dengan Menggunakan SOLID Design Principles

Published on
-
15 mins read
Authors

Pengantar

Menjadi seorang Software Engineer terkadang kita masih sering :

  • Membaca berulang-ulang beberapa kode untuk sampai ke bagian yang ingin kita ubah.
  • Sulit memahami dengan apa yang diharapkan oleh method/function yang sudah ada.
  • Menghabiskan banyak waktu hanya untuk memperbaiki sebuah bug kecil ( minor ).
  • Menghabiskan lebih banyak waktu untuk membaca kode yang ada daripada menulis kode secara langsung.

Nah, dari keresahan-keresahan tersebut. Ada sebuah prinsip, agar hidup kita sebagai seorang Software Engineer dapat lebih mudah/terbantu. Yap, prinsip tersebut adalah SOLID Design Principles.

Kita akan belajar dan mencoba memahami poin-poin berikut :

  • Apa itu SOLID Design Principles?
  • Bagaimana prinsip tersebut dapat membuat hidup kita (sebagai seorang Software Engineer) lebih mudah?
  • Apa saja prinsip-prinsip yang ada pada SOLID?
  • Apa tujuan dari masing masing prinsip tersebut?
  • Dan bagaimana jika kita tidak menggunakan prinsip SOLID tersebut dalam project kita?

Secara umum, dengan adanya prinsip SOLID paling tidak ada beberapa poin penting yang bisa kita ambil manfaatnya, yaitu :

  • Untuk membuat kode kita lebih mudah dipelihara ( maintenance ).
  • Untuk memudahkan perluasan sistem dengan fungsionalitas baru dengan cepat tanpa merusak yang sudah ada.
  • Untuk membuat kode kita lebih mudah dibaca dan dipahami, sehingga kita menghabiskan lebih sedikit waktu untuk mencari tahu apa yang sebenarnya perlu dilakukan dan kita bisa lebih banyak waktu untuk mengembangkan sebuah solusi.

Apa itu SOLID Design Principle?

SOLID adalah sebuah akronim dari lima prinsip Object-Oriented Design (OOD) yang dipelopori pertama kali oleh Robert C. Martin. Prinsip ini biasa diterapkan pada saat berkecimpung dalam dunia pemrograman berorientasi objek.

Prinsip ini adalah praktek dalam mengembangkan sebuah program dengan mempertimbangkan pemeliharaan serta pengembangan lebih lanjut agar kode dapat mudah untuk dirawat, mudah dimengerti serta bersifat fleksibel.

Dengan mengimplementasikan prinsip ini, kita dapat terbantu dalam menghindari bad code, mudah dalam melakukan refactoring serta sistem kita dapat dikembangkan secara Agile atau Adaptive (mudah dalam beradaptasi mengikuti perkembangan).

SOLID sendiri merupakan sebuah akronim dari lima prinsip, yang terdiri dari :

  • S - Single Responsibility Principle
  • O - Open Closed Principle
  • L - Liskov Substitution Principle
  • I - Interface Segregation Principle
  • D - Dependency Inversion Principle

Single Responsibility Principle

Sebuah class hanya boleh memiliki satu tanggung jawab, sehingga tanggung jawab untuk class tersebut hanyalah tunggal, yaitu tanggung jawab yang hanya berkaitan dengan class tersebut.

Maksud dari penjelasan di atas adalah, sebuah class hanya diperuntukkan dengan hal yang berkaitan atau berhubungan dengan class tersebut. Sebagai contoh, kita mempunyai class dengan nama Payment.

Maka di dalam class tersebut seharusnya hanya berurusan dengan kegiatan payment saja, tidak lebih, seperti melakukan proses order misalnya ataupun menyimpan data pembeli. Kedua hal tersebut memang masih ada hubungannya dengan payment, akan tetapi tanggung jawab tersebut dapat dipisah, agar class Payment bisa fokus menangani kegiatan payment saja.

Atau pada kasus yang lain kita contohkan pada Laravel, kita bisa deklarasikan validasi request di dalam controller secara langsung. Seperti berikut

app\Http\Controllers\UserController.php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
class UserController extends Controller
{
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\User $user
* @return \Illuminate\Http\Response
*/
public function store(Request $request, User $user)
{
$validatedData = $request->validate([
'name' => 'required',
'email' => 'required|unique:users|email',
'password' => 'required',
]);
$user->name = $request->name;
$user->email = $request->email;
$user->password = $request->password;
$user->save;
return response()->json(['user' => $user], 201);
}
}

Namun alih-alih dengan cara tersebut, method store seharusnya hanya bertanggung jawab untuk menyimpan data saja, bukan melakukan validasi data. Untuk menerapkan Single Responsibility Principle, kita dapat memanfaatkan fitur FormRequest pada Laravel. Jadi, proses validasi form dapat berdiri sendiri, tidak menjadi satu dalam method store. Sehingga, kita bisa me-refactor kode menjadi seperti berikut

  1. Membuat class baru bernama StoreUserRequest yang ditujukan hanya untuk validasi form user.
app\Http\Requests\StoreUserRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'name' => 'required',
'email' => 'required|unique:users|email',
'password' => 'required',
];
}
}
  1. Membuat class baru bernama UserRepository, dimana pada contoh ini, kita membuat function create untuk menyimpan data user.
app\Repositories\UserRepository.php
<?php
namespace App\Repositories;
use App\Models\User;
class UserRepository
{
/**
* Saves the resource in the database
*
* param object $userData
* @return bool
*/
public function create($userData)
{
$user = new User();
$user->name = $userData->name;
$user->email = $userData->email;
$user->password = bcrypt($userData->password);
$user->save;
return $user;
}
}
  1. Kemudian pada method store di class UserController, kita cukup mendeklarasikan StoreUserRequest dan UserRepository sebagai parameters yang nantinya bisa dipanggil di dalam method kemudian disimpan pada sebuah variable. Dan boom! Kode kita terlihat lebih clean sekarang dan mudah untuk dibaca.
app\Http\Controllers\UserController.php
<?php
namespace App\Http\Controllers;
use App\Repositories\UserRepository;
use Illuminate\Http\Requests\StoreUserRequest;
use Illuminate\Http\Response;
class UserController extends Controller
{
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Requests\StoreUserRequest $request
* @param \App\Repositories\UserRepository $userRepository
* @return \Illuminate\Http\Response
*/
public function store(StoreUserRequest $request, UserRepository $userRepository)
{
$user = $userRepository->create($request);
return response()->json(['user' => $user], 201);
}
}

Dengan menggunakan Single Responsibility Principle maka kode di controller kita tidak terlihat banyak (fat) dan akan mudah untuk dibaca.

Tambahan: prinsip seperti ini akan terlihat dan terasa lebih mudah ketika project kita semakin kompleks dan besar.


Open Closed Principle

Sebuah entitas atau object harus terbuka untuk diperluas (extend), namun tertutup untuk dapat dimodifikasi.

Memperluas ( extend ) disini dapat diartikan ketika kita menambah fungsionalitas sebuah object tanpa harus mengubah kode yang sudah ada. Yang mana kita bisa memisahkan setiap behaviour atau tingkah laku object agar mudah di extend oleh class lain.

Tujuan dari prinsip ini adalah untuk menjaga kode yang sudah ada. Kode yang sudah berjalan agar dapat terhindar dari kerusakan maupun error yang bisa disebabkan ketika kita mengimplementasikan sebuah fitur baru.

Pada bagian ini, kita bisa contohkan ketika kita mempunyai sebuah class bernama Payment dan di class ini berisikan dengan hal yang berhubungan pembayaran pada sistem kita. Pada class ini kita asumsikan mempunyai dua method pembayaran yaitu melalui BANK A dan BANK B misalnya.

app\Entities\Payment.php
<?php
namespace App\Entities;
class Payment
{
public function payWithBankA()
{
// proses pembayaran dengan BANK A
}
public function payWithBankB()
{
// proses pembayaran dengan BANK B
}
}

Kemudian pada bagian controller, kita bisa memanfaatkan function-nya sebagai berikut:

app\Http\Controllers\OrderController.php
<?php
namespace App\Http\Controllers;
use App\Entities\Payment;
use App\Factory\PaymentFactory;
use Illuminate\Http\Request;
class OrderController extends Controller
{
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$payment = new Payment();
switch ($request->type) {
case 'Bank_A':
$payment->payWithBankA();
break;
case 'Bank_B':
$payment->payWithBankB();
break;
default:
return new Exception('Metode pembayaran tidak didukung');
break;
}
}
}

Kode yang sudah kita tulis, sekilas tidak ada masalah. Apa yang menjadi masalahnya? Masalah akan terjadi ketika kita mencoba mengimplementasikan metode pembayaran yang lainnya. Sebagai contoh, dikemudian hari sistem kita perlu penambahan metode pembayaran melalui BANK C misalkan.

Nah, jika kita tetap mempertahankan kode yang sudah ada. Maka setiap kita mengimplementasikan metode pembayaran baru dikemudian hari, seperti penambahan metode BANK C tadi, kita perlu melakukan perubahan pada class Payment yang sudah kita buat tadi. Dan apabila ada kode yang bermasalah, dapat dipastikan method yang sudah kita deklarasikan pada satu file tersebut akan ikut terkena dampaknya sehingga sistem kita bisa bermasalah.

Nah, dengan menerapkan Open Closed Principle kita dapat meminimalisir permasalahan tersebut. Mari kita coba.

  1. Pertama, kita buat terlebih dahulu sebuah class interface/contracts yang harus diikuti oleh setiap class yang mengimplementasikannya. Pada kasus ini, kita bisa membuat sebuah interface Payment dengan method pay.
app\Contracts\Payment.php
<?php
namespace App\Contracts;
interface Payment
{
public function pay();
}
  1. Kemudian, ketika kita ingin membuat metode pembayaran baru, kita cukup membuat class dengan nama entitas yang ingin kita buat seperti berikut
app\Entities\Bank_A.php
<?php
namespace App\Entities;
use App\Contracts\Payment;
class Bank_A implements Payment
{
public function pay()
{
// proses pembayaran menggunakan BANK A
}
}
app\Entities\Bank_B.php
<?php
namespace App\Entities;
use App\Contracts\Payment;
class Bank_B implements Payment
{
public function pay()
{
// proses pembayaran menggunakan BANK B
}
}
  1. Selanjutnya, kita perlu membuat sebuah class baru yang tugasnya untuk meneruskan permintaan request metode pembayaran ke masing-masing class sebagai berikut:
app\Factory\PaymentFactory.php
<?php
namespace App\Factory;
use App\Entities\Bank_A;
use App\Entities\Bank_B;
use Exception;
class PaymentFactory
{
public function initializePayment($type)
{
switch ($type) {
case 'BANK_A':
return new Bank_A();
break;
case 'BANK_B':
return new Bank_B();
break;
default:
return new Exception('Metode pembayaran tidak didukung');
break;
}
}
}
  1. Sehingga, pada OrderController kita cukup memanggilnya seperti ini ketika kita menggunakannya.
app\Http\Controllers\OrderController.php
<?php
namespace App\Http\Controllers;
use App\Entities\Payment;
use App\Factory\PaymentFactory;
use Illuminate\Http\Request;
class OrderController extends Controller
{
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function pay(Request $request)
{
$paymentFactory = new PaymentFactory();
$payment = $paymentFactory->initializePayment($request->type);
$payment->pay();
}
}

Mungkin terlihat sedikit meribetkan diri sendiri :D dan banyak penggunaan class. Akan tetapi, disisi lain prinsip ini sangat mempermudah apabila salah satu class bermasalah, kita cukup berurusan dengan class yang bermasalah tanpa menyentuh class yang lainnya.


Liskov Substitution Principle

Misalkan q(x) adalah properti yang dapat dibuktikan tentang objek x dari tipe T. Maka q(y) harus dapat dibuktikan untuk objek y dari tipe S di mana S adalah subtipe dari T.

Maksud dari konsep ini yaitu subclass atau class turunan ( child ), harus dapat disubstitusikan untuk kelas induk ( parent ) nya. Konsep ini terkait dengan proses inheritance suatu fungsi atau objek. Dalam Liskov Substitution Principle, super class harus dapat digantikan dengan objek dari subclass-nya tanpa berefek pada suatu kode yang sudah terimplementasi. Hal ini dapat dilakukan dengan membuat objek dari subclass yang memiliki perilaku sama dengan superclass, misalnya parameter dan return value yang sama dengan superclass.

Ini membuktikan bahwa setiap implementasi abstraction ( interface ) harus dapat diganti di mana pun itu dan abstraksinya pun dapat diterima. Pada dasarnya, perlu diperhatikan saat kita menggunakan interface di dalam sebuah kode, kita tidak hanya memiliki kontrak input yang diterima interface, akan tetapi juga output yang dikembalikan oleh class yang berbeda yang mengimplementasikan interface tersebut dan mereka seharusnya berasal dari tipe yang sama.

Mari kita mulai dengan sebuah kasus. Kita asumsikan bahwa kita mempunyai sebuah abstraksi class bernama Duck yaitu gambaran dari objek Bebek. Di dalam class tersebut pastinya terdapat beberapa method atau kegiatan apa saja yang bisa dilakukan oleh seekor bebek. Misalnya, bebek tersebut bisa berbunyi ( quack ), terbang ( fly ), dan berenang ( swim ). Maka kodenya akan terlihat seperti berikut

Duck.php
<?php
abstract class Duck
{
abstract public function quack() : string;
abstract public function swim() : string;
abstract public function fly() : string;
}

Kemudian, kita coba implementasikan abstraksi class diatas pada sebuah objek mainan bebek karet atau pada kasus ini kita mencoba membuat class dengan nama RubberDuck sebagai gambarannya. Dan mainan bebek karet ini ternyata tidak bisa terbang layaknya bebek yang asli.

RubberDuck.php
<?php
class RubberDuck extends Duck
{
public function quack()
{
$person = new Person();
if ($person->squeezeDuck($this)) {
return 'The duck is quicking `qweek`';
}
throw new Exception("A rubber duck can't quack on its own.");
}
public function swim()
{
$person = new Person();
if ($person->throwDuckInTub($this)) {
return 'The duck is swimming';
}
throw new Exception("A rubber duck can't swim on its own.");
}
public function fly()
{
throw new Exception("A rubber duck can't fly on its own.");
}

Kita akan menyalahi aturan prinsip Liskov apabila kita memaksa ( override ) method yang sudah kita deklarasikan. Dikarenakan kita perlu mengganti juga semua method yang dimiliki oleh parent class dan kita tidak menggunakan kembali method yang sudah ada. Jadi bagaimana kita bisa memperbaikinya? Kita bisa menghilangkan abstraksi Duck dengan memanfaatkan contract/interface. Kita tidak akan menggunakan basis class Duck lagi. Akan tetapi kita akan membuat bebek yang bisa berbunyi, terbang ataupun hanya bisa berenang saja misalkan.

Sehingga kita perlu mempunyai kode seperti berikut

<?php
interface QuackableInterface
{
public function quack() : string;
}
interface FlyableInterface
{
public function fly() : string;
}
interface SwimmableInterface
{
public function swim() : string;
}

Dan kemudian pada class RubberDuck kita implementasikan interface yang hanya kita perlukan.

RubberDuck.php
<?php
class RubberDuck implements QuackableInterface, SwimmableInterface
{
public function quack() : string
{
$person = new Person();
if ($person->squeezeDuck($this)) {
return 'The duck is quicking `qweek`';
}
throw new Exception("A rubber duck can't quack on its own.");
}
public function swim() : string
{
$person = new Person();
if ($person->throwDuckInTub($this)) {
return 'The duck is swimming';
}
throw new Exception("A rubber duck can't swim on its own.");
}

Jadi, seekor bebek akan tetap menjadi bebek, dan sebuah mainan bebek karet akan tetap menjadi mainan, bukan seekor bebek yang asli. Kita harus bisa subtitusikan sub-class atau class turunan kapan saja dan kita dapat mengekspetasikan untuk melakukan apa yang ingin kita harapkan dalam sebuah objek.


Interface Segregation Principle

Sebuah client tidak boleh ketergantungan menggunakan method/function yang tidak digunakan.

Apabila ada interface yang mana isi dari method-nya terlalu umum dan banyak, maka perlu dipecah menjadi lebih kecil lagi agar nantinya kode mudah diimplementasikan oleh class lainnya.

Dalam pembuatan interface, akan lebih baik jika kita membuat banyak interface dengan fungsi yang spesifik. Tujuan dari pemisahan interface adalah agar tidak memaksa client menggunakan kode yang tidak dibutuhkan/diperlukan.

Jika sudah ada interface yang tersedia, jangan menambahkan kode baru di interface. Lebih baik menambah interface baru yang masih berhubungan dengan interface lama, kemudian baru kita melakukan implement. Apalagi, dalam suatu class kita dapat melakukan implementasi lebih dari satu interface, jadi mengapa kita tidak memanfaatkan itu?

Kita coba asumsikan bahwa kita mempunyai class bernama Subscriber dan class ini merupakan turunan dari class Model.

<?php
use Illuminate\Database\Eloquent\Model;
class Subscriber extends Model
{
public function getNotifyEmail()
{
// mengirimkan pemberitahuan email
}
}

Kemudian kita juga mempunyai class Notifications, dimana di dalamnya terdapat method send yang berfungsi untuk mengirimkan pemberitahuan pada penerima ( subscriber ).

<?php
class Notifications
{
public function send(Subscriber $subscriber, $message)
{
Mail::to($subscriber->getNotifyEmail())->queue($message);
}
}

Pada kode diatas, method send membutuhkan class Subscriber yang mana tipe datanya berupa Eloquent. Dikarenakan class Subscriber merupakan turunan dari class Model pada Laravel.

Dan yang perlu diperhatikan disini adalah kita akan ketergantungan pada method yang ada pada class Subscriber karena dengan alasan apapun ketika class Model berubah maka class Subscriber juga akan ikut berubah. Itulah yang menyebabkan atau dinamakan dengan ketergantungan antar class. Lalu bagaimana kita mengatasi ketergantungan ini?

Untuk mengatasi masalah ini, kita perlu membuat sebuah interface baru yang mana hanya mempunyai method getNotifyEmail. Interface ini pada dasarnya menyatakan bahwa subscriber kita perlu mengimplementasikan method getNotifyEmail sehingga kita bisa mendapatkan data email dari para subscriber. Dan dengan implementasi interface ini, method send pada class Notifications tidak lagi ketergantungan dengan class Subscriber. Kode kita akan menjadi sepert berikut

<?php
interface NotifiableInterface
{
public function getNotifyEmail() : string;
}
class Notifications
{
public function send(NotifiableInterface $subscriber, $message)
{
Mail::to($subscriber->getNotifyEmail())->queue($message);
}
}

Kita hanya mengurangi bagian parameter pada method send yang sebelumnya kita passing class dari Subscriber menjadi interface NotifiableInterface sehingga kita hanya bergantung pada satu method interface yang ada.


Dependency Inversion Principle

  1. High-Level Class tidak boleh ketergantungan dengan Low-Level Class. Dimana keduanya harus bergantung pada sebuah abstraksi.
  2. Mampu melakukan perubahan implementasi tanpa mengubah kode High-Level

Kode yang terorganisir dengan baik selalu memiliki hierarki (hubungan/susunan). Ada modul tingkat tinggi ( High-Level Class ) dan modul tingkat rendah ( Low-Level Class ). Akan tetapi, terkadang sering kali kita membawa modul tingkat rendah langsung ke modul tingkat tinggi. Dan itu menyalahi aturan jika menggunakan prinsip ini.

Setiap class yang memiliki kompleksitas tinggi tidak boleh bergantung pada class yang memiliki kompleksitas rendah, dan untuk melakukan komunikasi setiap class harus melalui abstraksinya ( interface ).

Biasanya, kita langsung menuliskan kode seperti dibawah ketika ingin menampilan data pengguna, alih-alih melakukan query pada method index didalam controller.

UserController.php
<?php
public function index(User $user)
{
$users = $user->where('created_at', '>=', Carbon::yerterday())->get();
return response()->json(['users' => $users], 200);
}

Dengan prinsip Dependency Inversion kita bisa memanfaatkan repository dan interface agar sebuah class dapat terorganisir dengan baik dan memiliki susunan atau hierarki.

  1. Membuat class baru bernama UserRepository yang mengimplementasikan interface UserRepositoryInterface
<?php
use App\Models\User;
class UserRepository implements UserRepositoryInterface
{
/**
* Gets user records from database registered
* after certain date
*
* param Carbon $date
* @return Illuminate\Database\Eloquent\Collection
*/
public function getAfterDate(Carbon $date)
{
return User::where('created_at', '>=', $date)->get();
}
}
  1. Membuat class interface dengan bernama UserRepositoryInterface sebagai contract kita.
UserRepositoryInterface.php
<?php
interface UserRepositoryInterface
{
public function getAfterDate(Carbon $date);
}
  1. Kemudian pada controller kita coba passing dengan interface yang sudah kita buat tadi.
UserController.php
<?php
public function index(UserRepositoryInterface $user)
{
$users = $user->getAfterDate(Carbon::yerterday());
return response()->json(['users' => $users], 200);
}

Oke, kode kita sekarang terlihat lebih clean dan sekaligus mempunyai susunan yang teratur.

Penutup

Konsep SOLID perlu dipahami oleh setiap Software Engineer setelah mempelajari Object Oriented Programming (OOP).

Sebagai catatan, Konsep SOLID ini merupakan sebuah prinsip, bukan aturan. Jadi perlunya melihat konteks dari kode yang ada, bukan semata-mata harus diimplementasikan sesuai dengan prinsip ini.

Jika kode kita sudah mudah untuk dibaca atau mungkin sangat sederhana kemudian kita mencoba menerapkan prinsip ini, maka yang akan terjadi kita akan membuatnya lebih rumit dan akan menyulitkan diri sendiri untuk memeliharanya ( maintenance ) nanti. Sehingga kita perlu mencari tahu trade-offs dan common sense dari penggunaan konsep ini.

Konsep SOLID hanyalah sebuah alat, bukan GOAL atau tujuan. Jadi, yang terpenting capaian dari konsep ini adalah kode kita bisa lebih baik, lebih terstruktur yang perlu diupayakan bukan mengupayakan konsepnya untuk dapat dicapai.

Dan yang terakhir, dengan implementasi konsep SOLID ini, seharusnya kode yang kita tulis akan lebih reuseable, maintainable, scalable, dan testable.

Sekian dan Semoga bermanfaat. Sampai Jumpa.

Referensi:

  1. The First Five Principles of Object Oriented Design
  2. Prinsip Pemrograman SOLID
  3. SOLID Design Principle Laravel
  4. SOLID Principles Explanation and Examples
  5. Katerina Trajchevska in Laravel Conference