DEV Community

Tomas Grasl
Tomas Grasl

Posted on

Don't blame AI before you check your architecture

Give an AI coding agent a small task: add voucher activation to an existing subscription system.

The generated code might look reasonable. The endpoint works, the response is correct, and the happy-path test passes. But the voucher rules now live in a controller, next to a database query and a call to a payment provider.

Was the model bad? Maybe. I would also look at the examples the repository gave it.

If similar rules already live in controllers, services and persistence callbacks, “follow the existing architecture” leaves quite a lot open to interpretation. A human joining that project would have questions too.

This is where I think architecture deserves more attention in AI-assisted development. The structure of a project supplies context for the next change. Clear responsibilities make that change easier to describe and its mistakes easier to detect.

PHP and Symfony make a useful example here. PHP sometimes gets dismissed before the discussion even starts, but typed interfaces, dependency injection and static analysis give us plenty to work with.

One feature, several places to put it

Consider an illustrative subscription application. Customers can already buy a subscription through a payment provider. Now we want this feature:

Allow a customer to activate a subscription with a voucher.

That sentence leaves business decisions open. Does the voucher cover a particular plan? Can it be redeemed more than once? What happens if the customer already has an active subscription?

For this example, let's choose explicit rules: a voucher activates one eligible subscription, expires at a defined time, and can be redeemed once. An already-active subscription must be rejected. Voucher activation must not charge the customer.

These are invented requirements for the example, not a report from a production system.

Suppose the relevant code looks like this:

src/
  Controller/SubscriptionController.php
  Service/SubscriptionManager.php
  Service/PaymentHelper.php
  Entity/Subscription.php
  Repository/SubscriptionRepository.php
Enter fullscreen mode Exit fullscreen mode

There is nothing inherently wrong with these directories. The difficulty starts when responsibilities overlap: the controller checks eligibility, the manager changes status, and the payment helper also persists subscriptions.

Now an agent has several plausible places to add voucher validation. Reading more files may reveal the intended convention. It may also reveal three incompatible conventions.

The prompt can explain the desired design, but that explanation is doing work the codebase could help with.

Give the change a clear home

For an application with enough business logic to justify it, I would consider a structure like this:

src/Subscription/
  Domain/
    Subscription.php
    Voucher.php
    SubscriptionRepository.php
    VoucherRepository.php
  Application/
    CreateSubscription.php
    ActivateSubscriptionWithVoucher.php
  Infrastructure/
    DoctrineSubscriptionRepository.php
    DoctrineVoucherRepository.php
    StripePaymentGateway.php
  UI/
    Http/ActivateSubscriptionWithVoucherController.php
Enter fullscreen mode Exit fullscreen mode

The application use case coordinates activation. Domain objects or a domain policy enforce the business rules. Repository interfaces describe the persistence operations those rules and use cases need. Doctrine implements them at the edge.

The HTTP controller handles the request, invokes the use case and maps the result to a response. It should not become a second implementation of voucher eligibility.

This follows the separation behind Alistair Cockburn's hexagonal architecture: the application communicates with external systems through ports and adapters. The folder names are our choice; the useful property is that business behavior can be exercised independently of the HTTP and database adapters.

For our example, the intended source-code dependencies are:

UI ------------> Application ------------> Domain
Infrastructure -------------------------> Domain
Enter fullscreen mode Exit fullscreen mode

The infrastructure classes implement interfaces defined inward. The application's wiring connects those interfaces to concrete adapters.

At runtime, a use case can call a Doctrine-backed repository through an interface. Its source code still does not need to import Doctrine.

Symfony's autowiring and interface aliases support this wiring. A constructor can request an interface while the container supplies the configured implementation. Symfony does not choose the business boundaries for us.

An agent working on voucher activation now has a named use case to inspect and a dependency rule to follow. Reviewers can ask precise questions: why did this patch add an HTTP dependency to the domain? Why is a payment gateway involved in a feature that must not charge anyone?

