AICode Generation.NETRoslynPerformance

Je kostenmodel is verouderd, je onderhoudsmodel niet: ambitieuzer bouwen met AI in .NET

Dan Luu vindt dat we met AI veel ambitieuzere software moeten bouwen. Hij heeft half gelijk. Hoe je als .NET-developer kiest wat de moeite waard is, met een Roslyn-analyzer en een benchmark-gate als voorbeeld.

JB
Jean-Pierre Broeders
Freelance .NET Developer · 26 oktober 2026 · 12 min lezen
Je kostenmodel is verouderd, je onderhoudsmodel niet: ambitieuzer bouwen met AI in .NET

Iedere developer heeft een lijstje met dingen die "nooit de moeite waard" zijn. Een eigen Roslyn-analyzer voor de huisregels van je team. Een source generator die die ene trage reflectieroute vervangt. Een parser die precies jouw logformaat snapt, sneller dan een generieke regex. Een intern CLI-tool dat drie handmatige stappen in de release vervangt. Dat lijstje is ooit opgesteld met een prijskaartje in je hoofd, en volgens Dan Luu klopt dat prijskaartje niet meer.

Zijn essay We should be more ambitious with the software we build stond dit weekend bovenaan Hacker News, met bijna 800 punten en ruim 700 reacties in de discussie. Ik ben het grotendeels met hem eens. Alleen vergeet de discussie één kostenpost die door AI amper goedkoper is geworden.

Wat Luu eigenlijk beweert

De kern: de meeste productieve programmeurs die Luu kent, schrikken nog steeds terug voor projecten die tegenwoordig prima haalbaar zijn. Hij noemt dat het meedragen van een "pre-agentic cost model". Een compiler, een eigen regex-engine, een herimplementatie van een database: dat waren klussen voor teams die "never ship anything". Nu kan één ervaren developer zoiets in een paar dagen of zelfs uren neerzetten.

Zijn voorbeelden zijn concreet. Michael Malis bouwt pgrust, een herimplementatie van PostgreSQL in Rust. Of dat project ooit slaagt, maakt volgens Luu weinig uit: het grondige testen heeft al echte bugs in Postgres zelf opgeleverd, in SP-GiST, de JSONPath-formatcache, pgcrypto, de dictionary loader en money-overflow. Luu zelf noemt een regex-engine die voor zijn workloads sneller is dan de generieke alternatieven, een ripgrep-fork die hij in vijf uur bouwde, en een zstd-implementatie die voor ongeveer een dollar aan modelkosten tot stand kwam. Een team van zeven mensen bij Anthropic bouwde volgens hem in vijf maanden een eigen versiebeheersysteem en een code-reviewtool.

Belangrijk detail: Luu zegt er zelf bij dat AI-projecten vaak van lage kwaliteit zijn. Zijn punt gaat over wat ervaren mensen nu kunnen. Wat iedereen met een prompt kan, is een ander verhaal.

Het lijstje van een .NET-developer

Vertaal dat naar ons werk en het lijstje wordt herkenbaar. Dit zijn projecten waarvan ik jarenlang zei dat ze te duur waren voor wat ze opleverden:

  • een Roslyn-analyzer die een teamafspraak afdwingt, zodat code review er niet meer over hoeft te gaan
  • een source generator die reflectie in een hot path vervangt
  • een gespecialiseerde parser voor een formaat dat je in grote volumes verwerkt
  • een migratietool die een oud XML-configuratieformaat omzet naar appsettings.json voor tweehonderd services tegelijk
  • een eigen load-test-harness die het verkeerspatroon van je klant naspeelt, met de pieken die alleen in hun productie voorkomen

Elk van deze dingen kostte vroeger een paar weken. Met een goed model en iemand die weet wat hij wil, is het nu een middag tot een paar dagen. Luu heeft gelijk dat veel mensen dat nog niet hebben verwerkt.

Kreinin heeft ook gelijk

In de appendix van het essay laat Luu Yossi Kreinin aan het woord, en die zegt eigenlijk het tegenovergestelde: de meeste mensen zouden juist minder ambitieus moeten zijn met wat ze met AI bouwen. Zijn argument is scherp. Veel problemen lenen zich slecht voor agentic loops, "since you don't have clear pass/fail criteria", en zelfs als die criteria er zijn, hebben mensen moeite om over grote black boxes te redeneren. Wie AI een flow laat versnellen, krijgt volgens hem vaak rommelige code die "overfit" is op de geteste gevallen en niet onderhoudbaar blijft.

