Skip to content

Serverless Framework to CDK: Should You Consolidate Lambdas First?

Consolidating Lambdas into a Hono Lambdalith before a Serverless Framework to CDK move roughly doubles the modeled effort. When it pays, and the pitfalls.

Ayhan Sipahi Ayhan Sipahi

A typical Serverless Framework service with 80 REST routes sits at about 477 CloudFormation resources, against a hard cap of 500 per template. A CDK port with default L2 constructs costs as much or more per route, so the limit follows the service into CDK. A common idea at this point is to merge the per-route functions into one Hono Lambdalith before the move: fewer functions, fewer definitions to port. The effort model below says otherwise. For a typical near-limit service, that order roughly doubles the calendar time: an estimated 25.5 days against 12.5 for a direct, route-for-route port.

Consolidating first pays only when the merge is already justified by the five Lambdalith criteria. In that case it belongs before the IaC move: consolidating afterwards in CDK comes out at about 29.5 days in the model, so consolidating first saves about 4 days per service. That is roughly 14% in the base model and as little as 5% when Phase 2 is priced at the direct path’s own rates. The default is therefore to migrate route for route and leave function granularity alone. With agentic coding the work-day column is the one that shrinks; the counts that do not shrink point the same way, more sharply: 2 production steps against 5, and no behavior-changing PRs against 7.

The question belongs to teams running several Serverless Framework services near the limit who have already decided to move to CDK in TypeScript. Whether to move at all is a separate decision, covered in Serverless Framework vs AWS CDK: Should You Migrate? (Part 1). The three orders of work are priced below with the step tables visible, so the estimate can be re-run with other route counts and another team’s PR cadence. The pitfalls that cost log history or block a stack refactor on any of the paths follow the model. The model’s numbers are estimates; its inputs come from packaging a sample service with Serverless Framework 3.40.0 and synthesizing a sample app with aws-cdk-lib 2.275.0.

Resources in a Serverless Framework Template#

Packaging a sample service with Serverless Framework 3.40.0 (two http functions, one httpApi function, one SQS consumer, one DynamoDB table, one queue) produces a template with 31 resources. The per-function share is what matters for a near-limit service:

ItemResources in the template
Per http function5: AWS::Lambda::Function, AWS::Lambda::Version (with DeletionPolicy: Retain), AWS::Logs::LogGroup, AWS::Lambda::Permission, AWS::ApiGateway::Method; plus one AWS::ApiGateway::Resource per path segment, shared between routes under the same path
Per httpApi function6: Function, Version, LogGroup, Permission, AWS::ApiGatewayV2::Integration, AWS::ApiGatewayV2::Route
Per SQS consumer4: Function, Version, LogGroup, AWS::Lambda::EventSourceMapping
Shared per serviceThe deployment bucket and its policy, one IamRoleLambdaExecution for every function, the RestApi and its Deployment, the HttpApi and its stage

The typical service used in the model has 80 REST routes, about 25 path resources, 10 non-HTTP functions (queue consumers, schedules, stream processors), 4 stateful resources, and about 8 shared resources: 80 × 5 + 25 + 10 × 4 + 8 + 4 = 477. A scheduled function costs 5 instead of 4 (an EventBridge rule and its invoke permission), which moves the total by a few resources at most. The CloudFormation quotas page sets the cap at 500 resources per template. A service at 477 has room for about four more routes.

Two levers relieve the count without merging anything. The first is provider.versionFunctions: false, which stops the Framework from creating a Version per function. Packaging the sample service with it set gives 27 resources instead of 31, and the typical service drops from 477 to about 387. It does not fit a service that needs published versions: provisioned concurrency cannot target $LATEST, SnapStart needs a published version, and alias-based canaries or CodeDeploy traffic shifting need two. Existing versions stay in the account, because their DeletionPolicy is Retain, and they keep counting against the account’s code storage quota until deleted. The second lever is the log group. Lambda’s logging configuration lets several functions write to one custom log group; the Lambda docs state that “You can configure multiple Lambda Event Functions to send logs to the same CloudWatch log group”. Serverless Framework v4 exposes this as provider.logs.lambda.logGroup, and its reference says that when the option is set the Framework “will not create LogGroups for any functions”. For the typical service that removes about 90 more resources, to about 297 with both levers. Two cautions apply. Serverless Framework v3 has no shared log-group option; disableLogs only switches a function’s log group off. And dropping the generated log groups from the template deletes them unless they are retained first, which is the first pitfall below. Neither lever changes the CDK side: the CDK counts below already leave out versions and, by default, log groups.

