Software Engineering in the Age of AI — Part 1: Building the Foundation
One of my favorite software engineering contracts was with Bosch. On the surface, there was nothing particularly remarkable about the technology. It was a fairly typical n-tier enterprise application, with a C# backend, an ASP.NET front end, and SQL Server handling data persistence. The kind of application enterprise developers have been building for decades.
What made the project memorable wasn’t the technology, but the way the team worked. The entire development team was organized around Test-Driven Development (TDD), supported by a complete Continuous Integration and Continuous Deployment (CI/CD) solution. Every morning began with an integration report on your code. If something had failed, you knew about it, and your first responsibility was to correct the problem. If everything passed, you moved on to your next deliverable.
It was a remarkably satisfying way to develop software. There was very little ambiguity about whether your work was complete. The requirements had been broken into manageable components, the expected behavior was defined, and automated testing provided continuous feedback. Integration wasn’t a major event at the end of a development cycle; it was part of the daily routine. The process didn’t eliminate defects, but it made them visible early, when they were generally easier and less expensive to correct.
Because we were working within an established n-tier architecture, we weren’t simply producing isolated pieces of functionality. We were building components that fit into a larger, maintainable system. I remember that contract fondly because the engineering discipline made development more productive, not less.
That experience came back to me recently while watching a video about a developer who used AI to recreate much of Adobe’s and Microsoft’s application portfolios in a matter of days. The headline was about vibe coding: one developer, an AI coding agent, and an astonishing amount of software produced in a very short time. Buried beneath the headline, however, was a far more interesting detail. One of the more convincing applications reportedly had more than 1,700 automated tests. The AI wasn’t simply generating code and hoping for the best. It was implementing functionality, running tests, correcting failures, and repeating the process.
That sounded very familiar. Despite the extraordinary changes in technology, the underlying approach was something software engineers had been practicing for decades. We have been here before, and the lesson is worth revisiting as artificial intelligence begins to transform the way we develop software.
The More Things Change
Every few years, our industry discovers a new way to write software faster. Fourth-generation programming languages promised that business applications could be developed without conventional programmers. Rapid Application Development and visual programming environments dramatically reduced the time required to build user interfaces. Low-code and no-code platforms promised to turn business users into application developers. Each innovation delivered something valuable, and some permanently changed the way we build software. None, however, eliminated the need to understand requirements, manage complexity, design architectures, validate functionality, or maintain applications over time.
Artificial intelligence is the latest development in that history, although its potential impact may be considerably greater. Today, a developer can describe a requirement in natural language and ask an AI agent to produce an implementation. Depending on its capabilities and available tools, the agent can generate thousands of lines of code, modify existing applications, create user interfaces, execute tests, and correct defects. Work that once required days of implementation can sometimes be accomplished in hours or even minutes.
This is an extraordinary capability, but it is also easy to confuse the speed of code generation with the speed of software engineering. Producing a working demonstration is not the same as delivering a working product, and delivering a working product is not the same as building a maintainable system. A system must also integrate reliably with its dependencies, protect its data, accommodate changing requirements, and move safely from development into production.
These distinctions mattered before AI, and the acceleration of code generation makes them more important rather than less. The difficulty of software engineering has never been limited to the amount of time required to type an implementation. Much of the real work lies in deciding what should be implemented, how the pieces should fit together, and how to demonstrate that the result behaves correctly.
The Problem With Vibe Coding
The phrase vibe coding originally described a development style in which the programmer gives an AI instructions, accepts its generated implementations, and concentrates on whether the result appears to work rather than examining the underlying code. As the term has entered wider usage, it has increasingly become a catchall description for software developed with AI assistance.
For prototyping, experimentation, and exploring unfamiliar technologies, this approach can be extraordinarily effective. I have no objection to using AI to accelerate development; I use AI coding tools myself. What concerns me is the suggestion that because an AI can produce functional software, we can dispense with the engineering practices developed over decades.
Consider the recent ArtCraft applications. A developer used AI to rapidly build open-source alternatives to established Adobe and Microsoft products. The results are impressive, particularly considering the time involved, but the applications remain works in progress. Some lack compatibility features, plugin support, or the extensive handling of unusual conditions that mature commercial applications accumulated over many years. That isn’t a criticism of the developer. It illustrates the substantial difference between implementing recognizable functionality and reproducing the full behavior of a mature software product.
What I found particularly interesting was the reported use of extensive automated testing in the PhotoCraft application. Rather than relying solely on whether the interface looked convincing, the implementation was reportedly exercised against a substantial collection of tests, including scenarios involving real Photoshop files. Other applications in the collection had not yet undergone comparable compatibility testing, making their completeness much more difficult to evaluate.
The distinction is more important than the number of lines of code generated or the time required to produce them. An AI agent can implement functionality very quickly, but speed alone tells us little about whether that functionality is correct. Automated tests provide observable evidence, expose regressions, and allow changes to be evaluated against defined expectations. They are not a guarantee of quality, but they are a substantial improvement over accepting an implementation simply because it looks right.
This brings us back to a principle that predates generative AI by decades: software engineering is not merely the act of writing code. It is the discipline of producing software that behaves correctly, integrates reliably, and remains maintainable throughout its useful life.
Test-Driven Development Is Not New
Test-Driven Development became prominent through Extreme Programming in the late 1990s. Its basic approach is straightforward: write a test describing the expected behavior, verify that the test fails, implement enough functionality to satisfy the test, and then refactor the implementation while ensuring that the tests continue to pass. This repeating sequence is commonly called Red–Green–Refactor.
The value of TDD goes beyond detecting defects. It requires developers to think about how a component should behave before deciding how to implement it. Expected behavior is expressed in an executable form, creating a contract that can be checked repeatedly as the software evolves. When subsequent changes cause a test to fail, the team receives immediate evidence that some previously established behavior may have been affected.
There are limits to what testing can establish. A test may confirm that software behaves according to its specification, but it cannot independently establish that the specification satisfies the business requirement. Nor should we confuse having a large automated test suite with practicing TDD. Tests written after implementation can provide tremendous value, but the defining characteristic of TDD is that tests help drive the implementation rather than merely validating it afterward.
These distinctions are particularly important when working with AI agents. An AI can generate a thousand tests for an incorrect requirement just as easily as it can generate a thousand lines of unnecessary code. The quality of the specification remains fundamental, and the responsibility for establishing that specification ultimately belongs to the engineering process.
What AI changes is the economics of applying these practices. An agent can execute the Red–Green–Refactor cycle repeatedly, examining test failures, adjusting implementations, and rerunning tests without becoming fatigued by the mechanical repetition. Tasks that developers have sometimes neglected because of time pressure can become routine parts of agent-assisted implementation.
However, that process requires more than a model capable of writing code. The agent needs access to a properly configured development environment, a reliable testing framework, and the ability to receive meaningful feedback from the tools it uses. This is where I think many discussions about AI-generated software begin in the wrong place. They start with the prompt, when they should start with the environment in which the resulting software will be engineered.
First, Build the Factory
When I begin a software development project, my first concern isn’t which model will write the code. It’s where the code will live, how it will be built, how it will be tested, and how it will eventually reach production. These are ordinary software engineering questions, and we should answer them before the first implementation task, regardless of whether a human or an AI writes the code.
Throughout this series, we’re going to use a small Task Management application to demonstrate the process. The application will eventually allow users to create, assign, update, and track tasks. There is nothing particularly innovative about the business problem, and that’s intentional. We want the engineering process to remain the focus rather than spending our time explaining an unfamiliar application.
The example will use a conventional technology stack built around C#, ASP.NET Core, and SQL Server, reflecting the kind of enterprise application that opened this discussion. We’ll establish the development environment, repository, and automation first. In the second article, we’ll define the architecture and engineering practices. In the third, we’ll decompose a business requirement into an independently testable Work Order and direct an AI coding agent to implement it.
The application is simply a vehicle for demonstrating a repeatable engineering process. The same approach could be applied to another programming language, database, or application framework without changing its fundamental principles.
Establish a Development Environment
I prefer to begin with local development and local inference. That doesn’t mean the methodology requires a local AI model, nor does it imply that local models will always provide the best results. Commercial APIs and hosted coding agents can participate in precisely the same engineering process. Starting locally simply gives us direct control over the initial development environment, the tools we use, and the way software is built and tested.
For our example, we need a workstation with an integrated development environment such as Visual Studio Code, Git for source control, the .NET SDK, access to a development database, and a testing framework such as xUnit. We’ll also need a coding agent capable of reading project files, modifying source code, and invoking development tools. Initially, that agent will use a locally hosted model exposed through a compatible inference interface.
An important distinction here is that the coding agent and the inference provider are separate responsibilities. The agent is the software that performs development operations. It reads files, modifies code, invokes compilers, executes tests, and reports results. The inference provider supplies the model that interprets instructions and helps the agent determine what actions to take. Although these components are frequently packaged together, they need not be tightly coupled.
Separating the agent from the inference provider gives us greater flexibility as models improve. We may choose one local model for routine implementation work and a commercial service for a task requiring capabilities unavailable locally. Different models and agents will have different context limits, tool-calling abilities, and levels of performance, so interchangeability is not automatic. Nevertheless, the objective is to make the engineering process independent of the particular model used to execute it.
Establish Source Control
Once the development tools are in place, the next step is to create the project repository. Git provides the history and traceability necessary to understand how the application evolves. Every meaningful change should be identifiable, allowing us to determine what was modified, why the change was made, and which version of the software passed verification.
This becomes especially important when an AI agent can modify multiple files during a single assignment. Without disciplined source control, it can be difficult to determine whether a change fixed a problem, introduced a regression, or altered functionality outside the intended scope. Version control also gives us a reliable means of comparing implementations, reviewing changes, and recovering from unsuccessful work.
For the Task Management application, we’ll begin with a straightforward repository structure:
task-manager/
├── .github/
│ └── workflows/
├── .gitignore
├── README.md
├── src/
├── tests/
└── docs/
The structure will evolve as we establish the application’s architecture, but it provides a reasonable starting point. We’ll create an initial commit, establish an integration strategy, and define how completed work enters the main branch. Whether we use GitHub, Gitea, or another Git hosting solution is less important than maintaining a disciplined process for managing changes.
A coding agent should not be free to make arbitrary modifications to the protected integration branch simply because it has access to the repository. Its changes should be reviewed and integrated according to the same engineering rules that apply to human developers. This is one reason I consider the repository, rather than an agent’s conversational history, to be the authoritative record of the software project.
Separate Development From Production
Environment separation is another principle that predates AI by decades. Development environments exist so that engineers can experiment, make mistakes, test changes, and correct defects without disrupting production systems. Testing and staging environments provide opportunities to validate integrated changes under controlled conditions, while production environments host approved releases and operational data.
In my own work, repositories and coding agents operate on my development workstation. Production is a separate environment into which approved changes are deployed. The introduction of an AI coding agent doesn’t require changing that arrangement, although it provides an additional reason to maintain strict separation between environments.
For our example, we’ll establish three logical environments. Development will support implementation and testing with controlled or disposable data. Testing or staging will validate integrated releases under conditions that approximate production. Production will run approved versions of the application with appropriate operational permissions and data protection.
These environments do not necessarily require three physical servers. Containers, virtual machines, managed services, or other isolation mechanisms may be suitable depending on the application’s requirements. What matters is that each environment has a defined purpose and access boundary, and that changes move between environments through an established process rather than through ad hoc modifications.
An agent working on the Task Management application doesn’t need production database credentials. It needs the source code, development dependencies, and an environment where it can execute its assigned work safely. Restricting access in this manner isn’t a special security practice invented for AI. It’s ordinary environment discipline applied to another participant in the development lifecycle.
Establish Automated Testing
Before implementing business functionality, we should establish the testing framework. That means creating a test project, ensuring that tests can be executed from the command line, and verifying that the test runner works correctly. For our C# example, xUnit is an appropriate choice, and we should be able to execute the tests using the standard .NET tooling.
dotnet test
Initially, there may be very little to test, and that’s perfectly acceptable. The objective is to establish automated testing as a fundamental part of the project rather than an activity introduced after substantial functionality has already been developed. As the application grows, we’ll add tests for individual components, interactions between components, and complete application workflows.
We should also recognize that unit testing is only one part of software verification. Database interactions, authentication, external services, and user-interface behavior may require integration and end-to-end tests. Regression tests will become important as defects are discovered and corrected. The testing strategy should reflect the architecture and business requirements rather than attempting to force every verification problem into a unit test.
For AI-assisted development, we’ll eventually require implementation assignments to identify expected behavior and the tests needed to demonstrate completion. Those assignments will be organized as bounded Work Orders, which we’ll examine in the third article. At this stage, we’re establishing the infrastructure that makes their execution and verification possible.
Build the CI/CD Pipeline
This brings us to the part of software engineering that made my Bosch experience so memorable. The development team didn’t simply write code and hope that integration would work. The integration process continuously evaluated the application and reported problems while they were still manageable. That feedback allowed developers to address defects before they became entangled with additional changes.
We want the same capability in our sample project. When a developer or coding agent submits a change, the integration pipeline should retrieve the committed source, restore dependencies, compile the application, execute automated tests, and report the results. Changes that fail the required checks should not be promoted into the protected integration branch.
As the application develops, we’ll expand those checks to include static analysis, security scanning, dependency validation, integration testing, and deployment packaging. We’ll also establish policies for reviewing changes and determining when an implementation is ready for release.
For a .NET application, these operations can be performed using standard SDK commands and a CI platform such as GitHub Actions, Gitea Actions, or another suitable runner. The particular platform is an implementation choice. Our engineering process should define what must happen, while the tools provide the means of carrying it out.
It’s also worth distinguishing continuous integration, continuous delivery, and continuous deployment. Continuous integration ensures that changes are regularly combined and verified. Continuous delivery makes the application ready for release through a repeatable process. Continuous deployment takes the additional step of automatically releasing changes that satisfy the pipeline’s requirements.
For this project, we’ll favor continuous delivery with an explicit production release gate. Passing automated tests should make software eligible for deployment, but it should not automatically grant an AI agent unrestricted authority to release changes into production. That decision remains governed by our deployment process.
Make Verification Visible
The final part of establishing our development foundation is making feedback visible. One reason the Bosch process worked so well was that we didn’t have to guess whether yesterday’s work had integrated successfully. The results were available each morning, and the development team could act on them immediately.
Our development environment should provide the same clarity. When examining a change, we should be able to determine whether the application builds, which tests passed, which tests failed, and whether the change satisfies the project’s integration requirements. The results should be retained so that failures can be investigated and the progression of the software can be understood.
For an AI agent, this feedback becomes particularly valuable. A vague instruction to make an application work leaves considerable room for interpretation, while compiler errors, failed tests, and integration reports provide specific evidence that can guide corrections. The agent can use that evidence to improve its implementation, provided the tests and verification procedures accurately represent the intended behavior.
There is also an important distinction between an agent reporting that its work is complete and an independent system verifying that the work has passed its required checks. A successful agent session is not equivalent to a successful integration build. The CI system should evaluate the committed implementation independently, using the same standards regardless of whether the changes came from a human developer or an AI agent.
This is the kind of accountability that disciplined software engineering has always required. AI changes the speed at which implementations can be produced and corrected, but it doesn’t eliminate the need for independent evidence that those implementations work.
AI Doesn’t Eliminate the Development Lifecycle
There is a tendency to treat AI software development as an entirely new discipline. Certainly, some aspects are new. Coding agents can interpret natural-language instructions, use development tools, and perform implementation tasks with a degree of autonomy that would have been difficult to imagine only a few years ago. Their capabilities are improving rapidly, and they will undoubtedly change the way engineering teams organize their work.
What hasn’t changed is the underlying need for source control, automated testing, architectural discipline, environment separation, and reliable deployment processes. These practices address problems inherent in software systems rather than limitations of human programmers. Replacing some of the implementation effort with AI does nothing to eliminate those problems.
In fact, faster code generation may increase the importance of these disciplines. When an agent can generate code faster than a human can comfortably review it, automated verification becomes essential. When an agent can modify substantial portions of an application in minutes, source control and change isolation become essential. When multiple agents work on different components simultaneously, architectural boundaries and integration processes become essential.
The same reasoning applies to deployment. An agent capable of executing development tools is useful precisely because it can act rather than merely recommend actions. That capability must operate within controlled environments and established permissions. A well-designed development lifecycle allows the agent to work productively without granting it unnecessary authority over production systems.
The real change, therefore, isn’t that AI makes software engineering obsolete. It is that AI exposes the consequences of treating software engineering as merely the act of writing code. As implementation becomes less expensive, the relative value of requirements analysis, architecture, verification, and lifecycle management increases.
Local First, but Inference Agnostic
There is one additional principle I want to establish before we move on to the next article: our engineering methodology should remain inference-agnostic.
We’ll begin with local inference because it allows us to maintain direct control over our initial development arrangement and experiment with autonomous development without requiring a commercial AI service. However, we should not confuse this preference with a technical requirement. Local inference has its own limitations, including hardware capacity, model capabilities, and operational overhead. Commercial inference services may be better suited to some assignments.
The engineering process should remain the same regardless of which inference provider we select. The repository should remain authoritative, the tests should execute independently, and the CI/CD pipeline should verify changes against the same acceptance criteria. Changing the model should not require redefining how we manage source code, test functionality, or deploy applications.
This separation is consistent with the architectural principles we’ll examine in the next article. The inference provider is a replaceable component of the development arrangement, not the authority that defines the engineering methodology. A model may implement code and propose solutions, but the project’s documented requirements, architecture, and verification process establish the standards against which that work is evaluated.
That approach also protects the investment we make in our development process. Models will change, vendors will change, and the economics of inference will continue to evolve. Our repositories, engineering standards, automated tests, and deployment pipelines should remain useful through those changes.
Building the Foundation
At this point, we haven’t implemented a single business feature of our Task Management application, and that’s intentional. We’ve established the foundation on which the application can be developed responsibly: a controlled development environment, versioned source code, automated testing, a repeatable build process, a CI/CD pipeline, and a defined path from development through testing to production.
We’ve also established an important architectural boundary between the coding agent and the inference provider. That separation allows us to experiment with different models and execution tools without making the project’s engineering processes dependent on a particular AI platform.
Some developers may consider this excessive preparation for a small application that an AI agent could generate in an afternoon. If our only objective were to demonstrate that an AI can produce a Task Management application, they might have a point. But that isn’t what we’re trying to accomplish. We’re establishing a process for engineering software that can be tested, maintained, extended, and deployed reliably, regardless of who or what writes the implementation.
The next step is to establish how that software should be designed and how the development agent should be instructed to work. This requires us to examine the evolution of software development methodologies, the importance of architectural separation, and the ways established engineering standards can be expressed as persistent instructions for AI agents.
We’ll address those subjects in the second article before moving on to the practical execution of Work Orders in the third.
The More They Stay the Same
I still think about those mornings at Bosch. You arrived, examined the integration results, corrected anything that needed attention, and moved on to the next deliverable. The process was predictable because the engineering discipline was already in place. We understood how the application was structured, how changes were evaluated, and what was required to integrate our work successfully.
The technology has changed considerably since then. Today, an AI agent can write code, run tests, and propose corrections at a speed that would have been extraordinary at the time. But the fundamental engineering questions remain remarkably familiar. We still need to understand what we’re building, define how it should behave, determine how it fits into the larger system, and demonstrate that changes can be deployed without breaking existing functionality.
These aren’t new problems, and we already know a great deal about solving them. The arrival of AI gives us an opportunity to apply those lessons more consistently while dramatically reducing the effort required for implementation. Rather than abandoning established engineering disciplines, we should be using AI to make them more effective.
The more things change, the more they stay the same.
Next in the series: How We Got Here — the evolution of software development methodologies, architectural discipline, and the use of persistent project instructions to govern AI coding agents.
Software Engineering in the Age of AI: Before the First Prompt · How We Got Here · The Work Order Is the New Unit of Software Development