← Blog

Engineering mindset in the age of AI

· 27 min read

EngineeringAI engineering.NETJavaCareer

A coding agent writes in minutes what used to take a day. That sentence is true, and it is the least interesting thing about the last two years. The interesting part is what it exposed: writing the code was never where engineering happened. It was where engineering hid. While you typed, you thought – about the edge case, about the migration, about the second node. Now the typing is gone, and the thinking either happens before and after, on purpose, or it doesn't happen at all.

The thesis: AI took the typing, not the engineering. The model is not the bottleneck any more – the codebase, the harness and the review are. What keeps those three in shape is a way of thinking, and that way of thinking just became the scarce resource.
beforethinktypeverifythe thinking happened while typing – or its absence stayed hidden in itwith an agentthinktypeverifyfreed – goes to review, or to nothingThe code was always the smallest part of the work. It only shows now, because the rest disappeared.
Fig. 1 · The bar that shrank was the one the thinking used to live in. What is left is thinking and verifying – and if either is missing, there is nothing left to hide it behind.

What the mindset is

It is not a skill and it is not tooling. It is responsibility for what happens in production, not for what compiles. In practice it is three questions before every decision: what can this thing do, what can it not do, and how do I check. The questions are the same for a library, a cloud service and a language model. Only the answers differ.

Two habits sit underneath. The first is recognising an edge case before it recognises you: the empty list, the second node, the row count that is a thousand times larger in production. The second is quieter and matters more now than it ever did: being able to take a task and cut it into parts you can move through on a schedule. Delegating work to an agent is easy. Splitting your own work into pieces that can be checked one at a time is the skill – it is what makes the delegation reviewable.

What an edge case looks like when it is caught in review rather than in production, from one of my own products. A housekeeping job deletes audit rows older than a configured retention. The happy path is one line. The question is what happens when an operator types 0 into that setting:

// A housekeeping job deletes audit rows older than a configured retention.
// The edge case: "0" or a negative duration puts the cutoff at or after now,
// which matches every row. One mistyped setting, and the next pass empties the table.
if (retains(settings.AuditEntryDuration, out var cutoff))
    db.AuditEntries.RemoveRange(db.AuditEntries.Where(e => e.TimestampUtc < cutoff));

private static bool retains(TimeSpan? duration, out DateTime cutoffUtc)
{
    cutoffUtc = default;
    var now = DateTime.UtcNow;
    if (!duration.HasValue || duration.Value <= TimeSpan.Zero || duration.Value > now - DateTime.MinValue)
        return false;                       // unset, zero, negative or absurd: keep everything
    cutoffUtc = now - duration.Value;
    return true;
}

Nothing about that guard is hard. It is the habit of asking "and what if the number is zero" before the job runs at 2 a.m. on a customer's database – and the agent, which wrote the one-line version first, had no reason to ask.

The migration

You rename a column. The agent generates an EF Core migration. It compiles, the tests are green, it runs on the dev database. Here is what it generated:

// What the agent generated for "rename Customer.Name to FullName".
// Compiles, tests are green, the dev database has twelve customers.
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DropColumn(name: "Name", table: "Customers");       // 2 million values, gone
    migrationBuilder.AddColumn<string>(name: "FullName", table: "Customers", nullable: true);
}

Locally that is fine – twelve customers, all of them yours. On the dev database it is fine too, there is nothing in it. In QA, forty thousand names are gone. In production, two million. The engineering question was never "does it run". It was: does it run backwards, is it backward compatible, and what happens to the rows that already exist?

drop + add – what the agent generateddev · 12 rowsgreen build, green testsQA · 40,000 rowsevery name is NULLprod · 2,000,000 rowsdata gone, no way backone migration, one direction, three environmentsexpand → contract – what production needs1 · add FullName, copy Name into itdown: drop FullName2 · deploy code that reads FullNameold column still there, old code still works3 · drop Name – a later releasedown: add Name, copy it backevery step runs backwards, so every step may run forwards
Fig. 2 · Expand first, contract later. The new column goes in and is filled while the old code still runs; the old column goes out in a later release, once nothing reads it. Every step has a working down, which is the only reason any step is allowed to go up.
// Step 1 – expand. Runs against production with the old code still deployed.
public partial class AddCustomerFullName : Migration
{
    protected override void Up(MigrationBuilder mb)
    {
        mb.AddColumn<string>(name: "FullName", table: "Customers", nullable: true);
        mb.Sql("UPDATE \"Customers\" SET \"FullName\" = \"Name\" WHERE \"FullName\" IS NULL;");
    }

    // The way back has to exist before the way forward is allowed to run.
    protected override void Down(MigrationBuilder mb)
    {
        mb.DropColumn(name: "FullName", table: "Customers");
    }
}

// Step 2 – contract. A later release, once nothing reads "Name" any more.
public partial class DropCustomerName : Migration
{
    protected override void Up(MigrationBuilder mb) => mb.DropColumn(name: "Name", table: "Customers");