Resources After a Default CDK Port#

Synthesizing with aws-cdk-lib 2.275.0 shows that the CDK side costs at least as much per route when the port uses default L2 constructs. A sample app with three REST routes under one parent path and one SQS consumer, each function granted access to one table, synthesizes 33 resources. Per REST route the breakdown is 6: the Function, its own AWS::IAM::Role, the AWS::IAM::Policy that the grant creates, two AWS::Lambda::Permission entries, and the Method; path resources are shared exactly as in Serverless Framework. Per SQS consumer with a grant it is 4: Function, Role, Policy, EventSourceMapping.

Two defaults drive the extra count. LambdaIntegrationOptions.allowTestInvoke defaults to true, and the CDK API reference explains that it “will add another permission to the AWS Lambda resource policy which will allow the test-invoke-stage stage to invoke this handler”. The role prop of lambda.Function defaults to a generated role per function, and the first grant* call creates that role’s default policy as a separate resource. A third one is optional. The @aws-cdk/aws-lambda:useCdkManagedLogGroup feature flag, introduced in v2.200.0, is recommended as true in the CDK feature flags list, and a fresh cdk init writes that value. With the flag on, each function adds a seventh resource: an AWS::Logs::LogGroup with DeletionPolicy: Retain.

For the typical service, the arithmetic is 80 × 6 + 25 + 10 × 4 + 4 + 5 shared API resources = about 554, or about 644 with managed log groups. Without any table grant it would be about 464 to 474; a queue consumer’s event source still grants its role access to the queue. In other words, with default constructs the direct path crosses 500 as soon as functions receive grants, so it needs a stack split design before the first port PR. The split strategies themselves are in Breaking Through CloudFormation’s 500 Resource Barrier and are not repeated here. One distinction with that post matters here: its Strategy 0 consolidates shared roles, policies, security groups, and alarms before splitting, and keeps every function as it is. Merging handlers into a Lambdalith changes function granularity, which is a different decision with its own price. The same arithmetic also answers a line in the granularity post that treats function-count pain as a tooling problem for IaC modules. Modules shorten the code; the 500 cap counts template resources. The levers are therefore a stack split or resource trims.

A CDK port can also mirror the Serverless Framework shape instead of taking the defaults: one shared role passed through role, allowTestInvoke: false on every integration, one shared log group through logGroup. By arithmetic from the per-construct counts above (this variant was not synthesized), each route then costs 3 resources: Function, Permission, Method. The typical service lands near 300 in one stack. The trade-off is IAM: a shared role recreates the single-role shape that Serverless Framework uses by default and gives up per-function least privilege. The effect on the effort model appears in the sensitivity rows below; because it makes the direct path cheaper, it strengthens the verdict.

Three Paths, Priced in PRs and Soak Days#

The model counts pull requests, work days, and production steps. Work days are the baseline a team without coding agents would recognize; the last subsection re-weights the same steps for a team with them. A production step is any deploy that moves traffic or state; each one is followed by a soak before the next step starts. Calendar days are work days plus soak days. Everything in this section is an estimate. The unit costs are the assumptions, and a team with a different PR cadence should replace them before reading the totals.

Unit Costs and the Typical Service#

  • Mechanical PR (IaC port batch, cutover switch, retire): 0.5 day.
  • Design or code-shape PR (CDK or Hono skeleton, stack split design, a Hono route batch with parity fixtures, gateway features rebuilt in code): 1 day.
  • One exception: writing parity tests against a deployed API costs 2 days, because the fixtures are built while the old and new APIs run side by side. Replaying fixtures that already exist is a mechanical 0.5 day.
  • Soak after each production step: 2 days.
  • Batch sizes: 20 functions per IaC port PR; 15 routes per Hono port PR; 3 retarget waves (zero-traffic routes, low-traffic routes, the few top routes).
  • Typical service: H = 80 HTTP routes, E = 10 non-HTTP functions, S = 4 stateful resources, C = 8 carve-outs (the 10% of routes that keep their own function).

Direct Path#

The direct path changes one thing, the IaC tool. Every function keeps its boundary (one function per route) and its role scope. The CDK functions run next to the old ones during the parity tests, so they get new names and new log groups. The API is recreated in CDK, tested against the old one, and the custom domain mapping is then pointed at it.

