31 Mar 2026 • Admin KhalimZone
CI4 Service Layer & Repository Pattern
Bye Gemuk Controller — Implementasi Service Layer & Repository Pattern di CI4
Kalau Controller kamu udah ratusan baris dan isinya campur aduk — query database, validasi bisnis, kirim email, dan return response semua dijejelin di satu tempat — itu sinyal kuat bahwa kode kamu butuh distrukturin ulang.
Service Layer dan Repository Pattern adalah dua pola arsitektur yang bakal bikin kode CI4 kamu jauh lebih modular, testable, dan gampang di-maintain. Ini bukan teori akademis — ini yang gue pakai di production.
Setiap layer punya satu tanggung jawab. Controller cuma tau cara menerima request dan return response. Service tau business logic-nya. Repository tau cara ngambil dan nyimpen data. Model tau struktur tabel-nya.
Mulai dengan mendefinisikan kontrak — apa yang bisa dilakukan oleh sebuah Repository. Ini kuncinya: Controller dan Service gak boleh tau implementasi detailnya.
namespace App\Interfaces;
interface UserRepositoryInterface
{
public function findById(int $id): ?array;
public function findByEmail(string $email): ?array;
public function findAllActive(): array;
public function create(array $data): int;
public function update(int $id, array $data): bool;
public function delete(int $id): bool;
}
Dengan Interface, kamu bisa swap implementasi — misalnya ganti dari MySQL ke API eksternal — tanpa ngubah satu baris pun di Service atau Controller.
Repository adalah satu-satunya tempat yang boleh ngobrol langsung dengan Model atau Query Builder. Semua logika query dikurung di sini.
namespace App\Repositories;
use App\Interfaces\UserRepositoryInterface;
use App\Models\UserModel;
class UserRepository implements UserRepositoryInterface
{
public function __construct(
private readonly UserModel $model
) {}
public function findById(int $id): ?array
{
return $this->model->find($id);
}
public function findByEmail(string $email): ?array
{
return $this->model
->where('email', $email)
->first();
}
public function findAllActive(): array
{
return $this->model
->where('status', 'active')
->orderBy('created_at', 'DESC')
->findAll();
}
public function create(array $data): int
{
$this->model->insert($data);
return $this->model->getInsertID();
}
public function update(int $id, array $data): bool
{
return $this->model->update($id, $data);
}
public function delete(int $id): bool
{
return $this->model->delete($id);
}
}
Service adalah tempat business logic hidup — validasi bisnis (bukan validasi form), transformasi data, orchestrasi antar-repository, dan hal-hal seperti kirim email atau notifikasi.
namespace App\Services;
use App\Interfaces\UserRepositoryInterface;
use App\Exceptions\UserAlreadyExistsException;
use App\Exceptions\UserNotFoundException;
class UserService
{
public function __construct(
private readonly UserRepositoryInterface $users
) {}
public function register(array $data): int
{
// Business rule: email harus unik
if ($this->users->findByEmail($data['email'])) {
throw new UserAlreadyExistsException(
'Email sudah terdaftar: ' . $data['email']
);
}
// Hash password sebelum simpan
$data['password'] = password_hash(
$data['password'],
PASSWORD_BCRYPT
);
$data['status'] = 'active';
$data['created_at'] = date('Y-m-d H:i:s');
return $this->users->create($data);
}
public function getActiveUsers(): array
{
$users = $this->users->findAllActive();
// Strip password sebelum expose ke luar
return array_map(fn($u) => array_diff_key(
$u, ['password' => '']
), $users);
}
public function deactivate(int $id): void
{
if (!$this->users->findById($id)) {
throw new UserNotFoundException("User #$id tidak ditemukan");
}
$this->users->update($id, ['status' => 'inactive']);
}
}
Service tidak boleh tau apapun soal HTTP — jangan pernah panggil $this->request atau redirect() di dalam Service. Itu urusan Controller.
Daripada return false atau array error yang ambigu, lempar Exception yang deskriptif. Ini bikin flow error jauh lebih predictable dan mudah di-handle.
namespace App\Exceptions;
class UserAlreadyExistsException extends \RuntimeException {}
class UserNotFoundException extends \RuntimeException {}
Cukup satu baris per exception class. Controller yang handle try-catch dan translate exception ini jadi HTTP response yang tepat.
Hasilnya: Controller cuma tau cara terima request, panggil Service, dan return response. Gak ada satu baris query, gak ada business logic di sini.
namespace App\Controllers;
use App\Services\UserService;
use App\Exceptions\UserAlreadyExistsException;
class UserController extends BaseController
{
public function __construct(
private readonly UserService $userService
) {}
public function index()
{
return view('users/index', [
'users' => $this->userService->getActiveUsers(),
]);
}
public function store()
{
if (!$this->validate([
'nama' => 'required|min_length[3]',
'email' => 'required|valid_email',
'password' => 'required|min_length[8]',
])) {
return redirect()->back()->withInput()
->with('errors', $this->validator->getErrors());
}
try {
$this->userService->register($this->request->getPost());
return redirect()->to('/users')->with('success', 'User berhasil didaftarkan.');
} catch (UserAlreadyExistsException $e) {
return redirect()->back()->withInput()
->with('error', $e->getMessage());
}
}
}
CI4 punya sistem Dependency Injection ringan lewat Config\Services. Daftarin Service dan Repository di sini biar bisa di-resolve otomatis.
use App\Repositories\UserRepository;
use App\Services\UserService;
use App\Models\UserModel;
class Services extends BaseService
{
public static function userService(bool $getShared = true): UserService
{
if ($getShared) {
return static::getSharedInstance('userService');
}
$repo = new UserRepository(new UserModel());
return new UserService($repo);
}
}
Sekarang di Controller atau mana pun, kamu bisa panggil service('userService') atau inject via constructor — dan CI4 yang handle instantiation-nya.
Untuk testing, kamu tinggal swap implementasi Repository-nya dengan mock — tanpa menyentuh Service atau Controller sama sekali. Itulah kekuatan Interface.
Biar konsisten dan gampang dinavigasi tim, ikutin struktur ini:
app/
├── Controllers/
│ └── UserController.php
│
├── Services/
│ └── UserService.php ← business logic
│
├── Repositories/
│ └── UserRepository.php ← data access
│
├── Interfaces/
│ └── UserRepositoryInterface.php
│
├── Exceptions/
│ ├── UserAlreadyExistsException.php
│ └── UserNotFoundException.php
│
└── Models/
└── UserModel.php ← CI4 Model biasa
Setiap folder punya satu tujuan. Kalau kamu butuh fitur baru, kamu tau persis harus buka file mana — dan yang lebih penting, tau file mana yang tidak perlu disentuh.
Service Layer dan Repository Pattern bukan over-engineering — ini investasi yang terbayar lunas saat project makin besar dan tim makin banyak. Controller jadi tipis, logic bisnis bisa di-test tanpa database, dan swap implementasi bisa dilakukan tanpa ripple effect ke seluruh codebase. Kalau lo udah coba ini di project lo, drop di komentar — gue penasaran hasilnya.