    protected override void Down(MigrationBuilder mb)
    {
        mb.AddColumn<string>(name: "Name", table: "Customers", nullable: true);
        mb.Sql("UPDATE \"Customers\" SET \"Name\" = \"FullName\";");
    }
}

I am a code-first believer: the application applies its own pending migrations on start-up, so I almost never touch a database by hand because of the app. In one of my products that is the first thing the process does, before the cache or any service is wired:

// Program start-up, before anything else is wired: the app owns its schema.
log.LogInformation("Migrating database");
await db.Database.MigrateAsync();          // applies whatever is pending, on every start
log.LogInformation("Migration complete");
await cache.InitializeAsync();             // the cache port, whichever adapter is configured

The other half of that discipline is that the same binary can be started in CLI mode for the things that must not happen silently: moving an installation between database providers, clearing it, or migrating it on purpose. Two flags carry the whole mindset. Without them the tool refuses, and the message says why.

# The web server binary, started with a verb instead of as a web server.
app database migrate                                              # to the latest known migration
app database copy --from-db-type Sqlite --to-db-type Postgres     # move an installation between providers
app database clear --accept-data-loss                             # refuses without the flag, and says why
app database copy ... --perform-migration                         # same: the operator opts in, explicitly

Two guards in that code are worth stealing. A database that contains migrations the binary has never heard of belongs to a newer version: refuse, don't "fix" it. And a CLI task that had to migrate first migrates the database back to where it found it when the task is done, by running the Down steps, so a maintenance job can never leave a customer on a schema their running version can't read. The way back is not a restore from backup. It is a command, and it runs by itself.

// CLI mode: the same binary, started with a verb. Two guards carry the mindset.
internal class DatabaseSession(ILogger log, AppDbContext db) : IAsyncDisposable
{
    private string? originalLatestMigration;
    private bool migratedAtInit;

    public async Task Initialize(IAcceptMigrationRequired input)
    {
        var known   = db.Database.GetMigrations().ToArray();
        var applied = (await db.Database.GetAppliedMigrationsAsync()).ToArray();

        // Guard 1: migrations in the database that this binary has never heard of.
        // That is a newer version's database. Refuse – don't "fix" it.
        if (applied.Any(a => !known.Contains(a)))
            throw new ApplicationException(
                "database is used by a newer version of the software. Update the software to continue.");

        var pending = (await db.Database.GetPendingMigrationsAsync()).ToArray();
        migratedAtInit = pending.Length > 0;
        originalLatestMigration = applied.LastOrDefault();

        // Guard 2: never migrate as a side effect. The operator says so, with a flag.
        if (migratedAtInit && !input.AcceptMigrationRequired)
            throw new ApplicationException(
                $"database requires {pending.Length} migrations. In rare cases this may lose data. " +
                "Pass --perform-migration if you know what you're doing.");

        if (migratedAtInit)
            await db.Database.MigrateAsync();
    }

    // The way back is not a backup. It is a command, and it runs by itself when the task is done,
    // so a maintenance operation cannot leave a customer on a schema their running version can't read.
    public async ValueTask DisposeAsync()
    {
        if (migratedAtInit && originalLatestMigration is not null)
        {
            log.LogInformation($"Migrating database back to {originalLatestMigration}");
            await db.Database.MigrateAsync(originalLatestMigration);   // runs the Down steps
        }
    }
}

The Java version of the same story has a twist worth knowing. Flyway runs versioned SQL forward beautifully; the undo migrations are a paid feature, and Liquibase needs an explicit rollback block. So in a Spring Boot project the way back is usually another forward migration – which makes the expand step even more important, because you have to be able to leave it in place.

-- V7__add_customer_full_name.sql  (expand: additive, old code keeps working)
ALTER TABLE customers ADD COLUMN full_name VARCHAR(200);
UPDATE customers SET full_name = name WHERE full_name IS NULL;

-- V9__drop_customer_name.sql  (contract: a later release, once nothing reads "name")
ALTER TABLE customers DROP COLUMN name;

-- There is no free "down" in Flyway: undo migrations are a paid feature, and
-- Liquibase needs an explicit rollback block. In Java the way back is another
-- forward migration – so the expand step must be safe to leave in place.
The agent writes the migration. Whether it runs backwards is my job. Not because the agent couldn't write the down step – it can, if asked – but because the agent has no production data. I do.

“Configure it and go”

The first place the mindset goes missing: install Claude Code, paste in someone's CLAUDE.md from the internet, add ten plugins and three MCP servers, and it will work. The tool stands in for understanding – exactly like copying an nginx config without knowing what keepalive is.

