Your AI Knows SOLID and Clean Code. Does It Actually Use Them?
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 lik

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. Enter fullscreen mode Exit fullscreen mode 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 } Enter fullscreen mode Exit fullscreen mode 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.
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:
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.
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:
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.
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.
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.
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. Enter fullscreen mode Exit fullscreen mode Three details in this file do most of the work:
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.
A few facts from the docs that change how you organize this ( source ):
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__`. Enter fullscreen mode Exit fullscreen mode If your team uses more than one tool The same ideas exist in every serious agent, with different file names:
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.
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 ] }], }, }, ]; Enter fullscreen mode Exit fullscreen mode 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) " }, }, ], }; Enter fullscreen mode Exit fullscreen mode 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" } ] } ] } } Enter fullscreen mode Exit fullscreen mode 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.
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; } Enter fullscreen mode Exit fullscreen mode 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 () }; } } Enter fullscreen mode Exit fullscreen mode 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:
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. Enter fullscreen mode Exit fullscreen mode The agent will not catch everything, but in my experience question 3 catches a fair amount of unnecessary indirection.
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.
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.
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.