Ramiz RajaBook a call
← Writing
Working With AI Agents

What Keeps Agent-Written Code Clean When Nobody Reads It

One boundary rule lived in a review prompt and quietly broke in 25 files. The ones that held were unit tests that fail the build, and a second model that reviewed the plan by reading the repo.

Ramiz RajaOctober 20, 20268 min read
What Keeps Agent-Written Code Clean When Nobody Reads It

AI agents write most of the code in my Android and iOS apps now, and I don't read it line by line. I read plans, verdicts and test results. That leaves a fair question: if nobody reads the code, what keeps the clean architecture clean?

Clean architecture with AI coding agents is what keeps the next change cheap. When an SDK changes, I want to touch one adapter, not forty screens. And the next agent should find one obvious place for the next feature.

The short answer: written rules don't do it on their own. Boundary tests that fail the build do, plus a second model that reviews the plan and reads the repo instead of trusting the writer. The rest of this piece is the evidence, starting with a boundary that broke.

The apps involved are my own: ResizePing (image and video resizing), StatusPing (WhatsApp status saving), PlannedPing (scheduled WhatsApp messages) and ChatPing (a bring-your-own-key AI chat app). All are native Kotlin on Android and Swift on iOS.

The rule that lived in a prompt

Here's the boundary that broke, because it shows the problem better than anything that held.

ResizePing is an image and video resizer. Its engine lives in a shared kit, ping-media-kit, which both the Android and iOS apps consume. The first integration task said the app's domain and UI layers must never import kit types. Everything goes through one app-side repository, so the kit could be replaced later without changing call sites.

The independent review that gated that task checked it with a grep:

`grep -rn com.pingfamily.mediakit app/src/main` returns hits ONLY in
`data/resize/EngineGateway.kt` and `data/resize/ResizeRepositoryImpl.kt`.
Any hit in `domain/` or `ui/` is a FAIL.

It passed. Then the app kept growing: video, combine-to-PDF, export options. I ran the same grep while writing this. It now finds kit imports in 20 files under ui/ and 5 under domain/. The iOS app has the same leak in its video view model.

Nobody decided to drop the rule. It was checked once, at one review, and never again. Every later task was reviewed against its own brief, and none of those briefs repeated it. A rule an agent reads once is a suggestion with a short half-life.

Write the boundary as a test that fails the build

StatusPing does the same thing for Play Billing, but the rule is a unit test instead of a sentence (lightly condensed):

/**
 * The seam is only worth having if it holds, and a reviewer will not notice the day someone adds
 * `import com.android.billingclient.api.BillingClient` to a view model. This test is the
 * enforcement; without it the seam is a convention, and a convention is not enforcement.
 */
class BillingSeamBoundaryTest {
    @Test
    fun `only the real wrapper imports the Play billing library`() {
        val sourceRoot = File("src/main/java/com/pingappslab/statusping")
        // A wrong working directory would make this test pass by finding no files at all.
        assertEquals("main source root not found at ${sourceRoot.absolutePath}", true, sourceRoot.isDirectory)

        val offenders = sourceRoot.walkTopDown()
            .filter { it.extension == "kt" && it.name != "RealBillingClientWrapper.kt" }
            .filter { file -> file.readLines().any { it.trimStart().startsWith("import com.android.billingclient") } }
            .map { it.name }
            .toList()

        assertEquals(emptyList<String>(), offenders)
    }
}

The full file is 37 lines and uses no framework. Because it's a plain JVM unit test, it runs in testDebugUnitTest with everything else, which is a stage of the app's release gate. There's nothing extra to wire up. Two details are there because a naive version would lie. It checks for an import line, not a mention, because the seam's own docs name the package. It also fails if the source root is missing, because a test run from the wrong directory would scan zero files and pass.

The billing manager was built and unit-tested against a fake wrapper first. Later, the real Play Billing wrapper replaced the fake. This is the whole diff of that swap:

  • data/billing/RealBillingClientWrapper.kt: new, 199 lines
  • di/BillingModule.kt: new, 47 lines (binds the real wrapper)
  • MainActivity.kt: +27 / -1 (connects and disconnects the client with the activity)
  • BillingSeamBoundaryTest.kt: new, 37 lines