Two concrete ways this costs money. A template says "run the full test suite after every change". Your suite takes 25 minutes. The agent obeys, on every small edit; by the end of the day the session has burned a week's worth of tokens and wall-clock time, and nobody can say why it felt slow. Or: an MCP server with forty tools, of which you use two. The other 38 definitions sit in the context of every request, degrade the reasoning a little, and never show up on the invoice – only in the quality.

My own setup is vanilla: Claude Code, my own agents, my own skills. If I need something else it is a script, ssh, or bash. A well-kept codebase is worth more than any amount of tooling, because the model works from what is in the repository. The test for every piece of configuration is simple: if you removed it, could you say what would change? If not, you don't understand it. You have configured it.

The length of your plugin list is inversely proportional to how well you understand what the model does.

Works on my machine, the higher level

The classic: it runs on my laptop, not in production. The AI-era version is more insidious: the code runs, the tests are green, and the agent checked it – in its own sandbox. One node, one user, five rows of test data, twenty database connections.

Take a well-structured modular monolith running on Kubernetes as three pods. The agent implements a feature: a user uploads a file, and a workflow step processes it. Locally it is perfect.

What the agent testedone processone user · five rowstests green ✓own disk · own memorytwenty connectionsProductionpod Aown disk · own memorypod Bown disk · own memorypod Cown disk · own memoryupload → local diskworkflow: file not foundstatus: “not running”three pods, three truths – 3 × pool × 200 users, and Postgres runs out of connectionsthe fix: nothing that lives only in the processBlob / S3 / MinIORedisshared PostgresIt wasn't in the prompt that there are three pods. The model has one machine: its own.
Fig. 3 · None of this is the model's fault. It does not know there are three pods – the prompt didn't say. The production context is yours to put in: in the CLAUDE.md, in the plan phase, and in a load test.

In Spring Boot the same bug arrives in idiomatic clothes, which is what makes it hard to see in review. @Cacheable on the default ConcurrentMapCacheManager is a per-JVM cache. MultipartFile.transferTo writes to this pod's disk. Spring Session defaults to memory, so a login on pod A is a 401 on pod B. And HikariCP's default pool of ten looks generous until you multiply it by pods and by users and read the Postgres max_connections setting.

@Service
public class WorkflowService {

    private final Path uploads = Path.of("/tmp/uploads");           // this pod's disk

    public String upload(MultipartFile file) throws IOException {
        var target = uploads.resolve(UUID.randomUUID() + ".csv");
        file.transferTo(target);                                     // pod A
        return target.toString();
    }

    @Cacheable("workflow-status")                                    // ConcurrentMapCacheManager: per JVM
    public WorkflowStatus status(String workflowId) {
        return repository.findStatus(workflowId);
    }
}

The fix is not clever. It is that nothing lives only inside the process: the cache is pluggable to Redis or a database table, the file store is an adapter over Blob, S3 or MinIO, and anything that is state is in the shared database. That is a configuration change, not a rewrite – if the ports were there from the start. How I build those ports is the micromonolith post; the point here is different. The point is who puts that rule in front of the agent.

application.yml

# application.yml – the same code, one Redis and one bucket away from three pods
spring:
  cache:
    type: redis                 # @Cacheable now lives outside the JVM
  session:
    store-type: redis           # a login on pod A is valid on pod B
  datasource:
    hikari:
      maximum-pool-size: 10     # × pods × users – this is what hits Postgres max_connections

files:
  store: s3                     # local | s3 | azure-blob | minio – behind one FileStore port
  bucket: shop-uploads

This is what the same port looks like in my own product, comments included – the comment in the configuration file is the contract with whoever installs it, and it says the multi-node thing out loud:

appsettings.json (shipped sample, trimmed)

{
  "CacheSettings": {
    // 'InMemory' (default) – the fastest, but only works reliably on single-node installations
    // 'None'               – no cache. Not supported with OAuth login
    // 'Redis'              – recommended for multi-node installations
    // 'SqlServer' | 'Postgres' | 'MySql' – a table in that database as the cache
    "Type": "InMemory",
    "InMemoryCacheSettings": { "MaxCacheSize": "1gb" },
    "RedisCacheSettings": { "Configuration": "localhost:6379", "InstanceName": "app" }
  }
}
// One port. The adapter is a case in a switch over the configured type – no code change to go multi-node.
public interface ICache
{
    Task<T?> GetOrCreateAsync<T>(string key, Func<CacheOptions, Task<T?>> create, CancellationToken ct = default)
        where T : class, new();
    Task SetAsync<T>(string key, T value, CacheOptions options, CancellationToken ct = default) where T : class, new();
    Task RemoveAsync(string key, CancellationToken ct = default);
    Task InitializeAsync();
}

switch (configuration.GetValue<CacheType>("CacheSettings:Type"))
{
    case CacheType.InMemory: services.AddMemoryCache(sizeLimit);              break;
    case CacheType.Redis:    services.AddRedis(section["Configuration"]!, section["InstanceName"]!); break;
    case CacheType.Postgres: services.AddPostgres(section["ConnectionString"]!, section["Schema"]!, section["TableName"]!); break;
    // SqlServer, MySql, None …
}

