C# C# Indonesia
← Semua catatan

Agentic Coding

Dua composition root yang tidak pernah dibandingkan — dan agent cuma membaca satu

Aplikasi yang punya lebih dari satu entrypoint mendaftarkan service-nya dua kali, sendiri-sendiri. Agent akan menyunting yang dia baca duluan, `dotnet build` tetap hijau, dan bedanya baru terasa di lingkungan yang bukan laptop Anda.

Catatan 10. Sembilan catatan sebelumnya soal apa yang perlu ditulis di konteks agent. Yang ini soal satu hal yang tidak akan agent temukan sendiri, seberapa bagus pun model-nya — karena bukti kesalahannya tidak ada di file yang dia buka.

Mulai dari aturan di sebuah repo TypeScript yang saya baca minggu ini:

These two files construct the same services … independently and drift silently. A capability wired into one but not the other works locally and fails in cloud, or the reverse. Nothing catches it: there is no shared factory and no test asserting parity.

Dua file. Keduanya merakit service yang sama. Tidak ada yang membandingkannya.

Kenapa ini bukan bug biasa

Bug biasa punya jejak: stack trace, tes merah, review yang menangkap. Yang ini tidak punya satu pun. Kode di kedua file benar secara lokal. Tiap file, dibaca sendirian, lolos review — karena yang salah bukan isinya, melainkan selisih antara keduanya. Dan selisih tidak muncul di diff satu file.

Di .NET polanya sangat umum. Anda punya API dan satu background worker:

// src/Shop.Api/Program.cs
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IPricingService, PricingService>();
builder.Services.AddScoped<IAuditWriter, SqlAuditWriter>();

// src/Shop.Worker/Program.cs
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IPricingService, PricingService>();
// IAuditWriter? tidak ada di sini.

dotnet build hijau. dotnet test hijau, kalau tes Anda memakai ServiceCollection sendiri — dan hampir semua tes unit begitu. Worker baru meledak saat runtime, dengan InvalidOperationException: Unable to resolve service for type 'IAuditWriter', di environment yang menjalankannya.

Yang diperparah agent

Beri agent task “tambahkan audit log ke proses order”. Dia akan membuka Shop.Api, menemukan pola registrasi, menambahkan satu baris, menjalankan build, dan melaporkan selesai. Semua langkahnya benar. Shop.Worker/Program.cs tidak pernah dia buka karena tidak ada alasan untuk membukanya — tidak ada referensi, tidak ada nama yang cocok saat grep, tidak ada tes yang gagal.

Ini kelas masalah yang sama dengan yang saya bahas di catatan 06: konteks yang tidak diminta tidak akan terbaca. Bedanya, di sini yang hilang bukan aturan, melainkan keberadaan file kedua.

Perbaikan pertama: satu extension method

Dokumentasi .NET menyebut konvensinya secara eksplisit — satu Add{GROUP_NAME} untuk mendaftarkan semua service sebuah fitur. Pakai itu sebagai satu-satunya pintu:

// src/Shop.Application/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddShopCore(
        this IServiceCollection services, IConfiguration config)
    {
        services.AddDbContext<ShopDbContext>(o =>
            o.UseNpgsql(config.GetConnectionString("Shop")));
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<IPricingService, PricingService>();
        services.AddScoped<IAuditWriter, SqlAuditWriter>();
        return services;
    }
}

Sekarang kedua Program.cs cukup memanggil builder.Services.AddShopCore(config) — satu baris, sama di keduanya. Tapi ini baru konvensi, bukan gerbang: tidak ada yang mencegah orang berikutnya — atau agent — menambahkan satu AddScoped langsung di Program.cs karena itu jalan terpendek.

Perbaikan kedua: tes paritas

Naikkan konvensinya jadi tes, persis seperti pada batas arsitektur di catatan 09:

public class CompositionRootParityTests
{
    private static HashSet<string> Describe(Action<IServiceCollection> compose)
    {
        var services = new ServiceCollection();
        compose(services);
        return services
            .Select(d => $"{d.ServiceType.FullName}|{d.Lifetime}")
            .ToHashSet();
    }

    [Fact]
    public void Worker_registers_every_core_service_the_api_does()
    {
        var config = new ConfigurationBuilder().Build();

        var api = Describe(s => ApiCompositionRoot.Configure(s, config));
        var worker = Describe(s => WorkerCompositionRoot.Configure(s, config));

        var missing = api.Except(worker)
            .Where(d => d.StartsWith("Shop.Application.")
                     || d.StartsWith("Shop.Domain."))
            .ToList();

        Assert.True(missing.Count == 0,
            $"Worker is missing: {string.Join(", ", missing)}");
    }
}

Filter namespace-nya penting: API memang boleh punya registrasi yang worker tidak punya — AddControllers, autentikasi, CORS. Yang tidak boleh berbeda adalah service domain dan application.

Satu lagi yang murah dan sering dilewat, pasang di kedua host:

builder.Host.UseDefaultServiceProvider(o =>
{
    o.ValidateScopes = true;
    o.ValidateOnBuild = true;
});

ValidateOnBuild memindahkan Unable to resolve service dari request pertama ke startup: setiap dependency yang tidak bisa dipenuhi untuk service yang terdaftar ketahuan saat host dibangun. Worker yang salah wiring mati saat deploy, bukan tiga jam kemudian saat pesan pertama masuk. Perlu dicatat batasnya — yang diperiksa hanya constructor dari service yang terdaftar; GetRequiredService<IAuditWriter>() yang dipanggil langsung di tengah kode tetap lolos sampai baris itu jalan. Itu justru alasan lain untuk tidak mengandalkan satu mekanisme saja.

Yang ditulis di CLAUDE.md

Tes menangkap pelanggaran. CLAUDE.md-lah yang mencegah agent membuatnya:

## Composition roots

There are two: `src/Shop.Api/Program.cs` and `src/Shop.Worker/Program.cs`.
Register services in `AddShopCore()` (`src/Shop.Application/DependencyInjection.cs`),
never directly in either `Program.cs`. `CompositionRootParityTests` asserts parity.

Tiga baris, dan yang paling berharga adalah yang pertama: jumlah composition root Anda ada berapa. Itu fakta yang tidak bisa disimpulkan dari file mana pun sendirian.

Jadi pertanyaan yang saya pakai sekarang sebelum menyerahkan repo ke agent: kalau dia hanya membuka satu entrypoint, apa yang dia lewatkan — dan apakah ada yang merah kalau dia melewatkannya?

Referensi: https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection/service-registration