The manager, the view models and the paywall screen aren't in it. That is what "swaps stay cheap" looks like in a real diff. It holds in later work too, because an agent that adds a billing import to a view model gets a red build. Nobody has to remember the rule.

The iOS apps do the same in Swift. ChatPing's ImportBoundaryTests, excerpted:

/// ... is build-failing, not just a convention — this test is the enforcement mechanism
final class ImportBoundaryTests: XCTestCase {
    private let forbiddenModules = ["CoreData", "SwiftData", "CloudKit"]

    func testChatPingCoreHasNoForbiddenImports() throws {
        try assertNoForbiddenImports(under: "Sources/ChatPingCore")
    }
    func testStoreKitImportIsAbsentFromUI() throws {
        try assertNoImport("StoreKit", under: "Sources/ChatPingUI")
    }
    // The positive check: if this fails, the scanner is looking in the wrong place.
    func testStoreKitImportIsPresentInPlatform() throws { /* expects one `import StoreKit` */ }
}

The last test is the Swift version of the missing-directory check. The rule says StoreKit lives only in the platform module, so the test also proves the scanner can find it there.

Why grep in a test, and not ArchUnit or Konsist? Those express the same rule with more power, and on a big Kotlin codebase I'd reach for one. A grep test has no dependencies and works the same way in Swift, where there's no common equivalent. The strongest option is separate Gradle modules or Swift packages, so the compiler refuses the import. ChatPing does that for its iOS modules. My Android apps are single-module, and the test is the cheap version of it.

If a repo already leaks, don't wait for a clean slate. Start the test with an allowlist of today's offenders, fail on any new file, and shrink the list as files move behind the seam. Illustrative, not from a shipped app:

// Known leaks at the time the test was added. Delete entries as files move; never add one.
// Compare paths relative to the source root, not bare names (the .map { it.name } above),
// or a new ui/other/VideoScreen.kt would match an old entry and slip through.
private val allowed = setOf("ui/resize/ResizeViewModel.kt", "ui/video/VideoScreen.kt" /* ... */)
// offenderPaths: the same scan, mapped with { it.relativeTo(sourceRoot).path }
assertEquals(emptyList<String>(), offenderPaths.filterNot { it in allowed })

What the agents are told, and why that's only the start

Each repo still has written rules, in the CLAUDE.md the agents load at the start of every session. PlannedPing's architecture section opens like this:

## Architecture
MVVM plus Clean Architecture. Local-first. Four-layer package structure: ui, domain, data, core, di.
- Repository interfaces live in the domain layer, implementations in the data layer

## Modularity and Design Patterns
All services and handlers must implement interfaces — no concrete dependencies in orchestrators.
- When WhatsApp changes its UI, add a new strategy implementation and update the factory
  threshold — nothing else should need to change

That last line is the one I care about most. PlannedPing automates WhatsApp through its UI, and WhatsApp changes its UI often. The rule names the exact change that should stay small, so an agent can check its own design against it.

These files are the Claude Code coding guidelines and the Cursor rules for clean architecture rolled into one. They're worth writing carefully. But they are input, not enforcement. ResizePing had a file like this too.

A reviewer that isn't the writer catches layer mistakes in the plan

The code that writes a feature never approves it. A separate model, OpenAI's Codex, reviews the plan before any code exists, then the code before merge. I wrote up that split in how I review AI changes in a production app. For architecture, the plan review is where it pays most.

On a PlannedPing feature, round one of the plan review failed and asked for a domain-layer interface so the new screen wouldn't depend on the data layer. The revised plan reused an existing interface and called it a domain seam. Round two:

FAIL — two new blocking gaps remain.
2. Module 4 incorrectly calls `NativeSendServiceStarter` a domain seam. It is in
`data.scheduler`, so injecting it into the new UI ViewModel breaks the project's
clean-layer boundary. Price: production UI depends directly on data infrastructure.
Relocate the existing interface to `domain.execution` (keeping the existing Android
implementation/binding) before injecting it; do not create a second seam.

