The codebase I inherited at AventIQ had 93 API Gateway routes, all pointing at a single Lambda monolith. Every deployment required re-uploading the entire function bundle. Policy size limits were being hit. Cold starts were getting worse. Adding a new endpoint meant navigating thousands of lines of mixed-domain logic.
Here's how I refactored it.
The Problem With Serverless Monoliths
A Lambda monolith emerges naturally. You start with one function, add a route handler, add another, and six months later you have a 93-route API backed by a single 45MB deployment package. It deploys fine — until it doesn't.
In our case, we hit AWS's inline policy size limit for the Lambda execution role. The IAM inline policy that controlled which AWS resources the function could access had grown so large (from accumulated resource ARN additions over time) that AWS rejected new policy updates:
An error occurred (LimitExceeded) when calling the UpdateFunctionConfiguration operation:
Function policy size limit of 20480 bytes exceeded for function: aventiq-backend
The quick fix is to use managed policies instead of inline policies. The right fix is to decompose the monolith so each function has a minimal, focused policy.
Designing the Domain Boundaries
Before writing any CDK, I mapped the existing 93 routes to domains. This is the most important step — bad boundaries create a distributed monolith, which is worse than the original.
I used three criteria for a valid domain:
- Own its data — the domain's Lambda functions should only touch one primary DynamoDB table (or one PostgreSQL schema).
- Fail independently — an outage in one domain shouldn't cascade to unrelated domains.
- Deploy independently — a change to lead management logic shouldn't require redeploying email workflow code.
The 93 routes collapsed into 21 controllers across 7 domains:
| Domain | Controllers | Routes |
|---|---|---|
| Leads | 4 | 18 |
| Companies | 3 | 11 |
| Email Workflows | 4 | 16 |
| CRM Operations | 3 | 14 |
| AI Search | 3 | 12 |
| Auth & Users | 2 | 8 |
| Analytics | 2 | 14 |
Each controller is one Lambda function that handles a logical group of operations within a domain — not a single route per function (that's over-engineering), and not all routes per domain in one function (that's still a monolith).
The CDK Structure
// lib/stacks/leads-stack.ts
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda-nodejs";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import { Construct } from "constructs";
export class LeadsStack extends cdk.Stack {
public readonly api: apigateway.RestApi;
constructor(scope: Construct, id: string, props: cdk.StackProps) {
super(scope, id, props);
// Each controller is its own Lambda — minimal blast radius
const leadDiscoveryFn = new lambda.NodejsFunction(this, "LeadDiscovery", {
entry: "src/leads/discovery/handler.ts",
handler: "handler",
runtime: lambda.Runtime.NODEJS_20_X,
timeout: cdk.Duration.seconds(30),
memorySize: 512,
environment: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY!,
LEADS_TABLE: process.env.LEADS_TABLE!,
},
bundling: {
// Only bundle what this function actually uses
externalModules: ["@aws-sdk/*"], // Available in Lambda runtime
minify: true,
sourceMap: false,
},
});
// Minimal IAM — only the permissions this function needs
leadsTable.grantReadWriteData(leadDiscoveryFn);
openAISecret.grantRead(leadDiscoveryFn);
// NOT granting email table access — that's the Email domain's problem
}
}
The bundling.externalModules configuration is critical. By excluding @aws-sdk/* from the bundle (it's pre-installed in the Lambda Node.js runtime), each function's deployment package is typically 2–5MB instead of 30–45MB. Faster deploys, faster cold starts.
The Handler Pattern
Each controller follows a consistent structure:
// src/leads/discovery/handler.ts
import { APIGatewayProxyHandler } from "aws-lambda";
import { z } from "zod";
import { discoverLeads } from "./service";
import { createResponse, createErrorResponse } from "@/lib/lambda-utils";
const DiscoverLeadsSchema = z.object({
query: z.string().min(10).max(500),
industry: z.string().optional(),
limit: z.number().int().min(1).max(50).default(20),
});
export const handler: APIGatewayProxyHandler = async (event) => {
try {
const body = JSON.parse(event.body ?? "{}");
const input = DiscoverLeadsSchema.parse(body);
const leads = await discoverLeads(input);
return createResponse(200, { leads });
} catch (err) {
if (err instanceof z.ZodError) {
return createErrorResponse(400, "Invalid request", err.errors);
}
console.error("LeadDiscovery error:", err);
return createErrorResponse(500, "Internal server error");
}
};
The createResponse and createErrorResponse utilities live in a shared layer — more on that below.
Lambda Layers for Shared Code
The one piece of infrastructure that makes this architecture maintainable is a Lambda Layer for shared utilities:
// lib/layers/shared-utils-layer.ts
const sharedUtilsLayer = new lambda.LayerVersion(this, "SharedUtils", {
code: lambda.Code.fromAsset("src/shared"),
compatibleRuntimes: [lambda.Runtime.NODEJS_20_X],
description: "Shared utilities: response helpers, auth middleware, DB clients",
});
The shared layer contains:
lambda-utils.ts— response helpers, error classesauth-middleware.ts— JWT verification shared across all functionsdb.ts— PostgreSQL connection pool (shared, not instantiated per-request)logger.ts— structured logging with correlation IDs
The database connection pool deserves special attention. Lambda's execution environment is reused between invocations (within the same container), which means a connection pool initialised outside the handler is reused:
// src/shared/db.ts
import { Pool } from "pg";
// Initialised once per Lambda container, reused across invocations
let pool: Pool | null = null;
export function getPool(): Pool {
if (!pool) {
pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5, // Small pool — Lambda concurrency handles horizontal scale
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
}
return pool;
}
With the monolith, every cold start paid the cost of initialising a connection pool, then immediately sharing it across all routes. With decomposed functions, each domain's cold starts are independent, and the pool stays small because each function only handles one controller's load.
The Deployment Pipeline
Each domain is a separate CloudFormation stack, deployed independently via GitHub Actions:
# .github/workflows/deploy-leads.yml
name: Deploy Leads Domain
on:
push:
branches: [main]
paths:
- "src/leads/**"
- "lib/stacks/leads-stack.ts"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npx cdk deploy LeadsStack --require-approval never
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
The paths filter is what makes this worthwhile: a change in the leads directory only deploys the Leads stack. The Email domain doesn't redeploy. Analytics doesn't redeploy. Each domain has its own pipeline that triggers only on changes to that domain's code.
The Policy Size Problem — Solved
The original problem (policy size limits) is solved structurally. Each Lambda function has a minimal IAM policy:
// Only the permissions this specific function needs
const leadDiscoveryPolicy = new iam.Policy(this, "LeadDiscoveryPolicy", {
statements: [
new iam.PolicyStatement({
actions: ["dynamodb:Query", "dynamodb:GetItem", "dynamodb:PutItem"],
resources: [leadsTable.tableArn], // One table — not "*"
}),
new iam.PolicyStatement({
actions: ["secretsmanager:GetSecretValue"],
resources: [openAISecretArn],
}),
],
});
The monolith had a single execution role with 93 resource ARNs and 40+ action permissions. Each domain controller now has 3–6 actions and 1–3 resource ARNs. The policy document fits in under 2KB. Problem eliminated.
The Numbers After Refactoring
Before: 93 routes, 1 Lambda, ~45MB bundle, policy size errors, 8–12s cold starts.
After: 21 controllers, 7 domain stacks, ~3MB average bundle size, no policy errors, 400–800ms cold starts.
Cold start improvement is the most user-visible change. The lead discovery flow went from occasionally timing out on the first request after a dormant period to being consistently fast. The AI search endpoints, which had the most dependencies and the largest bundle, went from 12s cold starts to under 1s.
What I'd Do Differently
Define the domain boundaries before the first route is added, not after 93. The decomposition exercise — mapping routes to domains, identifying what each domain owns — should be a day-one architecture decision. Retrofitting it costs 10x what it costs to design correctly upfront.
Use API Gateway HTTP API instead of REST API. HTTP API has lower latency, cheaper pricing, and a simpler configuration model. The only reason to use REST API is if you need API Gateway features like request validation, usage plans, or custom authorisers — all of which can be implemented in Lambda anyway.
Invest in observability on day one. After decomposition, distributed tracing (AWS X-Ray) becomes essential for debugging cross-domain interactions. I added it after the fact; it should have been in the initial CDK scaffold.
The Lesson
Serverless architectures don't automatically give you microservices benefits. A monolith deployed to Lambda is still a monolith — it's just a monolith that charges by the millisecond. The decomposition work is the same as in traditional services: define bounded contexts, enforce ownership, deploy independently.
The AWS constraints (policy size limits, bundle size cold start penalties) are actually useful forcing functions. They push you toward the architectural decisions you should have made anyway.