2026-06-03
Lambda Function Granularity: Single Responsibility, Single Scope, or Single Domain?
How to slice AWS Lambda functions for HTTP APIs and events: default to single-purpose, treat the single-domain Lambdalith as an earned exception, and use the platform forces that decide it.
Problem
When teams build an HTTP API on AWS Lambda, an early decision quietly sets the shape of everything that follows: how many functions to deploy. Tutorials map one route to one function, so teams copy that and reach 60 functions per service, each with its own IAM role, bundling config, and a slice of a sprawling template. The consolidation backlash answers with a “Lambdalith”: one function per service running an in-process router. My position is the opposite of that backlash. Default to single-purpose functions (one per route, one per event), and treat the single-domain Lambdalith as a consolidation you earn, not a starting point. The spectrum comes first, then the platform forces (cold start, concurrency, IAM, observability) that decide where on it you land.
The decision is easy to get wrong because the costs people cite against single-purpose are loud and the costs of consolidation are quiet. Function sprawl shows up in your console on day one. The shared concurrency pool a Lambdalith creates shows up only when one route gets hot in production. So the naive read favors merging, and it is exactly backwards.
Before the spectrum, one distinction does most of the work here. “Single responsibility” is a code principle: keep each handler focused. “How many functions to deploy” is a topology decision about deploy units. These are independent. A Lambdalith can hold perfectly single-responsibility handlers behind its router while being one deploy unit; a single-purpose function can hold a tangled god-handler. You want single-responsibility code always, and single-purpose deploy units by default. Conflating the two is what makes the “AWS sanctions monoliths now” argument feel stronger than it is. Keep them separate and most of the tension dissolves.
Note: The subject here is handler topology, the number of functions you deploy, not stack layout. For how to organize the CDK constructs and stacks around those functions, see AWS CDK code organization: service vs domain-based. The two decisions are related but separate.
The Granularity Spectrum
Granularity sits on one axis, from most functions to fewest. Three named points anchor it.
- Single-purpose (nano-function): one Lambda per route or action.
POST /ordersandGET /orders/{id}are separate functions. Maximal isolation, maximal count. This is the default. - Single-scope: one Lambda per resource or feature. All of
/orders/*lives in one function. The milder consolidation; a middle ground. - Single-domain (Lambdalith): one Lambda per bounded context, running an in-process HTTP router (Hono, Fastify, Express, or any framework through the AWS Lambda Web Adapter) behind an API Gateway or ALB proxy integration. Fewest functions; the earned exception.
The two most authoritative voices both endorse single-purpose on principle. The AWS Well-Architected Serverless Lens states it directly: “Speedy, simple, singular: Functions are concise, short, single-purpose.” Yan Cui’s analysis of the same question concludes that “having many single-purposed functions is clearly the better way to go” for discoverability, debugging, and team scaling, and argues the function-count objection is solved by tooling and naming conventions rather than by merging functions. His piece predates the Lambdalith term and much of today’s tooling, so treat the date as a currency caveat; the reasoning still holds. The job here is not to restate that orthodoxy. It is to defend it against the strongest consolidation arguments on their own ground.
The hero exhibit is the default itself: one handler, one job, scoped tightly.
// src/orders/create.ts - one function, one job
import type { APIGatewayProxyHandlerV2 } from "aws-lambda";
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const body = JSON.parse(event.body ?? "{}");
// ...create the order
return { statusCode: 201, body: JSON.stringify(body) };
};
// In IaC, this function gets a role scoped to exactly the Orders table,
// its own concurrency budget, and its own metrics and alarms, for free.
That “for free” is the whole argument, and the next sections make it concrete. Each force below is a property you get by deploying separately and forfeit by merging.
Lens 1: Cold Start Tracks Traffic, Not Function Count
The most common pro-Lambdalith claim is “fewer functions means fewer cold starts.” Stated as a fact, it is wrong; the real relationship is about traffic distribution.
Lambda provisions a separate execution environment per concurrent request and incurs a cold start whenever it must initialize a new environment. So a function stays warm in proportion to its own concurrent traffic, not the total number of functions in your account. Merging ten low-traffic routes into one function does concentrate their traffic, which can raise that one function’s warmth. But this is a consequence of traffic distribution, not a free reduction that consolidation hands you. A single-purpose function carrying meaningful steady traffic keeps its own warm environments without any merging. Therefore the honest framing is: consolidation can improve warmth for routes that are individually too quiet to stay warm, and that is one real input to the exception later, not a blanket win.
Two guardrails matter when this lens comes up. First, there is no published AWS target for INIT duration. What AWS documents is that the INIT phase is limited to 10 seconds, and if it does not finish, Lambda retries INIT at the first invocation under the configured function timeout (the 10-second cap does not apply to provisioned concurrency or SnapStart). The widely repeated “under 500 ms” figure is community framing, not an AWS number. Second, whether a larger Lambdalith package meaningfully worsens an individual cold start is plausible but not something AWS quantifies, and bundling with tree-shaking flattens much of it; weigh it as a qualitative trade-off, not a measured penalty.
Cold start here is a lens on the granularity decision, not a tuning guide. For SnapStart, provisioned concurrency, and package-init tactics, see AWS Lambda cold start optimization.
Lens 2: Concurrency and the Noisy Neighbor
This is the strongest single argument for the single-purpose default, and it is a documented mechanism rather than an anecdote.
A Lambdalith shares one function’s concurrency across every route it serves. Lambda throttles a function when it reaches its reserved concurrency limit, or when the account hits its regional ceiling (1,000 concurrent executions by default, a soft limit you can raise). There is also a request-rate limit of ten times the concurrency quota, so 10,000 requests per second at the default; that matters for sub-100 ms handlers. Put those together: inside a Lambdalith, a traffic spike on one hot route consumes the function’s shared pool, and the resulting throttles land on the whole function. A slow checkout route can throttle a health check that happens to live behind the same router. That is the noisy-neighbor problem, and the Lambdalith creates it by construction.
Single-purpose functions give each route its own concurrency boundary. A spike on one route cannot starve another, because they draw on separate function-level budgets. This isolation is the asymmetry that runs through the whole post: you cannot bolt per-route concurrency isolation back onto a Lambdalith after merging, because the function is the unit of concurrency. The only way to re-isolate a route inside a Lambdalith is to pull it back out into its own function. By contrast, the sprawl that single-purpose creates is a tooling problem you solve with IaC modules and naming conventions. One side of the trade is a platform guarantee you forfeit; the other is housekeeping.
A note on the levers, because they are often confused. Reserved and provisioned concurrency are complementary, not interchangeable, and both are configured per function. Reserved concurrency sets a function’s maximum concurrent instances, carves that capacity from the account pool, and carries no additional charge; the function is throttled when it reaches that reserved limit. Provisioned concurrency pre-initializes environments so there is no cold start within the provisioned count, and Lambda bills for that initialization even if an instance never serves a request. The load-bearing point for granularity: because these controls are per function, any route that needs its own scaling budget has to be its own function. Consolidation forfeits per-route scaling control.
Lens 3: IAM Blast Radius
A single-purpose function gets an IAM role scoped to exactly what that one handler touches. The create-order function can write the Orders table and nothing else. This is least privilege at the finest grain the platform offers, and it is free in the sense that you were going to attach a role anyway.
A Lambdalith needs the union of every permission its routes require. If the bounded context reads three tables, writes two, publishes to a topic, and reads a secret, the single role holds all of it, and every route runs with that full set. The blast radius of a bug or a compromised dependency is the union, not the single permission the current request needs. You can narrow this with in-code authorization checks, but that moves a guarantee the platform was enforcing into application logic you have to write, test, and keep correct. The same asymmetry holds: per-function IAM scoping is a platform boundary; reconstructing it inside a shared function is your code’s job now.
This lens does not forbid consolidation. It prices it. Merging is acceptable when the routes genuinely share an IAM scope, so that the union is barely wider than each route alone. When they do not, consolidation over-grants, and the over-grant is permanent for as long as the routes share a function.
Lens 4: Observability You Get vs Observability You Rebuild
Per-function CloudWatch metrics are automatic. Invocations, errors, duration, throttles, and concurrency all arrive split by function with no instrumentation. With single-purpose functions, that split is per route, so p50, p95, and p99 latency, error rate, and throttle count are already attributed to the exact route without any work.
A Lambdalith collapses every route into one function’s metrics. A spike in p99 tells you the domain is slow, not which route. To recover per-route granularity you emit the matched route as a structured-log or Embedded Metric Format dimension and rebuild the per-route view from there. Powertools for AWS Lambda is the idiomatic way to do it. This is entirely doable, but notice the shape: single-purpose hands you per-route observability as a platform default, and a Lambdalith asks you to reconstruct it in application code. Recommendation that follows: if you consolidate, add the route dimension on day one, before you need it during an incident, because adding it under pressure is the worst time.
The Lambdalith Counter-Case, Honestly
The consolidation argument is not empty, and the strongest version deserves a fair hearing. Function sprawl is real: 60 functions per service means 60 roles, 60 log groups, and a template that is genuinely harder to read and reason about. Cross-route refactors touch many deploy units. And AWS itself ships the Lambda Web Adapter, an AWS-maintained extension that lets you run a normal Express, Fastify, Koa, or Next.js app inside Lambda without rewriting to the handler signature; the adapter translates the Lambda invoke into a localhost HTTP request to your web server. That is a strong signal that the Lambdalith is a sanctioned pattern.
Here is the shape of the exception when it is warranted. The in-process router behind one function:
// src/orders/handler.ts - one function for the whole Orders domain (the EXCEPTION)
import { Hono } from "hono";
import { handle } from "hono/aws-lambda";
const app = new Hono();
app.get("/orders/:id", (c) => c.json({ id: c.req.param("id") }));
app.post("/orders", async (c) => c.json(await c.req.json(), 201));
export const handler = handle(app); // single Lambda handler for every Orders route
On the API Gateway side, a single greedy {proxy+} resource with an ANY method forwards every path and method to this one function. The Lambda Web Adapter reaches the same destination without the rewrite, by running your existing web server unchanged behind the adapter layer.
But read what that signal actually says. The Web Adapter sanctions the Lambdalith as an option; it does not make it the default. AWS shipping a tool to run web frameworks in Lambda is not AWS retracting “Functions are concise, short, single-purpose” from its own Well-Architected guidance. Both can be true: single-purpose is the default, and the Lambdalith is a legitimate, supported choice when you have earned it. And the sprawl complaint, examined honestly, is a tooling problem. Sixty functions defined through a reusable IaC module with a naming convention are not sixty hand-maintained templates; they are one module instantiated sixty times. That is the asymmetry one more time: sprawl is solvable with tooling you control, while the isolation you give up by merging (concurrency, IAM, metrics, a deploy blast radius of one) is a platform guarantee you cannot re-create by writing more code.
Consolidation has a way of biting back, and the failure mode is predictable. A team merges a cohesive context into one Lambdalith to cut function count, and it reads cleanly until one route starts receiving bursty traffic. That route consumes the shared pool, sibling routes begin throttling, and the per-function metrics cannot say which route is the cause until a route dimension is added after the fact. Nothing about that is exotic; it is the concurrency and observability lenses arriving together, exactly as the mechanism predicts. The fix is to pull the hot route back into its own function with reserved concurrency, which is to say: the default was right for that route, and the merge had quietly removed the boundary that would have contained the problem.
When the Lambdalith Is Earned
Reach for a single-domain Lambdalith only when every one of these holds for a cohesive bounded context. If any fails, stay single-purpose; if most hold but one or two routes need isolation, keep those routes as their own functions and put the rest behind a Lambdalith (a hybrid).
- Many low-traffic routes. The context has routes whose individual functions would rarely stay warm, so concentrating their traffic is a real warmth gain rather than a theoretical one.
- No route needs isolated concurrency. Because reserved and provisioned concurrency are per function, any route that needs its own scaling budget must stay its own function. A shared pool has to be acceptable for every route you merge.
- A common IAM scope. Merging does not meaningfully over-grant; the union of permissions is barely wider than each route alone, so least privilege survives at the domain grain.
- One deploy cadence and owner. No route changes far more often than the others or belongs to a different team; a deploy blast radius of the whole domain is acceptable.
- Reconstructable observability. You accept losing free per-function metrics and will emit the matched route as a structured-log or EMF dimension to recover per-route p50/p95/p99 and throttle counts.
Single-scope (one function per resource family) is the milder version of this exception. Use it when a resource family genuinely benefits from one deploy unit but the full bounded-context Lambdalith is more merging than the pain justifies.
Two housekeeping notes for the code that lives in these functions, single-purpose or Lambdalith alike: use the modular AWS SDK v3 (@aws-sdk/*), since SDK v2 reached end-of-support on 2025-09-08, and target a current runtime (nodejs24.x is the current Node LTS on Lambda). Neither depends on your granularity choice, but both compound with it across many functions. For the handler-internal habits that hurt regardless of topology, see AWS Lambda anti-patterns TypeScript developers bring from monoliths.
Closing
Default to single-purpose Lambda functions, one per route and one per event, and keep your handler code single-responsibility regardless of topology. The isolation that single-purpose buys (per-route concurrency, least-privilege IAM, free per-route metrics, and a deploy blast radius of one) is a set of platform guarantees you cannot bolt back on after merging, while the function-count pain that argues for consolidation is a tooling problem you can solve with IaC modules and naming conventions. So the answer to “single responsibility, single scope, or single domain?” is single responsibility by default. Reach for a single-domain Lambdalith only when a cohesive context’s function-count pain is proven and all five exception conditions hold: many low-traffic routes, no route needing isolated concurrency, a common IAM scope, one deploy cadence and owner, and observability you are willing to rebuild from structured logs. The single next step worth taking is to write down which of those five your candidate context actually satisfies before you merge anything.
References
- Understanding Lambda function scaling - AWS guide on concurrency: the 1,000 account default, reserved vs provisioned (both per function), no additional charge for reserved, throttling when the reserved limit is reached, and the request-rate rule.
- Configuring provisioned concurrency - AWS documentation noting that Lambda bills for initialization even when a provisioned instance never serves a request.
- Lambda quotas - AWS reference for the account regional concurrency default (1,000, soft and raisable) and related limits.
- Understanding the Lambda execution environment lifecycle - AWS documentation on the INIT phase 10-second limit, retry at first invocation, the cold-start definition, and per-request environment provisioning.
- Lambda runtimes - AWS list of supported runtimes including
nodejs24.x, with the deprecation policy. - Well-Architected Serverless Applications Lens: design principles - AWS source for “Functions are concise, short, single-purpose” and “think concurrent requests, not total requests”.
- Set up Lambda proxy integrations in API Gateway - AWS guide to the greedy
{proxy+}resource and a singleANYmethod routing all paths to one backend, the Lambdalith front door. - AWS Lambda Web Adapter - AWS-maintained extension to run Express, Fastify, Next.js, Flask, and FastAPI unmodified in one function, the signal that AWS sanctions the Lambdalith.
- Hono on AWS Lambda - In-process router for a TypeScript Lambdalith, with
handle(app)fromhono/aws-lambda. - Few monolithic functions or many single-purposed functions? - Yan Cui’s analysis concluding single-purpose wins for discoverability, debugging, and team scaling (predates the Lambdalith term).
- Announcing end-of-support for AWS SDK for JavaScript v2 - AWS announcement of v2 maintenance mode (2024-09-08) and end-of-support (2025-09-08), recommending v3.
- Powertools for AWS Lambda (TypeScript) - Idiomatic structured logging, tracing, and metrics, the practical way to recover per-route observability inside a Lambdalith.
Related posts
DI containers, monolithic SDKs, god-handlers, top-level secret fetches, and heavy ORMs - what they cost on cold start, and the functional shape that replaces them.
Technical implementation guide for running Bun and Deno on AWS Lambda using custom runtimes, with real performance benchmarks, cost analysis, and production deployment patterns.
A comprehensive guide to understanding Effect, learning it incrementally, and integrating it with AWS Lambda. Includes real code examples, common pitfalls, and practical patterns from production usage.
Learn to build maintainable, type-safe Lambda middleware using Middy's builder pattern, Zod validation, feature flags, and secrets management for enterprise serverless applications.
A practical guide to using the CloudEvents specification and TypeScript SDK in serverless projects. Learn how to create, parse, and validate standardized events across AWS Lambda, EventBridge, and other event-driven systems.