StepPRsWork daysProduction steps
CDK skeleton, CI, stack split design220
Port H + E = 90 function definitions52.50
Parity tests against the deployed new API120
Align state definitions, then stack refactor211
Cutover at the custom domain10.51
Retire the Serverless Framework stack10.50
Total128.52, so 12.5 calendar days

Its weak point is the cutover. All 80 routes switch at once at the custom domain. Parity gaps show up only against the deployed new API, because this path has no standalone harness.

Consolidation Inside Serverless Framework (Hono First, Phase 1)#

Phase 1 merges the HTTP routes into one Hono function while the service is still on Serverless Framework. This is the shape that AWS’s own guidance argues against for Event Functions; the decision section below quotes that guidance and sets the conditions for overriding it. The mechanics of the catch-all route, the retarget waves, and the delete step are in Single-Purpose Lambdas to a Hono Lambdalith, Route by Route, shown there in CDK; the sequence is the same in serverless.yml.

StepPRsWork daysProduction steps
Hono skeleton, catch-all route, standalone parity harness220
Port H - C = 72 routes into Hono550
Gateway features rebuilt in Hono (auth, validation, a route metric dimension)220
Retarget waves31.53
Log group Retain PR, then the PR that deletes the retargeted functions210
Phase 1 total1411.53, so 17.5 calendar days

The gateway-feature row is the one teams forget to price, and it applies only to routes whose explicit Method is deleted in favour of the catch-all. For those routes, per-method request validation, per-method throttling, and the Resource dimension of the API Gateway metrics all collapse into the proxy entry, and the route metric has to be rebuilt inside the app, for example as an Embedded Metric Format dimension. Authorizers are the exception: a REQUEST authorizer receives $context.path and can return a per-path policy, so it can stay at the gateway. A route that keeps its explicit Method pointed at the Hono function keeps every gateway feature, at the cost of one Method resource per route. By the same arithmetic as above, that end state sits near 200 resources for the typical service instead of about 100 with the catch-all, still far from the cap.

The Smaller CDK Move (Hono First, Phase 2)#

After Phase 1 the service has 1 + C + E = 19 functions, which fit one CDK stack with default constructs and no split design.

StepPRsWork daysProduction steps
CDK skeleton and CI110
Port the 19 functions and the proxy API10.50
Parity tests reusing the Phase 1 fixtures over HTTP10.50
Align state definitions, then stack refactor211
Cutover at the custom domain10.51
Retire the Serverless Framework stack10.50
Phase 2 total742, so 8 calendar days

Totals and Sensitivity#

The third path, direct first and consolidation later in CDK, is Phase 1 run inside CDK after the direct path. The Retain PR drops out, because CDK log groups are either outside the template or already retained; the delete PR stays. That is 13 PRs, 11 work days, 3 production steps, 17 calendar days on top of 12.5.

PathEnd statePRsCalendar days (estimate)
Direct90 functions across split stacks1212.5
Hono first (Phase 1 + 2)One Hono function, 8 carve-outs, 10 non-HTTP functions2125.5
Direct, then consolidate in CDKSame as Hono first2529.5

Direct: 12.5 days (model)

Hono first, phase 1: 17.5 days

Phase 2: 8 days

Consolidate in CDK: 17 days

Serverless Framework service: 90 functions, about 477 resources

CDK: 90 functions, split stacks with default constructs

Serverless Framework: Hono function plus 18 others

CDK: 19 functions, one stack

The ratio 25.5 / 12.5 is about 2.0, and (29.5 - 25.5) / 29.5 is about 14%. The variants a team is likely to argue about keep the ratio between about 1.7 and 2.2. The saving is the fragile number. It is a 4-day residual made of three Phase 2 discounts (a smaller skeleton, 19 functions to port instead of 90, fixtures replayed instead of written), and the last row prices two of them away:

VariantDirectHono firstDirect, then consolidate
Base (2-day soaks)12.525.529.5
1-day soaks10.520.524.5
Adapter-wrapped handlers instead of route rewrites (port step 2 PRs, 1 day, on both consolidating paths)12.521.525.5
Single-stack direct port (shared role, allowTestInvoke: false; skeleton 1 PR, 1 day)11.525.528.5
Phase 2 at direct-path rates (parity written again, 2 days; skeleton and CI, 2 days)12.528.029.5

