I use AI to write code. Here is how I keep the codebase mine.
AI coding tools made me faster and made a lot of codebases worse. The difference is a few habits that cost me an hour a day and save me weeks.
5 min readaiengineeringprocess
I use AI coding tools every day, including to build the site you are reading. I also spend a good part of my working life inside codebases that were built with the same tools by someone who did not read the output — and those two facts are not a contradiction. They are the whole point.
Used well, these tools compress the time between an idea and a working product from weeks to days. Used carelessly, they produce something that works on Tuesday and cannot be changed by Friday, because nobody understands it and nothing is tested. This is what I actually do to stay on the right side of that line. None of it is clever. All of it is discipline.
I read every line that ships
This is the rule everything else hangs off, so I’ll say it plainly: nothing generated goes into a commit without me reading it as carefully as I would read a pull request from a new teammate.
Not skimming. Reading. Asking, for each block, “why is this here, and what happens when it is wrong?” The tools are good enough now that the code looks right almost every time, which is precisely why reading matters more than it used to. The bugs are no longer syntax errors you’d catch in a glance; they’re a retry loop with no limit, a check that runs before the rate limit instead of after, a fallback that fails open when it should fail closed.
Reading costs me maybe an hour a day. I have never once regretted it, and I have on several occasions found the kind of issue that would have become a 3 a.m. phone call.
Small diffs, one intent each
The failure mode I see most often is “make me a user system” as a single prompt, followed by a two-thousand-line diff that touches twelve files. Even if it works, nobody can review it — including the person who asked for it.
I work the way I would with a junior engineer I trust but supervise: one intent at a time. “Add the migration for the likes table.” Read it. “Now the endpoint, POST only, same-origin check like the contact form.” Read it. “Now the button.” Each step is small enough to hold in my head, which means each step is small enough to be wrong in an obvious way.
It feels slower. It is faster, because I never spend a day untangling a change I don’t understand.
Tests are the contract, not the afterthought
If the tool writes the code and I write nothing, I have no independent statement of what the code is supposed to do. So for anything with logic — validation, money, security, a parser — I decide what the test is before the code exists, and the code has to pass it.
The test does not have to be big. A single test_* file that fails if the logic breaks is enough. What matters is that it was written from my understanding of the requirement, not derived from the implementation, so it catches the case where the model and I disagree about what “done” means.
For this site, that looks like: a unit test for every validation function, an end-to-end test that fills in the contact form with a disposable email and expects a specific error, and an accessibility check in both themes on every page. When a generated change breaks one of those, the tool did not “make a mistake”; the contract did its job.
Conventions the tool can’t drift from
Generated code drifts. Ask for the same thing on three different days and you’ll get three styles, three naming schemes, three ways to handle errors. Left alone, a codebase built this way becomes a museum of the tool’s moods.
The fix is boring and effective: make the conventions mechanical. A formatter that rewrites everything on save. A linter with the rules I actually care about, failing the build. A short document that says how a page is structured, where content lives, what an endpoint looks like — written once and pointed at every time. The same rules a human team needs, applied more strictly, because the “team” has no memory between sessions.
On this site every colour is a named token, every endpoint follows the same pattern as the first one, and every page is built from the same handful of primitives. Not because generated code can’t vary, but because I chose not to let it.
Understanding first, then speed
The tools are extraordinary at producing a plausible solution to a problem they have not fully understood. So am I, on a bad day. The difference is that I can notice.
Before I ask for a change, I trace the real flow myself: which files it touches, what calls what, where the data comes from. Then I ask. The shortest diff in the wrong place is not lazy; it’s a second bug. Comprehension is the one part of the job I never delegate.
When I don’t use it
Some things I still write by hand, or at least design by hand:
- Data models and migrations. These decisions outlive every line of application code. I want to have made them on purpose.
- Security boundaries. Authentication, authorisation, anything that touches a secret or a payment. I’ll accept help, but the shape is mine.
- Anything I can’t evaluate. If I couldn’t tell a good answer from a bad one, I have no business shipping the tool’s answer. I go and learn first.
The result
Working this way, I get most of the speed and none of the “we can’t touch that module” dread. Products go from a first sketch to production in weeks, and a year later they can still be changed by whoever inherits them — which, on a small team, is usually me.
If you’re weighing up whether AI-built software is something you can bet a business on: it is, with the habits above, and it isn’t without them. The tool is not the risk. Unread code is.
I’m happy to talk about how this applies to what you’re building — start a conversation here.