Backend in Depth
Clean Code for Backends: Naming, Small Functions and Boundaries
The few clean-code rules that improve backend code reviews: pure domain logic, clear dependencies, no hidden I/O, and when not to add an abstraction.
On this page
- Words used in this lesson
- What "clean" means in a backend
- Naming
- Small functions that do one thing
- Separate pure logic from I/O
- Dependency injection without a framework
- SOLID, explained for backends
- DRY, and when it is harmful
- Errors are part of the design
- Make the standards automatic
- Scenario: you inherit a 600-line controller
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: clean backend code is code where business rules are easy to find, easy to test and hard to break. It has small functions with honest names. It keeps logic apart from I/O (talking to the outside world). And it receives its dependencies as inputs instead of grabbing them itself.
You have shipped many production frontends. The backend questions in a senior interview check whether you can design for change. "Clean code" is not about style. It is a small set of habits that make a codebase cheap to change when you are under pressure. This lesson gives you those habits and the words (SOLID, DRY). It also shows you honestly when too much abstraction hurts.
Words used in this lesson
Read these once. Each one comes back later.
- A business rule is a statement about how the business works that your code must follow. Example: "gold customers get 10% off".
- I/O (input/output) is any work where your code reaches outside its own variables and calculations: the database, the network, the filesystem, the clock, random numbers.
- A dependency is something a piece of code needs to do its job, such as a database client, an email service or the clock.
- A function is a named block of code that takes inputs and gives back a result.
- A side effect is a change a function makes outside itself, such as saving a row or sending an email.
- A pure function is a function that only uses its inputs and has no side effects. The same input always gives the same output.
- A unit test is a small automatic test that runs one piece of code on its own and checks the result.
- A fake is a simple, working stand-in for a real dependency (for example, a repository that keeps data in memory). A mock is a stand-in that also records how it was called, so a test can check those calls. The difference in one line: a fake behaves like the real thing; a mock remembers how it was used.
- A controller is code that receives an HTTP request and builds the HTTP response. A service is code that holds the business rules. A repository is code that reads and writes the database. The difference in one line: the controller talks HTTP, the service talks business, the repository talks data.
- An interface (in TypeScript) is a description of which methods and fields something must have. It has no code inside. An abstraction is a general description that hides the details. Example:
PaymentGatewayhides whether Stripe or another provider does the work. - Coupling is how strongly one piece of code depends on another. Tight coupling means a change in one forces a change in the other.
- Refactoring is changing the structure of code without changing what it does.
What "clean" means in a backend
A backend codebase is clean when a new teammate can answer these questions quickly:
- Where does this rule live? (There is one obvious place.)
- What does this function touch? (The database, the network, the clock, the filesystem.)
- How do I test it? (Without starting the whole system.)
- What happens when it fails? (There is a clear, typed result.)
Everything below helps you answer these four questions.
Naming
Names are the cheapest documentation. These rules pay off:
- Name by intent, not by implementation.
getActiveSubscriptionsExpiringIn(days)is better thangetData2(). - Functions are verbs, data are nouns, and booleans are questions. For example:
calculateInvoiceTotal,invoice,isOverdue,hasPaidPlan. - Be clear about units and meaning. Use names like
timeoutMs,amountInPaise,expiresAtUtc. A baretimeout = 30has caused outages (is it seconds or milliseconds?). - Avoid empty words like
data,info,manager,helperandutil. AUserManagerusually hides several jobs. - One idea, one word. Do not mix
fetch,get,retrieveandloadfor the same thing. - Name length should match scope.
iin a three-line loop is fine. A name at module level should be descriptive.
// ❌ Reader must decode everythingfunction proc(d: any, f: boolean) { if (d.s === 2 && !f) return d.a * 0.18 + d.a; return d.a;}// ✅ Intent is visible; units are explicit; no flag argumentconst GST_RATE = 0.18;function totalWithTax(order: Order): number { return order.status === "taxable" ? order.subtotal * (1 + GST_RATE) : order.subtotal;}Small functions that do one thing
You should be able to understand a function without scrolling. It should work at one level of detail. A function that checks input, queries the database, applies business rules, sends email and formats the response is really four functions stuffed into one.
Practical tips:
- If you need a comment to explain a block of code, move that block into its own function. Name the function after the comment.
- Early returns and guard clauses are better than deep nesting. A guard clause is a quick check at the top that exits early when something is wrong.
- Avoid boolean "flag" parameters. A flag parameter is a true/false argument that switches the function between two behaviours, like
createUser(data, true). The reader cannot tell whattruemeans, and the function does two things. Split it, or pass a named option. - Prefer few parameters. More than three often means you are missing an object (like
{ userId, from, to }). - A function that returns a value should not also cause a side effect. A side effect is a change outside the function, like saving to a database. This idea is called command-query separation: a command changes something and returns nothing useful, and a query returns an answer and changes nothing.
// ❌ Nested and mixed concernsasync function checkout(req: Request, res: Response) { if (req.body.items) { if (req.body.items.length > 0) { const user = await db.user.findUnique({ where: { id: req.userId } }); if (user) { if (user.balance >= total(req.body.items)) { /* ... 80 more lines ... */ } else res.status(402).send("no money"); } else res.status(404).send("no user"); } else res.status(400).send("empty"); } else res.status(400).send("no items");}// ✅ Guard clauses; each step has a nameasync function checkout(input: CheckoutInput, ctx: Ctx): Promise<Order> { if (input.items.length === 0) throw new ValidationError("Cart is empty"); const user = await ctx.users.getById(input.userId); // throws NotFoundError const total = priceItems(input.items); // pure assertCanAfford(user, total); // pure, throws PaymentRequiredError return ctx.orders.create({ userId: user.id, items: input.items, total });}Separate pure logic from I/O
This is the most useful habit for structuring code:
- Pure functions (defined above) take inputs and return outputs. They use no database, no clock, no randomness and no network. Think of a calculator: the same input always gives the same answer. This makes them easy to test and reuse.
- I/O stays at the edges. Controllers, repositories, API clients and queue consumers deal with the outside world. They call the pure logic.
// Pure: the business rule. No imports from Prisma or Express.export function calculateDiscount(order: { subtotal: number; itemCount: number }, customer: { tier: "free" | "gold" }): number { if (customer.tier === "gold") return Math.round(order.subtotal * 0.1); if (order.itemCount >= 10) return Math.round(order.subtotal * 0.05); return 0;}// Impure: gathers inputs, calls the rule, persists the result.export async function applyDiscount(orderId: string) { const order = await orders.getById(orderId); const customer = await customers.getById(order.customerId); const discount = calculateDiscount(order, customer); await orders.setDiscount(orderId, discount);}Now you can test calculateDiscount with a table of inputs in milliseconds, with no mocks and no fakes.
Avoid hidden I/O
Some things look harmless but are hidden dependencies: new Date(), Math.random(), process.env.X, and a global database client imported at the top of a service. They make tests slow or flaky, and they make behaviour surprising. Pass them in instead:
// ❌ Hidden clock — how do you test "expires in 24 hours"?function isExpired(token: Token) { return token.expiresAt < new Date(); }// ✅ Explicitfunction isExpired(token: Token, now: Date) { return token.expiresAt < now; }Dependency injection without a framework
"Dependency injection" sounds hard, but the idea is simple. A function receives the things it needs. It does not create them or import them itself. Think of a cook who is handed the ingredients, instead of going shopping. You do not need a container or decorators.
// A factory returns a service wired with its dependencies.export function createOrderService(deps: { orders: OrderRepo; payments: PaymentGateway; clock: () => Date }) { return { async place(input: PlaceOrderInput) { const order = await deps.orders.create({ ...input, createdAt: deps.clock() }); await deps.payments.charge(order.id, order.total); return order; }, };}// In production: real repos. In tests: tiny fakes.const service = createOrderService({ orders: prismaOrderRepo, payments: stripeGateway, clock: () => new Date() });SOLID, explained for backends
SOLID is a memory aid for five design rules. Each letter is the first letter of one rule. Interviewers often ask about them. Answer with a backend example, not just a definition.
| Principle | Meaning | Backend example |
|---|---|---|
| Single Responsibility | A module has one reason to change | The controller changes when the HTTP contract changes. The service changes when the business rule changes. The repository changes when the schema changes |
| Open/Closed | You can add new behaviour without editing old code | Add a new payment provider by adding a class that implements PaymentGateway, not by editing a giant switch |
| Liskov Substitution | A subtype (a more specific version) must work wherever its parent works | Every PaymentGateway must follow the same contract (same errors, and a second charge call for the same order must not charge twice). If not, swapping one for another breaks the callers |
| Interface Segregation | Keep interfaces small and focused | A reporting job depends on a ReadOnlyOrderRepo, not on the full repo with write methods |
| Dependency Inversion | Depend on abstractions (a general description, such as an interface), not on concrete classes (the real code that does the work) | The service depends on an EmailSender interface. SES, SendGrid or a test fake can plug in |
In TypeScript, interfaces and plain function types are enough.
interface PaymentGateway { charge(orderId: string, amountMinor: number): Promise<{ id: string }>; }class StripeGateway implements PaymentGateway { /* ... */ }class FakeGateway implements PaymentGateway { charges: string[] = []; async charge(id: string) { this.charges.push(id); return { id: "fake" }; } }DRY, and when it is harmful
DRY is a design rule that means "Don't Repeat Yourself": each piece of knowledge should live in one place. It is about repeated knowledge, not repeated text. Two functions can look alike but follow different business rules. They are not duplicates. If you merge them, you tie together things that will later change in different ways.
- The rule of three is a guideline that says: accept the duplication twice. Make an abstraction the third time, when you can see what really changes.
- "Duplication is far cheaper than the wrong abstraction." Imagine one shared function with seven boolean flags to cover every caller's special case. That is worse than three simple copies.
- Do remove duplication when there is one true source of truth: a pricing rule, a permission check, a validation schema.
- Do not share code between services only to avoid copying. The coupling (the way they depend on each other) usually costs more.
When not to abstract
- You have one implementation and no realistic second one. A
UserRepositoryInterfacewith one Prisma class and a factory may be extra ceremony. (The other side of the argument: it makes testing easy. Weigh the value.) - The abstraction only guesses future needs ("what if we switch databases?"). You almost never do. When you do, your guessed abstraction is rarely the right one.
- The abstraction hides important differences. For example, a queue and an HTTP call may both be called a "client", but they fail in different ways.
A good test: would deleting this layer make the code simpler without losing a real benefit? If yes, delete it.
Errors are part of the design
Make failure clear and typed. A typed error is an error with its own class, a stable code and a status, so the program can tell one failure from another. Do not return null or throw plain strings:
export class AppError extends Error { constructor(message: string, public status: number, public code: string, options?: { cause?: unknown }) { super(message, options); }}export class NotFoundError extends AppError { constructor(what: string) { super(`${what} not found`, 404, "NOT_FOUND"); } }export class ConflictError extends AppError { constructor(msg: string) { super(msg, 409, "CONFLICT"); } }Lesson 04 has the details. The main point here: domain code (the business logic) throws domain errors. A domain error is an error that names a business problem, such as "order not found". Only the outermost layer turns domain errors into HTTP responses.
Make the standards automatic
Standards that depend on review comments slowly fade. Use tools to enforce them:
- TypeScript
strictis a compiler setting that turns on all the strict type checks. AddnoUncheckedIndexedAccess, which makesarray[i]possiblyundefined. Whole groups of bugs then cannot even compile. - ESLint is a linter: a tool that reads your code without running it and reports mistakes and rule breaks. Use it with rules for unused variables, floating promises (
@typescript-eslint/no-floating-promises), noany, and import boundaries. For example, forbidservices/from importingexpress. - Prettier is a tool that rewrites your code in one fixed style, so nobody has to discuss formatting.
- CI (continuous integration) is a server that runs your checks automatically on every change. It runs lint, typecheck and tests on every PR (pull request, a proposed code change). Branch protection is a setting that blocks merging until those checks pass.
- Pre-commit hooks are small scripts that run just before each commit. A tool like
lint-stagedruns the linter only on the files you changed, so feedback is fast. - Architecture tests (with a tool such as
dependency-cruiser) fail the build if one layer imports another layer it should not.
Scenario: you inherit a 600-line controller
Do not rewrite it. Refactor it safely in small steps:
- Lock in the current behaviour with tests first. Write characterization tests. A characterization test is a test that records what the code does now, quirks included, so you notice if a change alters it. Here they are HTTP-level tests against a test database.
- Move input validation into a schema (Lesson 03).
- Move the pure business rules into functions with unit tests (pricing, eligibility, state transitions).
- Move data access into repository functions.
- Keep the controller thin: read the input, call one service function, and turn the result or error into an HTTP response.
- After each step, run the tests and commit. Small commits are easy to review and to undo.
- Fix bugs you find in separate commits. This keeps the refactor from changing behaviour.
Common mistakes
- Business logic in controllers or route handlers. You cannot reuse it from a job or a CLI.
- God services: one huge service that does everything, like a
UserServicewith 40 methods. - Hidden dependencies, like global singletons (one shared object that any file can import), or
process.envread in the middle of a function. - Premature abstraction means adding an abstraction before you need it. These are layers with a single implementation and no reason to exist.
- Premature DRY means removing duplication too early. This merges code that looks the same today but changes for different reasons.
- Boolean-flag parameters, and functions that return different shapes.
- Comments that repeat the code, with no comments that explain why.
- Swallowed errors (
catch {}) and vague errors likeError("failed").
Interview questions
What makes code "maintainable" to you?
A change should touch only a few places, and a reader should be able to predict its effect. In practice, I look for these things: clear names, small functions with one job, business rules kept apart from I/O so they are testable without infrastructure, clear dependencies, typed errors, and automatic checks (types, lint, tests) that protect behaviour. I measure it by how long a new teammate needs to make a safe change.
Scenario: you inherit a 600-line controller. How do you refactor it safely?
I write characterization tests first. Then I take layers out in small commits: a validation schema, pure business rules, repository functions, and last a thin controller. The behaviour stays the same at each step. Bugs I find go in separate commits. I avoid a big rewrite because it hides regressions (things that used to work and now break) and blocks other work.
When is DRY harmful?
DRY is harmful when it merges code that only looks the same but follows different rules that change for different reasons. The shared helper then gets more flags and branches for every caller. I wait for the third repeat (the rule of three). I only share the knowledge that is truly shared, such as one pricing rule or one permission check.
How do you enforce standards across a team?
I use tools, not reminders. That means strict TypeScript, ESLint (including import-boundary and floating-promise rules; a floating promise is an async call whose result nobody waits for or handles), Prettier, CI checks on every PR, and pre-commit hooks. For architecture, I add dependency-boundary checks that fail the build if a layer imports the wrong thing. Then review comments can focus on design, not formatting.
Explain the Single Responsibility Principle with a backend example.
A module should have one reason to change. A controller changes when the HTTP contract changes. A service changes when the business rule changes. A repository changes when the data model changes. Now imagine one function that validates input, applies pricing rules, writes to the database and formats the response. Any of those four changes forces you to edit and retest the same code.
What is dependency injection and do you need a framework for it?
It means giving a function what it needs (a repository, a clock, a payment gateway), instead of letting it import or create those things. You can do it with plain factory functions or constructor arguments. A framework or container can help in very large codebases, but you do not need one. Plain injection already gives the main benefit, which is easy testing with fakes.
Why separate pure logic from I/O?
Pure functions always give the same output for the same input, and they need no setup. So you can test them in milliseconds with a table of inputs, and reuse them anywhere. I/O code is harder to test and more likely to fail. If I keep it thin and at the edges, less of my logic depends on infrastructure.
How do you decide whether an abstraction is worth it?
I ask three questions. Does it remove real duplication of knowledge? Is there more than one implementation, or a real need for testing? Would deleting it make the code simpler? If it only exists for flexibility I might need one day, I leave it out. I add it when the need is real.
Key takeaways
- Clean code makes it obvious where a rule lives, what a function touches, how to test it, and what fails.
- Name by intent. Keep functions small with one job. Prefer guard clauses to nesting.
- Keep pure logic in the middle and I/O at the edges. Pass in dependencies (clock, repositories, gateways) instead of importing them.
- SOLID is five design rules that keep changes small and separate. Explain each one with a backend example.
- DRY is about knowledge, not text. Make an abstraction on the third repeat. Delete layers that give nothing back.
- Automate your standards (strict types, lint, CI, architecture checks). Refactor old code behind tests, in small steps.
What's next
Lesson 01: API Folder Structure. We apply these ideas to project layout: routes, controllers, services and repositories, and where each rule belongs.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- What Makes a Good API: Resources, URLs and the Contract · API Design
- Design a URL Shortener (Long URL to Short URL) · Practical Design Problems
- Design a Rate Limiter · Practical Design Problems
Related series