The file store has the same shape: local disk, Azure Blob, S3, MinIO, Google Cloud Storage, Box and SharePoint behind one interface, picked the same way. None of it is clever. It is the decision, made once, that nothing lives only inside the process.

Whoever writes "no in-process-only state; three pods in production" into the project's instructions gets a Redis adapter from the agent. Whoever doesn't gets a Dictionary. The conclusion is not that AI code is unfit for production. It is that the production context is yours to supply, and that discovering it in review is the expensive way.

Green tests, red prod – the AI-era works on my machine. The model has one machine: its own. The number of your pods is yours to know.

What a best practice really is

Not a rule. Condensed experience, with context attached. Someone got burned, wrote it down, and the lesson is true at a particular scale, with a particular team size, under particular constraints. Microservices are the pain of a several-hundred-person, several-time-zone organisation. Kubernetes is Google's scale. One hundred percent test coverage is a project where a bug costs a life. A best practice has two parts, the rule and its context, and whoever carries only the rule carries half.

Netflix is the example everyone reaches for, so it is worth reading what they actually wrote. They run Spring Boot microservices on the JVM at a scale almost nobody else has. Moving from JDK 8 to 17 gave them roughly 20% better CPU usage with no code change, purely from improvements in G1. On JDK 21 with Generational ZGC, garbage-collection pauses went from over a second to effectively zero, with better P99 latency at equal or better CPU – and they made it the default collector on 21 and later.

The scar – Netflix, Spring Boot on the JVMJDK 8 → 17: ~20% less CPUno code change – G1 got betterJDK 21 + Generational ZGCpauses of over a second → effectively zerovirtual threads + synchronizedcarriers pinned, silent deadlock, CLOSE_WAITWhat you may copystay on a current LTSJava 25, .NET 10 – the free performance is realread the post-mortem, then flip the flagthe lesson comes with a JDK version attachedtheir service topologyanswers their team count, not yoursA best practice is a scar and a lesson. Copy only the lesson and you don't know where it will hurt.
Fig. 4 · Their scars, and what you may take from them. The topology answers a question about their organisation. The upgrade discipline answers a question about physics, and physics is the same at your scale.

None of that means a five-person team should copy their architecture in miniature. What does transfer, almost unconditionally: stay on a current LTS. Java 21 and now 25, .NET 8 and now 10 – every one of those releases carried runtime and GC work that you get for the price of a version bump and a test run. Falling behind compounds, because each skipped LTS makes the next upgrade bigger and scarier, and the performance you didn't collect is gone.

The same company also published the counter-scar, which is the part people skip. "Just enable virtual threads" was the Java 21 advice everywhere. Netflix enabled them on Spring Boot 3 with Tomcat and got services that hung silently: no error, no stack trace, sockets stuck in CLOSE_WAIT. Virtual threads that blocked inside synchronized blocks stayed pinned to their carrier threads until every carrier was pinned and nothing could run – a deadlock that looked like quiet. JDK 24 fixed the pinning through JEP 491. The best practice was right; it just came with a JDK version attached, and the version was the context.

// Java 21: a virtual thread that blocks inside synchronized pins its carrier thread.
// Enough of these at once and every carrier is pinned – the pool is full and nothing
// is running. No exception, no stack trace, just sockets in CLOSE_WAIT.
synchronized (cacheLock) {
    value = client.fetch(key).get();          // blocks while pinned
}

// The same wait behind a java.util.concurrent lock releases the carrier.
lock.lock();
try {
    value = client.fetch(key).get();
} finally {
    lock.unlock();
}
// JDK 24 (JEP 491) removed the pinning for synchronized. The advice "just enable
// virtual threads" was true – with a JDK version attached.

A smaller preference, same reasoning. I don't want one-line cleverness in a codebase; I want things written out so the next developer understands them without a whiteboard. The AI era made this stronger, not weaker: if a person can read it, the model can too, and the model reads it far more often than the person does.

A best practice is a scar and a lesson. If you copy only the lesson, you don't know where it will hurt.

The model’s defaults are the internet’s averages

A language model learned from the average of the internet, and on the internet the loudest patterns win. Without context, it gives you the statistically expected answer – which is the architecture of the companies that blog the most, applied to the app you actually have. Here is what you get without asking:

You ask for.NET default you getJava default you get
data accessa Repository and a UnitOfWork over DbContext – which already is both. Two layers that do nothing and hide the LINQa Service, a Repository, a Mapper and a DTO per entity – a quartet for every table
five CRUD endpointsCQRS with MediatR: fifteen handler classes, reads and writes separated on an admin page with ten usersa hexagonal Maven multi-module with seven modules
a plan"let’s extract notifications into its own service" – for one team with one data source, where it is a module in an assemblySpring Cloud with Eureka, a Config Server and an API Gateway – for three developers
resiliencea retry policy on every call, including the ones that must not be retriedHystrix, Ribbon and Zuul – in maintenance since 2018; Netflix itself moved to Resilience4j and Spring Cloud removed them
deploymenta Kubernetes manifest with an HPA – for an app with in-process state, where scaling out is a bug generatorthe same manifest, with a Helm chart