The ordering is unchanged in every row, and that part is structural rather than a finding: Hono first carries a 72-route port and three extra production steps that the direct path never pays, so it stays behind at any soak length. The saving is what the rows test. It is about 10% in the single-stack row and about 5% in the last one. Two choices in the model favour Hono first. The Retain, delete, and retire deploys are live stack updates counted without a soak, and the third path’s end state is a floor, because no row collapses its split stacks after the merge. Not modelled: the learning curve on CDK, parallel work across engineers, incident time during soaks, and cross-stack references in a split design, which can turn the direct path’s port batches into design-grade PRs (about 15 days instead of 12.5) and widen the saving again.

PathCost (model)Operational overheadFailure modes
Direct12 PRs, 12.5 days, 2 production steps, about 16.5 review hoursStack split design with default constructs, or a single stack with a shared role and trimmed permissionsAll routes switch at once at the custom domain; parity gaps found only against the deployed new API; old log groups deleted with the retired stack unless retained
Hono first21 PRs, 25.5 days, 5 production steps, about 47.5 review hoursGateway features rebuilt in code; a route metric dimension to own; two IaC states in sequenceLog history lost if the Retain deploy is skipped; per-route throttling and metrics collapse for routes moved to the catch-all; 72 routes share one function’s scaling rate
Direct, then consolidate25 PRs, 29.5 days, 5 production steps, about 52.5 review hoursTwo migrations; 90 functions ported, then mergedBoth of the above; the stack split design serves a shape that is then discarded

What Agentic Coding Does Not Compress#

The work-day column is the one an agent shrinks. It drafts the 90 definitions, the 72 route ports, and the parity fixtures, and a synth diff or a fixture replay checks the mechanical ones by machine. Three things stay with a human. A production step still needs someone to approve it, watch it, and be ready to roll it back. A behavior-changing PR still needs someone to decide that the new code is the old behavior, and a security-relevant one needs that decision made with care. An irreversible operation still needs a rehearsal. Counting those from the step tables above gives a second comparison that does not depend on who writes the code.

Count, from the step tablesDirectHono firstDirect, then consolidate
Production steps255
Behavior-changing PRs (route ports, gateway features in code)077
Of which security-relevant (auth and validation rebuilt in code)022
Irreversible operations (refactor execute, log-group deletion, retire)233
Design decisions a human owns (split design, Hono skeleton, carve-out list, gateway mapping)134
Human review hours (estimate, unit costs below)16.547.552.5

The review-hour row prices the same steps in human time instead of work days: 0.5 hour for a mechanical, machine-checked PR; 2 hours for a design PR; 3 hours for a behavior-changing route batch and 4 for a security-relevant one; 2 hours to write parity fixtures and 0.5 to replay them; 2 hours of attendance per production step; 1 extra hour per irreversible operation. Under this lens the ratio between Hono first and direct widens from about 2.0 to about 2.9, and the order-of-consolidation saving falls from 4 days to about 5 hours, about 10%. The reason is that the items that made Phase 2 cheap in days, fewer definitions to port and a smaller skeleton, are the items an agent makes cheap on the direct path as well. Calendar days do not change under this lens, because soaks are the largest term and do not depend on who writes the code; they are reported, not decisive.

Decision Order and the Five Lambdalith Criteria#

The migration does not decide whether to consolidate; the service does. AWS Lambda: Single-Purpose Functions vs Lambdalith sets out five criteria under which a Lambdalith is the better shape: many low-traffic routes, no route that needs isolated concurrency, a common IAM scope across the routes, one deploy cadence and one owner, and observability that can be rebuilt inside the app. When all five hold, the merge is worth doing on its own, and the only question the migration adds is the order. When any of them fails, the merge costs more than it returns whatever the IaC tool, and the direct path is the whole plan.

AWS’s own guidance has to be named here, because the merged shape is the one it warns about. The Lambda developer guide lists “The Lambda monolith” among its anti-patterns: “a monolithic Lambda function would handle all API Gateway routes and integrate with all necessary downstream resources”. It gives six drawbacks (package size, least privilege, upgrades, maintenance, code reuse, testing) and names the preferred alternative as “mapping a single Lambda function to a single, well-defined task”. The Serverless Applications Lens adds: “Take advantage of Amazon API Gateway native routing functionality instead of using the routing of web frameworks”. Both sentences describe Phase 1. The five criteria are the conditions under which three of the six drawbacks are small: a common IAM scope answers least privilege, one deploy cadence answers upgrades, one owner answers maintenance. Package size and testing fall to the carve-out rule’s 1 MB dependency limit and to the standalone harness. Code reuse is not addressed; the merge neither helps nor hurts it. AWS also holds a second position, split by function type. The anti-pattern page is written for Event Functions, and the Lambda Web Functions docs say “You can use familiar Node.js web frameworks such as Express, Hono, or React Router with no Lambda-specific adapters or wrappers”. A Hono app behind API Gateway is still an Event Function, so the anti-pattern page is the one that applies to Phase 1. The merge is a conditional override of AWS’s default, not a refutation of it.

