A dark title card reading "When AI Started Writing My Code, I Had to Rethink What I Needed to Own", with the flow observe → decide → apply

When AI Started Writing My Code, I Had to Rethink What I Needed to Own

2026.08.09

I’m building an Electron-based Markdown note-taking app. The editor alone is over 40,000 lines; the full codebase has grown past 160,000 lines. I started out writing everything myself. For the past year or so, most of the code has been written with AI agents.

I knew I was supposed to review the code AI wrote.

In practice, that discipline is surprisingly hard to maintain. Hand a feature to an AI agent, and a plausible implementation comes back quickly. The explanations sound convincing. The models keep getting better.

At first I read every line. Then, gradually, if the tests passed and the behavior looked right, I accepted it. More than once I resolved to go back to careful review. It never lasted.

Meanwhile, the codebase grew past 160,000 lines. At some point, asking what percentage of it I could personally explain stopped being a meaningful question.

Code I didn’t understand kept piling up on top of code I didn’t understand.

I’ve come to think of this as a kind of cognitive debt.

Accepting code I couldn’t explain made me anxious. I started treating that anxiety with tests.

Treating the anxiety with tests

Whenever I added a feature or fixed a bug, I insisted on extensive test coverage. End-to-end tests that boot the actual app kept getting added. Logic that a unit test could have covered got pushed up into E2E tests too.

Tests accumulated without me ever having the full picture of what already existed or which layer was verifying what. Eventually the E2E suite alone reached about 60,000 lines across 118 files.

When I finally measured things, the numbers were ugly.

  • E2E tests accounted for 86% of total test runtime.
  • When I analyzed the E2E failures, only about 4% reflected genuine product bugs. The rest were mostly timing and synchronization issues.
  • One test file started and stopped the app 62 times, burning 272 seconds.

There was a worse discovery. In one E2E test covering twelve formatting cases, the core assertions for three of them sat behind an if guard and had not run once in months, even as the test kept passing. The expected values written into those cases didn’t match the actual behavior, and the guard was hiding that too.

That test was written by AI. The safety net I’d built to ease my anxiety about not understanding the code had itself been built in a way I didn’t understand.

Every small fix came with an E2E tax. Code I didn’t understand kept growing. Cleaning up the code required the tests to act as a safety net, but the tests were themselves in need of cleanup. It was hard to know where to even start.

And worse than the slowdown was the feeling that I was losing control of my own project.

Deciding what to own, instead of understanding everything

This is where I started looking at the problem differently.

I stopped treating “I don’t understand all my code” as the thing to fix.

As AI writes more of the code, reading and understanding every implementation at the same depth is only going to get harder. If that’s true, then “I personally understand everything” can’t be my only basis for confidence.

So I started thinking about ownership in two forms.

Owning the code

Being able to read, understand, and change the implementation directly. Feasible when the code is small, has a clear role, and carries few dependencies.

Owning the behavior

Being able to control the expected behavior through boundary contracts and verification, without understanding the entire implementation. Knowing what it takes in, what it returns, which external systems it depends on, and what behavior it must guarantee.

Owning all 160,000 lines at the code level was never a realistic goal for me. What was realistic was to shrink the parts that require code ownership and restructure the rest so I could own its behavior.

That takes boundaries. Clear points of contact with the outside world, a small public surface, and no internal dependencies bypassing those boundaries.

When those conditions hold, even if the inside turns to spaghetti, the cost doesn’t propagate past the boundary. The cost of changing the inside doesn’t disappear. It just doesn’t spread across the rest of the project.

Which means I don’t have to trace through the whole implementation every time. Even if AI writes most of it, as long as the code stays inside the boundary and the contract is verified, it stays within what I can control.

The problem I needed to solve wasn’t the cognitive debt itself. It was deciding how far my understanding actually has to reach.

A rule for where tests belong

Development goes on, and every feature and bug fix brings new tests. Odds are, most of those tests will be written by AI as well.

