﻿# Engineering mindset in the age of AI

> AI took the typing, not the engineering – and the thinking is now the bottleneck. What that mindset is, how its absence looks (tool faith, green tests and red prod, best practices without their context), why AI repriced every trade-off, the difference between simple and naive, and what a junior loses when the consequences land on someone else. With migrations, three pods and an Excel export, in .NET and Java.

Source: https://laszlonemes.com/blog/engineering-mindset · Published: 2026-09-26 · Author: László Nemes

September 26, 2026 · 27 min read

`Engineering` `AI engineering` `.NET` `Java` `Career`

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.

> **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.
>
> *Diagram:* Two timelines of the same task. Before agents, a task was a short think, a long stretch of typing and a short verification, and the thinking could hide inside the typing. With an agent the typing shrinks to almost nothing, so the task is thinking and verifying, and if the thinking is missing it now shows immediately.

## 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:

```csharp
// 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:

```csharp
// 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?**

> **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.
>
> *Diagram:* The same column rename as two migrations. Drop plus add passes on the developer's twelve rows, then destroys forty thousand rows in QA and two million in production, and has no way back. Expand then contract adds the new column and copies the data first, deploys code that reads it, and drops the old column in a later release, with a working down step at every stage.

```csharp
// 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:

```csharp
// 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.

```shell
# 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.

```csharp
// 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*.

```sql
-- 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.

> **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.
>
> *Diagram:* Left: the sandbox the agent tested in, one process with one user and a few rows, tests green. Right: production with three pods. The upload was written to pod A's local disk, the workflow started on pod B and cannot find the file, and pod C's in-memory status cache says there is no such workflow. The fix is that nothing lives only inside the process: files in a shared blob store, cache in Redis, state in the shared database.

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.

```java
@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](https://laszlonemes.com/blog/micromonoliths); the point here is different. The point is *who* puts that rule in front of the agent.

application.yml

```yaml
# 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)

```json
{
  "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" }
  }
}
```

```csharp
// 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.

> **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.
>
> *Diagram:* Left, Netflix's Java scars: moving from JDK 8 to 17 gave about twenty percent less CPU with no code change, JDK 21 with Generational ZGC took garbage-collection pauses from over a second to effectively zero, and enabling virtual threads produced a silent deadlock through synchronized blocks. Right, what transfers to a small team: stay on a current LTS and read the post-mortem before flipping a flag. What does not transfer is their service topology, which answers their team count, not yours.

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
// 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 get | Java default you get |
| --- | --- | --- |
| data access | a Repository and a UnitOfWork over `DbContext` – which already is both. Two layers that do nothing and hide the LINQ | a Service, a Repository, a Mapper and a DTO per entity – a quartet for every table |
| five CRUD endpoints | CQRS with MediatR: fifteen handler classes, reads and writes separated on an admin page with ten users | a 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 assembly | Spring Cloud with Eureka, a Config Server and an API Gateway – for three developers |
| resilience | a retry policy on every call, including the ones that must not be retried | Hystrix, Ribbon and Zuul – in maintenance since 2018; Netflix itself moved to Resilience4j and Spring Cloud removed them |
| deployment | a Kubernetes manifest with an HPA – for an app with in-process state, where scaling out is a bug generator | the 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](https://laszlonemes.com/blog/shadow-ai-local-ai).
- **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:

```csharp
// 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.**

> **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.
>
> *Diagram:* Two columns. Cheaper now: static types, whose price was human keystrokes; tests, documentation and readable code; and the compiler as the agent's feedback loop. More expensive now: review and supervision, because code is cheap and checking is not; under-specified tickets, because the missing decision lands on whoever runs the agent; and a confident tone, because the model never says it is unsure.

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](https://laszlonemes.com/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.**

> **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.
>
> *Diagram:* A two-by-two of how much you built against whether you can name what you left out. Little built and you know what you left out is simple: an in-memory cache with Redis one configuration line away. A lot built and you know why is earned complexity: Kubernetes because you have the workloads to feed it. Little built without knowing what is missing is naive-simple: a static dictionary because there is one node for now. A lot built without knowing why is naive-complex: seven projects for five endpoints.

- **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.

```csharp
// "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.

```csharp
// 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.

> **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.
>
> *Diagram:* Two loops. How seniors were made: you write it, ship it, it breaks at three in the morning, you fix it, and the lesson returns to the person who wrote it. The AI-era version: the agent writes it, the tests are green, the junior forwards it, someone else fixes it in production, and the lesson lands on someone who did not make the decision. The loop that made seniors is open.

### “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`.

```java
// 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.

> **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.
>
> *Diagram:* Top lane, what the agent shipped: a GET request loads all two million rows as entities, builds the whole workbook in memory, copies it into a byte array and runs out of memory while the request is still open. Bottom lane, the version with three decisions: the request enqueues a job and returns an id; the job runs on one pod, streams rows out of the database and rows into the file; the file lands in a shared store and the user gets an expiring link. Excel's ceiling is 1,048,576 rows per sheet, so the third decision is a question back to the requester.

- **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.

```csharp
// 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);
```

```java
// 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.

```csharp
// 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 say | You 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

- [Bending pause times to your will with Generational ZGC](https://netflixtechblog.com/bending-pause-times-to-your-will-with-generational-zgc-256629c9386b) – Netflix TechBlog on JDK 21 and making Generational ZGC the default.
- [Java 21 Virtual Threads – Dude, Where's My Lock?](https://netflixtechblog.com/java-21-virtual-threads-dude-wheres-my-lock-3052540e231d) – the pinning post-mortem: Spring Boot 3, Tomcat, `synchronized`, `CLOSE_WAIT`.
- [How Netflix Really Uses Java](https://www.infoq.com/presentations/netflix-java/) – Paul Bakker's talk, including the JDK 17 CPU numbers.
- [JEP 491: Synchronize Virtual Threads without Pinning](https://openjdk.org/jeps/491) – delivered in JDK 24.
- [Hystrix](https://github.com/Netflix/Hystrix) – the maintenance notice and the pointer to Resilience4j.
- [EF Core migrations](https://learn.microsoft.com/ef/core/managing-schemas/migrations/) and [Flyway undo migrations](https://documentation.red-gate.com/flyway/flyway-concepts/migrations/migration-types/undo-migrations) – the up, the down, and what the down costs.
- [Apache POI SXSSF](https://poi.apache.org/components/spreadsheet/how-to.html#sxssf) – streaming workbooks, and the row window.
- [Excel specifications and limits](https://support.microsoft.com/en-us/office/excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3) – 1,048,576 rows per sheet.