De HN-discussie leverde er direct een voorbeeld bij. Een reageerder wees erop dat pgrust uit ClickBench was gehaald, met de beschuldiging dat het voor de benchmarkqueries handgeschreven paden had. Luu heeft dat in zijn stuk nagekeken: de beschuldiging van opzettelijk vals spelen klopt volgens hem niet, en Malis erkent dat het te vroeg was om de preview in te sturen. Hoe je het ook leest, het patroon is precies wat Kreinin beschrijft. Een benchmark is een pass/fail-criterium, en wat je met een agent optimaliseert tegen een smal criterium, wordt smal.

Ik denk dat ze allebei gelijk hebben, en dat het verschil zit in één vraag die je vooraf stelt: heb ik een orakel? Iets dat zonder mijn oordeel zegt of het resultaat goed is. Heb je dat, dan mag je ambitieus zijn. Heb je het niet, dan bouw je een black box die je over een half jaar niet meer durft aan te raken.

Voorbeeld 1: een Roslyn-analyzer met een hard orakel

Een analyzer is het ideale ambitieuze project, juist omdat het orakel zo duidelijk is. De testframeworks van Roslyn laten je in de broncode markeren waar een diagnostic hoort te verschijnen, en de test faalt als hij ergens anders of niet verschijnt. Daar valt niet over te twisten.

Stel dat je team TimeProvider gebruikt (sinds .NET 8 in de BCL) en je in reviews steeds weer DateTime.Now in domeincode tegenkomt. Dan is dit de analyzer:

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoSystemClockAnalyzer : DiagnosticAnalyzer
{
    public const string Id = "FIT001";

    private static readonly DiagnosticDescriptor Rule = new(
        Id,
        title: "Gebruik TimeProvider in plaats van de systeemklok",
        messageFormat: "'{0}' leest de systeemklok direct; injecteer TimeProvider",
        category: "Design",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
        ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        context.RegisterCompilationStartAction(start =>
        {
            var dateTime = start.Compilation.GetSpecialType(SpecialType.System_DateTime);
            var dateTimeOffset = start.Compilation.GetTypeByMetadataName("System.DateTimeOffset");

            start.RegisterOperationAction(ctx =>
            {
                var property = ((IPropertyReferenceOperation)ctx.Operation).Property;

                if (property.Name is not ("Now" or "UtcNow" or "Today"))
                    return;

                var owner = property.ContainingType;
                if (!SymbolEqualityComparer.Default.Equals(owner, dateTime) &&
                    !SymbolEqualityComparer.Default.Equals(owner, dateTimeOffset))
                    return;

                ctx.ReportDiagnostic(Diagnostic.Create(
                    Rule, ctx.Operation.Syntax.GetLocation(), $"{owner.Name}.{property.Name}"));
            }, OperationKind.PropertyReference);
        });
    }
}

Het project target netstandard2.0, refereert Microsoft.CodeAnalysis.CSharp en zet <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> aan. De test met Microsoft.CodeAnalysis.CSharp.Analyzer.Testing is het orakel:

using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Xunit;

public class NoSystemClockAnalyzerTests
{
    [Fact]
    public async Task Meldt_DateTime_Now_in_domeincode()
    {
        var test = new CSharpAnalyzerTest<NoSystemClockAnalyzer, DefaultVerifier>
        {
            ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
            TestCode = """
                using System;

                class Invoice
                {
                    DateTime DueDate() => {|FIT001:DateTime.Now|}.AddDays(30);
                    DateTimeOffset Stamp() => {|FIT001:DateTimeOffset.UtcNow|};
                }
                """
        };

        await test.RunAsync();
    }

    [Fact]
    public async Task Laat_TimeProvider_met_rust()
    {
        var test = new CSharpAnalyzerTest<NoSystemClockAnalyzer, DefaultVerifier>
        {
            ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
            TestCode = """
                using System;

                class Invoice(TimeProvider clock)
                {
                    DateTimeOffset DueDate() => clock.GetUtcNow().AddDays(30);
                }
                """
        };

        await test.RunAsync();
    }
}