Without a consistent answer to “what kind of test should this be?”, the same problem repeats. I’d already seen where that leads: 60,000 lines of E2E.

So I set a placement rule.

E2E is for behavior that can only be verified with a real browser or OS. Everything else — the decision logic — gets extracted into pure functions and pushed down to unit tests wherever possible.

The decision comes down to one question:

What would have to change in the product for this test to fail?

If it fails when a pure function’s return value changes, it’s a unit test. If it fails when the rendered DOM structure or DOM-API-level behavior changes, a DOM environment like jsdom may be enough. If it can only fail when the real browser’s editing behavior or the OS input system gets involved, that’s when E2E is warranted.

Then I hit a problem with this strategy almost immediately.

Unit tests require the code to be divided into testable units: pieces with few external dependencies and clear boundaries that I could run in isolation without booting the whole app. Where those units didn’t exist, the strategy broke down. I kept getting pushed back toward higher-level tests, often E2E.

Much of my code had no such units. When a single function walked the DOM, queried the DB, and made the decision, I couldn’t pull the decision out on its own. The answer to “what would have to change for this test to fail?” kept coming back as “the entire app.”

So before touching the tests, I audited the structure.

The problem was hidden dependencies

I traced every symbol that outside code imports from the 40,000-line editor folder.

Forty-five. Fewer than I’d expected.

The problem wasn’t the size of the public interface. It was the dependencies that don’t go through it.

For the record, AI didn’t create these dependencies. I designed this app’s basic structure myself, before I started using AI seriously. My priorities back then were simple: a structure I could understand just by reading the code, and code that was concise and easy to write. Each of the three things below was a reasonable decision by those standards.

1. Global prototype methods

The editor works with DOM elements line by line. Questions like “is this element a line?” and “what type of line is it?” come up all over the code, so at boot the editor attached its most-used DOM helper methods to Element.prototype. That made the code read like a sentence anywhere, with no imports.

// Editor boot code — every DOM element in the app grows these methods
Element.prototype.isLine = isLine
Element.prototype.getLineType = getLineType
// ... 16 helper methods like these

// The intended effect: reads like a sentence, no imports needed
if (line.isLine() && line.getLineType() === 'list') {
  // ...
}

// The unintended effect: a service file unrelated to the editor,
// with zero editor imports
const enabled = !line.getLineType()?.startsWith('code')

That service file has no editor imports at all. Yet it’s calling an editor-internal function. Anything installed on the prototype is callable from anywhere in the app.

There were 16 injected methods, used about 350 times internally. Some had leaked outside the editor, and the tests called them directly too.

When I designed this, I carried the context in my head: these functions are for editor internals only. AI has no such implicit boundary. If a function can be called, it’s a function that can be used.

At the time, I cared about each individual line reading easily. Looking back, that choice improved local readability while making system-level dependencies less visible.

The problem was never the functions themselves. It was that the dependency doesn’t show up in the imports.

2. Scattered DB calls

In Electron, the renderer process that draws the screen doesn’t own the database; it talks to the main process over IPC (inter-process communication). Every path the editor uses to read or write the DB goes through these IPC calls.

I audited those too.

I had never gathered these calls into a dedicated layer or gateway. Wherever the data was needed, that’s where the call happened. Working alone, holding the whole structure in my head, an extra layer felt like overkill.

The audit found 48 distinct IPC methods being called directly from 19 files.

Among them was a function like this.

export const getNewLineOrder = async (line: Element, fileId: string) => {
  const prev = line.previousElementSibling // ① DOM traversal

  const prevLine = await window.api.getLineById(/* ... */) // ② DB query

  if (prevLine.type === 'table') { // ③ branch on the query result
    const next = await window.api.getNextLineByLineOrder(/* ... */) // ④ another DB query

    // ⑤ compute the sort order — the actual point of this function
    // ⑥ on conflict, recursively adjust positions — touching DOM and DB again
  }
}

