Why Clean Code Is Not Enough
Writing readable code is table stakes. What separates good engineers from great ones is how they think about the system surrounding that code.
Every developer who has read Clean Code comes away with the same lesson: names matter, functions should be small, and comments are a last resort. These are good instincts. But after applying them for a few years, you start to notice something unsettling — the codebase is legible line by line, yet something is still wrong.
The architecture fights you. The abstractions leak. Adding a feature requires touching six files you did not expect. The code is clean, but the system is not.
The Difference Between Code and System
Clean code operates at the level of a function or a class. A well-named function with a single responsibility is genuinely easier to work with. But a codebase is not a collection of functions — it is a network of decisions, each one constraining the decisions that come after it.
When you write getUserById, you have made a decision about how identity works in your system. When you put it inside a UserService, you have decided that users are a domain concept worth grouping. When that service reaches directly into the database, you have coupled two things that will be painful to separate later.
The mess is not in the code. The mess is in the space between the code.

Clean code cannot save you from a poor domain model. It can only make the poor model more legible.
What Actually Matters at Scale
After a few years working on systems with real traffic and real teams, a different set of concerns become dominant:
Boundaries, not names. Where does one module end and another begin? Can you change the database without touching the HTTP layer? If not, the naming inside each layer is almost irrelevant — the coupling will kill you.
Deletability, not extensibility. The old advice says to write code open for extension, closed for modification. In practice, the most valuable thing a piece of code can be is easy to delete. If you cannot remove it without surgery, it has too much power.
Explicit dependencies, not convenient globals. A function that reaches into a singleton or a global config is a function that cannot be understood in isolation. Dependency injection feels ceremonial at first. At scale, it is the only thing that keeps tests passing and refactors contained.
A Concrete Example
Consider a simple feature: sending a welcome email when a user registers.
The clean-code approach might look like this:
// UserService.ts
async function registerUser(email: string, password: string) {
const user = await db.users.create({ email, password: hash(password) });
await emailService.sendWelcome(user.email);
return user;
}
The names are fine. The function is short. But it has made several quiet architectural decisions:
- Registration is coupled to email delivery. If the email service is down, registration fails.
- The function has two side effects that are not atomic. A crash between the two leaves the system inconsistent.
- Testing this function requires mocking two external systems.
A systems-level approach separates these concerns:
// registration.ts — pure domain logic, no side effects
async function registerUser(email: string, password: string) {
const user = await db.users.create({ email, password: hash(password) });
return user;
}
// events/user.ts — side effects triggered by domain events
onEvent('user.registered', async (user) => {
await emailQueue.enqueue({ type: 'welcome', to: user.email });
});
The code is arguably less clean in the traditional sense — it is spread across more files. But the system is more honest: email delivery is a background concern, and the registration flow does not depend on it.

The Lesson
Clean code is a necessary skill. Read the book, apply the principles, practice until they become instinct. But do not mistake it for architecture.
The questions that matter at scale are:
- What are the boundaries of this system?
- What can I delete without touching anything else?
- Where are the hidden dependencies?
Answer those, and the naming and formatting become almost easy — because the structure tells you what the names should be.
Clean code without system thinking is like elegant handwriting on a poorly reasoned argument. The craft is real, but it does not change the conclusion.
Setiap developer yang pernah membaca Clean Code pulang dengan pelajaran yang sama: nama-nama penting, fungsi harus kecil, dan komentar adalah pilihan terakhir. Ini adalah insting yang baik. Tapi setelah menerapkannya selama beberapa tahun, kamu mulai memperhatikan sesuatu yang mengkhawatirkan — codebase ini mudah dibaca baris per baris, namun ada yang masih salah.
Arsitekturnya melawanmu. Abstraksinya bocor. Menambahkan fitur mengharuskan menyentuh enam file yang tidak kamu duga. Kodenya bersih, tapi sistemnya tidak.
Perbedaan Antara Kode dan Sistem
Kode bersih beroperasi di tingkat fungsi atau kelas. Sebuah fungsi yang diberi nama dengan baik dengan satu tanggung jawab memang lebih mudah dikerjakan. Tapi codebase bukan kumpulan fungsi — ia adalah jaringan keputusan, di mana setiap keputusan membatasi keputusan yang datang setelahnya.
Ketika kamu menulis getUserById, kamu telah membuat keputusan tentang bagaimana identitas bekerja dalam sistemmu. Ketika kamu meletakkannya di dalam UserService, kamu telah memutuskan bahwa pengguna adalah konsep domain yang layak dikelompokkan. Ketika layanan itu langsung mengakses database, kamu telah menggabungkan dua hal yang akan menyakitkan untuk dipisahkan nanti.
Kekacauan bukan di kodenya. Kekacauan ada di ruang di antara kode.