The last Java row is the one that shows the mechanism most clearly. The model isn't wrong about what Netflix used; it is a few years behind on what Netflix stopped using, because the internet wrote about the beginning far more than about the end. We used to fight the "this is how Netflix does it" reflex inside the team. Now we fight the model's version of it too, and the model doesn't get tired.

The model doesn't know you're one team with one data source. If you don't say so, you get thirty services for an internal admin page.

Trade-off thinking, repriced

This is the core. There is no best solution – you pay a price, and you know what it is. Three questions: what does it cost, what breaks, can it be undone. Some of mine, with the price named:

  • Where the model runs. Frontier API, your own cloud tenant, or local – it is data sensitivity plus whether the arithmetic works out, per use case, never "always". The long version is in the shadow AI post.
  • Lock-in. Not a sin, a price paid on purpose – as long as you know the migration path for the day the vendor relationship goes bad.
  • Settings synchronised across nodes by a background job, with about a minute of eventual consistency. It could have been pub/sub. The price of pub/sub is one more infrastructure component; a minute of inconsistent settings costs nothing here, and a Redis dependency in a boxed product costs a great deal.
  • Kubernetes all year, or a few VMs for the season. Complexity paid twelve months for a two-week peak is a bad trade, and it is bad in the spreadsheet, not in taste.

The settings job, since it is the one people push back on most, is small enough to show whole. The price is in the last comment:

// Settings live in the database. Every node re-reads them on a timer – no pub/sub, no broker.
public interface IHostedJob<TSettings> where TSettings : HostedJobSettings
{
    string Name { get; }
    IOptionsMonitor<TSettings> GetSettings();
    Task Run(TSettings settings, IServiceProvider scopedSp, CancellationToken ct);
}

internal class AppConfigRefreshJob(IOptionsMonitor<AppConfigRefreshSettings> settings)
    : IHostedJob<AppConfigRefreshSettings>
{
    public string Name => "AppConfigAutoRefresh";
    public IOptionsMonitor<AppConfigRefreshSettings> GetSettings() => settings;
    public Task Run(AppConfigRefreshSettings _, IServiceProvider sp, CancellationToken ct) =>
        sp.GetRequiredService<IAppConfigService>().RefreshConfig();     // the whole job
}

// appsettings.json – the price, in one line: a node may be a minute behind
// "AppConfigAutoRefresh": { "Enabled": true, "IntervalSettings": { "Interval": "00:01:00" } }

What AI did to this is not that it decided any of these. It changed the prices.

Cheaper nowstatic typespaid in keystrokes – now zerotests · docs · readable codecheap to write; the agent reasons better on themthe compiler as feedbackthe cheapest deterministic loop an agent can getMore expensive nowreview and supervisionthe code is cheap; the checking is notunder-specified tickets“the AI fills it in” – someone decides, unknowinglya confident toneten times the code, no “I'm not sure” signalAI didn't settle the trade-offs. It repriced them.
Fig. 5 · What was “right but expensive” is now close to free, and what was cheap is now the cost centre. Whoever still budgets by the old prices is optimising the wrong column.

Static types are the clearest case. Their price was human keystrokes, and for a model that price is zero. In return the compiler is the cheapest deterministic feedback loop you can give an agent: rename a property, and the C# or Java compiler points at forty places; in Python the forty-first turns up at runtime, in production. The agent learns from the first and never sees the second. Tests, documentation and readable code went the same way – cheap to produce, and they pay back in tokens and reasoning quality. My longer position on types and on token optimisation is on the State of AI page.

The other column is where the budget went. Code is cheap; checking it is not, which is why a day of supervising an agent is more tiring than a day of writing. And the under-specified ticket became expensive in a way it never was: "the AI will fill it in" means the missing decision is made by whoever runs the agent, often a junior, who doesn't know a decision was made.

There is no best solution. Only one whose price you know. AI didn't settle trade-offs – it repriced them. The price of types was human typing; that is now zero, and the debate is over.

Simple vs. naive

From the outside they look the same. From the inside they are opposites: the simple solution knows what it left out. The naive one doesn't know it left anything out.

