Two things are true at once, and most of the argument online refuses to hold both.

AI coding tools have genuinely changed how fast software can be built. And a lot of AI-assisted code being merged right now is going to be expensive in about eighteen months.

Both are true because the tools compress a specific part of the job — the mechanical part — and leave the rest exactly as hard as it was. If you know which part is which, you get the speed without the bill. If you do not, you get output that looks finished and behaves like a draft.

This is the workflow I actually use, and the lines I do not cross.

The division that decides everything

Before any of the tactics, one split:

Hand over freely

Scaffolding, boilerplate, repetitive refactors, first-draft tests, migration scripts, documentation drafts, unfamiliar syntax, throwaway prototypes, regex, one-off data transforms.

Never hand over

Architecture, security decisions, data modelling, anything touching money or eligibility, the final read of code that reaches production, and the question of whether you are solving the right problem.

The dividing line is not difficulty. It is reversibility. A badly generated component is a twenty-minute fix. A badly chosen data model is a quarter.

The one rule that keeps quality intact

Never merge code you could not have written yourself, and could not now explain to someone else in review.

This sounds restrictive. In practice it barely slows you down, because reading code is much faster than writing it. What it eliminates is the specific failure mode that causes real damage: code in your repository that works, that nobody understands, and that becomes undebuggable the first time it breaks at an awkward hour.

If you find yourself thinking “I am not totally sure why this works, but the tests pass” — that is the signal. Stop and read it properly, or throw it away and write it yourself.

Where it genuinely earns its place

1. The blank-file problem

Starting is disproportionately expensive. Getting a structurally reasonable first version on screen in thirty seconds changes the shape of the task from “create” to “edit”, which is a much cheaper mode to work in.

I ask for the shape, not the finished thing:

text
Write a React hook that manages a multi-step enrolment form.
Constraints:
- steps come from config, not hard-coded
- each step validates independently
- back must not lose entered data
- expose: current step, go next/back, per-step errors, isValid

Do not add a state library. Do not fetch anything.
Show the interface first, then the implementation.

The constraints matter more than the request. Without them you get a generic answer that pulls in a dependency you did not want and assumes an architecture you do not have.

2. Explaining code you did not write

This is where I get the most value, and it is underrated. Dropped into an unfamiliar module, the fastest route to a mental model is to have the structure explained, then verify it against the code myself.

text
Here is a 400-line service file. Do not suggest improvements.
Tell me:
1. What it is responsible for, in two sentences
2. Its external dependencies and what it assumes about each
3. Every place it can throw or return early
4. Anything that looks like it was added later and does not fit the pattern

Point four is the useful one. It reliably surfaces the bolted-on special case that turns out to be the reason you were called in.

3. The adversarial review

Before I open a pull request, I ask for the argument against my own code.

text
This is my implementation. Assume it is going to production in a
regulated system with real money attached.

Do not tell me what is good. Give me:
- inputs that break it
- concurrency or ordering assumptions it makes silently
- what happens when the downstream call is slow, fails, or returns partial data
- anything that would be a security problem if the caller were hostile

Perhaps a third of what comes back is noise. The remaining two-thirds regularly contains one thing I genuinely had not considered, and it costs two minutes to ask.

4. First-draft tests

Generated tests are excellent at coverage of the shape and poor at knowing what actually matters. So I use them as a starting grid and then add the cases that come from domain knowledge — the member with no dependents, the plan that changed mid-year, the vendor feed that arrived twice.

The trap with generated tests

They tend to assert that the code does what the code does. If you generate tests from an implementation, a bug in the implementation becomes an asserted requirement. Write the important assertions from the spec, then let the tool fill in the mechanical ones.

Where I do not let it near

Architecture

It will happily produce a confident recommendation. What it cannot know is your team's skill distribution, your existing infrastructure, the client contract that says data cannot leave a region, or the fact that you tried the obvious approach two years ago and it failed for a reason nobody wrote down.

I do use it to enumerate options and their trade-offs — that is a genuinely useful thinking aid. The choice stays with me.

Security

Generated auth code looks plausible and is frequently subtly wrong: a token validated by decoding rather than verifying, a permission check on the client that is missing on the server, a query built by string concatenation because the prompt did not mention injection.

Anything touching authentication, authorisation, payments or personal data gets written deliberately and reviewed by a person who knows the domain.

The problem definition

The most expensive mistake is not a bad implementation. It is a beautifully implemented solution to a misunderstood problem. No tool will tell you that the client asked for a report when what they needed was an alert.

The habit that keeps it honest

After any meaningful generated block, I ask myself three questions before it goes anywhere near a branch:

01

Do I understand every line? Not “does it look reasonable” — can I explain what each part does and why it is there?

02

Does it match how the rest of this codebase does things? Generated code has no memory of your conventions. Consistency is the first casualty.

03

What happens when it fails? Generated code is written for the happy path with striking reliability.

Ninety seconds of work, and it catches almost everything worth catching.

The skill that is quietly at risk

There is a real cost that nobody puts on the invoice: if you never struggle, you stop building intuition.

Debugging skill in particular comes from having sat with a problem long enough to develop a feel for where bugs hide. Outsource that consistently and in three years you will be fast at producing code and slow at understanding why it is broken — which is the wrong way round, because the second skill is the one that is scarce.

So I keep some things manual on purpose. I read stack traces myself before pasting them anywhere. I write the tricky logic by hand. Not out of principle, but because that is where the skill actually comes from, and I would like to still have it.

What this looks like in a team

Individually all of this is a preference. Across a team it has to be an agreement, or you get five different styles and a codebase nobody recognises. What we settle on:

  • Generated code is reviewed to the same standard as handwritten code. No special category, no lighter touch.
  • The author is accountable for it. “That is what it gave me” is not a review response.
  • If a pull request introduces a pattern that does not exist elsewhere in the codebase, it needs a reason.
  • Nothing touching auth, payments or personal data is generated wholesale.

The point

The tools are excellent at the half of the job that was always mechanical. They are not, yet, good at the half that requires knowing what you are actually building and what it would cost to be wrong.

Use them hard on the first half. Keep the second half yours. That is the whole discipline.

Speed is easy to get now. Speed that you do not pay back with interest is still an engineering decision.