Question, in orderIf yesIf no
Do all five Lambdalith criteria hold for this service?Next questionDirect path (default)
Is the IaC move scheduled anyway?Hono first, then CDK (about 4 days, 5% to 14%, cheaper than consolidating afterwards, in the model)Consolidate on its own merits; the migration does not decide it
Does an HTTP API service approach 300 integrations?Routers are forced either way; plan the consolidationNo change
Do the services use per-function roles?Add the IAM union cost to the consolidation sideShared role: merging changes no IAM scope

The IAM criterion deserves a note, because it is where Serverless Framework services differ from the CDK defaults the granularity post assumes. The Serverless Framework docs state: “By default, one IAM Role is shared by all the Lambda functions in your service.” A service on that default already carries the blast radius of a single role, so merging routes into one function does not widen IAM scope. That is not the same as no cost. The shared role is standing least-privilege debt; the direct path, with CDK’s per-function roles, is the one that can retire it, and the merge keeps it. Services with per-function roles do pay the IAM union cost when they merge, and the granularity post’s IAM lens applies in full. On v4 that shape comes from provider.iam.role.mode: perFunction or per-function iam.role.statements; on v3 it is usually the serverless-iam-roles-per-function plugin.

Pitfalls on Either Path#

Each pitfall below comes from packaging the sample service, synthesizing the sample app, or the primary docs.

Log Groups Deleted with Their Functions#

Serverless Framework generates an AWS::Logs::LogGroup per function with no DeletionPolicy and no RetentionInDays; packaging the sample service confirms this. Removing a function from functions therefore removes the log group from the template, and CloudFormation deletes it together with the function’s whole log history. The CloudFormation DeletionPolicy docs describe the fix: on a stack update, Retain keeps the physical resource and removes it from CloudFormation’s scope. In practice this means two deploys for every batch of deleted functions, and the Phase 1 table above prices them as the Retain PR and the delete PR.

# serverless.yml, deploy 1 of 2: keep the log group when the function goes away
resources:
  extensions:
    GetOrderLogGroup:          # {normalizedFunctionName}LogGroup for function "getOrder"
      DeletionPolicy: Retain
      UpdateReplacePolicy: Retain
      Properties:
        RetentionInDays: 30

Deploy 2 removes getOrder from functions and the GetOrderLogGroup extension in the same commit. The extension cannot stay behind: in the v3 source (merge-custom-provider-resources.js), extending a resource that is no longer generated fails with RESOURCE_EXTENSION_NOT_EXISTING. The same file restricts extensions to Condition, CreationPolicy, DeletionPolicy, UpdatePolicy, UpdateReplacePolicy, Properties, DependsOn, and Metadata; anything else fails with RESOURCE_EXTENSION_UNSUPPORTED_ATTRIBUTE. The logical ID follows the documented normalisation: a - in the function name becomes Dash, _ becomes Underscore, and the first character is uppercased, so get-order maps to GetDashOrderLogGroup. UpdateReplacePolicy is in the extension because DeletionPolicy does not cover a replacement, and LogGroupName is a replacement property, so the Retain PR must leave the name alone.

The same deletion happens at the end of either path, when the Serverless Framework stack is retired: every log group still in it goes with it. Setting DeletionPolicy: Retain on those log groups in the align deploy before the refactor keeps the history and adds no PR to the model.

Limits of CloudFormation Stack Refactoring#

Both paths move the stateful resources (tables, queues, buckets, user pools) from the Serverless Framework stack into a CDK stack by stack refactoring, so that the data never moves. The stack refactoring docs set constraints that shape the plan:

  • A refactor cannot create or delete resources, change configuration, or add parameters, conditions, or mappings; those changes go into normal stack updates before the refactor.
  • The CLI handles up to 5 stacks per refactor, no stack may be left empty, and stacks with a stack policy are not supported.
  • Resources that refer to pseudo parameters whose values differ between stacks, such as AWS::StackName, cannot move.
  • Only FULLY_MUTABLE resource types move, and only if they are not on the unsupported list. That list includes AWS::ApiGateway::Method, AWS::ApiGateway::BasePathMapping, AWS::Lambda::EventInvokeConfig, AWS::Route53::RecordSet, and AWS::DynamoDB::GlobalTable; AWS::ApiGatewayV2::ApiMapping is not on it.