can name itleft out“nothing”little builthow much you builta lot builtSimplememory cache · Redis is one config lineknows what it left outEarned complexityk8s, with the workloads to feed itknows why it paidNaive-simplestatic Dictionary – “one node for now”the outage is already in itNaive-complexseven projects for five endpointsthe same mistake, more expensive
Fig. 6 · Simplicity is not a property of the output. It is a property of the decision. The bottom row is the same mistake at two price points – and the agent produces either one, depending on what you asked.
  • Simple: a monolith written for multiple nodes from day one. IDistributedCache with memory behind it, and Redis one configuration line away. A file-store port with local disk as the default and Blob or S3 one line away. SQLite for a zero-config start, PostgreSQL with a connection string. In Spring: Caffeine locally, spring.cache.type=redis when it is time. I don't run it on several nodes until I have to – but the door is open.
  • Naive-simple: static Dictionary<string, WorkflowState> as the cache, or a static ConcurrentHashMap, because "there's one node for now". Same appearance; the bug from the three-pods section is already inside.
  • Naive-complex: hexagonal everything, ports and adapters for each dependency, seven projects in the solution – or seven Maven modules – for a five-endpoint app. The same mistake as the Dictionary, at a higher price.
// "One node for now." This is the sentence the outage is written in.
public static class WorkflowRuntime
{
    public static readonly Dictionary<string, WorkflowState> Running = new();
}

// Same shape, different sentence: in-memory today, Redis is a line of configuration.
public sealed class WorkflowRuntime(IDistributedCache cache)
{
    public Task<WorkflowState?> GetAsync(string id, CancellationToken ct) =>
        cache.GetAsync<WorkflowState>($"workflow:{id}", ct);
}

"SQLite for a zero-config start, PostgreSQL with a connection string" is not a slogan, it is a switch expression. The price, named: transactions, locking and SQL dialects differ, so the code stays near the lowest common denominator, and every provider needs its own migrations and its own place in CI.

// The database is an attached resource: four providers behind one context interface,
// chosen from configuration. Empty configuration means SQLite and the app just starts.
IApplicationDbContext db = info.Type switch
{
    InternalDbType.Sqlite   => new SqliteDbContext(options(o => ConfigureSqlite(o, info.ConnectionString))),
    InternalDbType.MsSql    => new SqlServerDbContext(options(o => ConfigureSqlServer(o, info.ConnectionString, info.Schema))),
    InternalDbType.Postgres => new PostgresDbContext(options(o => ConfigurePostgres(o, info.ConnectionString, info.Schema))),
    InternalDbType.MySql    => new MySqlDbContext(options(o => ConfigureMySql(o, info.ConnectionString, "8.0.28"))),
    _ => throw new ArgumentOutOfRangeException(nameof(info.Type), "database type not recognised in config"),
};

The test is one question: can you name what you gave up? If the answer is "nothing", the answer is naive.

Naivety has two faces: the Dictionary cache and the seven-project CRUD.

Junior → senior

The mindset isn't taught in a lecture. It is made of consequences. You wrote it, it shipped, it broke, you fixed it at three in the morning, and you never did it that way again. The senior knows where the bodies are because they buried them.

Now the writing is disappearing, and with it the incidental learning that happened while writing. The risk is not that juniors can't code. It is that they skip the school of consequences: the agent writes it, the tests are green, the junior forwards it, and someone else finds the bug in production. The feedback is disconnected from the one person who could have learned from it.

How seniors were madeyou write ityou ship itbreaks at 3 a.m.you fix itthe lesson returns to youThe AI-era versionthe agent writes ittests are greenyou forward itsomeone else fixes itthe lesson lands elsewhereThe senior knows where the bodies are because they buried them. The junior now grows up unable to dig.
Fig. 7 · The loop on the left is how every senior you know was made. The loop on the right has the same four steps and no return arrow. Closing it again is now a management task, not a matter of luck.

“Make an Excel export”

The ticket says that and nothing more. The agent picks a library, loads the whole table into memory, and writes it out. Locally, with five hundred rows, it is lovely. In production, with two million rows, it is an OutOfMemoryError.

// The ticket said "make an Excel export". The agent made one.
@GetMapping("/orders/export")
public ResponseEntity<byte[]> export() throws IOException {
    var orders = orderRepository.findAll();                 // all 2,000,000 rows, as entities
    try (var workbook = new XSSFWorkbook();                 // whole workbook in memory
         var out = new ByteArrayOutputStream()) {           // and a second copy of it
        var sheet = workbook.createSheet("orders");
        for (int i = 0; i < orders.size(); i++) { /* one row per order */ }
        workbook.write(out);
        return ResponseEntity.ok(out.toByteArray());        // the request has been open for minutes
    }
}

The junior didn't know that three decisions were made here – which library, streaming versus in-memory, and a background job versus a request-response – because the ticket was written as if there were no decisions. A few years ago the junior would have written this by hand and found out at the second loop that it doesn't fit. Now the agent hands over the "working" naive version straight away.

