Setelah modul-modul terpisah (chapter pertama section ini) dan urusan-urusannya dibelah (chapter lalu), muncul pertanyaan yang menentukan kesehatan jangka panjang: bagaimana relasi antar modul itu sendiri dikelola? Di Section Build System kita sudah membahas dependency management dari sisi alat — vendor, submodule, vcpkg, Conan — yang mengurus library eksternal milik orang lain. Chapter ini membahas lapisan yang lebih mendasar dan lebih sering melukai proyek: dependensi internal, yaitu modul siapa butuh modul siapa untuk bisa dikompilasi. Alat build hanya melaksanakan graf yang kita gambar; chapter ini tentang menggambar grafnya dengan benar.

Coupled vs Decoupled: Satu Layanan, Dua Desain #

Kopling (coupling) adalah jumlah pengetahuan yang harus dimiliki satu modul tentang isi modul lain. Contoh berikut memperagakan ujung keduanya: dua desain LayananPesanan yang mengerjakan hal yang sama — mengirim notifikasi — tetapi dengan cara yang sangat berbeda terhadap pengirimnya:

cpp
// kopling.cpp — satu layanan, dua desain: kopling erat vs kopling longgar
// Kompilasi: g++ -std=c++23 -Wall -Wextra kopling.cpp -o kopling

#include <print>
#include <string>

// ===== Implementasi konkret yang dipakai desain A =====
class PengirimEmail {
public:
    void kirim(const std::string& tujuan, const std::string& isi) {
        std::println("[email ke {}] {}", tujuan, isi);
    }
};

// ===== Desain A: kopling erat — layanan membangun detailnya sendiri =====
namespace kopling_erat {
    class LayananPesanan {
    public:
        void proses(const std::string& pelanggan, int total) {
            // Layanan mengetahui, memilih, dan membangun pengirimnya sendiri
            PengirimEmail email;
            email.kirim(pelanggan,
                        "Pesanan Anda diterima. Total Rp" + std::to_string(total));
        }
    };
}

// ===== Desain B: kopling longgar — layanan hanya kenal antarmuka =====
namespace kopling_longgar {
    class Pengirim {                 // antarmuka minimal: satu method saja
    public:
        virtual ~Pengirim() = default;
        virtual void kirim(const std::string& tujuan, const std::string& isi) = 0;
    };

    class PengirimWa : public Pengirim {
    public:
        void kirim(const std::string& tujuan, const std::string& isi) override {
            std::println("[wa ke {}] {}", tujuan, isi);
        }
    };

    class PengirimSms : public Pengirim {
    public:
        void kirim(const std::string& tujuan, const std::string& isi) override {
            std::println("[sms ke {}] {}", tujuan, isi);
        }
    };

    class LayananPesanan {
    public:
        explicit LayananPesanan(Pengirim& pengirim) : pengirim_(pengirim) {}

        void proses(const std::string& pelanggan, int total) {
            pengirim_.kirim(pelanggan,
                            "Pesanan Anda diterima. Total Rp" + std::to_string(total));
        }

    private:
        Pengirim& pengirim_;   // hanya kenal antarmuka, bukan implementasi
    };
}

int main() {
    std::println("== desain A: kopling erat ==");
    kopling_erat::LayananPesanan layananA;
    layananA.proses("[email protected]", 48000);   // selalu email, selamanya

    std::println("== desain B: kopling longgar ==");
    kopling_longgar::PengirimWa wa;
    kopling_longgar::LayananPesanan layananB(wa);
    layananB.proses("[email protected]", 48000);

    kopling_longgar::PengirimSms sms;
    kopling_longgar::LayananPesanan layananC(sms);
    layananC.proses("[email protected]", 32000);   // pengirim ditukar, layanan utuh
    return 0;
}

Output yang Dihasilkan:

text
== desain A: kopling erat ==
[email ke [email protected]] Pesanan Anda diterima. Total Rp48000
== desain B: kopling longgar ==
[wa ke [email protected]] Pesanan Anda diterima. Total Rp48000
[sms ke [email protected]] Pesanan Anda diterima. Total Rp32000

Perbedaannya ada di satu baris keputusan. Desain A: LayananPesanan membangun PengirimEmail sendiri di dalam proses() — class layanan mengetahui detail konkret (nama class, cara konstruksinya), sehingga koplingnya erat. Konsekuensinya nyata di output: layanan A selamanya mengirim email; mengganti ke WhatsApp berarti menyunting isi LayananPesanan, menguji ulang seluruhnya, dan semua pemakai ikut dikompilasi ulang. Desain B: layanan menerima Pengirim& — hanya nama antarmuka yang ia kenal — dan pilihan konkret (Wa, Sms) dibuat di main(), tempat perakitan. Baris layananC(sms) membuktikannya: implementasi ditukar tanpa satu baris LayananPesanan berubah. Perhatikan juga antarmukanya: satu method saja, persis yang dibutuhkan layanan — bukan satu antarmuka besar berisi kirim, terima, sambungkan, dan putuskan yang dipakai separuhnya.

Arah Dependensi dan Mengapa Ia Harus Satu Arah #

