I’ve been using LLMs for more than a year now. After fifteen years in the industry, it has dramatically changed the way I write code in just a few months. During the first six months, I was experimenting with about everything the hype had to offer me as the new way to code with LLMs: spec-kit and other skills for spec-driven development, ralph-loops, planning, not planning too much, committing specs…
I’ve come to a few practical conclusions from this experience, two of which are:
- that specs are good but should be short-lived and not mixed with code: this is the reason the software industry has been using issues and tickets for decades, which we close once the work is done, and there is absolutely no reason to suddenly commit them alongside the code, unless you want to have two conflicting sources of knowledge that either start drifting or are costly to maintain. Plus, once the spec is implemented, LLMs are excellent at understanding business logic from the code alone, probably even better than from plain English.
- vibecoding, in the sense of “not reading the code LLMs write”, is perfectly fine for some solo, low-stakes projects, but for most of them, it is not, and humans should still read and understand the code they commit, and be accountable about it. Marek Chotoborski has written an excellent blog post about it with which I agree on one critical point: teams should be aligned on whether they read the code, and no one should review code sent by someone who didn’t understand it. This very much resonates with Debian’s stance on LLMs: do whatever you want, but resist the AI pressure to lift existing rules: be accountable, understand what you build, and keep following best practices.
My workflow with LLMs is mostly stabilized now, and I experiment a lot less with new things than a few months ago. Everything the hype has been offering in the last couple months is just new avatars of tools that conflict with those two conclusions. For big features or refactoring, I’ve minimized the time and the interruptions needed on my part. A lot of coding style instructions and human-written documentation, careful planning, automated review and fix loops, then human review, manual adjustments: I don’t think, in the current state of the tooling, I can reduce my input time more without lifting the constraint of being accountable and understanding what I built.
So I no longer worry much about not doing the optimal thing. Most of my frustration comes from one thing: reading contributions from developers more junior than me. LLMs still don’t have my experience as a coder on technologies I’m the most expert of. They (mostly) no longer write code that is insecure, or contains tons of bugs. They even have a tendency to anticipate every edge case, even those that we know will never happen (why should a method for canceling a customer subscription accept an end date in the future if there is simply no form for doing it?). But they’re still terrible at writing code that matches a philosophy.
Frameworks
All coding frameworks, libraries, packages, technologies have a philosophy. Mostly, this philosophy guides patterns that are used in them, patterns that you should use. Compare, for example, in JavaScript, Express and its middleware ecosystem, with, in Python, Django.
In Express, you directly handle the request object, and wire it to everything you need. You take the request object, send its body to your validation library, then move the validated data to your ORM, save the object, etc.
Modern Django, with class-based views, takes an entirely different approach. It provides UpdateView, DetailView, TemplateView, and such. UpdateView is itself a subclass of ModelFormMixin and ProcessFormView. ModelFormMixin is a subclass of FormMixin and SingleObjectMixin. Each of them wires different things to one another: the request body to the form, the editing object to the form, the form to its validation, the validation success branch to the redirect response. The form is responsible for saving to the database, and is itself a complex class hierarchy. Each of these wirings is a class method that can be overridden. This is no magic: being an expert at Django means knowing each and every one of those mixins, knowing how to combine them, and extending only the small method that needs to, for example, add an extra call to a notification service just after saving the form. The complete request stack trace is not visible. What is visible is what you changed from the normal behavior of the framework. If you respect this approach, in return, you get the guarantee, which was extremely valuable for coders before LLMs, that you never forget. To validate user input, to put the object in the template variables, to check permissions, or to handle 404 if the object ID from the URL doesn’t exist.
But LLMs, with Django, will almost always, unless specifically instructed not to do so, go for writing long post() methods, that validate by hand that required fields exist in the POST body, won’t use the help of a Form object, and save directly through the ORM rather than through this form.
Bugs
Let’s be honest: it’s not only an LLM problem. People who lack experience with Django, or any other framework, will always do that. When you don’t know the tools a library offers you, you don’t use them. That’s what junior developers have been doing for decades.
But for a long time, it was easy to teach them: when they did so before LLMs, they almost always demonstrated the necessity of using the framework. Their code was full of bugs, of unhandled subcases, of XSS or DoS vulnerabilities, SQL injections. So they learned, as we did before them, that frameworks were not only invented to go faster but also to help write secure and solid code by default.
Now, it’s a different story. LLMs don’t use the framework, but still, it works. This is spaghetti, often unreadable code, but there is often little to say in terms of security or bugs. The only rationale for using the framework correctly becomes respecting its philosophy, and that it’s supposedly easier to read. But for whom is it easier to read? For the experienced developer, using the framework for years, knowing exactly what its default behavior is, and where it is trustworthy. The junior knows nothing, so trusts nothing. Reading a post() method’s full body with the exact steps of the request handling detailed one by one is way easier for them. Now they can have that, and have it secured and reviewed by LLMs. Why would they do it any other way?
LLMs do it by hand
If LLMs can check that we didn’t forget any good practices, are frameworks still useful when their role is mostly to enforce them? It depends on where you stand as a developer on the topic of accountability. Ironically, before LLMs, not using the framework for a particular feature, which you sometimes had to do when it wasn’t of any help, was often called “doing it by hand”. Now, LLMs have a strong tendency to “do it by hand” more than any human developer. And if it doesn’t work, they’ll add more code, sometimes to the point of rewriting entire features that already exist off the shelf. For LLMs, not using a technology the team chose for a project is not an antipattern. They just don’t need it.
For teams that want to understand what is going on, and read the code, there is a trade-off between writing code that follows the framework patterns, and letting LLMs write custom code everywhere, that can be understood with almost no external context. The former asks for more human work and feedback in the loop before posting pull requests; the latter makes pull requests way harder to read and, through exhaustion, pushes teams down the path of vibecoding.
Are you still learning?
There has been plenty of writing about how we may fail to train a new generation of senior devs if juniors are replaced with LLMs. But another question is: how do LLMs impact the way seniors teach juniors? And the same question remains, even for seniors: How do they learn to use new tools? Most of the frameworks or libraries I have an in-depth knowledge of come from moments I dug into them to solve bugs that I couldn’t solve reading my code alone. Bugs that were to be fixed in my code, but that could only be understood by knowing the exact behavior of the library code I was using.
Since I have been using LLMs the way I use them now, the only times I was forced to dig into library code were when there was a bug in the library itself and I had to contribute the fix upstream.
The only way to fight this is by being very opinionated about it. Constantly pushing the model to use the framework, to avoid doing it by hand, especially when there are bugs. When there is a bug, the model’s reflex is to add more code around it. That is precisely the moment where we used to open the library source and learn how it works. So that is the moment to push back, and make the model (and ourselves) understand what the framework is actually doing.
For months now, that’s what all my AGENTS.md or CLAUDE.md have been about in most repositories. Letting the model fetch the documentation may be useful, but first, we need to make it accept that it has to use the tools the framework provides.
I believe developers who will keep growing through the next decade are the ones who stay consistent with this, and teach others to do so. Resist the antipattern of letting LLMs build everything from scratch. Half of being an experienced developer is knowing how existing things work.