Querying DescribeType in eu-central-1 shows FULLY_MUTABLE for the usual stateful set: DynamoDB Table, SQS Queue, S3 Bucket, Cognito UserPool and UserPoolClient, SNS Topic, EventBridge EventBus and Rule, Secrets Manager Secret, KMS Key, and Logs LogGroup. AWS::Lambda::Permission is IMMUTABLE, which does not matter because it is stateless and recreated anyway. The gateway methods do not move either; they are recreated in CDK. The custom domain mapping is the switch, and it does not need recreating: the CloudFormation reference marks RestApiId on AWS::ApiGateway::BasePathMapping and ApiId on AWS::ApiGatewayV2::ApiMapping as “No interruption” updates, so the cutover is one property changed in place, wherever the mapping lives (the Serverless Framework stack, a domain plugin, or a stack of its own). A mapping that lives in the Serverless Framework stack is retained at retire time and imported into CDK afterwards; an HTTP API mapping can also move by refactor, a REST base path mapping cannot. That is why both paths carry a cutover step, and why it has no gap.

The packaged v3 template contains no AWS::StackName reference; function names are literal <service>-<stage>-<function> strings. User-written resources still need a check: run grep -c AWS::StackName .serverless/cloudformation-template-update-stack.json after serverless package, and replace any hit with a literal name in a normal deploy before the refactor.

Two points about the refactor remain untested here and should be rehearsed in a sandbox. Resources that stay in the Serverless Framework stack, typically the shared role’s policy and function environment variables, often Ref or GetAtt the table that moves. Removing the table from the source template breaks those references, so a normal deploy has to replace them with literal names or an export before the refactor. CDK templates also carry a bootstrap version parameter, a bootstrap rule, and a metadata resource, and the docs say a refactor cannot add parameters or resources. The safer sequence is therefore to deploy the destination CDK stack first and refactor into it. That first deploy cannot carry the table, because a second table with the same name fails, and the stack cannot be empty either, so it needs a placeholder resource; the refactoring docs suggest an SNS topic or a wait condition. The table enters the destination template in the refactor itself, as a move. The docs offer --enable-stack-creation to let the refactor create the destination stack; the sequence here deliberately leaves it out.

aws cloudformation create-stack-refactor \
  --stack-definitions \
    StackName=orders-service-prod,TemplateBody@=file://sf-without-state.json \
    StackName=orders-state-prod,TemplateBody@=file://cdk.out/OrdersStateStack.template.json \
  --resource-mappings file://resource-mapping.json

aws cloudformation describe-stack-refactor --stack-refactor-id <id>       # wait for Status CREATE_COMPLETE
aws cloudformation list-stack-refactor-actions --stack-refactor-id <id>   # preview the MOVE actions
aws cloudformation execute-stack-refactor --stack-refactor-id <id>

cdk refactor and a Serverless Framework Source Stack#

cdk refactor is in preview behind --unstable=refactor. The CDK refactoring guide describes it as comparing the app’s current code with its deployed state to detect moved constructs, and says nothing about stacks from other tools. The inference here is that a Serverless Framework stack is outside that comparison, so the command is not the tool for a cross-tool move. For the cross-tool move, the plain aws cloudformation create-stack-refactor call above is the tool, with a hand-written resource mapping from the Serverless Framework logical IDs to the CDK ones.

Table Instead of TableV2#

Synthesizing shows that TableV2 produces AWS::DynamoDB::GlobalTable, which sits on the refactor exclusion list. The DynamoDB global tables best-practices page also states that a Table cannot be converted to a GlobalTable by changing its CloudFormation type, and that the attempt might delete the table. Table with the same key schema and PAY_PER_REQUEST synthesizes identical Properties, plus DeletionPolicy and UpdateReplacePolicy set to Retain. Because a refactor cannot change configuration, the Serverless Framework definition has to match first, in a normal deploy, and the differing logical IDs go into the resource mapping.