The fix was a pure package move: 4 files, 6 lines added, 3 removed, landed before the view model that needed it. Found after the feature shipped, the same mistake would have meant untangling a screen. It cost one review round because the reviewer read the package paths, not the plan's description of them.

Two things make this reviewer useful instead of noisy. First, it's told to check the repo itself. The ResizePing review prompt opened with:

Independent adversarial gate. Inspect source + tests + build/test evidence directly;
do NOT trust `qa/worker-report-m1-integration.md`.

Second, every finding has to state a price: what breaks, and for whom. That's the "Price:" line in the verdict above, and it lets me drop findings that aren't worth another round.

A guard you haven't seen fail isn't a guard

The other way enforcement fails is quieter. On RecallPing, I deliberately mangled a Kotlin file in app/src/main and ran ktlintCheck. It passed. The plugin had been applied to the root project only, so it linted the build scripts and nothing else, and reported success. Another app had shipped with no ktlint at all.

So the rule now is that a linter or check script only counts once it has been seen to fail on a real violation. For multi-rule scripts, each fail branch has to be seen failing separately. The shared UI kit's boundary script builds this in:

$ scripts/check-boundaries.sh --self-test
self-test: OK — all 9 rule groups are load-bearing

--self-test writes a bad sample for each of its nine rule groups, including dependency direction between modules and forbidden domain imports, and asserts that each rule rejects it. That proves each rule rejects a violation. It doesn't prove the real scan looks in the right places, so the missing-directory check from the billing test is still worth adding to each rule.

One contract, two native apps

The last piece covers per-platform guidelines. My apps are native on both sides, Kotlin and Swift, with no shared runtime. Shared behaviour lives in kits with a written contract, JSON test cases and fixture files that both implementations read. ping-media-kit has 17 shared JSON files of cases, presets and limits. The Android and iOS PDF compressor tests load the same cases and assert the same expected results.

That lets each platform follow its own idioms and guidelines while the covered behaviour stays the same. An implementation that disagrees with an unchanged shared case fails its test on either platform. A change to the cases themselves is a contract change, so it needs its own review. It only covers what the cases cover, and some cases allow a tolerance on size estimates. The same idea keeps a phone-driving agent honest, covered in the agent that drives a real iPhone: the evidence is the check, not the agent's summary.

Keeping AI-generated code maintainable

Put together, here's what I set up now on any repo where agents write the code:

  1. Name the layers and the seams in the agent rules, including the specific change each seam should keep small.
  2. Turn every boundary that matters into a test that fails the build. Grep-in-a-test is fine. Make it fail when it scans nothing, and allowlist existing leaks rather than waiting.
  3. Review the plan with a different model before code exists, and make it read the repo, not the plan's claims about the repo.
  4. Prove every guard fails on a deliberate violation, branch by branch, and keep that proof runnable.
  5. Put cross-platform behaviour in a shared contract with fixtures, and let each platform implement it natively.

ResizePing is the counterexample. Its boundary got one grep at one review. Had that grep been a test like StatusPing's, the first of those 25 imports would have failed the build. If you've inherited a codebase where the layers have already leaked, that's the kind of rescue I take on.

Frequently asked questions

Can AI coding agents follow clean architecture? They can work to written rules, but a rule that lives only in a prompt decays. ResizePing's rule broke in 25 files. The boundaries that held were the ones checked by a test that fails the build.

How do you enforce layer boundaries in AI-generated code? Turn each boundary into a unit test that scans the source and fails the build on a forbidden import, make it fail when it scans nothing, and prove it fails on a deliberate violation.

Is a CLAUDE.md or Cursor rules file enough? No. It's input, not enforcement. Keep it, because it names the seams, but back every boundary that matters with a test and have a second model review the plan against the repo.


Ramiz Raja is a senior mobile developer and technical lead with 12+ years shipping and rescuing production apps on Android, iOS, and Flutter. More at codebyramiz.com.