Kode bersih tidak bisa menyelamatkanmu dari model domain yang buruk. Ia hanya bisa membuat model yang buruk lebih mudah dibaca.
Apa yang Sebenarnya Penting di Skala Besar
Setelah beberapa tahun bekerja pada sistem dengan traffic nyata dan tim yang nyata, serangkaian kekhawatiran yang berbeda menjadi dominan:
Batasan, bukan nama. Di mana satu modul berakhir dan modul lain mulai? Bisakah kamu mengubah database tanpa menyentuh HTTP layer? Jika tidak, penamaan di dalam setiap layer hampir tidak relevan — coupling akan membunuhmu.
Kemampuan dihapus, bukan extensibility. Saran lama mengatakan untuk menulis kode yang terbuka untuk ekstensi, tertutup untuk modifikasi. Dalam praktik, hal paling berharga yang bisa dimiliki sepotong kode adalah mudah dihapus. Jika kamu tidak bisa menghapusnya tanpa operasi bedah, ia memiliki terlalu banyak kekuatan.
Dependensi eksplisit, bukan global yang nyaman. Sebuah fungsi yang mengakses singleton atau konfigurasi global adalah fungsi yang tidak bisa dipahami secara terpisah. Dependency injection terasa seremonial pada awalnya. Pada skala besar, itu adalah satu-satunya hal yang menjaga tes lulus dan refactor terkendali.
Contoh Konkret
Pertimbangkan fitur sederhana: mengirim email selamat datang saat pengguna mendaftar.
Pendekatan clean-code mungkin terlihat seperti ini:
// UserService.ts
async function registerUser(email: string, password: string) {
const user = await db.users.create({ email, password: hash(password) });
await emailService.sendWelcome(user.email);
return user;
}
Namanya baik-baik saja. Fungsinya pendek. Tapi ia telah membuat beberapa keputusan arsitektur yang diam-diam:
- Registrasi digabungkan dengan pengiriman email. Jika layanan email mati, registrasi gagal.
- Fungsi ini memiliki dua efek samping yang tidak bersifat atomik. Crash di antara keduanya membuat sistem tidak konsisten.
- Menguji fungsi ini membutuhkan mocking dua sistem eksternal.
Pendekatan tingkat sistem memisahkan kekhawatiran ini:
// registration.ts — logika domain murni, tanpa efek samping
async function registerUser(email: string, password: string) {
const user = await db.users.create({ email, password: hash(password) });
return user;
}
// events/user.ts — efek samping dipicu oleh event domain
onEvent('user.registered', async (user) => {
await emailQueue.enqueue({ type: 'welcome', to: user.email });
});
Kodenya bisa dibilang kurang bersih dalam pengertian tradisional — ia tersebar di lebih banyak file. Tapi sistemnya lebih jujur: pengiriman email adalah kekhawatiran di latar belakang, dan alur registrasi tidak bergantung padanya.

Pelajarannya
Kode bersih adalah keterampilan yang diperlukan. Baca bukunya, terapkan prinsip-prinsipnya, latih sampai menjadi insting. Tapi jangan kelirukan itu dengan arsitektur.
Pertanyaan yang penting pada skala besar adalah:
- Apa batasan sistem ini?
- Apa yang bisa saya hapus tanpa menyentuh hal lain?
- Di mana dependensi tersembunyi?
Jawab itu, dan penamaan serta pemformatan menjadi hampir mudah — karena strukturnya memberi tahu kamu nama-nama apa yang seharusnya ada.
Kode bersih tanpa pemikiran sistem seperti tulisan tangan yang elegan pada argumen yang kurang dipertimbangkan. Keahliannya nyata, tapi itu tidak mengubah kesimpulannya.