Based on battle-tested principles from clean code, domain-driven design, and pragmatic software engineering.
Core Principle: Code is written once and read many times. Every decision should optimize for the next person who has to understand, change, or debug it — including your future self.
Every function, class, and module should do one thing and do it well. If you struggle to name it without using "and" or "or", it's doing too much.
When to split:
- A function exceeds ~30 lines
- A class has more than one reason to change
- A module handles both business logic and infrastructure concerns
Example:
// Bad: one function doing three different jobs
processOrderAndSendEmailAndUpdateInventory(order)
// Good: each step is isolated and composable
const validated = validateOrder(order)
const saved = await saveOrder(validated)
await notifyCustomer(saved)
await updateInventory(saved)
Keep layers clean and unidirectional. Business logic must never leak into infrastructure, and infrastructure must never dictate business rules.
[API / Controller] → Handles HTTP, input parsing, response formatting
↓
[Service / Use Case] → Orchestrates business logic, pure domain operations
↓
[Repository / Gateway] → Handles persistence, external APIs, I/O
Rules:
- Controllers don't contain business logic
- Services don't know about HTTP or SQL syntax
- Repositories don't validate business rules
Not every repeated pattern needs an abstraction. Premature abstraction is as harmful as duplication.
Abstract when:
- The same logic appears 3+ times in different contexts (Rule of Three)
- The concept has a clear, stable name in the domain
- The abstraction simplifies call sites without hiding important context
Don't abstract when:
- Two things look similar but change for different reasons
- The abstraction would require more parameters than the original code
- You're guessing about future requirements
A well-named variable, function, or class eliminates the need for a comment. Spend time on names — they are read far more often than they are written.
Functions: Use verb + noun. Name them by what they do, not how they do it.
// Bad
handle(), process(), doStuff(), compute()
// Good
validateUserEmail(), fetchOrderById(), calculateShippingCost()
Booleans: Use is, has, can, should prefixes.
// Bad
active, premium, flag, check
// Good
isActive, hasPremiumSubscription, canEdit, shouldRetry
Collections: Always plural.
// Bad
userList, orderArray, itemData
// Good
users, orders, items
Avoid:
- Single-letter names outside of short loops (
i,jare fine in a 3-line for loop) - Abbreviations unless universally understood (
url,id,dtoare fine;usrMgris not) - Misleading names (
data,info,manager,handler— they say nothing) - Encoding types in names (
userString,orderList— the type system does this job)
Pick a pattern and stick to it across the entire codebase. Inconsistency forces readers to hold two mental models simultaneously.
If you fetch data with getUser() in one place, don't use fetchOrder() in another. Choose get or fetch and be uniform.
Don't treat error handling as an afterthought. Errors should be as explicit and intentional as your happy path.
Rules:
- Never silently swallow exceptions
- Fail fast: validate inputs at the boundary, not deep in the call stack
- Distinguish between expected errors (invalid input, not found) and unexpected ones (database down, null reference)
- Error messages should help diagnose the problem, not just describe it
Bad:
try {
processOrder(order)
} catch (e) {
console.log("error") // swallowed, untraceable
}
Good:
try {
processOrder(order)
} catch (e) {
logger.error("Failed to process order", {
orderId: order.id,
reason: e.message,
stack: e.stack
})
throw new OrderProcessingError("Order processing failed", { cause: e })
}
Handle errors where you have enough context to do something useful about them. Catching an exception only to rethrow it unchanged adds noise without value.
- Repository layer: Translate infrastructure errors into domain errors (e.g.,
DbException→ResourceNotFoundException) - Service layer: Handle business rule violations
- Controller/API layer: Translate domain errors into HTTP responses
Tests should verify what the code does, not how it does it internally. Tests coupled to implementation details break whenever you refactor, even if the behaviour is unchanged.
What to test:
- Public interfaces and return values
- Business rules and edge cases
- Error conditions and boundary values
- Integration points between layers
What not to test:
- Private methods directly
- Framework or library internals
- One-liner getters/setters with no logic
Every test should follow Arrange → Act → Assert. Keep each section distinct and readable.
test("should return error when email is already taken") {
// Arrange
const existingUser = createUser({ email: "test@example.com" })
await userRepository.save(existingUser)
// Act
const result = await registerUser({ email: "test@example.com" })
// Assert
expect(result).toBeError(EmailAlreadyTakenError)
}
Test names are documentation. They should read as plain sentences describing a scenario.
// Bad
test("user test 1")
test("registration fails")
// Good
test("should reject registration when email is already in use")
test("should assign default role when no role is specified")
Balance your test suite by speed and scope:
[E2E] ← Few, slow, high confidence
[Integration] ← Some, moderate speed
[Unit Tests] ← Many, fast, isolated
Unit tests cover logic. Integration tests cover wiring. E2E tests cover critical user paths only.
Use Conventional Commits. Every commit message should complete the sentence: "If applied, this commit will..."
feat: add JWT refresh token rotation
fix: prevent double-submission on order form
refactor: extract payment validation into separate service
chore: update dependencies to latest patch versions
docs: add API authentication section to README
Rules:
- Subject line: max 72 characters, imperative mood, no period
- Body (when needed): explain WHY, not what — the diff shows the what
- Reference issue tracker IDs when relevant:
fix: handle null address (#142)
feature/short-description
fix/short-description
refactor/short-description
chore/short-description
Keep names lowercase, hyphen-separated, and specific enough to be identifiable at a glance.
Each commit should represent a single logical change. If your commit message requires "and", consider splitting it.
Don't mix:
- Refactoring with feature work
- Formatting changes with logic changes
- Multiple unrelated fixes in a single commit
When working with AI coding agents (like Claude Code), the same principles that make code readable for humans make it workable for AI — but the stakes are higher. An AI has no accumulated context between sessions. Everything it needs to understand your codebase must be visible in the code itself.
AI agents reason better on small units of code with a clear contract. A 200-line function with multiple responsibilities and implicit side effects is hard to modify safely for anyone — human or AI.
A good heuristic: if a function can be understood in full within a single screen, it can be modified confidently.
Implicit dependencies — global state, hidden context, ambient configuration — are invisible to an AI unless it reads every file. Prefer dependency injection and explicit parameters.
// Bad: hidden dependency on global config
function sendEmail(to) {
const client = globalMailClient // where does this come from?
client.send(to, config.defaultSender)
}
// Good: all dependencies are visible at the call site
function sendEmail(to, mailClient, senderAddress) {
mailClient.send(to, senderAddress)
}
Type signatures are contracts. An AI (and a human) can understand what a function does just by reading its signature, without diving into the implementation.
Well-typed code also catches a large class of AI-introduced bugs at compile time before they reach runtime.
Magic, metaprogramming, and overly terse code are hostile to AI modification. If understanding a line requires knowing the implicit conventions of a specific framework version, an AI will often get it wrong.
// Hard for AI to modify safely: implicit, magic-heavy
@AutoMap
class OrderDto extends BaseDto<Order> {}
// Easy to modify safely: explicit, self-contained
class OrderDto {
id: string
customerId: string
totalAmount: number
status: OrderStatus
}
If the same concept is handled differently in different parts of the codebase, an AI will generalise inconsistently. Pick one pattern for error handling, one pattern for async operations, one pattern for data fetching — and use it everywhere.
Consistency turns pattern-matching from a liability into an asset.
These rules are non-negotiable. They apply to every project, every language, every environment.
Secrets management:
- Never hardcode credentials, tokens, or keys in source code
- Use environment variables or a secrets manager (Vault, AWS Secrets Manager, etc.)
- Ensure
.envfiles are in.gitignorebefore the first commit — not after
Input validation:
- Validate and sanitise all external input at the system boundary
- Never trust data from clients, third-party APIs, or even your own database if the schema allows null
- Use allowlists, not denylists
Dependencies:
- Pin dependency versions in production
- Run
audit/outdatedchecks regularly - Remove unused dependencies — they are attack surface
Principle of least privilege:
- Database users should only have the permissions they need
- API tokens should have the minimum required scopes
- Services should not share credentials with each other
Write clear code first. Profile before optimising. Most performance problems are in a small fraction of the codebase, and guessing wastes time while reducing readability.
Measure, don't assume.
Some problems are common enough to guard against by default, without profiling:
- N+1 queries: Never fetch a collection and then query each item individually in a loop. Use joins, eager loading, or batch fetching.
- Blocking async: In async code, never call synchronous blocking operations on the main thread/event loop.
- Missing indexes: Foreign keys and frequently filtered columns should be indexed. Add indexes at migration time, not after slowdowns appear in production.
- Unbounded queries: Always paginate queries that can return large result sets. Never
SELECT *without aLIMIT.
Cache at the right layer for the right reason:
- In-memory (local cache): Fast lookups for immutable or rarely-changing reference data
- Distributed cache (Redis etc.): Shared state across instances, session data, rate limiting
- HTTP cache headers: Static assets, public API responses
Always define: what invalidates the cache, and what happens if stale data is served.
Refactoring is not a project. It is a habit. Leave every file slightly better than you found it (Boy Scout Rule). Large refactoring projects accumulate risk and context-switch cost.
Code smells to act on:
- Long functions: More than ~30 lines is a warning sign
- Deep nesting: More than 2–3 levels of indented logic — flatten with early returns
- Duplicate logic: The same logic copy-pasted in 3+ places
- Large parameter lists: More than 3–4 parameters usually means a missing abstraction
- Feature envy: A function that spends most of its time accessing another object's data belongs in that object
- Magic numbers/strings: Unnamed literals with non-obvious meaning should be named constants
- Refactor with tests in place. If there are no tests, write them first.
- Never mix refactoring with feature work in the same commit.
- Refactor in small steps — one concern at a time, verifying correctness after each step.
You write code once. You (and others) read it dozens of times. Optimise for the reader.
In that order. Premature optimisation and premature abstraction are the same mistake.
Clever tricks, hidden conventions, and magic reduce readability without adding value. Say what you mean.
A codebase where everything follows the same patterns is easier to navigate, easier to reason about, and easier for AI agents to extend correctly.
Every time you touch a file, improve something small. Over time, this compounds into a significantly cleaner codebase.
"Any fool can write code that a computer can understand. Good programmers write code that humans can understand."
— Martin Fowler
"The ratio of time spent reading code versus writing it is well over 10:1. We are constantly reading old code as part of the effort to write new code."
— Robert C. Martin
These guidelines exist not to constrain you, but to reduce the cognitive load on everyone who works on the codebase — including AI agents and your future self.