Code Architecture by Init Amortization: Lean on Lambda, Heavy Only When Earned
Match architecture weight to each runtime's init-amortization: lean handlers on single-purpose Lambda, more on a Lambdalith, full OOP/DI only on long-lived runtimes.
Problem#
Part 1 answered the topology half of the Lambda question: single responsibility is a code principle, and how many functions to deploy is a separate topology decision. The other half is how much code architecture belongs inside a given runtime: how much OOP, dependency injection, and framework weight. The answer mirrors Part 1’s “earn the exception” shape: default to lean functional handlers, then earn heavier structure as each runtime’s init-amortization permits. The variable that decides it is how many invocations one initialization serves.
That distinction matters because teams pick architecture by habit. A full NestJS dependency-injection graph gets deployed as a single-purpose function, where it pays decorator scanning on nearly every cold start to serve roughly one purpose. The reverse also happens: a bare functional handler gets stretched across a long-lived Fargate service that never reuses a connection pool, throwing away the amortization the runtime was handing it for free. Both mismatches come from carrying a paradigm across a runtime boundary without asking what changed.
TypeScript and Node are the primary path here; Java and Spring stand in as the canonical heavy dependency-injection illustration, and that illustration carries a counter-intuitive twist.
The Init-Amortization Model#
One number governs the whole decision: invocations served per initialization. Architecture weight should track that ratio, because every byte of framework startup is paid once per initialization and reused for free afterward. The more reuse, the cheaper the weight.
The three runtimes sit at three points on that ratio.
- FaaS single-purpose: a cold environment runs init once, then serves roughly one logical purpose until it goes idle and is reaped. AWS documents that Lambda provisions a separate execution environment per concurrent request and incurs a cold start whenever it must initialize a new one. So init cost is paid disproportionately often relative to work done. Init weight is the dominant variable here.
- Lambdalith (single-domain): one warm environment runs init once, then serves every route in the bounded context. The same router build and dependency-injection graph is amortized across many heterogeneous requests. Moderate structure starts to pay.
- Fargate: a task starts once and runs for the container’s whole life, so init is paid once and amortized across every request, alongside the persistent pools and caches a long-lived process holds. Heavy dependency injection and hexagonal layering are essentially free here. This rung stands in for any long-lived runtime.
The amortization ratio climbs as you move down that list, and the affordable architecture weight climbs with it: that is the spine of the decision. Most of the decision lives at the top of it, on the Lambda side, where single-purpose versus Lambdalith is the choice that makes architecture weight actually cost something; weight is close to free by the time you reach Fargate.
Three Lifecycles, One Constraint#
The ratio comes from how each runtime lives and dies, so the lifecycles are worth stating once.
FaaS runs the INIT phase (download, environment setup, module init), then freezes the environment, then maybe reuses it warm for later invocations, then eventually reaps it. Scaling granularity is per request. Because reuse is opportunistic rather than guaranteed, you cannot assume a connection pool or in-memory cache survives between invocations. Designing for both the cold and warm path, and pushing connection management to a proxy such as RDS Proxy, is the discipline that follows.
A Lambdalith uses the same Lambda lifecycle, but the handler is a router. One init builds the router and shared services, and a warm environment serves N route handlers from that single build. The amortization is real but bounded by how often that one function stays warm under its own traffic.
Fargate serves requests from a long-running process. You scale by adding tasks, and there is no per-request cold start. Init is paid once at boot and reused for the task’s whole life, which is what makes a long-lived stateful object safe to build once and hold. The architecture consequence is the only one that matters here, and it holds for any long-lived runtime: expensive things get built once at boot and reused for every request that follows.
The Wider Compute Ladder#
Those three points sit on a longer ladder, and seeing the whole thing prevents two mistakes: treating Fargate as the next stop after Lambda, and treating single-purpose versus Lambdalith as a scaling choice. Single-purpose and Lambdalith are the same compute tier, Lambda, which scales per request and to zero; what differs between them is code topology.
Before the container jump, Lambda has headroom most teams underuse. A single function can run for up to 15 minutes, use up to 10,240 MB of memory and 10,240 MB of ephemeral storage, and ship as a container image. Reaching for a container the moment a function feels large is a common mismatch. A fatter Lambda, or a Lambdalith, often still fits, and it keeps scale-to-zero. (AWS App Runner used to fill the managed-container gap here, but it is closed to new customers, so it is not a path for new builds.)
The jump from a Lambdalith to Fargate is a slope, not a cliff, because two levers move a Lambda toward Fargate’s shape without leaving Lambda. The first is packaging. With the AWS Lambda Web Adapter you run a standard Express, Fastify, or Spring web server, packaged as the same container image you would deploy to Fargate, on Lambda. The artifact converges while the execution model stays per request and scales to zero. The second is warmth. Provisioned concurrency keeps initialized environments ready and works with either packaging, including the container image from the first lever. It bills for that warm floor even when idle, the same trade Fargate makes by holding a task. SnapStart does the same on Java, Python, and .NET, but only for zip packages on managed runtimes, not container images, so it pairs with the Web Adapter layer rather than the container-image path. With packaging and warmth both handled, the gap to Fargate narrows to billing granularity, background work, and Lambda’s 15-minute ceiling. You cross to Fargate when those are what you actually need.
Below Fargate is territory most Lambda-side teams never need: ECS or EKS on EC2 when you must manage capacity for GPUs, bin-packing, or daemon workloads, then raw EC2 and bare metal. Each step trades scale-to-zero for control, but none of it changes the architecture question, only the operations around it.
For the architecture decision, though, the ladder collapses into three bands. Ephemeral-per-request work (single-purpose Lambda) wants lean. Router-amortized work (Lambdalith) wants moderate. Every long-lived process that builds once and reuses for its lifetime, whether Fargate, ECS or EKS on EC2, or a plain EC2 service, sits in the same band where full weight is affordable, because all of them amortize init over the process life. A Lambda with provisioned concurrency or SnapStart buys its way into that same band, because warmth is what amortizes init, whatever the runtime. Those bottom rungs differ in operations, cost, and scaling granularity; the architecture weight they can carry is the same. Only three points change the architecture: single-purpose Lambda, Lambdalith, and the long-lived band Fargate anchors.
Paradigm Per Runtime: Lean by Default#
The paradigm axis (functional versus OOP, no framework versus heavy framework) maps directly onto the ratio.
Lean functional handler on FaaS single-purpose#
Init weight is the variable to minimize here: a single-purpose function serves roughly one purpose per cold environment. A functional core (pure functions plus a thin imperative shell) maps naturally onto one handler. OOP in the small is fine: a value object, a small class, a focused service. The anti-pattern is dragging a full dependency-injection container and a decorator-scanning framework into a function that exists to do one job.
// src/orders/create.ts - lean functional handler, minimal deps, lazy init
import type { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
// Built once at module load, reused across warm invocations.
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.ORDERS_TABLE!;
// Pure core: no AWS, no I/O, trivially testable.
function toOrder(input: { sku: string; qty: number }) {
return { id: crypto.randomUUID(), ...input, createdAt: Date.now() };
}
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const input = JSON.parse(event.body ?? "{}");
const order = toOrder(input);
await ddb.send(new PutCommand({ TableName: TABLE, Item: order }));
return { statusCode: 201, body: JSON.stringify(order) };
};
The pure function holds the domain rule; the handler is the thin imperative shell. No container constructs a graph on the cold path; module load builds only a client, which warm invocations then reuse.
Moderate structure on a Lambdalith#
A Lambdalith builds its router and services once and amortizes them across every route, which is why it can afford a service layer and light dependency injection. The sweet spot is a mix: functional route handlers in front of a small OOP service layer, wired by a single composition root that runs at cold start and is shared by all routes.
// src/composition.ts - one composition root, built once, shared by every route
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
export class OrderService {
constructor(private readonly ddb: DynamoDBDocumentClient, private readonly table: string) {}
// ... domain methods reused across routes
}
// Light, hand-wired DI: no decorator scanning, built at module load.
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const orders = new OrderService(ddb, process.env.ORDERS_TABLE!);
// src/handler.ts - router amortizes one init across many routes
import { Hono } from "hono";
import { handle } from "hono/aws-lambda";
import { orders } from "./composition";
const app = new Hono();
app.get("/orders/:id", (c) => c.json(/* orders.get(...) */ { id: c.req.param("id") }));
app.post("/orders", async (c) => c.json(/* orders.create(...) */ await c.req.json(), 201));
export const handler = handle(app); // one Lambda, every route reuses `orders`
Hono is the low-init router here, but the same shape holds for Fastify or Express through the AWS Lambda Web Adapter, which Part 1 covered. NestJS on a Lambdalith is viable for the same amortization reason: its dependency-injection container is built once at cold start and reused across every route in the warm environment. The cost lands on the cold path, so the heavier the graph, the more the cold-start lever (below) matters.
Full OOP, DI, and hexagonal on Fargate#
Rich domain models, eager singletons, and persistent pools earn their keep on a Fargate task, which builds everything once and reuses it for the task’s life. With no per-request cold start, the weight is essentially free in amortized terms, which makes this the natural home for a full Spring or NestJS graph. The only failure mode is re-initializing per request inside a long-lived task, which throws away the advantage the runtime gave you.
For functional TypeScript patterns on any of these runtimes, see the Effect adoption guide; for Fargate production operations specifically, see Fargate production lessons. Those topics belong to the siblings that own them.
One Core, Tuned Edges#
This is the reframe that makes per-runtime tuning cheap instead of expensive: the clean-architecture core travels, and only the edges change. A ports-and-adapters or clean-architecture domain core is delivery-mechanism-agnostic by definition, so the same domain code moves from FaaS to Lambdalith to Fargate unchanged. What you tune per runtime is the composition root and the adapter edge: how much framework and dependency injection you wire around the core, and how eagerly. The core does not know which runtime it runs in, and it should not.
In practice the edges differ by exactly one thing: the composition root, already sketched above.
This is why “should I rewrite my domain logic when I move runtimes?” has a clean answer: no. If the domain core is coupled to the handler or the framework, a runtime change forces a rewrite, and that coupling is the pitfall. An agnostic core keeps a runtime change scoped to the composition root.
SnapStart and Provisioned Concurrency#
The default is lean, and the override is “I must keep a heavy edge on FaaS.” That happens, and AWS ships two levers for it. Which one you can use depends on your runtime language in a way that surprises most teams.
Provisioned concurrency pre-initializes execution environments so init, including dependency-injection graph construction, is paid before traffic arrives. AWS describes provisioned-concurrency environments as ready to respond in double-digit milliseconds. It carries an always-on charge because Lambda bills for that initialization even when an instance never serves a request. For the Node and TypeScript primary path, this is the only cold-start lever available.
SnapStart takes a Firecracker microVM snapshot of the initialized execution environment when you publish a version, then restores environments from that cached snapshot instead of initializing from scratch. AWS frames it as the answer to exactly this problem: the latency variability from one-time initialization code “such as loading module dependencies or frameworks,” which it notes “can sometimes take several seconds.” That is the heavy dependency-injection cold start, named by AWS. Three constraints limit where it helps:
- Runtime support is narrow. SnapStart supports Java 11+, Python 3.12+, and .NET 8+ only. Node.js (
nodejs24.x) and Ruby are not supported, nor are OS-only runtimes or container images. So the TypeScript audience cannot use SnapStart at all; provisioned concurrency is the Node lever. - It is mutually exclusive with provisioned concurrency on the same function version. You choose one.
- Pricing differs by runtime. SnapStart carries no additional charge on Java managed runtimes. On Python and .NET you pay a caching charge per published version plus a restoration charge each time an environment is restored. The older blanket “SnapStart is free” claim is Java-only; do not carry it to the other runtimes.
There is also a correctness constraint. Because one snapshot is reused across many environments, anything that must be unique per environment (seeded randomness, pre-opened connections) needs a runtime hook; AWS is explicit that “if your applications depend on uniqueness of state, you must evaluate your function code.” And SnapStart helps least exactly where the amortization spine predicts: AWS notes that functions invoked infrequently might not see the same improvement.
Now the twist. The framework most often dismissed as “too heavy for Lambda,” Spring on the JVM, has a free init lever in SnapStart. The lighter-weight Node path that gets called serverless-friendly has only the paid lever, provisioned concurrency. So “the JVM is too heavy for serverless” is partly obsolete, and “Node is always the serverless-friendly choice” is partly wrong for cold-start-sensitive, heavy-init paths. The asymmetry is straight from the AWS docs. For the full cold-start toolkit (package init, snapshot tuning, the lifecycle mechanics behind these levers), see AWS Lambda cold start optimization.
One more pitfall worth naming, because it is the most common misframe: treating “frameworks are slow on Lambda” as the whole story. It is an init-amortization problem. Once a dependency-injection graph is built and warm, it does not tax p50 or p99 request latency; the weight lives almost entirely in init. The fix is to measure init duration (Lambda’s Init Duration, visible in CloudWatch and X-Ray) and reuse ratio, separately from invoke latency, rather than blaming the framework for steady-state numbers it does not move.
Closing#
The boundary is clear: full framework and dependency-injection weight is right when amortization pays for it, which means a long-lived service (Fargate, or ECS/EKS on EC2) by default, or a Lambda you have put behind SnapStart (Java, Python, or .NET) or provisioned concurrency (the Node lever).
References#
- Understanding the Lambda execution environment lifecycle (opens in new tab) - AWS documentation on the INIT/freeze/restore phases, cold versus warm, and per-request environment provisioning: the lifecycle the amortization model is built on.
- Improving startup performance with Lambda SnapStart (opens in new tab) - AWS documentation on the Firecracker microVM snapshot mechanism, supported runtimes (Java 11+, Python 3.12+, .NET 8+, not Node or Ruby), the framework-loading purpose statement, mutual exclusion with provisioned concurrency, pricing, and the uniqueness-of-state caveat.
- AWS Fargate on Amazon ECS (“Architect for AWS Fargate”) (opens in new tab) - AWS documentation on the long-running task model, per-task isolation, task-level scaling, and the absence of per-request cold start.
- Lambda quotas (opens in new tab) - AWS documentation on Lambda’s hard limits: up to 15 minutes per invocation, up to 10,240 MB of memory, and 512 to 10,240 MB of ephemeral storage; the headroom to exhaust before reaching for a container.
- AWS App Runner availability change (opens in new tab) - AWS notice that App Runner is closed to new customers; why the managed-container rung between Lambda and Fargate is not a path for new builds.
- Configuring provisioned concurrency (opens in new tab) - AWS documentation on pre-initialized environments, double-digit-millisecond readiness, and billing for initialization even when an instance never serves a request: the Node-path cold-start lever.
- Well-Architected Serverless Applications Lens: design principles (opens in new tab) - AWS source for “Functions are concise, short, single-purpose,” the principle behind lean FaaS handlers.
- NestJS documentation: Custom providers (opens in new tab) - The dependency-injection container, providers, and module graph for the canonical heavy-DI Node framework, viable on a Lambdalith and natural on Fargate.
- Spring Boot reference documentation (opens in new tab) - Classpath scanning, the IoC container, and auto-configuration: the canonical heavy-DI JVM illustration, and the runtime where SnapStart is free.
- Hono documentation (opens in new tab) - An ultra-light router; the low-init option for a Lambdalith or edge runtime.
- The Clean Architecture (Robert C. Martin) (opens in new tab) - The delivery-mechanism-independent core; the basis for “the core travels, tune the edges.”
- Hexagonal Architecture (Ports and Adapters, Alistair Cockburn) (opens in new tab) - Ports and adapters as the edge you tune per runtime around an unchanged core.
- AWS Lambda Web Adapter (opens in new tab) - AWS-maintained extension to run an Express, Fastify, or similar HTTP server unchanged inside a Lambdalith.
Related posts
A practical guide to learning Effect incrementally and integrating it with AWS Lambda, with real code examples, common pitfalls, and production patterns.
typescript · functional-programming · lambda +4
How to slice AWS Lambda functions: default to single-purpose, treat the single-domain Lambdalith as an earned exception, and the platform forces that decide it.
lambda · serverless · architecture +2
Build maintainable, type-safe Lambda middleware with Middy's builder pattern, Zod validation, feature flags, and secrets management for serverless apps.
lambda · middleware · typescript +7
When a Lambda fleet outgrows Middy's static middleware model, how a project-specific engine handles per-request config, and what owning one costs
lambda · middleware · performance +6
Discover how Middy transforms Lambda development with middleware patterns, moving from repetitive boilerplate to clean, maintainable serverless functions
lambda · middleware · serverless +5