Setiap #include dan setiap tipe parameter adalah panah: “A butuh B”. Desain yang sehat menjaga graf panah itu satu arah ke bawah, dari lapisan tinggi ke lapisan rendah, seperti diagram lapisan parsing/analisis/laporan pada chapter lalu. Alasannya praktis: bila analisis butuh parsing tetapi parsing tidak butuh analisis, mengubah parsing menuntut kita memeriksa analisis — tidak sebaliknya. Kita selalu tahu ke mana efek perubahan mengalir, dan urutan menguji bagian menjadi jelas: bagian bawah dulu, karena bagian atas bergantung padanya.

Arah panah juga menentukan biaya kompilasi: mengubah header yang berada di dasar graf memicu kompilasi ulang seluruh yang di atasnya. Makin sedikit panah yang menuju modul mendasar — dan makin ramping antarmukanya — makin kecil badai rebuild. Kaidah penasihatnya sederhana: modul yang paling sering berubah seharusnya punya paling sedikit dependen (dependents), dan modul paling stabil boleh banyak diminta.

Circular Dependency: Lingkaran yang Mengunci Dua Modul Sekaligus #

Kesalahan arah yang paling sering adalah panah yang saling silang — circular dependency. Di header, gejalanya klasik: a.h meng-include b.h demi deklarasi class B, dan b.h meng-include a.h demi class A:

cpp Lingkaran include — dua header saling membutuhkan (tidak dikompilasi)
// a.h
#pragma once
#include "b.h"        // A butuh B
class A { B* mitra_; };

// b.h
#pragma once
#include "a.h"        // B butuh A — lingkaran tertutup
class B { A* mitra_; };

Include guard mencegah include tanpa henti, tetapi salah satu file kebagian urutan buruk: saat a.h dimuat lebih dulu, #include "b.h" menggantung pada a.h yang sedang setengah dimuat — compiler tiba di class A { B* mitra_; }; tanpa pernah melihat definisi B, dan error “unknown type name” muncul di file yang tampaknya benar. Bahkan bila berhasil dikompilasi lewat urutan keberuntungan, lingkaran tetap meninggalkan dua luka struktural: dua modul tidak bisa dipisahkan menjadi dua library, dan salah satunya tidak bisa diuji tanpa membawa yang lain ikut dibangun.

Solusinya selalu satu pola: putuskan salah satu panah dengan mengangkat kebutuhan bersama ke tempat yang lebih rendah. Bila A dan B saling memanggil, biasanya keduanya memakai satu konsep yang sama — pindahkan konsep itu ke modul ketiga yang lebih dasar, atau jadikan kebutuhan itu antarmuka yang diimplementasikan masing-masing pihak:

cpp Solusi: konsep bersama turun satu lapis, panah tidak lagi silang (sketsa)
// konsep-mitra.h — milik lapisan yang lebih rendah, dipakai keduanya
#pragma once
struct Mitra;                       // forward declaration saja

// a.h — tidak lagi meng-include b.h
#include "konsep-mitra.h"
class A { Mitra* mitra_; };

// b.h — tidak lagi meng-include a.h
#include "konsep-mitra.h"
class B { Mitra* mitra_; };

Kedua header kini hanya menunjuk ke bawah ke konsep-mitra.h; lingkaran pecah menjadi dua panah searah. Ini persis kekuatan forward declaration dari chapter Abstraction dan Encapsulation — panah “butuh” yang sebenarnya hanya butuh nama, bukan isi, bisa dipotong dengan deklarasi tanpa definisi.

Inversion: Membalik Arah Panah yang Saljuang #

Kasus lingkaran dan kasus kopling erat di atas ternyata satu obatnya juga: dependency inversion. Di desain A, panahnya LayananPesanan → PengirimEmail — kebijakan tingkat tinggi (proses pesanan) menunjuk langsung ke detail tingkat rendah (protokol email). Kebijakan semacam itu seharusnya paling stabil, tetapi justru ia yang ikut berubah setiap kali detail berubah. Inversi membalik panah: layanan menetapkan kontrak Pengirim (detail yang harus memenuhi kontrak itulah yang menunjuk ke kontrak), dan detail konkret ditempel dari luar saat perakitan. Panah LayananPesanan → detail berubah menjadi detail → antarmuka ← LayananPesanan; kedua sisi kini hanya bergantung pada abstraksi, tidak saling bergantung.

Perhatikan bahwa antarmuka Pengirim di desain B dimiliki oleh penggunanya (LayananPesanan), bukan oleh implementasinya — inilah beda inversi dengan sekadar punya interface. Antarmuka menjawab “apa yang layanan butuhkan”, bukan “apa yang pengirim mampu”. Pembanding cepat dua desain:

AspekDesain A (erat)Desain B (longgar)
Pengetahuan layanan tentang pengirimNama class, konstruksi, semuanyaHanya antarmuka Pengirim
Menambah kanal baru (SMS, WA)Sunting LayananPesananClass baru tanpa menyentuh layanan
Menguji layanan sendirianTidak bisa — email ikut terkirimBisa — injeksi implementasi uji
Arah dependensiLayanan → detail konkretDetail konkret → antarmuka

Kolom terakhir tabel di atas — menguji layanan dengan implementasi palsu — adalah kemenangan praktis terbesar dari inversi, dan layak dibahas dengan bukti yang bisa dijalankan. Chapter berikutnya membahasnya sebagai teknik dengan nama sendiri: dependency injection.

Referensi: Abstract classes (cppreference) , Virtual functions (cppreference) .