what the agent shipped – one decision, the libraryGET /exportrequest stays openfindAll()2,000,000 entitiesXSSFWorkbookwhole sheet in memorybyte[]a second copyOutOfMemoryfine with 500 rowslocally, with five hundred rows, this is a working featurethe version with three decisions – job, streaming, and a questionPOST /exportreturns a job idqueuesame params → same filejob · one podstream DB → stream fileBlob / S3any pod can serve itexpiring linkthe user comes backa sheet holds 1,048,576 rows – two million doesn't fit, so the third decision is a question, not codeThe naive version made one decision. The good one made three, and knew it was making them.
Fig. 8 · Same ticket, two shapes. The top lane is one decision that nobody noticed being made. The bottom lane is three decisions, and the third one is not code – it is a question back to whoever wrote the ticket.
  • A job, not a request. The endpoint enqueues and returns a job id; the job runs on one pod, writes to the shared file store, and the user gets an expiring download link. Idempotent: the same parameter hash means the same file, so a double click doesn't start a second two-million-row export.
  • Streaming at both ends. Out of the database as one ordered read – AsNoTracking().AsAsyncEnumerable() or a DbDataReader in .NET, a JPA Stream with a fetch-size hint in a read-only transaction in Java; keyset pagination if you must page, never OFFSET. Into the file row by row: OpenXML's OpenXmlWriter, MiniExcel or LargeXlsx in .NET, SXSSFWorkbook instead of XSSFWorkbook in POI. No MemoryStream, no byte[]. Styles only on the header, by style index, not per cell.
  • Excel has a ceiling. A sheet holds 1,048,576 rows. Two million doesn't fit on one. Several sheets – or the better move: ask what it is for. Another system wants CSV or Parquet. A human almost certainly wants a filtered, aggregated view, not two million rows.
// A background job on one pod. The request only enqueued it and returned a job id.
// OpenXML needs a seekable stream, so: a temp file on disk, then a streamed upload.
var tempPath = Path.GetTempFileName();
await using (var file = File.Create(tempPath))
using (var doc = SpreadsheetDocument.Create(file, SpreadsheetDocumentType.Workbook))
{
    var sheetPart = doc.AddWorkbookPart().AddNewPart<WorksheetPart>();
    using var writer = OpenXmlWriter.Create(sheetPart);       // SAX-style: writes, never holds the sheet
    writer.WriteStartElement(new Worksheet());
    writer.WriteStartElement(new SheetData());
    WriteRow(writer, "Id", "Customer", "Total");              // styles only here, by style index

    var orders = db.Orders.AsNoTracking()
        .Where(o => o.CreatedAt >= from)
        .OrderBy(o => o.Id)                                   // one ordered read, no OFFSET pages
        .AsAsyncEnumerable();

    await foreach (var o in orders.WithCancellation(ct))      // one entity in memory at a time
        WriteRow(writer, o.Id, o.CustomerName, o.Total);

    writer.WriteEndElement();
    writer.WriteEndElement();
}

await fileStore.UploadAsync($"exports/{job.Key}.xlsx", tempPath, ct);   // shared store, any pod can serve it
File.Delete(tempPath);
// SXSSF keeps a window of rows in memory and flushes the rest to a temp file.
try (var workbook = new SXSSFWorkbook(100);
     var out = fileStore.openWrite("exports/" + job.key() + ".xlsx")) {

    var sheet = workbook.createSheet("orders");
    int r = 0;
    writeHeader(sheet.createRow(r++));

    // @Transactional(readOnly = true) + a fetch-size hint: the driver streams, JPA doesn't cache
    try (Stream<Order> orders = orderRepository.streamByCreatedAtAfterOrderByIdAsc(from)) {
        for (var it = orders.iterator(); it.hasNext(); ) {
            var o = it.next();
            var row = sheet.createRow(r++);
            row.createCell(0).setCellValue(o.id());
            row.createCell(1).setCellValue(o.customerName());
            row.createCell(2).setCellValue(o.total().doubleValue());
            entityManager.detach(o);                       // or the persistence context grows with the file
        }
    }

    workbook.write(out);
    workbook.dispose();                                    // deletes the temp files
}

The naive version made one decision. The good one made three – and the third is a question, not code. That question is the engineering.

For honesty, the counter-example from my own code. One of my products exports its audit log with the whole workbook in memory, written to a temp file that deletes itself when the response closes. That is the top lane of Fig. 8. It is also the right shape there, because the housekeeping job from the first section caps the audit table at a configured retention, so the export has a ceiling I can name. Same shape as the naive version; the difference is that I can say why.

// My own audit export: whole workbook in memory, then a self-deleting temp file. The naive shape –
// and the right one here, because a housekeeping job caps the audit table at a configured retention,
// so this export has a ceiling I can name.
var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("Audit");
for (var i = 0; i < entries.Length; i++)
{
    ws.Cell(i + 2, 1).Value = entries[i].TimestampUtc.ToString(format);
    ws.Cell(i + 2, 4).Value = CsvFieldWriter.EscapeFormula(entries[i].UserName);   // "=cmd|…" stays text
    ws.Cell(i + 2, 5).Value = CsvFieldWriter.EscapeFormula(entries[i].DetailsJson);
}