# serverless.yml, normal deploy before the refactor: match what CDK synthesizes
resources:
  Resources:
    OrdersTable:
      Type: AWS::DynamoDB::Table
      DeletionPolicy: Retain
      UpdateReplacePolicy: Retain
      Properties:
        TableName: ${self:service}-${sls:stage}-orders
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - { AttributeName: pk, AttributeType: S }
          - { AttributeName: sk, AttributeType: S }
        KeySchema:
          - { AttributeName: pk, KeyType: HASH }
          - { AttributeName: sk, KeyType: RANGE }
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'

// Table, not TableV2: TableV2 synthesizes AWS::DynamoDB::GlobalTable,
// which stack refactoring does not support.
const orders = new dynamodb.Table(this, 'OrdersTable', {
  tableName: 'orders-service-prod-orders',
  partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
  sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
})

One Hono App for REST and HTTP API Events#

A fleet with both REST and HTTP API services does not need two Hono apps. The adapter ships as @hono/aws-lambda (the older hono/aws-lambda import path is deprecated), and its handle() accepts four event types: the REST payload (APIGatewayProxyEvent), the HTTP API v2 payload (APIGatewayProxyEventV2), ALB events, and VPC Lattice v2 events. Code should import handle from @hono/aws-lambda. The router code is in the two linked Lambdalith posts. What matters here is that the Phase 1 harness and route ports are reusable across API types, which is part of why Phase 2 is small. Lambda Web Functions After a Hono Lambdalith covers what changes once the Hono app runs on Lambda Web Functions; it does not alter the order question here.

A Verdict Rule Written Down Before the First PR#

The model gives an expectation; the service gives the actual value. Writing the rule down before the first PR keeps the two from being reconciled after the fact.

  • Per service: carve-outs at most 20% of HTTP routes, and p90 latency no more than 10% worse in matched weekday windows, using the window method in Lambda Cold Starts Follow Idle Gaps, Not Bundle Size. Take latency from API Gateway access logs ($context.integrationLatency grouped by $context.path; $context.resourcePath and $context.routeKey show only the proxy), weighted to the old route mix. Lambda REPORT lines are per function, so after the merge they no longer separate routes.
  • Fleet level: production-escaped defects of the consolidation phase at most those of the CDK phase, and human review hours of both phases together at most the direct-path estimate. Review hours come from the PR timeline (ready for review to final approval), with PRs labelled mechanical, behavior, or rework so the mix is visible; calendar days are reported alongside, since soaks dominate them and do not depend on who writes the code. The model predicts that the review-hours check fails (47.5 against 16.5); it stays, because the estimate is what the migration tests.
  • Carve-out rule: a route keeps its own function if any of these holds: memory need at least twice the service median, its own dependencies add more than 1 MB to the bundle, a sensitive IAM permission no other route needs, or its own concurrency budget (reserved concurrency, or bursts large enough to use up the function’s scaling rate).
  • Unit costs calibrated to the team’s own PR cadence and frozen before work starts.

Resources per stack (the count from aws cloudformation list-stack-resources) belong on a CI check with an alarm at 450 on every path. A split stack can drift back toward the cap as fast as the original did.

Where the Default Stops Holding#

Three situations change the plan. The first is a service for which all five Lambdalith criteria hold. Consolidate it, and do so before the IaC move; the model puts the saving at about 4 days per service: about 14% against consolidating afterwards in CDK, about 10% if the direct port would have fit one stack anyway, and about 5% once Phase 2 is priced at the direct path’s rates; under the agentic lens it is about 5 review hours, about 10%. The second is an HTTP API service that approaches the integration quota. The API Gateway HTTP API quotas page lists “Integrations per API” at 300 and marks it as not adjustable. With one integration per function, a service past that point needs routers (several routes per function) or an API split whatever the IaC tool; only the timing of the consolidation is open. On the REST side the comparable quota, “Resources per API”, is 300 but adjustable. The documented way to reduce it is {proxy+} paths, which is the Lambdalith shape. The third is a service with very few routes. It is nowhere near the cap, the paths cost about the same, and the question does not arise.

For a service that fails any of the five criteria, or whose team cannot absorb five production steps, the direct path holds. The relief for the resource count is then the Serverless Framework v4 shared log group or the single-stack CDK shape. For a service that passes all five, consolidate first and then move. Either way the plan starts with the per-service counts, the team’s own unit costs in the tables above, and a verdict rule written before the first PR. The estimates stay estimates until a migration reports its numbers against them.

References#

Related posts