Hier kan een model weinig fout doen zonder dat je het ziet. Laat het de randgevallen erbij verzinnen (een using static System.DateTime, een alias, gegenereerde code) en elk randgeval wordt een test met {|FIT001:...|}-markup. Je leest de tests, het model schrijft de implementatie. Dat is de verdeling die werkt.

Voorbeeld 2: een snellere parser, en waarom je benchmark liegt

Het tweede type project is precies waar Kreinin voor waarschuwt. Luu bouwde een regex-engine die sneller is "voor zijn workloads". Die toevoeging doet ertoe. Stel je verwerkt miljoenen logregels in logfmt (level=info msg="order placed" orderId=8812) en je wilt de [GeneratedRegex] vervangen door een handgeschreven parser op ReadOnlySpan<char>:

using System.Text.RegularExpressions;

public static partial class LogfmtRegex
{
    [GeneratedRegex("""(\w+)=("[^"]*"|\S+)""")]
    public static partial Regex Pair();
}

public static class LogfmtParser
{
    public static int CountPairs(ReadOnlySpan<char> line)
    {
        int count = 0;

        while (!line.IsEmpty)
        {
            line = line.TrimStart(' ');
            int eq = line.IndexOfAny('=', ' ');

            if (eq <= 0 || line[eq] != '=')
            {
                // Geen key=value-token: sla het hele token over.
                int next = line.IndexOf(' ');
                if (next < 0) break;
                line = line[next..];
                continue;
            }

            line = line[(eq + 1)..];
            int end;

            if (!line.IsEmpty && line[0] == '"')
            {
                int close = line[1..].IndexOf('"');
                end = close < 0 ? line.Length : close + 2;
            }
            else
            {
                end = line.IndexOf(' ');
                if (end < 0) end = line.Length;
            }

            count++;
            line = line[end..];
        }

        return count;
    }
}

Let op: deze twee zijn het niet overal eens. De regex telt retries= niet mee, want \S+ eist minstens één teken. De span-parser telt hem wel. Bij een key met een streepje (trace-id=abc) tellen ze allebei één paar, maar de regex ziet alleen id als key. Een telling vangt dat verschil niet eens. Dat is het soort verschil dat een agent stilletjes introduceert terwijl hij de benchmark groen maakt. Hoe je zoiets systematisch vangt met een pariteitstest, beschreef ik eerder bij de Bun-herschrijving. Hier gaat het me om de benchmark zelf:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class LogfmtBenchmarks
{
    private string[] _lines = [];

    [Params("productie-steekproef.log", "randgevallen.log", "lange-regels.log")]
    public string Corpus { get; set; } = "";

    [GlobalSetup]
    public void Setup() => _lines = File.ReadAllLines(Path.Combine("corpus", Corpus));

    [Benchmark(Baseline = true)]
    public int RegexBaseline()
    {
        int total = 0;
        foreach (var line in _lines)
            total += LogfmtRegex.Pair().Count(line);
        return total;
    }

    [Benchmark]
    public int SpanParser()
    {
        int total = 0;
        foreach (var line in _lines)
            total += LogfmtParser.CountPairs(line);
        return total;
    }
}

public static class Program
{
    public static void Main(string[] args) =>
        BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
}

Drie corpora, en dat is bewust. Eén steekproef uit echte productielogs, één bestand met de vreemde regels die je in de praktijk tegenkomt, en één met extreem lange regels. Een agent die alleen tegen het eerste bestand optimaliseert, levert je iets dat op de demo indruk maakt en in productie tegenvalt. Dat is het ClickBench-probleem in het klein.

Ik noem hier bewust geen getallen. Die zijn afhankelijk van jouw corpus, en precies daarom moet je het criterium vastleggen voordat je begint. Bijvoorbeeld: minstens drie keer sneller op de productiesteekproef, nergens trager dan de baseline, nul allocaties per regel. Haalt de nieuwe parser dat niet, dan gooi je hem weg. Dat besluit neem je vooraf, want achteraf vind je altijd een reden om hem te houden.

De kostenpost die niet daalde

Hier wijk ik af van het enthousiasme op HN. Bouwen is goedkoop geworden. Bezitten niet.

Die analyzer moet mee als jullie naar een nieuwe SDK gaan, want een analyzer die tegen een nieuwere Microsoft.CodeAnalysis compileert dan de compiler in je SDK, laadt simpelweg niet. Die parser heeft iemand nodig die snapt waarom retries= anders wordt behandeld als er over een jaar een incident is. Die migratietool draait één keer en daarna nooit meer, en dat is prima, zolang niemand hem als infrastructuur gaat zien.

