In 2025 I reviewed a 200 file pull request that nobody on the team could explain, including the author. "Copilot generated most of it," he said. "It compiles and the tests pass." It merged the next day.
That was the year in one sentence. Scaffolding, refactors, async conversions, test generation, migrations, all of it arriving faster than anyone could read it. Pull requests got bigger and merged quicker and every velocity metric went up. Underneath, engineers stopped being able to explain the systems they were shipping.
We traded understanding for output, and that trade is now sitting in our codebases waiting for someone to pay for it.
Reviews stopped reviewing
A code review used to be about intent, boundaries, ownership, failure modes, whether the thing would hold up. By the end of 2025 it was "does it compile, do the tests pass, Copilot wrote most of it anyway".
Speed isn't the problem. The problem is that when the reviewer can't trace the logic because they didn't write it, and the author can't either because they didn't either, nobody in that thread is reviewing anything. Two people are signing a document neither has read.
Modular in shape only
AI-generated architecture looks well organised until you follow a call through it. Here's one I found.
public interface IUserManager
{
Task<User> GetUserAsync(int id);
}
public class UserManager : IUserManager
{
private readonly IUserOrchestrator _orchestrator;
private readonly ILogger<UserManager> _logger;
public async Task<User> GetUserAsync(int id)
{
_logger.LogInformation("UserManager.GetUserAsync called with id: {Id}", id);
var result = await _orchestrator.GetUserAsync(id);
_logger.LogInformation("UserManager.GetUserAsync completed for id: {Id}", id);
return result;
}
}
public interface IUserOrchestrator
{
Task<User> GetUserAsync(int id);
}
public class UserOrchestrator : IUserOrchestrator
{
private readonly IUserRepository _repository;
private readonly ILogger<UserOrchestrator> _logger;
public async Task<User> GetUserAsync(int id)
{
_logger.LogInformation("UserOrchestrator.GetUserAsync called with id: {Id}", id);
var result = await _repository.GetByIdAsync(id);
_logger.LogInformation("UserOrchestrator.GetUserAsync completed for id: {Id}", id);
return result;
}
}
Three layers, two interfaces with one implementation each, four log statements and no business logic anywhere. The Manager manages nothing, it logs and delegates. The Orchestrator does the same. All of it could have been the repository call at the bottom.
Ask why the layers are there and you get "it's more scalable, the AI structured it this way for separation of concerns". Separation of which concerns? Nothing varies between the layers. There's no second implementation waiting, no decision being made at any level.
What you're left with is over-engineered and under-reasoned at the same time. Three extra frames in every stack trace, three files to touch for every change, and a shape that suggests a design nobody chose.
Ritual without reason
Then there's production code that runs its own garbage collection.
public class MemoryOptimizationService : IHostedService
{
private readonly ILogger<MemoryOptimizationService> _logger;
private Timer _timer;
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(OptimizeMemory, null, TimeSpan.Zero, TimeSpan.FromMinutes(5));
return Task.CompletedTask;
}
private void OptimizeMemory(object state)
{
var gen0Before = GC.CollectionCount(0);
var gen1Before = GC.CollectionCount(1);
var gen2Before = GC.CollectionCount(2);
var memoryBefore = GC.GetTotalMemory(false);
_logger.LogInformation("Starting memory optimization. Memory: {Memory}MB, Gen0: {Gen0}, Gen1: {Gen1}, Gen2: {Gen2}",
memoryBefore / 1024 / 1024, gen0Before, gen1Before, gen2Before);
GC.Collect(2, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();
GC.Collect(2, GCCollectionMode.Forced);
var gen0After = GC.CollectionCount(0);
var gen1After = GC.CollectionCount(1);
var gen2After = GC.CollectionCount(2);
var memoryAfter = GC.GetTotalMemory(true);
_logger.LogInformation("Completed memory optimization. Memory: {Memory}MB (saved {Saved}MB), Gen0: {Gen0}, Gen1: {Gen1}, Gen2: {Gen2}",
memoryAfter / 1024 / 1024, (memoryBefore - memoryAfter) / 1024 / 1024,
gen0After, gen1After, gen2After);
}
}
That runs every five minutes in production. I asked why.
"Copilot said it would improve memory performance and fix the memory leak."
Take that apart. We already pay Datadog to watch memory, GC pressure and collection counts, so the logging half of this duplicates a tool we own while burying the real signal. The collecting half fights the runtime. The .NET GC is generational and tuned against decades of real workloads, and forcing a blocking Gen2 every five minutes throws away everything it had learned about this one. Worse, it doesn't fix a leak, it hides one. If memory is climbing, forced collections delay the crash and remove the pattern you'd have used to diagnose it. And every one of those forced collections pauses all threads, so we bought ourselves a latency spike, on a timer, in production.
The damaging part is that it looked like it was working. Memory drew a nice sawtooth, the logs proudly reported megabytes saved, and everyone agreed it was doing its job. Nobody asked whether there had ever been a leak.
That's programming by ritual. Do the thing because it looks authoritative, not because it addresses the problem. AI suggested it, the graph looked reassuring, and the thinking stopped there.
The same instinct replaced a two-line email check with 200 characters of regex.
// Before: simple and maintainable
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
return false;
// After: Copilot's "improvement"
var pattern = @"^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|""(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*"")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])quot;;
if (!Regex.IsMatch(email, pattern)) return false;
The justification was "Copilot provided it, so it must be more robust". Nobody in that team could tell me what the pattern accepts, what it rejects, or why a form field needed any of it. It got trusted because it looked professional, which is the whole problem in one line.
Async without understanding
Async is where this shows up most, because it's the thing everyone half-knows. Synchronous work wrapped in Task.Run.
public async Task<User> GetUserAsync(int id)
{
return await Task.Run(() => _users.FirstOrDefault(u => u.Id == id));
}
That doesn't make anything faster. It takes an in-memory lookup and adds a thread pool hop to it. The Async suffix and the await keyword do all the work of looking scalable.
Async methods that block anyway.
public async Task<Data> FetchDataAsync()
{
var result = _httpClient.GetStringAsync(url).Result; // blocks the thread
return ProcessData(result);
}
An async method that never yields, holding a thread while it waits, and in the wrong context deadlocking outright. It compiles, it passes a test, and it will take out a thread pool under load.
Parallelism with no ceiling.
var tasks = items.Select(async item => await ProcessItemAsync(item));
await Task.WhenAll(tasks);
Fine for ten items. When items holds 50,000 records that's 50,000 operations in flight, and the connection pool, the rate limit and the memory all find out at the same time.
Every one of these came with the same explanation, that AI suggested async would make it scale better. The code looks modern and performs well right up to the point where load arrives. Async is for I/O with real waiting in it, not a seasoning you add to synchronous code.
Tests that test nothing
Unit tests used to catch bugs. Now they mostly confirm that the code does what the code does.
The pattern is a developer writing or generating an implementation, then prompting "write unit tests for this class". The AI obliges.
public class DiscountCalculator
{
public decimal Calculate(decimal price, string customerType)
{
if (customerType == "Premium")
return price * 0.9m;
return price;
}
}
// AI-generated tests
[Test]
public void Calculate_WithPremiumCustomer_Returns90PercentOfPrice()
{
var calculator = new DiscountCalculator();
var result = calculator.Calculate(100m, "Premium");
Assert.AreEqual(90m, result);
}
[Test]
public void Calculate_WithRegularCustomer_ReturnsFullPrice()
{
var calculator = new DiscountCalculator();
var result = calculator.Calculate(100m, "Regular");
Assert.AreEqual(100m, result);
}
The tests pass and the coverage number goes up. Nobody asked whether the code is right.
Gold tier customers are supposed to get 15%, and there's no gold tier here. A negative price sails through. A null customer type throws. Is "premium" the same as "Premium"? What does a VIP get? None of that is in the tests, because none of it is in the code, and the tests came from the code.
That's the failure. The tests describe current behaviour and the requirement was never in the room.
Buggy validation gets the same treatment.
public bool IsValidEmail(string email)
{
return email.Contains("@");
}
// AI-generated test
[Test]
public void IsValidEmail_WithAtSymbol_ReturnsTrue()
{
Assert.IsTrue(IsValidEmail("user@domain.com"));
}
The test passes. So does "@@@@". The implementation is wrong and the test now certifies it.
Off-by-one errors survive the same way. The requirement here was that the working day runs to five o'clock, and a booking at 17:00 is valid.
public bool IsWorkingHour(int hour)
{
return hour >= 9 && hour < 17; // Bug: 17:00 is a valid booking
}
// AI-generated test
[Test]
public void IsWorkingHour_At16_ReturnsTrue()
{
Assert.IsTrue(IsWorkingHour(16));
}
There's no test at 17 because there's no 17 in the code, and the requirement that mentions it never reached the model.
Writing a test used to be where the thinking happened. Edge cases, assumptions you'd taken for granted, the bug you find at the keyboard before anybody else sees it. Prompt for the tests instead and you've handed that job to something that cannot compare the implementation to a requirement it has never seen. It only has the code.
You get high coverage and low value. Suites that catch regressions against current behaviour and would never have caught the original bug. Green is not correct. Green is consistent with whatever got written first.
When comments lie
AI comments have a quieter version of the same problem. They say what the code does, not why it's there.
// Calculates the total price with discount applied
public decimal CalculateTotalPrice(decimal price, decimal discountPercent)
{
return price - (price * discountPercent / 100);
}
The comment restates the method name. What I wanted to know was why discount is a percentage here and a decimal multiplier three classes over, whether this runs before or after tax, what a negative discount is supposed to do, and why we aren't using the shared DiscountCalculator that already exists.
Then the implementation changes and the comment doesn't.
// Validates email format using regex
public bool IsValidEmail(string email)
{
return _emailService.ValidateAsync(email).Result; // Now calls external service
}
There's no regex left. There's a blocking call to an external service, and a comment describing something that was deleted. Nobody spotted it because the code and the comment were both generated, so neither carried any understanding to begin with.
A person would have written the sentence that mattered. "We moved to the external service because it checks deliverability, not just format." That's the line you need at 2am, and it's the line that goes missing when both halves come out of the same prompt.
Vulnerabilities, delivered with confidence
Insecure code that looks secure gets through review on the strength of looking secure. SQL first.
public async Task<User> FindUserAsync(string username)
{
var query = quot;SELECT * FROM Users WHERE Username = '{username}'";
return await _connection.QueryFirstOrDefaultAsync<User>(query);
}
String interpolation straight into a query, in 2025. When I raised it, the answer was "Copilot generated the data access layer", said as though that settled it.
Then there's the validation that looks thorough.
public IActionResult UpdateProfile(string bio)
{
// AI-generated XSS "protection"
bio = bio.Replace("<script>", "").Replace("</script>", "");
_userService.UpdateBio(User.Id, bio);
return Ok();
}
It strips <script> and lets through <img onerror=...>, <iframe>, and <ScRiPt> for anyone who can hold down shift. Code that looks like it handles security is worse than nothing there at all, because the next person reads it and stops looking.
Then the endpoints with no authorisation on them.
[HttpGet("admin/users/{id}")]
public async Task<User> GetUserDetails(int id)
{
return await _userRepository.GetByIdAsync(id);
}
The word admin is right there in the route and nothing enforces it. No [Authorize], no role check. The explanation was that the AI scaffolded the controller from the repository, and nobody went back to ask which of those endpoints needed protecting.
All three share a shape. The happy path is handled, the code looks professional, and the thing an experienced developer would have caught in the first ten seconds is missing. Looking right is exactly what gets it through review and into production.
Duplication as the default
Repeated patterns used to start a conversation about a shared library. Now they start another copy. Three retry implementations, three services, one company.
Service one.
public async Task<T> ExecuteWithRetry<T>(Func<Task<T>> operation)
{
int attempts = 0;
while (attempts < 3)
{
try
{
return await operation();
}
catch (HttpRequestException)
{
attempts++;
if (attempts >= 3) throw;
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempts)));
}
}
throw new InvalidOperationException("Max retries exceeded");
}
Service two.
public async Task<TResult> RetryOnFailure<TResult>(Func<Task<TResult>> action)
{
for (int i = 0; i < 3; i++)
{
try
{
return await action();
}
catch (Exception ex) when (ex is HttpRequestException || ex is TimeoutException)
{
if (i == 2) throw;
await Task.Delay((int)Math.Pow(2, i + 1) * 1000);
}
}
return default;
}
Service three, which shall also remain nameless.
private async Task<T> TryWithBackoff<T>(Func<Task<T>> func)
{
var maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
return await func();
}
catch (HttpRequestException e)
{
if (attempt == maxAttempts) throw;
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(delay);
}
}
throw new Exception("Retry failed");
}
Same exponential backoff three times over, differing in variable names, exception filters and whether the delay is a TimeSpan or an int. Each was generated on its own, the moment a developer needed retries. Each works.
The tool sees one file. It doesn't know this is the seventh version of the same thing in the estate, and the reviewer only sees the one in front of them too. Everyone stops noticing, because each copy is fine.
Then a service needs jitter to stop three thousand clients retrying in lockstep, and that fix lands in one of them. The other two carry on, drifting a bit further from the one that's now correct.
The blind spot upstairs
The clearest example I've watched was a PHP to .NET migration, AI tooling from day one. Behind schedule, full of defects, and carrying Mediator, CQRS and an Entity Framework setup nobody could justify. The team spends its days debugging architecture that no one designed.
Underneath it is a team that didn't understand the old system and doesn't yet understand the new one. AI didn't speed that up. It let them produce far more of it, much faster.
That's the part leadership keeps missing. Without engineering maturity, domain knowledge and some architectural discipline, these tools don't deliver more. They deliver more unfinished, brittle system per sprint.
Where debugging goes to die
Deployment scripts are the other place it shows. Three hundred lines of bash, of which this is the first fifty.
#!/bin/bash
set -e
echo "Starting deployment process..."
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups/$TIMESTAMP"
APP_DIR="/var/www/app"
CONFIG_FILE="$APP_DIR/appsettings.json"
if [ ! -d "$BACKUP_DIR" ]; then
mkdir -p "$BACKUP_DIR"
if [ $? -ne 0 ]; then
echo "Failed to create backup directory"
exit 1
fi
fi
echo "Backing up current deployment..."
cp -r "$APP_DIR" "$BACKUP_DIR/"
if [ $? -ne 0 ]; then
echo "Backup failed"
exit 1
fi
echo "Stopping application..."
systemctl stop myapp
if [ $? -ne 0 ]; then
echo "Failed to stop application"
cp -r "$BACKUP_DIR/app" "$APP_DIR"
systemctl start myapp
exit 1
fi
echo "Checking database connection..."
DB_HOST=$(grep -oP '(?<=Server=)[^;]+' "$CONFIG_FILE")
DB_PORT=$(grep -oP '(?<=Port=)[^;]+' "$CONFIG_FILE")
timeout 5 bash -c "cat < /dev/null > /dev/tcp/$DB_HOST/$DB_PORT"
if [ $? -ne 0 ]; then
echo "Database connection failed"
systemctl start myapp
exit 1
fi
# ... continues for 250+ more lines
It carries on like that for another 250 lines. An error check after every command, hand-rolled rollback, database settings scraped out of a config file with a regex, and no way to exercise any of it short of running a real deployment.
I asked why it wasn't split into testable functions, or written in something with exception handling. "Copilot generated the deployment script based on our requirements."
Nobody asked whether bash was the right tool for 300 lines of branching logic. In Python you would get functions you can test on their own, real exceptions instead of if [ $? -ne 0 ] twenty times, a debugger, and dictionaries instead of grep -oP. What we have instead is a script where the only way to find out why last night's deploy failed is to add an echo and sit through the 15 minute pipeline again.
Generation without reasoning
That's the thread through all of it. AI generates plausible-looking code. It doesn't understand systems, trade-offs, or the constraints of your data and your domain, and plausibility is exactly what hides architectural debt, duplication and the bug that shows up in month seven. Engineering judgement didn't stop being necessary. It got more expensive to be without.
What 2026 will reveal
The bill comes due at the first bad outage. On-call engineers debugging systems they can't explain, stack traces landing in layers nobody chose, and a temporary fix that takes six hours because someone has to reverse-engineer decisions that were never made in the first place. The same thing happens if these tools get pulled away by cost or regulation, only all at once.
The people who come out of that fine are the ones who can still recognise duplication, take a layer out, and reason about a system end to end with the tooling switched off. That's a muscle, and plenty of teams have spent a year not using it.
So bring back the boring things. Abstraction because you chose it. Duplication treated as a signal. Performance reasoned about rather than guessed at, security verified rather than assumed, and a review that's a conversation between two people who understand the domain rather than a check that the pipeline went green.
The most dangerous sentence in a 2025 code review was "that's how the AI built it". That isn't a decision, it's an abdication, and it's the one thing on this list that costs nothing to stop doing.
I still use these tools every day. They're worth it for boilerplate, for exploration, for the work I already know how to do. I read every line, and I throw away anything I can't explain. That last part is the job now.