AI could finally let us teach software engineering properly

In the previous post I discussed how LLM affect education in software engineering. We use code assignments with two purposes: to evaluate the students and their studies, and to train them in the engineering.

Evaluation was always problematic – proper evaluation targets understanding and reasoning behind a finished program. This requires a one-on-one conversation with a competent mentor, and is not scalable. But we needed more developers, so we pivoted to measuring an easier thing – the code itself, not the reasoning process behind. This is worse but for a time allowed educating more people for the same money, just worse. Now, if students have any incentive to cheat, they will use LLMs, which excel in generating artifacts. Cheaters got a cheap method of gaming the system for effortless grades. I argued that we need to scale back to small groups and fix the system so that it assists students in understanding their goals, explains why specific classes are useful to them, manages the load

In this post I focus on the other purpose – programming assignments train a software engineer. I want to explain how the struggle with the code is educational, how LLMs seduce us into taking shortcuts where we should not, and whether the skills formerly developed by manual coding are still needed in the modern, AI-heavy development.

Productive tension

If you studied a foreign language, you are familiar with a situation where you are more capable to understand language than properly speaking it. This is not limited to the language studies – when you study through a book you often fall in classic trap of re-reading notes or highlighting and feeling like you've learned. That illusion of competence is shattered once you try retelling it, or someone asks you a question.

The remedy for that is known. You try to speak, you close the tutorial and try recreating the code from scratch, you try explaining what you've read like I'm five – an infamous Feynman's technique.

Is writing code obsolete?

If you were studying to write software in 2015, the goal was to become someone capable of good engineering work. It is like becoming a basketball player who is able to score efficiently. Now suppose you go to a basketball school and there is a machine which can throw the ball perfectly in the basket. You are told that once the ball falls into the basket 100 times you are getting a grade. If you are serious about learning basketball, would you use the machine to throw the ball for you? This thought experiment for me proves that given a proper goal, motivation and sufficient resources, you are unlikely to bypass the struggle that you understand as formative to your skills.

Things have changed since then – maybe modern software engineering is more like managing a basketball team than being a player yourself. But being able to generate a complete program from a loose description in English does not change that in order to gain understanding you need to go on a journey, to experience tension, to struggle against unknown, against your old ways of thinking. You need to think like a developer even when you write a prompt in natural language. Through struggle you are reshaped into someone who is more capable to build and to understand. As a basketball team manager, you still have to understand good throws from bad ones in a variety of contexts, and how would you do that if you never tried yourself?

LLMs provide a shortcut evading the hard part of the road. If you don't know how to write something, ask LLM to generate it for you, and you save yourself a struggle. I believe it is less of a problem if students are properly motivated to learn – it is easy to demonstrate to anyone that something by ourselves gives us better grasp at the knowledge. But the hard part is to explain why such proper understanding is still needed if, even on your job, you will be routinely asking Claude Code to write things for you. What's the point of doing it? Most of us writes high-level code and we do not need to reimplement compiler, or to really master assembly to be effective.

Some say that with LLMs you don't need to be able to transform a textual description into an optimal code anymore – provided an adequate description, LLM will likely do it better than most programmers. But I disagree – once you generated the code, and you are looking at it, how would you tell that the code satisfies the description? What if LLM just misunderstood your metrics or gamed them? What if it has sprinkled your code with unnecessary and wrong tests and then fitted your code so that they pass? LLM can indeed suggest a better design solution than I do, sometimes, but it is still me who should understand it and tell a good one from a wrong one.

Are LLMs like compilers?

I believe LLMs are much unlike compilers, despite both somehow transforming texts between languages.

First, LLMs are purely stochastic devices, and even when they use tricks like formal verification, they still control the use of such constraining tools like formal models and machine-checked proofs. Compilers, even when they throw the dice for some optimizations, or select a behavior from a set of allowed behaviors, aim at always keeping the functional semantic intact, and do it formally. Even a faulty compiler is reliable in ways LLMs can ever be.