It was named like a utility function, but inside it was doing DOM traversal, DB queries, conditional branching, and sort-order computation all at once. One utility file made 15 separate DB calls.

In this state, I couldn’t answer “what does the editor need from the outside world?” by looking at an interface. I had to read 19 files.

And to verify just the sort-order computation (⑤) — the actual point of the function — I had to stand up all the DOM and DB machinery from ① through ④.

3. Decisions fused to event handlers

Browser event code had the same problem. A handler would receive the event, read the DOM, decide what to do, and mutate the DOM, all in one continuous flow. Working solo, I found this to be the shortest, most natural shape.

But even when the decision itself was simple, there was no way for me to call it in isolation. So an E2E test with a real browser became the path of least resistance.

One of the main things encapsulation provides is the ability to use code without knowing everything inside it. Working alone, I never felt a pressing need for that. The important context all lived in my head.

In hindsight, the three problems share a single root:

A function’s boundary didn’t reveal what it depended on.

And this structure was blocking the test strategy at every point.

  • Decisions were fused to the DOM, so there were no pure functions to unit test.
  • Global dependencies never appeared in imports, so a component’s contract was hard to pin down.
  • DB access was scattered across files, so there was no clear seam to swap out in tests.

Only then did it become clear what had to change.

Three structural changes

The goal was not a new architecture.

I wasn’t trying to eliminate dependencies — I was trying to make them visible.

1. Global dependencies became explicit imports

The functions on the prototype were already standalone functions. The problem was the one thin layer of convenience that made them globally callable.

// before: a global method — nothing reveals that this file depends on the editor
line.isLine()

// after: one import line exposes the dependency
import { isLine } from '…/editor'

isLine(line)

No big redesign was required. I removed the global injection and allowed access only through imports.

Then I added a lint rule. Now the linter rejects any new additions to Element.prototype, whether they come from AI or me.

2. External calls were gathered in one place

The DB calls themselves can’t go away. As long as Electron separates the renderer and main processes, IPC is a fact of life. What can change is where the calls live: behind a few clear boundaries instead of scattered across files.

The funny thing is, one part of my codebase already worked this way. The save path.

// Editor code that needs to save — knows nothing about window.api. Pushes values onto a queue.
saveQueue.push({ fileId, lineId, text, html })

// Inside save-queue.ts — the only place in the app that knows window.api.save*
async function flush(job: SaveJob) {
  await window.api.saveLineUpdate(job)
  await window.api.saveLineHtml(job.fileId, job.lineId, job.html)
}

Callers know only the queue; the queue implementation is the only thing that knows IPC. The app’s entire IPC dependency for saving collapses into one file. If the save mechanism changes, one file changes. In tests, I can plug in a fake queue instead of real IPC.

I started collecting the other IPC calls behind boundaries shaped like this one. Now “what does this component need from outside?” is answerable from a handful of files, and tests can swap a single boundary instead of patching a global API.

3. External state was separated from decisions

The biggest change was the browser event code. It used to look like this:

function onBeforeInput(e: InputEvent) {
  const line = (e.target as Element).closest('.line') // read the DOM

  if (
    e.inputType.includes('Backward') && // decide
    line?.getAttribute('type') === 'list'
  ) {
    // compute the deletion range — decide
    // mutate the DOM — act
  }
}
// No way to run just the decision → verifying it means booting a browser

Testing the decision logic here requires browser state. So I split the structure into three stages.

Observe → Decide → Apply

// ① Observe: read only the facts the decision needs, as plain values
type Observation = {
  inputType: string
  lineType: string | null
  caretAtStart: boolean
}

// ② Decide: a pure function from values to values — testable without a browser
function decide(obs: Observation): DeletePlan | null

// ③ Apply: apply the returned plan to the DOM
function onBeforeInput(e: InputEvent) {
  const obs = observe(e)   // ①
  const plan = decide(obs) // ②
  if (plan) apply(plan)    // ③
}