The names need to mean something

DDD is useful here because of the shared language it asks us to establish.

In this example, a voucher redeems an entitlement. A payment records a financial transaction. Both may lead to an active subscription, but they have different rules. Calling both operations process() hides a distinction the implementation needs to preserve.

Names such as ActivateSubscriptionWithVoucher put that distinction where both a developer and an agent will encounter it. CommonService gives them less to work with.

The names still need definitions. If “customer” means an account in one module and a billing contact in another, a consistent-looking class name can conceal a mismatch. A short glossary and examples of the intended behavior are useful companions to the code.

My expectation is that this reduces the decisions an agent has to infer. I am not claiming a measured improvement in model accuracy from renaming classes or introducing DDD.

Make violations visible

A directory named Domain cannot stop someone importing an HTTP client into it.

Deptrac can check PHP dependencies against configured layers and allowed relationships, including in CI. For the structure above, I would forbid domain dependencies on application, infrastructure and UI code, and reject application imports from infrastructure or UI.

The configuration needs to cover the relevant namespaces and external packages. Unclassified code can leave gaps. A green architecture check only proves that the dependencies it analyzed satisfy the rules we configured.

Behavior requires separate tests. For voucher activation, I would want tests showing that an expired voucher fails, an eligible voucher activates the subscription, and repeated redemption does not grant another entitlement. The application test should also verify that the payment gateway is not called.

There is a persistence problem hiding in that last requirement. Two requests can read the same unused voucher before either writes its result. A domain method that checks isRedeemed() cannot prevent that race by itself.

The persistence implementation needs an appropriate atomic update or locking strategy, with subscription activation and redemption committed consistently. I would include an integration test for competing redemption attempts. Neat layers do not settle transaction semantics.

These checks give the agent concrete feedback during a change. A developer still needs to decide whether the tests express the right behavior and inspect changes to the checks themselves. An agent that “fixes” a failing dependency rule by weakening it has changed the architecture contract.

Keep repository instructions short and connected to code

An AGENTS.md or CLAUDE.md file can tell an agent where to begin. For this example, useful instructions would point to the subscription module, explain the dependency direction and identify the commands for running its checks.

I would also include the rule that voucher activation never charges a customer, with a link to the test that verifies it. That is much more actionable than “always write clean code.”

Then I would ask the agent to inspect the existing activation path and propose the files it expects to change before editing. Its plan should account for the use case, domain rules, persistence behavior and tests. If it proposes putting the whole feature in the controller, there is something concrete to discuss before the patch grows.

Research gives a useful, narrower reference point here. Microsoft's CodePlan paper treats repository-level coding as a planning problem and combines dependency analysis with analysis of how changes affect other code. Its evaluation covers C# package migrations and Python edits. It is not evidence that hexagonal PHP applications automatically produce better AI results.

My practical inference is that dependencies deserve to be explicit enough to inspect and validate. The article's voucher example illustrates that idea; it is not a benchmark.

Start with the boundary that keeps breaking

I would not introduce this entire structure into a small CRUD application just to accommodate an AI agent. Every additional interface and layer creates work for the people maintaining it.

For an existing project, start with one recurring problem. If controllers keep acquiring subscription rules, move one coherent use case behind a clear entry point. Name its business concepts, add tests for the rules, and enforce the dependency boundary that matters.

Try the next related change with the agent. Look at whether its patch uses that entry point, whether the tests catch the mistakes you care about, and how much explanation the review still needs. Expand the structure only when it earns its place.

A better model or a clearer prompt may help. I would also check what the repository is teaching it: where behavior belongs, which dependencies are allowed, and how a wrong change gets rejected.

For the voucher feature, success means the business rules have one clear home, redemption is safe under competing requests, and the next developer can find all of it. That remains our responsibility when an agent writes the patch.

Top comments (0)