Second, a prompt in a natural language differs substantially from a code in high-level language. The code is still formal, even if it is a set of constraints like Prolog. Even high-level languages are underspecifying the behavior, which is where the law of leaky abstractions kicks in. Natural language is not formal and makes for a much, much underspecified description.

Treating LLMs like compilers means treating source code like "whatever satisfies the prompt". Most real programs are not anything-satisfying-specs, they are a patchwork of adhoc contextual compromises, many of which are undocumented. Because of Hyrum's law, these implementation features creep into spec implicitly. The code holds the record, but the spec does not. Because of a mostly rule-based nature of compilers, the behavior of the compiled code is more stable with regards to changes in the source code, so recompiling the code rarely breaks the implicit dependencies of adjacent systems.

The differences run way deeper than that and warrant their own post. I will write later why I think that most software can not be reduced to a throwaway code, regenerated from spec at a push of a button.

What should we teach juniors

I think that the education system should prepare students for junior roles and give them enough foundational knowledge to gradually apply more and more of it, as they progress towards senior roles. Therefore the educational struggle is only justified if we are targeting the right skills. Which skills?

Junior developer used to be a role often focused on doing grunt work and some form of apprenticeship. In the companies I knew junior devs were getting well scoped, clearly formulated tasks, focused on implementation, not design or decision-making in a complex, sometimes convoluted context.

These times are gone now, AI usually does it all better than a junior developer, as long as the tasks are well-scoped and clearly specified. Also, much, much faster. Of course, it requires a proper supervision, which requires experience and a more mature engineering mindset. You have to tell bad code from good code, and there is no universal rule to it – most non-trivial aspects are a tradeoff, therefore contextual, therefore choosing the right option or design requires an elaborated understanding of engineering practice and context. The role of junior developers is up for reinvention.

What should we expect from juniors now and how do we tutor them? I had a short discussion with a dear friend Filipp Guzeev from Veeam and came up with some propositions.

In system engineering we usually work in a loop:

Stakeholder analysis
who has any interest in the system?
  • stakeholders induce evolving requirements, which demand
    • metrics
    • risk assessment (what if we fail to satisfy them)
Functional architecture
some requirements get attached to functional components.
Resource allocation
for example, where will we deploy the code, what computation resources do we have, what do we lack etc.
Verification and validation
verification checks whether the code satisfies requirements, validation checks whether the code solves the business problems. We use metrics elaborated on the first step.

I think we can try to build the mentoring process around it. Instead of writing the code, the junior will have to go through these stages, as a semi-research project. Of course, they will limit only to the essential stakeholders. They will discuss intermediate results with his mentor. Requirements and metrics will have to make their way into the AI prompt. There should be a clear trace from requirements to the acceptance tests to the code, property-based tests for invariants, actually running the thing, adversarial review ("what input breaks this?") and so on.

The junior developer will have to think through the questions like "I am writing a protocol, how will it evolve? How should I version it? Do we need backwards compatibility? How will it react if the other party diverges from expected behavior?"

Junior developers usually do not have an expertise to know the answers to such questions, and they do not know the right questions to ask. But this is where they can grow the most – partly they may converse with LLM itself asking it to elucidate more such questions, try to figure the answer by themselves and then discuss the results with their mentor. The outcome of the junior's work should be not just the code in a pull request, but a report of his research, showing what questions did he ask and what were the answers. The report will keep trace of rejected alternatives too.

That research is one part of the learning experience. The second part is, unsurprisingly, reading the generated code and checking that the requirements are really satisfied in the code. LLM agents are quite good in gaming your requirements, for example, they may overfit to your tests without providing a correct general solution. This is where the capability of writing roughly the same things by hand is essential – it builds the degree of understanding required to then analyse and critique other people's, or agents', product.

