Single Responsibility: The Principle That Pays for Itself

The expensive version of "it does too much"
Early in my career I built controllers that validated input, queried the database, applied business rules, called third-party APIs, and formatted the response -- all in one function. It worked. Until a Stripe integration change broke the validation logic, and a logging update introduced a regression in the response format. Everything was coupled, so every change was a gamble.
After ten years of shipping production systems, I have settled on a simple rule: each piece of the system does one thing well. When something needs to change, you know exactly where to go -- and nothing else breaks.
This is not theoretical. Let me show you how this looks in practice, using real code from a SaaS platform I built.
Layer 1: Middleware that handles one concern
The backend has a middlewares/ folder. Each file does exactly one job. No exceptions.
// blockSuspiciousRequests.ts -- blocks vulnerability scanners
export const blockSuspiciousRequests = (
req: Request, res: Response, next: NextFunction
): void => {
const path = req.path.toLowerCase();
const hasBlockedExtension = BLOCKED_EXTENSIONS.some((ext) =>
path.endsWith(ext)
);
const hasBlockedPathPrefix = BLOCKED_PATH_PREFIXES.some((blocked) =>
path.startsWith(blocked)
);
if (hasBlockedExtension || hasBlockedPathPrefix) {
logger.warn(`Blocked suspicious request: ${req.method} ${req.path}`);
res.status(403).send("Forbidden");
return;
}
next();
};// logRequest.ts -- logs incoming requests, nothing else
export function logRequest(req: Request, res: Response, next: NextFunction) {
const skipPaths = ["/webhooks/whatsapp", "/healthz"];
if (skipPaths.includes(req.path)) return next();
logger.info(`Request: ${req.method} ${req.originalUrl}`, {
query: req.query, params: req.params, body: req.body,
});
next();
}// errorHandler.ts -- transforms errors into HTTP responses
export const errorHandler = (
error: Error | ApiError | ZodApiError,
req: Request, res: Response, next: NextFunction
) => {
if (error instanceof ZodApiError) {
res.status(error.statusCode || 400).json({
status: "error",
message: error.message,
errors: error.errors.map((err) => ({
field: err.field, message: err.message,
})),
});
return;
}
// ... handle ApiError, generic Error
};Three files, three concerns: security filtering, request logging, error formatting. When the security team asks me to block a new path pattern, I open blockSuspiciousRequests.ts. I do not touch logging. I do not touch error handling. The blast radius of every change is exactly one file.
Layer 2: Routes declare, controllers orchestrate
Routes are pure declaration -- which HTTP method, which path, which middleware, which controller method. They contain zero business logic.
// billing/routes.ts
export const billingRoutes = (router: Router) => {
const billingController = new BillingController();
router.post("/billing/checkout-session",
isClientRoute,
billingController.createCheckoutSession
);
router.get("/:businessId/billing/subscription-info",
isBusinessRoute,
billingController.getSubscriptionInfo
);
router.get("/:businessId/billing/payment-methods",
isBusinessRoute,
billingController.getPaymentMethods
);
};The route file answers one question: "what endpoints exist and who can access them?" That is it. The isClientRoute middleware handles authentication. The isBusinessRoute middleware handles business-level authorization. The controller handles the actual request.
This separation means I can add a new billing endpoint by adding one line to the routes file, without reading through hundreds of lines of request handling code to find the right place.
Layer 3: Controllers validate and delegate
Controllers sit between HTTP and business logic. Their job: validate the incoming request, call the right service, and format the response. They never contain business rules.
// billing/controller.ts
async createCheckoutSession(req: Request, res: Response, next: NextFunction) {
try {
const validatedData = createCheckoutSessionSchema.parse(req.body);
const { _id: clientId } = getClientUserFromRequest(req);
const clientAndBusiness = await this.clientApi.getClientAndBusiness(clientId);
if (!clientAndBusiness) {
return res.status(404).json({ success: false, message: "Client not found" });
}
const result = await this.billingApi.createCheckoutSession({
packageId: validatedData.packageId,
client: clientAndBusiness.client,
business: clientAndBusiness.business[0],
successUrl: validatedData.successUrl,
cancelUrl: validatedData.cancelUrl,
});
res.status(201).json({
success: true,
data: {
sessionId: result.sessionId,
checkoutUrl: result.checkoutUrl,
packageInfo: result.packageInfo,
},
});
} catch (error) {
if (error instanceof ZodError) {
return res.status(400).json({ success: false, errors: error.errors });
}
next(error);
}
}Notice what this controller does NOT do: it does not calculate prices, it does not talk to Stripe directly, it does not decide which coupon applies. It validates, delegates to billingApi, and formats. If the Stripe API changes, the controller does not change. If the validation schema changes, the Stripe integration does not change.
Layer 4: Domain logic in isolation
Business rules live in their own files, free from HTTP concerns and database details.
// challenges/domain/createChallenge.ts
export function createEmptyChallengeContent({
name, businessId, businessSlug, locale,
}: {
name: string;
businessId: string;
businessSlug: string;
locale: Locale;
}): IChallengeContent {
return {
_id: new ObjectId().toString(),
businessId,
name,
reward: emptyReward,
isActive: true,
publicUrl: `${businessSlug}/${sanitizeUrlSlug(name)}`,
blocks: [],
title: locale.split("-")[0] === "es"
? "Titulo de Ejemplo"
: "Example Title",
config: { locale, automaticValidation: false, uniqueParticipation: false },
};
}This function knows nothing about Express, nothing about MongoDB, nothing about HTTP status codes. It takes plain data in, returns plain data out. I can test it with a simple function call. I can reuse it from a CLI script, a migration job, or an API endpoint. It has one reason to change: when the business rules for challenge creation change.
The compounding payoff
The middleware folder has seven files. Each one is under 100 lines. When I onboard a new developer, I say: "Each middleware does one thing -- the filename tells you what." They are productive on day one.
middlewares/
blockSuspiciousRequests.ts -- security filtering
errorHandler.ts -- error formatting
isBusinessRoute.ts -- business authorization
isClientRoute.ts -- client authentication
isFromOurServer.ts -- server-to-server auth
isSuperAdminRoute.ts -- admin authorization
logRequest.ts -- request logging
Seven concerns, seven files, zero overlap. When something breaks in authentication, I open one file. When we need a new authorization level, I create one file. The system grows by addition, not by modification.
The same pattern repeats at every layer: route files declare endpoints, controllers validate and delegate, domain functions encapsulate rules, and shared services like R2Client or EventBus each own a single infrastructure concern.
The practical takeaway
Single responsibility is not about writing small functions. It is about making every change predictable. When the product manager says "change the billing flow," I know I will touch billingApi and the billing controller. When the security audit says "block SVG uploads," I know I will touch one content-type whitelist. When the new developer asks "where does authentication happen," I point to one file.
The principle pays for itself the first time a production incident happens at 2 AM and you find the problem in under five minutes because the error tells you exactly which layer failed, and that layer does exactly one thing.
Build systems where the filename tells you the responsibility. Future you will be grateful.