var stream = new FileStream(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite,
    FileShare.None, 4096, FileOptions.DeleteOnClose);      // gone when the response finishes
wb.SaveAs(stream);
stream.Position = 0;
return new FileStreamResult(stream, XlsxContentType) { FileDownloadName = "ExportAudit.xlsx" };

What I would actually do

If the typing is fast, the freed time should go into putting juniors in the room for architecture decisions early: watching, commenting, being wrong, and getting the consequence. The market is doing the opposite, and that is where the next decade's senior shortage is being built. Concretely:

  • The junior predicts what the agent will do before it runs, then we compare. The plan is their work, not the model's.
  • Review asks "why this, and what was the other path" – not "did it pass".
  • Breakpoints, a profiler, a load test. The AI is an excellent debugger; without the fundamentals you don't know where to put the breakpoint.
  • Tickets don't get under-specified on the theory that the AI will fill in the rest. The rest is the decision.

The senior's role flipped with it: not throughput, judgement. The senior is the one who knows when the model is lying – and the one who organises, for the junior, the consequences that AI took away.

The problem isn't that the junior can't code. It's that they don't know they made a decision.

Years vs. experience

Separate the years from the experience. Three years across several domains – in on the architecture decisions, a few stacks, LDAP and Active Directory set-ups, customers who each wanted something different – can see a problem in a wider frame than fifteen years of only Spring Boot. The fifteen-year engineer is superb at Spring Boot. The three-year engineer knows what the problem looks like from three other places, which is what puts it in context.

What I support is the generalist with one deep domain: know something about everything so you can place a problem, and go deep in one thing so you have stable ground to return to. The two also map onto career phases. The more of a generalist you are, the better a startup or a scale-up fits that period of your life. When you burn out, or simply need calmer water, the deep domain buys you a seat on an enterprise – a huge ship that turns slowly, and that is the point of it.

Ego, and where it comes from

This profession needs some ego and some confidence: yes, I'll solve this, and it will be good. For that to settle into something healthy, you need failures – incidents, mistakes, the occasional slap – and they shape you. Own the mistake, fix it, and the next time still say what you think. Not "nobody cares what I say because I was wrong last time"; everyone is wrong sometimes. Like falling off a horse: back in the saddle, fast, if you ever want to ride. Ego and confidence are what carry a feature or a decision through and let you influence other people – not by pushing them down, by sharing what you have seen.

Which closes the loop back to the previous section. Confidence without consequences is the naive kind. Confidence with a few scars is the engineering kind, and it is exactly the thing the current setup – agent writes, tests green, forward it – is quietly not producing.

Objections, and the answers

They sayYou say
"Best practices exist so we don’t relearn everything. Throw them out?"No. A default, not a dogma. A junior following a good default beats one inventing something. The trouble starts where Kubernetes is not an option but the answer, before the question.
"AI code isn’t worse than an average developer’s."It isn’t. The difference is volume and tone: ten times the code, in the same confident voice, without the human "I’m not sure about this" signal. Review load up, signal down. That is capacity arithmetic, not hostility.
"Juniors learn faster now – the AI explains everything."Explanation got cheap. Whoever read that the pool can run out knows it. Whoever watched it run out at three in the morning understands it. The second is not available in a chat.
"This is only true in your .NET, boxed-product world."The examples come from there because that is where I can be concrete. The principle – the model has no production context, the trade-offs were repriced – holds at a Python startup too. It just takes longer to hurt.
"You use it every day yourself."Intensively, as a tool, and I read every line. None of this is an argument against AI. It is an argument against thinking that stopped when the typing did.

Takeaways

  1. AI took the typing, not the engineering. The bottleneck moved to the codebase, the harness and review – and the mindset is what keeps those in shape.
  2. Three questions before every decision: what can it do, what can't it, how do I check. The same for a library, a cloud service and a model.
  3. The agent has no production data. Migrations run backwards, columns expand before they contract, and the number of pods goes into the instructions, not into the post-mortem.
  4. If you removed it, could you say what changes? That is the test for every plugin, MCP server and pasted instruction file.
  5. A best practice is a scar plus context. Copy Netflix's LTS discipline; don't copy their topology.
  6. The model's defaults are the internet's averages. Tell it you are one team with one data source, or you get thirty services.
  7. AI repriced the trade-offs. Types, tests and readable code got cheap; review and under-specified tickets got expensive. Budget by the new prices.
  8. Simple knows what it left out. Naive comes in two sizes, and the agent makes both.
  9. Close the consequence loop for juniors on purpose. Predict, compare, ask why, and put them in the room early. The senior's job is judgement, not throughput.

None of this is against AI. It is against thinking that stopped when the typing did.

Sources