One of the caveats might be a single point of failure – the mentee researching the questions by asking LLM, while LLM also writes the code, and will likely write the tests. That's the same supplier performing design and verification, with correlated blind spots. Systems engineering has a strong norm against this and requires an independence rule: requirements and acceptance criteria written before and without the model, or verified against a different source, or checked by a human. Otherwise the whole loop can be closed by the AI and the defense becomes another generated artifact. So, we might need to freeze the acceptance criteria mid-journey.

Finally, a major part of the engineering is complexity management. There are many kinds of complexity: essential vs accidental, coupling vs cognitive load, local vs global, complexity of description vs complexity of operation, and so on. The concept of complexity is complex and not reducible to a single face or metric.

An example is in order. Many systems are stitched together from parts we don't own. Each of those parts forces a choice. You can bind your code directly to a library and pin its version. The system stops moving, which is the point: nothing breaks. But nothing improves either – no bug fixes, no security patches, and the longer you wait, the more painful the eventual upgrade. Or you can put an adapter between your code and the library. Now a change to the library's interface stops at the adapter instead of spreading through your codebase, and swapping in a competitor becomes a day's work rather than a month's.

The adapter is more code, and more code is more complexity. Whether you get anything for it depends on facts you have to go out and check: how often this library breaks its interface, how deeply it has already spread through your system, whether a serious alternative exists. There is no general answer. That is what makes it a tradeoff and not a rule.

In good engineering, we do not just "keep things simple" – every action on the codebase does something to its complexity, and does not always decrease the overall complexity. The goal of management is to prevent any single part of the system from blowing up in complexity, according to contextually reasonable definition of complexity. This allows us to grow the system, maintain it, adjust it, interface with it. Human is the measure of all things complexity – if I can't handle it anymore, the system degraded into unmaintainability. Agents too have a limit to the complexity they can handle.

LLMs make teaching software architecture reachable

So juniors of today, in my opinion, are middle developers of yesterday. In order to control AI agents you need more engineering maturity, not less. And that's where LLMs, in my opinion, become an an exciting addition to software engineering education.

We were always struggling to teach the real important stuff – not the quirks of this or that language1 (like signed vs unsigned overflow in C), but system architecture, proper decomposition, code style. Not "this is how you code a hashmap in C++" but "if you have a functional component in a system that stores data, it has latency regardless of its implementation". The nature of a thing is more important than the form of a thing. But pragmatically we were limited to really small projects. First, because of time constraints. Second, because of fault tolerance – if a huge project fails midway, and its architecture is bad (expected from a student) you have to redo everything, which takes time. But on a small scale you can always write bad, smelly code, and mostly get away with it. It's on large projects where you see the cost of bad complexity management the hard way, when you need to implement a feature, or fix a bug, but there is nothing you can do with the code without breaking something, and no one understands it anymore.

LLMs enable us to finally pump the scale of assignments and expose the problems of mismanaged complexity and poor architecture. Bad interface – simulate an evolution and see how it becomes uglier with time, loses backwards compatibility and becomes a nightmare to support. See how it makes impossible to write a decent test suite. Bad decomposition – watch as god objects emerge, functionality gets split in unnatural ways, and logic gets scattered and duplicated everywhere. And so on. This is, perhaps, the greatest thing I could have hoped for in teaching software system engineering, as opposed to coding, mastering tools, platforms, or technologies that fade into obsolescence faster than you get your bachelors degree.

One thing to be cautious is the cost of bad design. In some cases LLMs mitigate it because refactorings become cheaper. One common observation is that in AI-heavy development bad design accumulates to a point when a complete rewrite is necessary. Fixing it intelligently requires a degree of understanding which is only, I think, achievable through a deep expertise and a capability of working with a complex, sometimes convoluted context. Perhaps we can catch the catastrophe by feeding the codebase to a fresh LLM with no context, and if it fails to implement some new feature coherently, this may signify that a human would fail too.

Footnotes:

1

… which breeds elitist cultures where you are not a real programmer if you don't know the difference between signed and unsigned overflow in C, or how the argument-dependent lookup works in C++/