I first read the facts the decision needed from the browser. Then I passed plain values, not the DOM element itself, to the decision function. Finally, I applied the resulting plan to the DOM at the edge. The event handler shrank to those three lines.

I learned a few things here.

Passing a DOM element straight in, as in decide(line: HTMLElement), isn’t real separation. From an Element, the function can climb back out to the entire live page through ownerDocument and friends.

What the decision function needs isn’t the Element. It’s the facts observed from the Element.

Dependency injection was a step in the right direction. With decide(id, api), at least the API dependency was explicit.

But what I wanted wasn’t just a function that’s easy to mock. Where possible, I wanted a decision function that needs no mocks at all.

With decide(id, api), I still had to read the implementation to know how many API calls it made and what state it touched. By reading the required state first and passing it to decide(observation) as plain values, I made the world the function had to deal with much smaller. With lint rules and structural constraints restricting access to global state, the function’s signature and tests started to look like a contract.

Running one of these decisions through the old E2E setup took 35.2 seconds. The same decision, as a values-in, values-out function, took 0.8 seconds.

Turns out it’s an old pattern

Only later did I realize that the structure I’d arrived at already had a name.

Functional Core / Imperative Shell.

Keep decisions and computation in a core that’s as pure as possible; keep the code that touches the outside world — DOM, DB, network — in a thin shell around it. This also echoes Ports and Adapters: external systems are accessed through a small set of explicit boundaries.

I hadn’t discovered anything new. I’d simply rediscovered a well-known pattern — late.

But one thing did strike me.

I’d seen this pattern before. I knew the standard argument, “it makes testing easier.” And working alone, that argument was never quite enough to make me split up working code. The context lived in my head.

What drove me back to this structure was AI.

Once I needed to control behavior without reading all the code, a pure core stopped being merely “code that’s nice to test.” It became a unit of ownership — something I can understand and verify.

The thinner the code that touches the outside world, the smaller the area I have to check myself. The more the decision logic is expressed as value-to-value transformations, the more of the verification burden tests and types can carry.

An old design principle took on a new meaning for me once AI entered the picture.

Some things still need E2E

Not everything can become a pure function. Computing the cursor’s actual on-screen coordinates, Korean input method editor (IME) composition, the browser’s native editing behavior — these are areas where browser and OS state are themselves part of the feature.

Forcing code like this into pure-function shape just produces DOM code under a different name.

Instead, for these parts, I narrowed the exposed boundary and pinned its behavior down with tests. That let me own the expected behavior instead of perpetually re-reading the implementation.

And these are exactly the “E2E where it’s truly needed” cases from earlier.

The goal was never zero E2E. It’s reserving E2E for behavior that only a real browser and OS can prove.

None of this made the codebase smaller

Splitting code this way doesn’t shrink the total. It usually grows.

In one real case from the refactor, a 57-line function became

  • 40 lines of observation code
  • 51 lines of decision code
  • 103 lines of tests

Counted purely in lines, more than triple.

There was a time when I would have looked at numbers like these and walked away from refactors just like this one. My criteria have changed.

Building with AI, what I care about isn’t the raw amount of code.

It’s how small and well-defined the part I have to understand is.

I’d rather have a little more code with smaller, explicit boundaries and contracts than less code that I only vaguely understand.

In exchange, the complexity any single function carries went down. Verification got dramatically faster.

And above all, the boundaries I need to protect no longer live only in my head. The compiler, linter, and tests enforce part of them for me.

When I first began delegating coding work to AI, my answer was “I’ll just have to review harder.” A few months of trying taught me that doesn’t hold up.

The question is no longer “How do I keep understanding all of the code?” It’s “How do I make the part I absolutely must understand and control as small as possible?”

Rules that depend on continuous human discipline are fragile. Wherever possible, the structure itself should make them hard to violate.

If I want AI to write more of my code, I need to be much more deliberate about the boundaries of what I own.