Skip to content

Keeping control of your architecture with AI-generated code

Albert Skibinski •
Keeping control of your architecture with AI-generated code

Safeguarding an application's architecture is not always easy. It is fragile enough with human-written code, and AI is no different (or makes it harder).

An LLM can easily produce code that compiles, passes the tests and looks clean in the diff, while quietly skipping a layer or coupling two layers together. The mistake is invisible locally; the damage is global and structural.

Below are the strategy and tools I currently use to protect the architecture as much as possible: a map with boundaries and enforcement.

The map

I use the sections of arc42 as inspiration for the structure of my architecture documentation. This gives me structured documentation so I can be sure all aspects are covered. I don't use the entire arc42 framework; it is far too large and cumbersome for me.

An example that lives in docs/architecture in the repository:

| Bestand                  | arc42 § | Scope                                  |
|--------------------------|---------|----------------------------------------|
| principles.md            | §2, §4  | dragende principes                     |
| service-contracts.md     | §8, §12 | contracten tussen services + glossary  |
| config-cascade.md        | §8      | config-assemblage                      |
| api.md                   | §5      | building block — API                   |
| data-model.md            | §8      | schema-conventies                      |
| worker.md                | §5      | building block — achtergrond-worker    |
| gateway.md               | §5      | building block — gateway / router      |
| web-client.md            | §5      | building block — web-client (SPA)      |
| plugins.md               | §5      | building block — plugin-framework      |
| billing.md               | §5      | building block — facturatie            |
| external-integration.md  | §3, §5  | context + building block — koppeling   |
| deployment.md            | §7      | deployment view                        |
| runtime-views.md         | §6      | runtime choreografie                   |
| decisions/               | §9      | ADR-laag                               |

The map consists of a collection of Markdown files. Some also contain Mermaid diagrams, which are particularly useful for visualizing things for people.

In addition:

  • Consistent structure and naming. A predictable organization (layers, directories, namespaces) makes the map readable from the paths themselves. The more consistent the convention, the less you need to look up.
  • A specialized architecture agent with rules. It knows the map and checks every change against it, not just at the end but already during refinement and planning.
  • On-demand exploration. For the current, concrete details, I simply let my agents run grep/Explore; for each task, they reconstruct exactly the part of the map they need. No precomputed graph that can become outdated: the structure is disciplined enough to navigate directly.

The architecture agent is used during planning, refinement and reviews. It has a set of rules and principles and uses the documentation above.

Boundaries and enforcement

A map is one thing, but boundaries without enforcement let code slip into places where it doesn't belong. To enforce them, I chose deptrac. You could also use PHPStan with custom rules.

Here are the four steps of how this works with deptrac. See also deptrac.yaml.

1. Layers. You divide your codebase into layers.

- name: Controller   → ^App\Controller\
- name: Service      → ^App\Service\
- name: App_x   → ^App\Apps\x\
- name: App_y → ^App\Apps\y\

2. The dependency graph.
Deptrac doesn't “read” your architecture from a drawing: it builds it from your code. Every use, type hint, new and extends becomes an arrow in a dependency graph. That graph doesn't live in a file anywhere; deptrac rebuilds it from the AST on every run. You can query it, though, with debug:dependencies:

$ vendor/bin/deptrac debug:dependencies App_x

This is comparable to what tools like graphify do, but I only use it to guard the boundaries.

3. The ruleset = an allow-list. For each layer, you list which layers it may call. Anything not explicitly allowed is forbidden.

Controller:  [Service, Repository, Entity, Enum, Security, Validator, Webhook, AppFramework]
App_x:  [AppFramework, Service, Repository, Entity, Enum, Security, Webhook, Validator, Doctrine]
AppFramework:[Entity, Enum]
Enum:        []

Explanation:

  • Controller may call Service, Repository and so on, but App_x is not listed. A controller importing App\Apps\x\… is a violation.
  • App_x may use the shared core and the AppFramework boundary, but App_y and App_z are not listed. X cannot reach into Y, and vice versa. No cross-app bleed.
  • AppFramework may only know Entity and Enum. The shared app infrastructure must remain app-agnostic; as soon as someone makes it reference a concrete app, the check fails.
  • Enum is a leaf: [], with no dependencies on anything else.

4. The violations check

Deptrac maps every edge in the graph to the layers and checks it against the ruleset. No match means a violation. Because it is an allow-list rather than a blocklist, a forbidden dependency you have never encountered before is automatically blocked too. That is what makes it future-proof.

  Report
 -------------------- ------
  Violations           0
  Skipped violations   15
  Uncovered            2219
  Allowed              1055
  Warnings             0
  Errors               0
 -------------------- ------

The uncovered items here are parts of vendor, such as Symfony itself.

Violations are readable, so agents can work on them immediately:

{
  "Report": { "Violations": 15, "Skipped violations": 0, "Allowed": 1055, ... },
  "files": {
    "src/Apps/x/Action/ListHandler.php": {
      "messages": [
        { "message": "… must not depend on … (App_x on App_y)", "line": 58, "type": "error" }
      ],
      "violations": 2
  }
}

But couldn't an agent quietly change your deptrac.yaml to add an “exception”? In theory, yes. That is why deptrac is committed: this diff also shows up in the review and is a deliberate step. This is a good role for your architecture agent, which examines this critically during review (one of its rules), but also for you.

In my own setup, my architecture agent already takes part during refinement and planning to catch this as early as possible.

And JavaScript/TypeScript?

Deptrac works for PHP projects, but a stack often includes JavaScript/TypeScript applications too. For those, you can look at the ESLint plugin JS Boundaries. It works similarly to deptrac.

If you don't want to use that, you can also look at dependency-cruiser, possibly combined with ArchUnitTS. Dependency-cruiser lets you set rules to prevent particular npm dependencies from being added (the hard “this must never happen” prohibitions). ArchUnitTS lets you add internal layering rules (“layer X may only use Y”) semantically. But most of this can be configured similarly with ESLint Boundaries.

Does this catch everything? Unfortunately not: a solution can still be implemented within the current rules when adding a new abstraction layer would have been better. But it does reduce the chances.

Albert Skibinski

About the author

  • Albert Skibinski is a product engineer and co-founder of Jafix.
  • I write about web development, long bike rides and food!