Matteo
Vincenti
Menu

Readable code when AI writes part of it

When to share a rule in AI-generated code: notifications, preview and delivery, counterexamples and checks for a handoff that can be verified.

A central selector connected to a screen showing a bell and to a holder containing an envelope.

Anyone returning to a function written with an AI agent should be able to identify the rule they need to change without finding the original chat. Today’s requirement may be satisfied; a month later, an exception will arrive, and someone will need to decide where it belongs and which behaviours to preserve.

Here, readable code means code that lets a maintainer locate a decision, reconstruct its precedence rules and recognise its effects. That is useful when several contributors, including agents, write the source. Line counts and the number of comments alone cannot tell us how easy that work will be.

Reviewing a pull request concerns whether to accept a change. This article concerns what remains in the repository after the merge: the knowledge needed to make the next change.

Finding the rule

Consider an example built for this analysis: routing notifications from a service. An event has a severity of critical, warning or info. The enabled setting turns delivery on; quietHours indicates whether quiet hours are in effect.

The policy for this example is explicit: disabling delivery suppresses even critical events. With delivery enabled, a critical event reaches the person on call through the page channel, including during quiet hours. A warning produces an email only outside quiet hours. Informational events produce no notification.

This function already expresses the rule in one place:

javascript
function notify(event, settings, deliver) {
  if (!settings.enabled) return;

  if (event.severity === 'critical') {
    deliver('page', event);
    return;
  }

  if (event.severity === 'warning' && !settings.quietHours) {
    deliver('email', event);
  }
}

The caller has already validated the inputs: both settings are booleans, and severity is one of the three values listed. The application supplies deliver; the question here is which channel receives which event. This snippet does not implement delivery, retries or duplicate-notification handling.

The order of the checks preserves a product decision. Moving quietHours to the beginning would also suppress critical alerts. Moving critical ahead of enabled would send alerts when delivery is disabled. These are behaviour changes, not formatting preferences.

A comment can preserve the reason for the first rule: for example, that enabled is a global switch and must take precedence over every severity. Repeating “return if disabled” does not convey that knowledge.

When to separate the decision from delivery

The channel selection can be extracted into a pure function: it returns a value based on its arguments, without sending messages or reading other settings.

javascript
function notificationChannel(severity, enabled, quietHours) {
  if (!enabled) return 'none';
  if (severity === 'critical') return 'page';
  if (severity === 'warning' && !quietHours) return 'email';
  return 'none';
}

function notify(event, settings, deliver) {
  const channel = notificationChannel(
    event.severity, settings.enabled, settings.quietHours
  );
  if (channel !== 'none') deliver(channel, event);
}

Now a caller can ask which channel would be selected without sending anything. That boundary is useful if a configuration screen needs to preview the same policy. Using the same function for the preview and for delivery avoids implementing that decision twice.

If the small original handler is the only caller, extraction needs a reason. The rule was already visible, and tests could record calls to deliver. The reader now has another function name to understand, a call to follow and the return value none to interpret. There is no need to create a function for every condition or a configurable rules engine for this rule.

Google’s code review practices connect complexity to the difficulty of understanding and changing code, and discourage generality for hypothetical future needs. The same criterion applies to an agent’s proposed refactor: which decision does it make easier to recognise, and what does that cost?

The next change

A new requirement arrives: during quiet hours, warnings should go into a deferred summary called a digest. To isolate the routing decision, assume the application can already collect and send that summary.

In the extracted function, this branch changes:

diff
-  if (severity === 'warning' && !quietHours) return 'email';
+  if (severity === 'warning') return quietHours ? 'digest' : 'email';

The change to the inline version is just as local:

diff
-  if (event.severity === 'warning' && !settings.quietHours) {
-    deliver('email', event);
+  if (event.severity === 'warning') {
+    deliver(settings.quietHours ? 'digest' : 'email', event);
   }

Both forms let the reader find the point to change. The extracted function becomes preferable when the decision needs to exist independently of delivery, as in the preview.

The precedence rules to preserve can also be read without JavaScript:

The next change
DeliverySeverityQuiet hoursBeforeAfter
DisabledAnyYes or noNo deliveryNo delivery
EnabledCriticalYes or noPagePage
EnabledWarningNoEmailEmail
EnabledWarningYesNo deliveryDigest
EnabledInfoYes or noNo deliveryNo delivery

The table covers all twelve combinations in the declared domain: three severities and two boolean settings. In the check run with Node 22.23.4, both the inline handler and the handler with the extracted function made the expected calls in every case. Applying the new policy to both forms changes just one outcome: an enabled warning during quiet hours. The checks reject two variants with incorrectly ordered conditions.

The check uses a function that records calls in place of real delivery. It verifies routing and protects the comparison between code structures; it does not measure how long a person takes to understand them.

Test the boundary with a second use

The preview makes the reason for extracting the decision concrete. After applying the digest diff to notificationChannel, the configuration screen can use it like this:

javascript
function preview(event, proposedSettings) {
  return notificationChannel(
    event.severity,
    proposedSettings.enabled,
    proposedSettings.quietHours
  );
}

This function returns a channel; the interface decides how to display it. It does not receive deliver, because previewing must not send anything. The notify handler shown earlier still uses the result to perform delivery.

If the screen contained a second copy of the old rule, an enabled warning during quiet hours would show “no delivery” while the service put it into the digest. In the extended check, preview and delivery were tested separately against the expected outcomes for all twelve combinations. The variant whose preview retained the old policy fails on that specific case.

I would share the evaluator when callers need to apply the same decision. Similar-looking functions are not enough: a preview of proposed settings and delivery using effective settings can legitimately produce different results. In the check, the warning produces digest with delivery enabled and none with delivery disabled, using the same evaluator. The caller needs to make the settings it passes visible.

This boundary introduces a shared dependency and a contract: both callers must interpret the same return values. When a channel’s meaning changes, its consumers need checking too. If the preview deliberately applies a different policy, a single function full of flags to serve both callers may become harder to maintain than two explicit decisions.

Ask the agent for the next change

Before accepting a refactor, I would use a concrete product request to compare the current and proposed code. For these notifications, it is the digest, with the global switch still taking priority.

First, ask the agent to map the change: where the decision changes, which callers use it and which behaviours must stay the same. Then ask for the smallest diff for each form. The comparison shows whether an abstraction brings a rule together or merely moves code behind a name.

Without a preview, the earlier diffs each change one branch; this case does not justify extraction. With a duplicated preview, the new policy must be updated in two implementations. With the shared evaluator, the rule changes in one place and both uses are checked. The benefit concerns maintaining that decision.

Choose a change already requested or plausible in the product’s context. Asking an agent to prepare for every possible future encourages the speculative generality that makes code difficult to follow. If the future request is still unknown, I would keep the code local and defer the abstraction.

What needs to stay in the repository

Martin Fowler explains internal quality through the work needed to find and understand the code being changed. In the notification example, that knowledge has concrete locations: the policy’s name, the precedence of its conditions, the behaviour table and the reason the global switch overrides even critical alerts.

The agent conversation can help reconstruct that knowledge, but it should not be the only place to find it. Tests preserve expected outcomes; a comment beside the rule or a short technical decision record preserves the reason when code alone cannot convey it. Update the explanation along with the behaviour.

There is no need to assume that AI always produces worse code. In its study of work with agents, Anthropic reports no difference in rates of subsequently removed lines or reverted commits when comparing human and agent code for the same employee, month and directory. These are indicators of code quality observed within that company; they do not directly measure how easily another developer can understand a rule.

In his interview with The Pragmatic Engineer, Peter Mattis predicts a future in which developers read much less source code, as they already do with assembly. Teams that still need to diagnose failures and change their systems need a way to find and understand the rules behind their behaviour.

For this case, I would leave a short handoff beside the code or in the change description, with references to the repository’s actual tests:

  • Decision: notificationChannel selects the channel; notify performs delivery and preview returns the choice without sending.
  • Precedence: enabled = false suppresses every severity. This is the policy assumed in this example; a different product rationale requires an explicit decision.
  • Change: an enabled warning during quiet hours moves from none to digest. The other eleven cases stay unchanged.
  • Design choice: sharing the evaluator avoids duplicating the same policy in the preview. With only the handler, the inline version remains sufficient.
  • Verification: check preview and delivery against expected outcomes, including disabled delivery, critical events during quiet hours and the new digest. The application remains responsible for actually delivering the summary.

The note preserves what a maintainer needs to navigate the change; the full outcome table belongs in tests. I would avoid duplicating it in a separate document that nobody updates. A sentence that merely repeats a code instruction can go; a constraint that cannot be recovered from the source should stay near the code it protects.

Finally, I would ask a colleague to read it without the original chat and identify where to change the policy, which precedence to preserve and which test to check. If they have to ask the agent what it meant, the handoff is still missing something.

Share this article

Have a question or a comment about this article?

Message me on LinkedIn
All articles