Ask any modern model to explain the Strategy pattern and you get a textbook answer: the intent, a UML sketch, a TypeScript example and a note on when not to use it.
Then, in the same session, ask it for a feature:
Add a 10% discount for VIP customers at checkout.
And a very typical result looks like this:
export async function checkout(cartId: string, userId: string) {
const cart = await db.cart.findUnique({ where: { id: cartId } });
const user = await db.user.findUnique({ where: { id: userId } });
let total = cart.items.reduce((sum, i) => sum + i.price * i.qty, 0);
if (user.type === "vip") {
total = total * 0.9;
} else if (user.type === "employee") {
total = total * 0.8;
}
// ... 150 more lines: payment, email, stock
}
It works. The tests pass. And it ignores almost everything the same model just explained so well: the function grew a new reason to change, a magic number appeared, the domain rule lives next to database calls and the next discount means another else if.
Knowing a principle and applying it by default are two different skills, and AI coding agents only get the first one for free. This post covers why that happens, which clean code rules for AI coding agents actually change the output, and where to write them (CLAUDE.md, AGENTS.md, path-scoped rules, linters and hooks) so the agent applies them more consistently across tasks, including the ones where you forget to ask.
Why the agent skips what it knows
The model is not ignorant. In my experience, it simply follows the strongest signals in its context. This is what I observe in practice, not a description of how the model works internally:
| What the agent sees | What it tends to produce |
|---|---|
| Your request ("add a discount") | The smallest change that satisfies it |
| The surrounding code | Matching its style, even if the style is a 300-line function |
| Passing tests | "Done" means green, not "easy to change" |
| No stated standards | Generic conventions that may not match your project |
Nobody asked for SOLID, so SOLID is optional. The same thing happens with security: an agent asked for "an app with login" will often store a JWT in localStorage, even though it can explain perfectly why that is a bad idea.
There is also the opposite failure. Write "follow SOLID and use design patterns" and some agents swing to ceremony: an IDiscountService with one implementation, a factory that builds one object, five files for a feature that needed twenty lines. That code is just as hard for a human to maintain, only in a different way.
So the problem is not "the AI doesn't know clean code". The problem is that vague principles produce either nothing or too much.
Who is this code really for?
Let's be honest about the premise. In most teams that use agents heavily, a human will rarely implement the next feature alone. The model that wrote the code, or its successor, will be in the loop.
I still think maintainability for humans matters, for four reasons:
- The 3 a.m. incident. When production breaks, someone has to read the code fast and trust what they read. That person may not have an agent session open, or the agent may be confidently wrong.
- Review. Every pull request an agent opens should be reviewed by a person. Code that takes an hour to understand gets approved without being understood.
- Tools change. Models get deprecated, teams switch tools, budgets get cut. Code that only one model can navigate is a form of vendor lock-in.
- The next session is a stranger too. Every agent session starts with a fresh context window, without the conversation that produced the code. Instruction files and memory features carry some knowledge across sessions (docs), but the reasons behind a design rarely make it there. Most of what the next session knows comes from reading the code.
That last one is the point I find most underrated. In my experience, code that a new human can understand quickly is also code that the next agent session understands quickly, usually with fewer files to open and fewer wrong guesses. Clean code stopped being only a courtesy to colleagues. It is now also part of the agent's context.
Turn principles into rules the agent can check
The Claude Code docs say it plainly: instruction files are treated "as context, not enforced configuration", and the more specific and concise the instructions, the more consistently they are followed (docs). Their examples are telling: "Use 2-space indentation" instead of "Format code properly".
The same applies to design. "Follow SOLID" is a vibe. "Inject dependencies through the constructor; never instantiate a repository inside a use case" is a rule you can verify in a diff.
A note before the table: these are my own, opinionated rules for object-oriented projects with a rich domain. Several (one class per use case, at most 3 parameters, no else) come from Object Calisthenics and preference, not from SOLID. What matters is the shape of each rule: concrete enough to check. Swap the content for your team's standards.
| Vague instruction | Rule the agent (and a reviewer) can check |
|---|---|
| Follow SRP | Each class or module has one reason to change. A use case does one thing, one class per use case. |
| Write clean code | Functions do one thing, take at most 3 parameters, no magic numbers or strings: use named constants or enums. |
| Use good architecture | The domain does not import frameworks, ORM, HTTP or database code. Dependencies point inwards. |
| Use design patterns | Use a pattern only for a concrete problem, say which problem, and name the pattern in the class name or a short comment. |
| Keep it readable | One level of indentation per method. No else: use guard clauses. |
| Don't over-engineer | Apply the rules pragmatically. If a rule hurts readability or fights the framework's idiom, choose clarity and mention the trade-off. |
The last row matters as much as the others. Without an explicit escape hatch, a strict rule set produces the ceremony problem from the previous section.
What my global file looks like
This is a trimmed and translated version of the global ~/.claude/CLAUDE.md I use on every project:
# Mandatory engineering standards
These rules apply to all code I write, in any project.
In existing projects, respect the architecture and conventions already in
place. Apply these rules to new code and refactors; don't rewrite what
wasn't asked.
## Clean Code
- Intention-revealing names; no obscure abbreviations.
- Small functions that do one thing, ideally ≤ 3 parameters.
- Comments explain "why", not "what". No dead code.
- No magic numbers or strings: named constants or enums.
## SOLID and Object Calisthenics
- Depend on abstractions; inject dependencies through the constructor.
- One level of indentation per method. No `else`: use guard clauses.
- Wrap primitives with domain meaning in Value Objects.
- Tell, don't ask: no indiscriminate getters and setters.
Apply with pragmatism: if a rule clearly hurts readability or conflicts
with the language/framework idiom, prioritize clarity and mention the
trade-off.
## Clean Architecture
- Layers: Domain, Application (use cases, ports), Infrastructure, Presentation.
- Dependencies point inwards: the domain depends on no other layer;
application depends on the domain; infrastructure and presentation
depend on the application's ports and the domain.
- The domain knows nothing about frameworks, ORM, HTTP or databases.
- Repositories and gateways are interfaces in the application layer,
implemented in infrastructure.
## Design patterns
- Use them when they solve a real problem, never as decoration.
- When you apply one, name it (class name or a short comment).
## Tests
- Domain and use cases have unit tests. AAA structure, descriptive names.
## Scope
- For tiny one-off scripts, apply Clean Code but don't force layers;
say when you are simplifying for that reason.
Three details in this file do most of the work:
- "Respect the existing conventions." Without it, an agent dropped into a legacy codebase will happily start rewriting modules into hexagonal architecture in the middle of a bug fix.
-
"Name the pattern." It makes the design visible and reviewable: a human reading
PercentageDiscountStrategyknows what to look for. Naming a pattern doesn't prove it was needed, though, so the rule next to it does the real work: use one only for a concrete problem, and say which. - "Mention the trade-off." When the agent bends a rule, it has to say so. That turns silent decisions into something you can review.
Where to write the rules
Not every rule belongs in the same file. I think about it in layers, from the broadest scope to the most specific, plus one layer that is not an instruction at all.
| Layer | Claude Code | What goes there |
|---|---|---|
| Personal, all projects |
~/.claude/CLAUDE.md and ~/.claude/rules/
|
Your engineering standards: the file above. |
| Organization | Managed CLAUDE.md (deployed by IT) |
Company-wide coding and security policies. |
| Project, shared with the team |
./CLAUDE.md or AGENTS.md
|
This project's architecture: layers, folders, commands, conventions. |
| Part of the codebase |
.claude/rules/*.md with paths:
|
Rules for src/domain/** that don't matter for scripts or UI. |
| Enforcement | Linters, dependency rules, hooks, CI | Anything that must never be violated. |
A few facts from the docs that change how you organize this (source):
-
Files stack, they don't override. All discovered
CLAUDE.mdfiles are concatenated, broadest first. Contradictions between them are a real problem: Claude "may pick one arbitrarily". -
Size matters. The recommendation is to stay under about 200 lines per file.
@pathimports help organization but not context cost, since imported files load at launch too. -
Path-scoped rules load only when needed. A rule with
paths: ["src/domain/**/*.ts"]enters the context only when the agent touches a matching file, so your domain rules don't crowd out everything else. -
AGENTS.mdworks too. Recent Claude Code versions readAGENTS.mdwhen there is no projectCLAUDE.md, and aCLAUDE.mdcan simply import it with@AGENTS.md.
A path-scoped rule for the domain layer looks like this:
---
paths:
- "src/domain/**/*.ts"
---
# Domain layer rules
- No imports from `src/infrastructure`, Prisma, Express or any HTTP client.
- Money is a `Money` value object, never a raw `number`.
- Pricing rules (discounts, fees, taxes) are pure and testable in
isolation: no I/O, no framework code.
- Start with a function. Introduce Strategy only when variations change
independently, and explain why in the PR description.
- Refactors preserve behavior: order, precedence and stacking of rules
don't change unless the task asks for it.
- Every new rule gets a unit test in `src/domain/**/__tests__`.
If your team uses more than one tool
The same ideas exist in every serious agent, with different file names:
| Tool | Always-on rules | Scoped rules |
|---|---|---|
| Claude Code |
CLAUDE.md (or AGENTS.md) |
.claude/rules/*.md with paths
|
| Cursor |
AGENTS.md or .mdc rules with alwaysApply: true
|
.cursor/rules/*.mdc with globs
|
| GitHub Copilot |
.github/copilot-instructions.md or AGENTS.md
|
.github/instructions/*.instructions.md with applyTo
|
| Codex and others | AGENTS.md |
Nested AGENTS.md per directory |
My rule of thumb: keep the project-level standards in one AGENTS.md and import or link it from the tool-specific files. Two copies of the same rules will drift apart within a month.
The other half of "where" is the why. Rules tell the agent how to write code; they don't explain why your system is shaped the way it is. That belongs in ADRs the agent reads before changing a decision, and for each feature, in a spec you review before the agent writes code.
Rules are context. Linters are law.
Instructions raise the probability of good code. They don't guarantee it. A long session, a large diff or a conflicting instruction is enough for a rule to be skipped. So every rule that can be checked by a machine should be.
"Law" needs a qualifier, though: a linter only enforces anything if it runs and a failure blocks the merge. A check that runs only on someone's laptop is just another suggestion. The guarantee comes from CI refusing to integrate code that fails.
Some Clean Code and Object Calisthenics rules have a reasonable approximation in core ESLint. For TypeScript, add them to a config that already sets up the parser, for example with typescript-eslint:
// eslint.config.js
import tseslint from "typescript-eslint";
export default [
...tseslint.configs.recommended,
{
files: ["src/**/*.ts"],
rules: {
"max-depth": ["error", 2], // a loose proxy for "one level of indentation"
"no-else-return": "error", // flags else after return, not every else
"max-params": ["error", 3],
"max-lines-per-function": ["error", 40],
complexity: ["error", 8],
"no-magic-numbers": ["warn", { ignore: [0, 1] }],
},
},
];
None of these rules proves the code is well designed. They catch the measurable symptoms (deep nesting, long functions, magic numbers) and leave the rest to review.
Architecture rules need a dependency checker. With dependency-cruiser, "the domain doesn't import the infrastructure" becomes a failing build:
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "domain-is-pure",
severity: "error",
from: { path: "^src/domain" },
to: { path: "^src/(infrastructure|presentation)|node_modules/(express|@prisma)" },
},
],
};
This rule forbids a few imports. It doesn't prove the domain is pure: a domain file can still read process.env or call Date.now(). Extend the list as you find the leaks that matter in your codebase.
Then close the loop so the agent sees the failure while it is still working, not in CI an hour later. A Claude Code PostToolUse hook runs after an edit. This one lints only the file that was just edited (it reads the path from the hook's JSON input, so it needs jq, and quotes it so paths with spaces work), and exit code 2 sends the output back to the agent:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "f=\"$(jq -r '.tool_input.file_path')\"; npx eslint --no-warn-ignored --max-warnings=0 \"$f\" >&2 || exit 2" }
]
}
]
}
}
Be clear about what this does: the hook runs after the edit. It doesn't block or undo the change; it hands the agent the error while it still has the context to fix it. The hard stop is still CI.
The split I use: the instruction file explains intent, automated checks in CI enforce the measurable part, and human review judges the rest. No single layer is enough on its own.
The VIP discount, with rules in place
Back to the opening example. Same request, but now the agent has the global standards, a project CLAUDE.md that describes the layers, and the domain rule above.
First, the refactor has to keep the behavior of the original code: one discount per customer, VIP checked before employee, no stacking. Anything else ("apply the best discount", "let discounts add up") is a business decision, not a refactor, and should come from the task, not from the agent.
With two fixed discounts, a pure function is enough. An illustrative result:
// src/domain/pricing/customer-discount.ts
const VIP_DISCOUNT = Percentage.of(10);
const EMPLOYEE_DISCOUNT = Percentage.of(20);
// Same rules as the original: VIP first, then employee, never both.
export function applyCustomerDiscount(customer: Customer, subtotal: Money): Money {
if (customer.isVip()) return subtotal.minus(VIP_DISCOUNT.of(subtotal));
if (customer.isEmployee()) return subtotal.minus(EMPLOYEE_DISCOUNT.of(subtotal));
return subtotal;
}
No interface, no class, no new file per tier. But the rule is out of the 150-line checkout, the numbers have names, and it can be tested without a database.
Now suppose marketing starts adding a campaign every quarter, each with its own conditions. That's a concrete problem where a Strategy can help, if the campaigns really vary independently. This is where the agent can propose one, and say why:
// src/domain/pricing/discount-policy.ts
// Strategy pattern: campaigns are added and removed independently.
export interface DiscountPolicy {
appliesTo(customer: Customer): boolean;
apply(subtotal: Money): Money;
}
// src/application/checkout/calculate-total.use-case.ts
export class CalculateTotal {
// Ordered list: the first policy that applies wins, as in the original if/else.
constructor(private readonly policies: OrderedDiscountPolicies) {}
execute(input: CalculateTotalInput): CalculateTotalOutput {
const subtotal = input.cart.subtotal();
const total = this.policies.applyFirstMatch(input.customer, subtotal);
return { total: total.toString() };
}
}
The name applyFirstMatch matters as much as the pattern. It tells the next reader, human or agent, that precedence is defined by order, which is exactly what the original if/else did.
What changed for whoever maintains this next:
- The pricing rule lives in one place, not next to payment and email code.
- It is testable in isolation, without a database.
- The design matches the problem. A function for two fixed rules; a Strategy once variations change independently.
-
The behavior is explicit. "First match wins" is in a name and a comment, not hidden in the order of
else ifbranches.
Ask for a self-review before "done"
One last instruction that works well in practice: make the agent check its own diff against the rules before it reports the task as finished.
Before you say the task is done:
1. Review your diff against the engineering standards in CLAUDE.md.
2. List any rule you broke on purpose and why.
3. Answer: could a developer who has never seen this repo understand the
change, and quickly find the contracts and decisions it depends on?
If not, fix it.
The agent will not catch everything, but in my experience question 3 catches a fair amount of unnecessary indirection.
Trade-offs and when to relax the rules
| Risk | What happens | What I do |
|---|---|---|
| Over-engineering | Interfaces with one implementation, layers for a 50-line script | The "pragmatism" and "scope" clauses; review for ceremony, not only for bugs |
| Imposed design | The agent picks a pattern because a rule named it, not because the problem needs it | Rules ask for properties (isolated, testable, behavior preserved), not for specific patterns |
| Fighting the idiom | Object Calisthenics vs. React components, DTOs, ORM entities | Path-scoped rules: strict in src/domain, relaxed in UI and adapters |
| Context cost | Huge instruction files reduce adherence to everything | Under ~200 lines per file; move the rest to scoped rules or skills |
| Contradictions | Global file says one thing, project file another | The global file defers to existing project conventions |
| False confidence | Rules exist, so nobody reviews the design | Linters for the measurable part, humans for the rest |
And to be clear about the limits: these rules don't make an agent a senior engineer. Explicit rules bring its defaults closer to your team's standards, automated checks cover the measurable part, and judgement is still needed for everything else. Choosing the right boundary for a module, or deciding that a pattern is not worth it, is still a judgement call that a human should be able to question.
FAQ
Isn't this just prompt engineering?
It's closer to writing a coding standard. The difference is that the reader is an agent that follows written rules far more literally than a new hire would, so the rules have to be concrete.
Should I put Object Calisthenics in every project?
I wouldn't apply all nine rules everywhere. They work well in the domain layer and badly in framework glue code. Scope them with paths.
Global or project file?
Personal standards in the global file, the project's architecture in the project file. If the project has its own conventions, they win.
Does the model really follow a 200-line file?
There's no guarantee, and size alone doesn't decide adherence: specific, consistent rules matter more than line count. The docs do note that longer files reduce adherence. That's why the measurable rules also live in ESLint and dependency checks.
What if the existing codebase violates all of this?
Tell the agent to apply the rules to new code and refactors only. Rewriting a legacy module as a side effect of a bug fix is how agents create the riskiest pull requests.
Takeaway
- Knowing is not applying. An agent that explains SOLID perfectly will still take the shortest path when nobody asks for more.
- Write rules, not principles. "Inject dependencies through the constructor" changes the output. "Follow SOLID" barely does.
- Ask for properties, not patterns. "Pure, testable, behavior preserved" plus a pragmatism clause prevents the over-engineering that strict rules invite.
- Put each rule in the right layer. Global standards, project architecture, path-scoped domain rules.
- Enforce what you can measure, in CI. A post-edit hook gives the agent fast feedback; only a blocking check in CI is a guarantee.
- Maintainable code serves the next agent session too. It starts from the code you leave behind.
What's the one rule in your CLAUDE.md or AGENTS.md that made the biggest difference in what your agent writes? I'd like to collect the good ones.
I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at @diogosoares.dev, where every article becomes a visual carousel you can save (this one includes the rules table). Let's also connect on LinkedIn, or check out diogosoares.dev.
Top comments (0)