Als freelancer voel ik dat scherper dan een vaste medewerker. Ik vertrek. Wat ik achterlaat, wordt onderhouden door mensen die er niet bij waren toen ik het met een model in een middag bouwde. Een reageerder op HN zei het goed: AI brengt je van nul naar MVP, maar haalt de complicaties na de MVP niet weg.

Daarom leg ik voor elk "vroeger te duur"-project nu vijf dingen vast, in een kort beslisdocument naast de code:

# Besluit: eigen logfmt-parser (vervangt GeneratedRegex)

Orakel:        pariteitstest tegen GeneratedRegex + corpus in /corpus
Succes:        >= 3x sneller op productie-steekproef, nergens trager,
               0 allocaties per regel
Timebox:       2 dagen; daarna stoppen, ongeacht de stand
Eigenaar:      team Ingest (niet de freelancer)
Exit:          feature flag Logging:UseSpanParser; regex-pad blijft
               bestaan tot 2027-03-01

De regel "Exit" is de belangrijkste. Een ambitieus project dat je niet zonder pijn kunt verwijderen, is een project dat je klant voor altijd betaalt. Met een feature flag en het oude pad nog aanwezig, kost een mislukking je twee dagen. Zonder kost hij je een rewrite.

Waar ik uitkom

Luu heeft gelijk dat je lijstje met "nooit de moeite waard" opnieuw moet. Een flink deel ervan is nu een middag werk, en wie dat negeert, laat echte winst liggen. Kreinin heeft gelijk dat zonder pass/fail-criterium die middag uitmondt in een black box.

Mijn vuistregel is simpel. Ambitieus bouwen met AI doe ik waar het orakel goedkoop en hard is: compilers, analyzers, parsers, protocollen, conversies met een referentie-implementatie ernaast. Waar het orakel een mens met een mening is, zoals bij UX, domeinlogica zonder spec of "het voelt sneller", blijf ik bescheiden. En voor alles daartussen schrijf ik het criterium en de exit op voordat het model één regel genereert.

Veelgestelde vragen over ambitieus bouwen met AI

Wat bedoelt Dan Luu met een pre-agentic cost model? Dat developers nog steeds inschatten wat een project kost alsof ze alles met de hand schrijven. Daardoor schrappen ze projecten zoals een eigen parser, compiler of analyzer bij voorbaat, terwijl een ervaren developer met een goed model die nu in uren of dagen kan bouwen.

Wanneer is een project geschikt om grotendeels door een AI-agent te laten bouwen? Als er een hard, automatisch pass/fail-criterium is dat geen menselijk oordeel nodig heeft. Denk aan een testsuite van een specificatie, een referentie-implementatie om tegen te vergelijken of de testinfrastructuur van Roslyn. Ontbreekt zo'n orakel, dan kun je de uitkomst niet beoordelen zonder alles zelf te lezen, en dan is de tijdwinst grotendeels weg.

Hoe voorkom je dat een AI-agent overfit op je benchmark? Benchmark tegen meerdere corpora die echte variatie bevatten, zoals een productiesteekproef, een bestand met randgevallen en extreme invoer. Leg het succescriterium vast voordat je begint, combineer de benchmark met een pariteitstest tegen de oude implementatie en wees wantrouwig als de winst alleen op één dataset zichtbaar is.

Moet een eigen Roslyn-analyzer mee-upgraden met de .NET SDK? Ja, in de zin dat je de versie van Microsoft.CodeAnalysis bewust moet kiezen. Een analyzer die tegen een nieuwere Roslyn-versie compileert dan de compiler in de SDK van je team, wordt niet geladen. Kies daarom de laagste versie die je nodig hebt en test de analyzer in CI tegen de SDK die jullie werkelijk gebruiken.

Verder lezen


Conclusie: Haal je lijstje met "te duur om te bouwen" vandaag nog tevoorschijn, want een deel ervan is nu een middag werk. Begin alleen aan de projecten waarvoor je vooraf een hard orakel, een meetbaar succescriterium en een exit kunt opschrijven, en spreek af wie het bezit als jij er niet meer bent.

Bronnen: Dan Luu, We should be more ambitious with the software we build · Hacker News-discussie.

Hulp nodig bij een vergelijkbaar vraagstuk?
Neem contact op