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 not “frameworks are slow”; it is how many invocations one initialization serves.
That distinction matters because teams pick architecture by habit, not by runtime fit. 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; it is the far end of the ratio, not the focus of the decision.
The amortization ratio climbs as you move down that list, and the affordable architecture weight climbs with it. That is the spine. Most of the decision lives at the top of it, on the Lambda side: single-purpose versus Lambdalith, where weight actually costs something. Fargate is the far end, where it does not.
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, not per request, 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: build expensive things at boot, never per request.
The constraint is the same in all three: build expensive things as rarely as the runtime lets you, and reuse them as much as it allows. The runtimes just differ in how much reuse they grant.
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. It is not 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, not in how much dependency injection or hexagonal layering they can carry. Adding rungs adds operational decisions, not architectural ones, which is why the rest of this post stays on the three points where the architecture actually changes.
Paradigm Per Runtime: Lean by Default
The paradigm axis (functional versus OOP, no framework versus heavy framework) maps directly onto the ratio. Lean is the default everywhere; you add weight only where amortization justifies it.
FaaS single-purpose: lean functional handler
A single-purpose function serves roughly one purpose per cold environment, so init weight is the thing to minimize. 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, and the only thing built at module load is a client that warm invocations reuse.
Lambdalith: moderate structure
A Lambdalith builds its router and services once and amortizes them across every route, so 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.
Fargate: full OOP, DI, hexagonal
A Fargate task builds everything once and reuses it for the task’s life, so rich domain models, eager singletons, and persistent pools earn their keep. 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; the focus here stays on code architecture.
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:
- FaaS: a tiny composition root, hand-wired or minimal, with adapters created lazily so the cold path stays cheap.
- Lambdalith: a single composition root built at cold start, shared across every route in the warm environment.
- Fargate: a full dependency-injection container with eager singletons and persistent adapters built at boot.
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. Keep the core agnostic and the only thing a runtime change touches is the composition root. The decision stops being “which architecture” and becomes “keep the core, tune the edges.”
When to Override: 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. They are the override cases, not the spine, and 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 make it a precise tool rather than a magic switch:
- 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, which loops straight back to the ratio.
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 counter-intuitive and it 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; this section names the levers as overrides rather than re-teaching them.
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, not a steady-state one. 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
Let init-amortization choose the architecture weight. Default each runtime to the lightest architecture its ratio justifies: lean functional handlers on single-purpose FaaS, a router plus thin service plus light dependency injection on a Lambdalith, and full OOP, dependency injection, and hexagonal layering on a long-lived runtime such as Fargate. Keep one delivery-agnostic domain core across all three and let only the composition root change at the edge. 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). The single next step is to look at one function you run today and ask how many invocations its init actually serves; that number, not the framework you happen to know, tells you how much weight it can carry.
References
- Understanding the Lambda execution environment lifecycle - 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 - 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”) - AWS documentation on the long-running task model, per-task isolation, task-level scaling, and the absence of per-request cold start.
- Lambda quotas - 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 - 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 - 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 - AWS source for “Functions are concise, short, single-purpose,” the principle behind lean FaaS handlers.
- NestJS documentation: Custom providers - 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 - Classpath scanning, the IoC container, and auto-configuration: the canonical heavy-DI JVM illustration, and the runtime where SnapStart is free.
- Hono documentation - An ultra-light router; the low-init option for a Lambdalith or edge runtime.
- The Clean Architecture (Robert C. Martin) - The delivery-mechanism-independent core; the basis for “the core travels, tune the edges.”
- Hexagonal Architecture (Ports and Adapters, Alistair Cockburn) - Ports and adapters as the edge you tune per runtime around an unchanged core.
- AWS Lambda Web Adapter - 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.
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.
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.
Run Bun and Deno on AWS Lambda with custom runtimes: real performance benchmarks, cost analysis, and production deployment patterns.
Build maintainable, type-safe Lambda middleware with Middy's builder pattern, Zod validation, feature flags, and secrets management for serverless apps.