NodeJSSeparation of Concerns

Separation of Concerns

Separation of Concerns (SoC) is the principle that each module, class, or function should have one reason to change — it should be responsible for one coherent piece of the system's purpose, and everything else should be someone else's problem. It's the principle behind layered architecture, MVC, module boundaries, and single-responsibility design. When concerns are well-separated, changes are local (you know exactly what to edit), tests are simple (you test one thing at a time), and code is reusable (a service that knows nothing about HTTP can be called from a REST route, a GraphQL resolver, or a CLI script). This page makes the principle concrete with Node examples.

One reason to change

TS
// ❌ Mixed concerns — this function has at least FOUR reasons to change:
app.post('/users', async (req, res) => {
  if (!req.body.email?.includes('@')) return res.status(400).json({ error: 'bad email' })
  const hash = await bcrypt.hash(req.body.password, 10)
  const [user] = await db.query('INSERT INTO users(email,pass) VALUES($1,$2) RETURNING *',
    [req.body.email, hash])
  await sendEmail(req.body.email, 'Welcome!')
  logger.info('user created', user.id)
  res.status(201).json({ id: user.id })
})

// Reasons to change: (1) API contract, (2) validation rules, (3) DB schema,
// (4) email template, (5) log format — one function owns all of them.
A function with many reasons to change is a SoC violation — split along the reasons: validation, business logic, persistence, notification
The test for SoC is asking: **"what would cause this code to change?"** The route handler above changes if the API contract changes, if validation rules change, if the password hashing cost factor changes, if the SQL schema changes, if the welcome email template changes, or if the log format changes — six independent reasons. **Each reason is a candidate for its own module**. Split: a **validation schema** (Zod) owns the request shape, a **service** owns the business workflow (hash password, trigger email), a **repository** owns the SQL, an **email service** owns the template, and the route handler becomes a thin coordinator. Now each part changes independently — updating the email copy doesn't touch the SQL, changing the hash cost doesn't touch the API contract.
Practical separations that pay off

Concern

Its own module

Why it helps

Input validation

*.schema.ts (Zod/Joi)

Rules change independently of business logic; reusable across REST + GraphQL

Business logic

*.service.ts

Testable without HTTP or a database; reusable from any caller

Data access

*.repository.ts

Swap ORM/DB without touching services; mock in tests

Error types

errors.ts

Consistent error codes; can be mapped to HTTP status in one place

Config

config.ts

One validated source of truth; no process.env reads scattered around

Logging

injected logger

Swap implementation; silence in tests without mocking console

Each separation pays a dividend — validation schemas reuse, services test cleanly, repositories swap, errors stay consistent
Separating concerns isn't organizational tidiness — each split pays a concrete dividend. **Validation schemas** extracted to their own files can be reused by REST and [GraphQL](/nodejs/graphql-resolvers) resolvers, and tested independently of the handler. **Services** with no HTTP imports are trivially unit-testable — pass inputs, assert outputs, no mock servers. **Repositories** behind an interface let you swap from raw SQL to an ORM, or replace them entirely in tests with an in-memory fake. **Error types** in one file means HTTP status mapping lives in one middleware, not scattered `if (err.message.includes('not found'))` checks. Each separation makes the code more predictable: when you need to change a thing, you know exactly which file owns it.
Cross-cutting concerns — where SoC gets tricky

TS
// Cross-cutting concerns cut across EVERY layer: logging, auth, timing, tracing.
// They belong in MIDDLEWARE or DECORATORS, not scattered in every function.

// ❌ Logging sprinkled everywhere — couples every function to the log format:
async function createUser(dto) {
  console.log('creating user', dto.email)
  // ...
  console.log('user created', user.id)
}

// ✅ Logging as cross-cutting infrastructure — applied at the layer boundary:
// HTTP middleware logs every request automatically (morgan, pino-http)
// Service functions receive a logger via injection, or a request-scoped store
Auth, logging, tracing, and timing are cross-cutting — use middleware and injection so they apply everywhere without polluting every function
Some concerns genuinely apply *everywhere* — every request should be logged, every service call should be traced, every route should check auth. These are **cross-cutting concerns**, and scattering them inside every function is its own SoC violation (every function has "a reason to change" due to logging format changes). The solutions are **middleware** (Express middleware logs/authenticates/times every request without each handler knowing) and **dependency injection** (a logger or tracer injected into services, so the infrastructure detail is supplied from outside and can be replaced in tests). The goal isn't zero cross-cutting — it's that the cross-cutting logic lives in one place and is applied systematically, not copy-pasted into every function.
Next
The twelve principles every production app should follow: [The Twelve-Factor App](/nodejs/twelve-factor-app).